History

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

Julius Marminge committed 4 months ago

Commit 430ece41 parent 4238a0e8 files · +631 −158Browse files

Unified · Split

8 files
  1. apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +108 −66
  2. apps/server/src/provider/Layers/CodexSessionRuntime.ts +30 −29
  3. packages/effect-codex-app-server/src/_internal/shared.test.ts +151 −0
  4. packages/effect-codex-app-server/src/_internal/shared.ts +13 −23
  5. packages/effect-codex-app-server/src/_internal/stdio.ts +1 −1
  6. packages/effect-codex-app-server/src/errors.ts +243 −13
  7. packages/effect-codex-app-server/src/protocol.test.ts +59 −2
  8. packages/effect-codex-app-server/src/protocol.ts +26 −24
apps/server/src/provider/Layers/CodexSessionRuntime.test.ts+108−66
@@ -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 { · 10 unmodified lines
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( · 18 unmodified lines
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,29 +267,29 @@ describe("isRecoverableThreadResumeError", () => { · 174 unmodified lines
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",
@@ -253,51 +297,49 @@ describe("openCodexThread", () => { · 1 unmodified line
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"; · 25 unmodified lines
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 · 53 unmodified lines
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: { · 294 unmodified lines
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: { · 71 unmodified lines
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( · 183 unmodified lines
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 = ( · 86 unmodified lines
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 = ( · 13 unmodified lines
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 = ( · 161 unmodified lines
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 = ( · 49 unmodified lines
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 = ( · 49 unmodified lines
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 = ( · 218 unmodified lines
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+151−0
@@ -0,0 +1,151 @@
1+import { assert, it } from "@effect/vitest";
2+import * as Effect from "effect/Effect";
3+import * as Schema from "effect/Schema";
4+
5+import * as CodexError from "../errors.ts";
6+import * as Shared from "./shared.ts";
7+
8+const decodeNestedNumberPayload = Schema.decodeUnknownEffect(
9+ Schema.Struct({ profile: Schema.Struct({ token: Schema.Number }) }),
10+);
11+const encodeUnknownJson = Schema.encodeSync(Schema.UnknownFromJsonString);
12+
13+it.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+
39+it.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+
57+it.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+
74+it.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+
99+it.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+
125+it.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+
138+it.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 @@
11 import * as Effect from "effect/Effect";
22 import * as Schema from "effect/Schema";
3−import * as SchemaIssue from "effect/SchemaIssue";
43
54 import * as CodexError from "../errors.ts";
65
7−const formatSchemaIssue = SchemaIssue.makeFormatterDefault();
8−
96 export const JsonRpcId = Schema.Union([Schema.Number, Schema.String]);
107
118 export const JsonRpcError = Schema.Struct({
@@ -30,16 +27,13 @@ export const decodeOptionalPayload = <A, I>( · 18 unmodified lines
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>( · 8 unmodified lines
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>( · 4 unmodified lines
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>( · 7 unmodified lines
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 = ( · 49 unmodified lines
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 @@
11 import * as Schema from "effect/Schema";
2+import type * as SchemaIssue from "effect/SchemaIssue";
3+
4+export const CodexAppServerRequestOperation = Schema.Literals([
5+ "decode-payload",
6+ "encode-payload",
7+ "handle-request",
8+]);
9+export type CodexAppServerRequestOperation = typeof CodexAppServerRequestOperation.Type;
10+
11+export 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+]);
24+export type CodexAppServerSchemaIssueKind = typeof CodexAppServerSchemaIssueKind.Type;
25+
26+export interface CodexAppServerSchemaIssueDiagnostics {
27+ readonly issueCount: number;
28+ readonly issueKinds: ReadonlyArray<CodexAppServerSchemaIssueKind>;
29+ readonly maximumPathDepth: number;
30+}
31+
32+const 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+
64+export 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+]);
76+export type CodexAppServerPayloadKind = typeof CodexAppServerPayloadKind.Type;
77+
78+const payloadKind = (payload: unknown): CodexAppServerPayloadKind => {
79+ if (payload === null) return "null";
80+ if (Array.isArray(payload)) return "array";
81+ return typeof payload;
82+};
83+
84+export 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+
94+export 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+]);
102+export type CodexAppServerProtocolParseOperation = typeof CodexAppServerProtocolParseOperation.Type;
103+
104+export const CodexAppServerTransportOperation = Schema.Literals([
105+ "read-input-stream",
106+ "read-process-exit-status",
107+]);
108+export type CodexAppServerTransportOperation = typeof CodexAppServerTransportOperation.Type;
109+
110+export const CodexAppServerIdentifierPurpose = Schema.Literals([
111+ "provider-event",
112+ "command-approval-request",
113+ "file-change-approval-request",
114+ "user-input-request",
115+]);
116+export type CodexAppServerIdentifierPurpose = typeof CodexAppServerIdentifierPurpose.Type;
2117
3118 export interface CodexAppServerProtocolErrorShape {
4119 readonly code: number;
@@ -37,24 +152,79 @@ export class CodexAppServerProcessExitedError extends Schema.TaggedErrorClass<Co · 32 unmodified lines
37152 export 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
49198 export 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+
210+export 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+
222+export 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 · 3 unmodified lines
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 · 8 unmodified lines
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 · 17 unmodified lines
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([ · 20 unmodified lines
140375 CodexAppServerProcessExitedError,
141376 CodexAppServerProtocolParseError,
142377 CodexAppServerTransportError,
378+ CodexAppServerIdentifierGenerationError,
379+ CodexAppServerInputStreamEndedError,
143380 ]);
144381
145382 export type CodexAppServerError = typeof CodexAppServerError.Type;
146−const isCodexAppServerRequestError = Schema.is(CodexAppServerRequestError);
147−
148−export 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) => { · 210 unmodified lines
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 = ( · 93 unmodified lines
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 = ( · 1 unmodified line
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
119−const normalizeIncomingError = (error: unknown, detail: string): CodexError.CodexAppServerError =>
111+const 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 · 135 unmodified lines
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 · 23 unmodified lines
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 · 20 unmodified lines
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 · 14 unmodified lines
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 · 4 unmodified lines
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 ),