authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:54:38-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:54:38-07:00
log430ece4c0aa49ede37bb3bd29f451fde97b1b64a
tree38f991aaf6086bdffb603ba7cb71f40c91808caf
parent4238a0e769c9f149d4083968823298ace1e2bd0b
signature Signed by PGP key B5690EEEBB952194

[codex] Structure Codex app-server request errors (#3258)

Co-authored-by: codex <codex@users.noreply.github.com>

8 files changed, 631 insertions(+), 158 deletions(-)

apps/server/src/provider/Layers/CodexSessionRuntime.test.ts+108-66
......@@ -1,8 +1,9 @@
11import * as NodeAssert from "node:assert/strict";
22
3import { it } from "@effect/vitest";
34import * as Effect from "effect/Effect";
45import * as Schema from "effect/Schema";
5import { describe, it } from "vite-plus/test";
6import { describe } from "vite-plus/test";
67import { ThreadId } from "@t3tools/contracts";
78import * as CodexErrors from "effect-codex-app-server/errors";
89import * as CodexRpc from "effect-codex-app-server/rpc";
......@@ -19,6 +20,23 @@ import {
1920} from "./CodexSessionRuntime.ts";
2021const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError);
2122
23describe("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
2240function makeThreadOpenResponse(
2341 threadId: string,
2442): CodexRpc.ClientRequestResponsesByMethod["thread/start"] {
......@@ -43,6 +61,32 @@ function makeThreadOpenResponse(
4361}
4462
4563describe("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,29 +267,29 @@ describe("isRecoverableThreadResumeError", () => {
223267});
224268
225269describe("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",
......@@ -253,51 +297,49 @@ describe("openCodexThread", () => {
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+30-29
......@@ -26,10 +26,9 @@ import * as Exit from "effect/Exit";
2626import * as Layer from "effect/Layer";
2727import * as Queue from "effect/Queue";
2828import * as Ref from "effect/Ref";
29import * as Scope from "effect/Scope";
3029import * as Schema from "effect/Schema";
30import * as Scope from "effect/Scope";
3131import * as Stream from "effect/Stream";
32import * as SchemaIssue from "effect/SchemaIssue";
3332import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3433import * as CodexClient from "effect-codex-app-server/client";
3534import * as CodexErrors from "effect-codex-app-server/errors";
......@@ -89,7 +88,6 @@ const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffe
8988
9089export type CodexTurnStartParamsWithCollaborationMode =
9190 typeof CodexTurnStartParamsWithCollaborationMode.Type;
92const formatSchemaIssue = SchemaIssue.makeFormatterDefault();
9391
9492export type CodexResumeCursor = typeof CodexResumeCursorSchema.Type;
9593type 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
661function 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
671665function 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);
packages/effect-codex-app-server/src/_internal/shared.test.ts created+151
......@@ -0,0 +1,151 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4
5import * as CodexError from "../errors.ts";
6import * as Shared from "./shared.ts";
7
8const decodeNestedNumberPayload = Schema.decodeUnknownEffect(
9 Schema.Struct({ profile: Schema.Struct({ token: Schema.Number }) }),
10);
11const encodeUnknownJson = Schema.encodeSync(Schema.UnknownFromJsonString);
12
13it.effect("preserves schema decode diagnostics without deriving the message from the cause", () =>
14 Effect.gen(function* () {
15 const error = yield* Shared.decodeOptionalPayload("thread/start", Schema.String, 42).pipe(
16 Effect.flip,
17 );
18
19 assert.instanceOf(error, CodexError.CodexAppServerRequestError);
20 assert.equal(error.code, -32602);
21 assert.equal(error.method, "thread/start");
22 assert.equal(error.operation, "decode-payload");
23 assert.equal(
24 error.message,
25 "Invalid payload for method 'thread/start' during 'decode-payload'",
26 );
27 assert.isTrue(Schema.isSchemaError(error.cause));
28
29 const protocolError = error.toProtocolError();
30 assert.equal(protocolError.code, -32602);
31 assert.equal(protocolError.message, error.message);
32 assert.property(protocolError, "data");
33 assert.notProperty(protocolError, "method");
34 assert.notProperty(protocolError, "operation");
35 assert.notProperty(protocolError, "cause");
36 }),
37);
38
39it.effect("preserves schema encode diagnostics", () =>
40 Effect.gen(function* () {
41 const error = yield* Shared.encodeOptionalPayload(
42 "thread/start",
43 Schema.Number,
44 "not-a-number" as never,
45 ).pipe(Effect.flip);
46
47 assert.equal(error.method, "thread/start");
48 assert.equal(error.operation, "encode-payload");
49 assert.equal(
50 error.message,
51 "Invalid payload for method 'thread/start' during 'encode-payload'",
52 );
53 assert.isTrue(Schema.isSchemaError(error.cause));
54 }),
55);
56
57it.effect("does not invent a cause when a method has no payload schema", () =>
58 Effect.gen(function* () {
59 const secret = "unexpected-payload-secret";
60 const error = yield* Shared.decodeOptionalPayload<never, never>("initialized", undefined, {
61 token: secret,
62 }).pipe(Effect.flip);
63
64 assert.equal(error.method, "initialized");
65 assert.equal(error.operation, "decode-payload");
66 assert.equal(error.payloadKind, "object");
67 assert.deepEqual(error.data, { payloadKind: "object" });
68 assert.isUndefined(error.cause);
69 assert.notInclude(error.message, secret);
70 assert.notInclude(encodeUnknownJson(error.toProtocolError()), secret);
71 }),
72);
73
74it.effect("keeps invalid payload values only in the exact schema cause", () =>
75 Effect.gen(function* () {
76 const secret = "codex-schema-payload-secret";
77 const cause = yield* decodeNestedNumberPayload({ profile: { token: secret } }).pipe(
78 Effect.flip,
79 );
80 const error = CodexError.CodexAppServerRequestError.invalidPayload(
81 "thread/start",
82 "decode-payload",
83 cause,
84 );
85 const { cause: directCause, ...directDiagnostics } = error;
86
87 assert.strictEqual(directCause, cause);
88 assert.equal(error.method, "thread/start");
89 assert.equal(error.operation, "decode-payload");
90 assert.equal(error.maximumPathDepth, 2);
91 assert.isAbove(error.issueCount ?? 0, 0);
92 assert.include(error.issueKinds ?? [], "Pointer");
93 assert.notInclude(error.message, secret);
94 assert.notInclude(encodeUnknownJson(directDiagnostics), secret);
95 assert.notInclude(encodeUnknownJson(error.toProtocolError()), secret);
96 }),
97);
98
99it.effect("retains the request-handler error as the internal error cause", () =>
100 Effect.gen(function* () {
101 const rootCause = new Error("socket closed");
102 const source = new CodexError.CodexAppServerTransportError({
103 operation: "read-input-stream",
104 cause: rootCause,
105 });
106 const error = yield* Shared.runHandler(
107 (_payload: void) => Effect.fail(source),
108 undefined,
109 "thread/start",
110 ).pipe(Effect.flip);
111
112 assert.equal(error.code, -32603);
113 assert.equal(error.method, "thread/start");
114 assert.equal(error.operation, "handle-request");
115 assert.equal(
116 error.message,
117 "Codex App Server request handler failed for method 'thread/start'",
118 );
119 assert.strictEqual(error.cause, source);
120 assert.strictEqual(source.cause, rootCause);
121 assert.notInclude(error.message, source.message);
122 }),
123);
124
125it.effect("passes request errors through without adding a wrapper", () =>
126 Effect.gen(function* () {
127 const source = CodexError.CodexAppServerRequestError.invalidParams("Invalid thread id");
128 const error = yield* Shared.runHandler(
129 (_payload: void) => Effect.fail(source),
130 undefined,
131 "thread/start",
132 ).pipe(Effect.flip);
133
134 assert.strictEqual(error, source);
135 }),
136);
137
138it.effect("retains the full notification payload decode cause chain", () =>
139 Effect.gen(function* () {
140 const error = yield* Shared.decodeNotificationPayload(
141 "item/agentMessage/delta",
142 Schema.String,
143 42,
144 ).pipe(Effect.flip);
145
146 assert.equal(error.method, "item/agentMessage/delta");
147 assert.equal(error.operation, "decode-notification-payload");
148 assert.instanceOf(error.cause, CodexError.CodexAppServerRequestError);
149 assert.isTrue(Schema.isSchemaError(error.cause.cause));
150 }),
151);
packages/effect-codex-app-server/src/_internal/shared.ts+13-23
......@@ -1,11 +1,8 @@
11import * as Effect from "effect/Effect";
22import * as Schema from "effect/Schema";
3import * as SchemaIssue from "effect/SchemaIssue";
43
54import * as CodexError from "../errors.ts";
65
7const formatSchemaIssue = SchemaIssue.makeFormatterDefault();
8
96export const JsonRpcId = Schema.Union([Schema.Number, Schema.String]);
107
118export const JsonRpcError = Schema.Struct({
......@@ -30,16 +27,13 @@ export const decodeOptionalPayload = <A, I>(
3027 return Effect.sync(() => undefined as A);
3128 }
3229 return Effect.fail(
33 CodexError.CodexAppServerRequestError.invalidParams(`${method} does not accept params`, raw),
30 CodexError.CodexAppServerRequestError.unexpectedPayload(method, "decode-payload", raw),
3431 );
3532 }
3633
3734 return Schema.decodeUnknownEffect(schema)(raw).pipe(
3835 Effect.mapError((error) =>
39 CodexError.CodexAppServerRequestError.invalidParams(
40 `Invalid ${method} payload: ${formatSchemaIssue(error.issue)}`,
41 { issue: error.issue },
42 ),
36 CodexError.CodexAppServerRequestError.invalidPayload(method, "decode-payload", error),
4337 ),
4438 );
4539};
......@@ -54,19 +48,13 @@ export const encodeOptionalPayload = <A, I>(
5448 return Effect.sync(() => undefined);
5549 }
5650 return Effect.fail(
57 CodexError.CodexAppServerRequestError.invalidParams(
58 `${method} does not accept params`,
59 payload,
60 ),
51 CodexError.CodexAppServerRequestError.unexpectedPayload(method, "encode-payload", payload),
6152 );
6253 }
6354
6455 return Schema.encodeEffect(schema)(payload).pipe(
6556 Effect.mapError((error) =>
66 CodexError.CodexAppServerRequestError.invalidParams(
67 `Invalid ${method} payload: ${formatSchemaIssue(error.issue)}`,
68 { issue: error.issue },
69 ),
57 CodexError.CodexAppServerRequestError.invalidPayload(method, "encode-payload", error),
7058 ),
7159 );
7260};
......@@ -77,12 +65,12 @@ export const decodeNotificationPayload = <A, I>(
7765 raw: unknown,
7866): Effect.Effect<A, CodexError.CodexAppServerProtocolParseError> =>
7967 decodeOptionalPayload(method, schema, raw).pipe(
80 Effect.mapError(
81 (error) =>
82 new CodexError.CodexAppServerProtocolParseError({
83 detail: error.message,
84 cause: error,
85 }),
68 Effect.mapError((error) =>
69 CodexError.CodexAppServerProtocolParseError.fromRequestError(
70 "decode-notification-payload",
71 method,
72 error,
73 ),
8674 ),
8775 );
8876
......@@ -96,6 +84,8 @@ export const runHandler = Effect.fnUntraced(function* <A, B>(
9684 }
9785
9886 return yield* handler(payload).pipe(
99 Effect.mapError((error) => CodexError.normalizeToRequestError(error)),
87 Effect.mapError((error) =>
88 CodexError.CodexAppServerRequestError.fromAppServerError(error, method),
89 ),
10090 );
10191});
packages/effect-codex-app-server/src/_internal/stdio.ts+1-1
......@@ -50,7 +50,7 @@ export const makeTerminationError = (
5050 Effect.match(handle.exitCode, {
5151 onFailure: (cause) =>
5252 new CodexError.CodexAppServerTransportError({
53 detail: "Failed to determine Codex App Server process exit status",
53 operation: "read-process-exit-status",
5454 cause,
5555 }),
5656 onSuccess: (code) => new CodexError.CodexAppServerProcessExitedError({ code }),
packages/effect-codex-app-server/src/errors.ts+243-13
......@@ -1,4 +1,119 @@
11import * as Schema from "effect/Schema";
2import type * as SchemaIssue from "effect/SchemaIssue";
3
4export const CodexAppServerRequestOperation = Schema.Literals([
5 "decode-payload",
6 "encode-payload",
7 "handle-request",
8]);
9export type CodexAppServerRequestOperation = typeof CodexAppServerRequestOperation.Type;
10
11export const CodexAppServerSchemaIssueKind = Schema.Literals([
12 "Filter",
13 "Encoding",
14 "Pointer",
15 "Composite",
16 "AnyOf",
17 "InvalidType",
18 "InvalidValue",
19 "MissingKey",
20 "UnexpectedKey",
21 "Forbidden",
22 "OneOf",
23]);
24export type CodexAppServerSchemaIssueKind = typeof CodexAppServerSchemaIssueKind.Type;
25
26export interface CodexAppServerSchemaIssueDiagnostics {
27 readonly issueCount: number;
28 readonly issueKinds: ReadonlyArray<CodexAppServerSchemaIssueKind>;
29 readonly maximumPathDepth: number;
30}
31
32const schemaIssueDiagnostics = (root: SchemaIssue.Issue): CodexAppServerSchemaIssueDiagnostics => {
33 let issueCount = 0;
34 let maximumPathDepth = 0;
35 const issueKinds = new Set<CodexAppServerSchemaIssueKind>();
36
37 const visit = (issue: SchemaIssue.Issue, pathDepth: number): void => {
38 issueCount += 1;
39 issueKinds.add(issue._tag);
40 maximumPathDepth = Math.max(maximumPathDepth, pathDepth);
41 switch (issue._tag) {
42 case "Filter":
43 case "Encoding":
44 visit(issue.issue, pathDepth);
45 break;
46 case "Pointer":
47 visit(issue.issue, pathDepth + issue.path.length);
48 break;
49 case "Composite":
50 case "AnyOf":
51 for (const child of issue.issues) visit(child, pathDepth);
52 break;
53 }
54 };
55
56 visit(root, 0);
57 return {
58 issueCount,
59 issueKinds: [...issueKinds],
60 maximumPathDepth,
61 };
62};
63
64export const CodexAppServerPayloadKind = Schema.Literals([
65 "null",
66 "array",
67 "string",
68 "number",
69 "boolean",
70 "bigint",
71 "object",
72 "symbol",
73 "function",
74 "undefined",
75]);
76export type CodexAppServerPayloadKind = typeof CodexAppServerPayloadKind.Type;
77
78const payloadKind = (payload: unknown): CodexAppServerPayloadKind => {
79 if (payload === null) return "null";
80 if (Array.isArray(payload)) return "array";
81 return typeof payload;
82};
83
84export interface CodexAppServerRequestDiagnostics {
85 readonly method?: string;
86 readonly operation?: CodexAppServerRequestOperation;
87 readonly cause?: unknown;
88 readonly issueCount?: number;
89 readonly issueKinds?: ReadonlyArray<CodexAppServerSchemaIssueKind>;
90 readonly maximumPathDepth?: number;
91 readonly payloadKind?: CodexAppServerPayloadKind;
92}
93
94export const CodexAppServerProtocolParseOperation = Schema.Literals([
95 "encode-wire-message",
96 "decode-wire-message",
97 "route-wire-message",
98 "decode-notification-payload",
99 "decode-request-payload",
100 "decode-response-payload",
101]);
102export type CodexAppServerProtocolParseOperation = typeof CodexAppServerProtocolParseOperation.Type;
103
104export const CodexAppServerTransportOperation = Schema.Literals([
105 "read-input-stream",
106 "read-process-exit-status",
107]);
108export type CodexAppServerTransportOperation = typeof CodexAppServerTransportOperation.Type;
109
110export const CodexAppServerIdentifierPurpose = Schema.Literals([
111 "provider-event",
112 "command-approval-request",
113 "file-change-approval-request",
114 "user-input-request",
115]);
116export type CodexAppServerIdentifierPurpose = typeof CodexAppServerIdentifierPurpose.Type;
2117
3118export interface CodexAppServerProtocolErrorShape {
4119 readonly code: number;
......@@ -37,24 +152,79 @@ export class CodexAppServerProcessExitedError extends Schema.TaggedErrorClass<Co
37152export class CodexAppServerProtocolParseError extends Schema.TaggedErrorClass<CodexAppServerProtocolParseError>()(
38153 "CodexAppServerProtocolParseError",
39154 {
40 detail: Schema.String,
155 operation: CodexAppServerProtocolParseOperation,
156 method: Schema.optionalKey(Schema.String),
157 detail: Schema.optionalKey(Schema.String),
158 issueCount: Schema.optionalKey(Schema.Number),
159 issueKinds: Schema.optionalKey(Schema.Array(CodexAppServerSchemaIssueKind)),
160 maximumPathDepth: Schema.optionalKey(Schema.Number),
41161 cause: Schema.optional(Schema.Defect()),
42162 },
43163) {
44164 override get message() {
45 return `Failed to parse Codex App Server protocol message: ${this.detail}`;
165 const method = this.method === undefined ? "" : ` for method '${this.method}'`;
166 return `Codex App Server protocol operation '${this.operation}' failed${method}.`;
167 }
168
169 static fromSchemaError(
170 operation: CodexAppServerProtocolParseOperation,
171 cause: Schema.SchemaError,
172 context: { readonly method?: string } = {},
173 ) {
174 return new CodexAppServerProtocolParseError({
175 operation,
176 ...context,
177 ...schemaIssueDiagnostics(cause.issue),
178 cause,
179 });
180 }
181
182 static fromRequestError(
183 operation: CodexAppServerProtocolParseOperation,
184 method: string,
185 cause: CodexAppServerRequestError,
186 ) {
187 return new CodexAppServerProtocolParseError({
188 operation,
189 method,
190 ...(cause.issueCount === undefined ? {} : { issueCount: cause.issueCount }),
191 ...(cause.issueKinds === undefined ? {} : { issueKinds: cause.issueKinds }),
192 ...(cause.maximumPathDepth === undefined ? {} : { maximumPathDepth: cause.maximumPathDepth }),
193 cause,
194 });
46195 }
47196}
48197
49198export class CodexAppServerTransportError extends Schema.TaggedErrorClass<CodexAppServerTransportError>()(
50199 "CodexAppServerTransportError",
51200 {
52 detail: Schema.String,
201 operation: CodexAppServerTransportOperation,
53202 cause: Schema.Defect(),
54203 },
55204) {
56205 override get message() {
57 return this.detail;
206 return `Codex App Server transport operation '${this.operation}' failed.`;
207 }
208}
209
210export class CodexAppServerIdentifierGenerationError extends Schema.TaggedErrorClass<CodexAppServerIdentifierGenerationError>()(
211 "CodexAppServerIdentifierGenerationError",
212 {
213 purpose: CodexAppServerIdentifierPurpose,
214 cause: Schema.Defect(),
215 },
216) {
217 override get message() {
218 return `Failed to generate Codex App Server identifier for ${this.purpose}.`;
219 }
220}
221
222export class CodexAppServerInputStreamEndedError extends Schema.TaggedErrorClass<CodexAppServerInputStreamEndedError>()(
223 "CodexAppServerInputStreamEndedError",
224 {},
225) {
226 override get message() {
227 return "Codex App Server input stream ended.";
58228 }
59229}
60230
......@@ -64,6 +234,13 @@ export class CodexAppServerRequestError extends Schema.TaggedErrorClass<CodexApp
64234 code: Schema.Number,
65235 errorMessage: Schema.String,
66236 data: Schema.optional(Schema.Unknown),
237 method: Schema.optionalKey(Schema.String),
238 operation: Schema.optionalKey(CodexAppServerRequestOperation),
239 issueCount: Schema.optionalKey(Schema.Number),
240 issueKinds: Schema.optionalKey(Schema.Array(CodexAppServerSchemaIssueKind)),
241 maximumPathDepth: Schema.optionalKey(Schema.Number),
242 payloadKind: Schema.optionalKey(CodexAppServerPayloadKind),
243 cause: Schema.optionalKey(Schema.Defect()),
67244 },
68245) {
69246 override get message() {
......@@ -78,6 +255,21 @@ export class CodexAppServerRequestError extends Schema.TaggedErrorClass<CodexApp
78255 });
79256 }
80257
258 static fromAppServerError(error: CodexAppServerError, method: string) {
259 if (error._tag === "CodexAppServerRequestError") {
260 return error;
261 }
262 return CodexAppServerRequestError.internalError(
263 `Codex App Server request handler failed for method '${method}'`,
264 undefined,
265 {
266 method,
267 operation: "handle-request",
268 cause: error,
269 },
270 );
271 }
272
81273 static parseError(message = "Parse error", data?: unknown) {
82274 return new CodexAppServerRequestError({
83275 code: -32700,
......@@ -101,19 +293,62 @@ export class CodexAppServerRequestError extends Schema.TaggedErrorClass<CodexApp
101293 });
102294 }
103295
104 static invalidParams(message = "Invalid params", data?: unknown) {
296 static invalidParams(
297 message = "Invalid params",
298 data?: unknown,
299 diagnostics: CodexAppServerRequestDiagnostics = {},
300 ) {
105301 return new CodexAppServerRequestError({
106302 code: -32602,
107303 errorMessage: message,
108304 ...(data !== undefined ? { data } : {}),
305 ...diagnostics,
109306 });
110307 }
111308
112 static internalError(message = "Internal error", data?: unknown) {
309 static invalidPayload(
310 method: string,
311 operation: "decode-payload" | "encode-payload",
312 cause: Schema.SchemaError,
313 ) {
314 const diagnostics = schemaIssueDiagnostics(cause.issue);
315 return new CodexAppServerRequestError({
316 code: -32602,
317 errorMessage: `Invalid payload for method '${method}' during '${operation}'`,
318 data: diagnostics,
319 method,
320 operation,
321 ...diagnostics,
322 cause,
323 });
324 }
325
326 static unexpectedPayload(
327 method: string,
328 operation: "decode-payload" | "encode-payload",
329 payload: unknown,
330 ) {
331 const diagnostics = { payloadKind: payloadKind(payload) };
332 return new CodexAppServerRequestError({
333 code: -32602,
334 errorMessage: `Method '${method}' does not accept a payload during '${operation}'`,
335 data: diagnostics,
336 method,
337 operation,
338 ...diagnostics,
339 });
340 }
341
342 static internalError(
343 message = "Internal error",
344 data?: unknown,
345 diagnostics: CodexAppServerRequestDiagnostics = {},
346 ) {
113347 return new CodexAppServerRequestError({
114348 code: -32603,
115349 errorMessage: message,
116350 ...(data !== undefined ? { data } : {}),
351 ...diagnostics,
117352 });
118353 }
119354
......@@ -140,13 +375,8 @@ export const CodexAppServerError = Schema.Union([
140375 CodexAppServerProcessExitedError,
141376 CodexAppServerProtocolParseError,
142377 CodexAppServerTransportError,
378 CodexAppServerIdentifierGenerationError,
379 CodexAppServerInputStreamEndedError,
143380]);
144381
145382export type CodexAppServerError = typeof CodexAppServerError.Type;
146const isCodexAppServerRequestError = Schema.is(CodexAppServerRequestError);
147
148export function normalizeToRequestError(error: CodexAppServerError): CodexAppServerRequestError {
149 return isCodexAppServerRequestError(error)
150 ? error
151 : CodexAppServerRequestError.internalError(error.message);
152}
packages/effect-codex-app-server/src/protocol.test.ts+59-2
......@@ -211,13 +211,70 @@ it.layer(NodeServices.layer)("effect-codex-app-server protocol", (it) => {
211211
212212 const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip);
213213 assert.instanceOf(bigintError, CodexError.CodexAppServerProtocolParseError);
214 assert.equal(bigintError.detail, "Failed to encode Codex App Server message");
214 assert.equal(bigintError.operation, "encode-wire-message");
215 assert.exists(bigintError.cause);
216 assert.equal(
217 bigintError.message,
218 "Codex App Server protocol operation 'encode-wire-message' failed.",
219 );
215220
216221 const circular: Record<string, unknown> = {};
217222 circular.self = circular;
218223 const circularError = yield* transport.notify("x/test", circular).pipe(Effect.flip);
219224 assert.instanceOf(circularError, CodexError.CodexAppServerProtocolParseError);
220 assert.equal(circularError.detail, "Failed to encode Codex App Server message");
225 assert.equal(circularError.operation, "encode-wire-message");
226 assert.exists(circularError.cause);
227 }),
228 );
229
230 it.effect("logs decode failures without copying the cause or wire payload", () =>
231 Effect.gen(function* () {
232 const secret = "codex-wire-secret-sentinel";
233 const { stdio, input } = yield* makeInMemoryStdio();
234 const events: Array<CodexProtocol.CodexAppServerProtocolLogEvent> = [];
235 const termination = yield* Deferred.make<CodexError.CodexAppServerError>();
236 yield* CodexProtocol.makeCodexAppServerPatchedProtocol({
237 stdio,
238 logIncoming: true,
239 logger: (event) =>
240 Effect.sync(() => {
241 events.push(event);
242 }),
243 onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid),
244 });
245
246 yield* Queue.offer(input, encoder.encode(`{"secret":"${secret}"\n`));
247 yield* Deferred.await(termination);
248
249 const event = events.find(({ stage }) => stage === "decode_failed");
250 assert.exists(event);
251 assert.equal(event.direction, "incoming");
252 const payload = event.payload as Record<string, unknown>;
253 assert.equal(payload.operation, "decode-wire-message");
254 assert.isNumber(payload.issueCount);
255 assert.isArray(payload.issueKinds);
256 assert.isNumber(payload.maximumPathDepth);
257 assert.equal("cause" in payload, false);
258 assert.equal("detail" in payload, false);
259 assert.notInclude(encodeUnknownJsonString(event), secret);
260 }),
261 );
262
263 it.effect("classifies an input stream ending without inventing a cause", () =>
264 Effect.gen(function* () {
265 const { stdio, input } = yield* makeInMemoryStdio();
266 const termination = yield* Deferred.make<CodexError.CodexAppServerError>();
267 yield* CodexProtocol.makeCodexAppServerPatchedProtocol({
268 stdio,
269 onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid),
270 });
271
272 yield* Queue.end(input);
273
274 const error = yield* Deferred.await(termination);
275 assert.instanceOf(error, CodexError.CodexAppServerInputStreamEndedError);
276 assert.equal(error.message, "Codex App Server input stream ended.");
277 assert.equal("cause" in error, false);
221278 }),
222279 );
223280});
packages/effect-codex-app-server/src/protocol.ts+26-24
......@@ -94,12 +94,8 @@ const encodeWireMessage = (
9494): Effect.Effect<string, CodexError.CodexAppServerProtocolParseError> =>
9595 encodeJsonString(message).pipe(
9696 Effect.map((encoded) => `${encoded}\n`),
97 Effect.mapError(
98 (cause) =>
99 new CodexError.CodexAppServerProtocolParseError({
100 detail: "Failed to encode Codex App Server message",
101 cause,
102 }),
97 Effect.mapError((cause) =>
98 CodexError.CodexAppServerProtocolParseError.fromSchemaError("encode-wire-message", cause),
10399 ),
104100 );
105101
......@@ -107,20 +103,19 @@ const decodeWireMessage = (
107103 line: string,
108104): Effect.Effect<unknown, CodexError.CodexAppServerProtocolParseError> =>
109105 decodeJsonString(line).pipe(
110 Effect.mapError(
111 (cause) =>
112 new CodexError.CodexAppServerProtocolParseError({
113 detail: "Failed to decode Codex App Server wire message",
114 cause,
115 }),
106 Effect.mapError((cause) =>
107 CodexError.CodexAppServerProtocolParseError.fromSchemaError("decode-wire-message", cause),
116108 ),
117109 );
118110
119const normalizeIncomingError = (error: unknown, detail: string): CodexError.CodexAppServerError =>
111const normalizeIncomingError = (
112 error: unknown,
113 operation: CodexError.CodexAppServerTransportOperation,
114): CodexError.CodexAppServerError =>
120115 isCodexAppServerError(error)
121116 ? error
122117 : new CodexError.CodexAppServerTransportError({
123 detail,
118 operation,
124119 cause: error,
125120 });
126121
......@@ -262,7 +257,13 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa
262257 ? options.onRequest(request).pipe(
263258 Effect.matchEffect({
264259 onFailure: (error) =>
265 respondError(request.id, CodexError.normalizeToRequestError(error)),
260 respondError(
261 request.id,
262 CodexError.CodexAppServerRequestError.fromAppServerError(
263 error,
264 request.method,
265 ),
266 ),
266267 onSuccess: (result) => respond(request.id, result),
267268 }),
268269 )
......@@ -292,6 +293,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa
292293 return Effect.fail(
293294 new CodexError.CodexAppServerProtocolParseError({
294295 detail: "Received protocol message in an unknown shape",
296 operation: "route-wire-message",
295297 }),
296298 );
297299 };
......@@ -318,8 +320,13 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa
318320 direction: "incoming",
319321 stage: "decode_failed",
320322 payload: {
321 detail: error.detail,
322 cause: error.cause,
323 operation: error.operation,
324 ...(error.method === undefined ? {} : { method: error.method }),
325 ...(error.issueCount === undefined ? {} : { issueCount: error.issueCount }),
326 ...(error.issueKinds === undefined ? {} : { issueKinds: error.issueKinds }),
327 ...(error.maximumPathDepth === undefined
328 ? {}
329 : { maximumPathDepth: error.maximumPathDepth }),
323330 },
324331 }),
325332 ),
......@@ -340,7 +347,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa
340347 Effect.matchEffect({
341348 onFailure: (error) =>
342349 handleTermination(() =>
343 Effect.succeed(normalizeIncomingError(error, "Codex App Server input stream failed")),
350 Effect.succeed(normalizeIncomingError(error, "read-input-stream")),
344351 ),
345352 onSuccess: () =>
346353 Ref.get(remainder).pipe(
......@@ -351,12 +358,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa
351358 handleTermination(
352359 () =>
353360 options.terminationError ??
354 Effect.succeed(
355 new CodexError.CodexAppServerTransportError({
356 detail: "Codex App Server input stream ended",
357 cause: new Error("Codex App Server input stream ended"),
358 }),
359 ),
361 Effect.succeed(new CodexError.CodexAppServerInputStreamEndedError({})),
360362 ),
361363 }),
362364 ),