Unified · Split
| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | import * as NodeAssert from "node:assert/strict"; |
| 2 | 2 | |
| 3 | +import { it } from "@effect/vitest"; | |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| 4 | 5 | import * as Schema from "effect/Schema"; |
| 5 | −import { describe, it } from "vite-plus/test"; | |
| 6 | +import { describe } from "vite-plus/test"; | |
| 6 | 7 | import { ThreadId } from "@t3tools/contracts"; |
| 7 | 8 | import * as CodexErrors from "effect-codex-app-server/errors"; |
| 8 | 9 | import * as CodexRpc from "effect-codex-app-server/rpc"; |
| @@ -19,6 +20,23 @@ import { · 10 unmodified lines | ||
| 19 | 20 | } from "./CodexSessionRuntime.ts"; |
| 20 | 21 | const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); |
| 21 | 22 | |
| 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 | + | |
| 22 | 40 | function makeThreadOpenResponse( |
| 23 | 41 | threadId: string, |
| 24 | 42 | ): CodexRpc.ClientRequestResponsesByMethod["thread/start"] { |
| @@ -43,6 +61,32 @@ function makeThreadOpenResponse( · 18 unmodified lines | ||
| 43 | 61 | } |
| 44 | 62 | |
| 45 | 63 | 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 | + | |
| 46 | 90 | it("includes plan collaboration mode when requested", () => { |
| 47 | 91 | const params = Effect.runSync( |
| 48 | 92 | buildTurnStartParams({ |
| @@ -223,29 +267,29 @@ describe("isRecoverableThreadResumeError", () => { · 174 unmodified lines | ||
| 223 | 267 | }); |
| 224 | 268 | |
| 225 | 269 | 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 | + }; | |
| 246 | 291 | |
| 247 | − const opened = await Effect.runPromise( | |
| 248 | − openCodexThread({ | |
| 292 | + const opened = yield* openCodexThread({ | |
| 249 | 293 | client, |
| 250 | 294 | threadId: ThreadId.make("thread-1"), |
| 251 | 295 | runtimeMode: "full-access", |
| @@ -253,51 +297,49 @@ describe("openCodexThread", () => { · 1 unmodified line | ||
| 253 | 297 | requestedModel: "gpt-5.3-codex", |
| 254 | 298 | serviceTier: undefined, |
| 255 | 299 | resumeThreadId: "stale-thread", |
| 256 | − }), | |
| 257 | − ); | |
| 300 | + }); | |
| 258 | 301 | |
| 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 | + ); | |
| 265 | 309 | |
| 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], | |
| 278 | 327 | ); |
| 279 | − } | |
| 280 | − return Effect.succeed( | |
| 281 | − makeThreadOpenResponse("fresh-thread") as CodexRpc.ClientRequestResponsesByMethod[M], | |
| 282 | − ); | |
| 283 | − }, | |
| 284 | − }; | |
| 328 | + }, | |
| 329 | + }; | |
| 285 | 330 | |
| 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 | + ); | |
| 303 | 345 | }); |
| @@ -26,10 +26,9 @@ import * as Exit from "effect/Exit"; · 25 unmodified lines | ||
| 26 | 26 | import * as Layer from "effect/Layer"; |
| 27 | 27 | import * as Queue from "effect/Queue"; |
| 28 | 28 | import * as Ref from "effect/Ref"; |
| 29 | −import * as Scope from "effect/Scope"; | |
| 30 | 29 | import * as Schema from "effect/Schema"; |
| 30 | +import * as Scope from "effect/Scope"; | |
| 31 | 31 | import * as Stream from "effect/Stream"; |
| 32 | −import * as SchemaIssue from "effect/SchemaIssue"; | |
| 33 | 32 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 34 | 33 | import * as CodexClient from "effect-codex-app-server/client"; |
| 35 | 34 | import * as CodexErrors from "effect-codex-app-server/errors"; |
| @@ -89,7 +88,6 @@ const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffe · 53 unmodified lines | ||
| 89 | 88 | |
| 90 | 89 | export type CodexTurnStartParamsWithCollaborationMode = |
| 91 | 90 | typeof CodexTurnStartParamsWithCollaborationMode.Type; |
| 92 | −const formatSchemaIssue = SchemaIssue.makeFormatterDefault(); | |
| 93 | 91 | |
| 94 | 92 | export type CodexResumeCursor = typeof CodexResumeCursorSchema.Type; |
| 95 | 93 | type CodexServiceTier = NonNullable<EffectCodexSchema.V2ThreadStartParams["serviceTier"]>; |
| @@ -390,7 +388,13 @@ export function buildTurnStartParams(input: { · 294 unmodified lines | ||
| 390 | 388 | ...(input.effort ? { effort: input.effort } : {}), |
| 391 | 389 | ...(collaborationMode ? { collaborationMode } : {}), |
| 392 | 390 | }).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 | + ), | |
| 394 | 398 | ); |
| 395 | 399 | } |
| 396 | 400 | |
| @@ -468,7 +472,7 @@ export const openCodexThread = (input: { · 71 unmodified lines | ||
| 468 | 472 | requestedRuntimeMode: input.runtimeMode, |
| 469 | 473 | resumeThreadId, |
| 470 | 474 | recoverable: true, |
| 471 | − cause: error.message, | |
| 475 | + cause: error, | |
| 472 | 476 | }).pipe(Effect.andThen(input.client.request("thread/start", startParams))), |
| 473 | 477 | ), |
| 474 | 478 | ); |
| @@ -658,16 +662,6 @@ function toCodexUserInputAnswers( · 183 unmodified lines | ||
| 658 | 662 | ).pipe(Effect.map((entries) => Object.fromEntries(entries))); |
| 659 | 663 | } |
| 660 | 664 | |
| 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 | − | |
| 671 | 665 | function currentProviderThreadId(session: ProviderSession): string | undefined { |
| 672 | 666 | return readResumeCursorThreadId(session.resumeCursor); |
| 673 | 667 | } |
| @@ -760,15 +754,16 @@ export const makeCodexSessionRuntime = ( · 86 unmodified lines | ||
| 760 | 754 | ); |
| 761 | 755 | const serverNotifications = yield* Queue.unbounded<CodexServerNotification>(); |
| 762 | 756 | 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 | + ); | |
| 772 | 767 | |
| 773 | 768 | const sessionCreatedAt = yield* nowIso; |
| 774 | 769 | const initialSession = { |
| @@ -788,7 +783,7 @@ export const makeCodexSessionRuntime = ( · 13 unmodified lines | ||
| 788 | 783 | |
| 789 | 784 | const emitEvent = (event: Omit<ProviderEvent, "id" | "provider" | "createdAt">) => |
| 790 | 785 | Effect.gen(function* () { |
| 791 | − const id = yield* randomUUIDv4; | |
| 786 | + const id = yield* randomUUIDv4("provider-event"); | |
| 792 | 787 | return yield* offerEvent({ |
| 793 | 788 | id: EventId.make(id), |
| 794 | 789 | provider: PROVIDER, |
| @@ -956,7 +951,7 @@ export const makeCodexSessionRuntime = ( · 161 unmodified lines | ||
| 956 | 951 | |
| 957 | 952 | yield* client.handleServerRequest("item/commandExecution/requestApproval", (payload) => |
| 958 | 953 | Effect.gen(function* () { |
| 959 | − const requestId = ApprovalRequestId.make(yield* randomUUIDv4); | |
| 954 | + const requestId = ApprovalRequestId.make(yield* randomUUIDv4("command-approval-request")); | |
| 960 | 955 | const turnId = TurnId.make(payload.turnId); |
| 961 | 956 | const itemId = ProviderItemId.make(payload.itemId); |
| 962 | 957 | const decision = yield* Deferred.make<ProviderApprovalDecision>(); |
| @@ -1012,7 +1007,9 @@ export const makeCodexSessionRuntime = ( · 49 unmodified lines | ||
| 1012 | 1007 | |
| 1013 | 1008 | yield* client.handleServerRequest("item/fileChange/requestApproval", (payload) => |
| 1014 | 1009 | Effect.gen(function* () { |
| 1015 | − const requestId = ApprovalRequestId.make(yield* randomUUIDv4); | |
| 1010 | + const requestId = ApprovalRequestId.make( | |
| 1011 | + yield* randomUUIDv4("file-change-approval-request"), | |
| 1012 | + ); | |
| 1016 | 1013 | const turnId = TurnId.make(payload.turnId); |
| 1017 | 1014 | const itemId = ProviderItemId.make(payload.itemId); |
| 1018 | 1015 | const decision = yield* Deferred.make<ProviderApprovalDecision>(); |
| @@ -1068,7 +1065,7 @@ export const makeCodexSessionRuntime = ( · 49 unmodified lines | ||
| 1068 | 1065 | |
| 1069 | 1066 | yield* client.handleServerRequest("item/tool/requestUserInput", (payload) => |
| 1070 | 1067 | Effect.gen(function* () { |
| 1071 | − const requestId = ApprovalRequestId.make(yield* randomUUIDv4); | |
| 1068 | + const requestId = ApprovalRequestId.make(yield* randomUUIDv4("user-input-request")); | |
| 1072 | 1069 | const turnId = TurnId.make(payload.turnId); |
| 1073 | 1070 | const itemId = ProviderItemId.make(payload.itemId); |
| 1074 | 1071 | const answers = yield* Deferred.make<ProviderUserInputAnswers>(); |
| @@ -1293,7 +1290,11 @@ export const makeCodexSessionRuntime = ( · 218 unmodified lines | ||
| 1293 | 1290 | const rawResponse = yield* client.raw.request("turn/start", params); |
| 1294 | 1291 | const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( |
| 1295 | 1292 | 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 | + ), | |
| 1297 | 1298 | ), |
| 1298 | 1299 | ); |
| 1299 | 1300 | const turnId = TurnId.make(response.turn.id); |
| @@ -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 | +); | |
| @@ -1,11 +1,8 @@ | ||
| 1 | 1 | import * as Effect from "effect/Effect"; |
| 2 | 2 | import * as Schema from "effect/Schema"; |
| 3 | −import * as SchemaIssue from "effect/SchemaIssue"; | |
| 4 | 3 | |
| 5 | 4 | import * as CodexError from "../errors.ts"; |
| 6 | 5 | |
| 7 | −const formatSchemaIssue = SchemaIssue.makeFormatterDefault(); | |
| 8 | − | |
| 9 | 6 | export const JsonRpcId = Schema.Union([Schema.Number, Schema.String]); |
| 10 | 7 | |
| 11 | 8 | export const JsonRpcError = Schema.Struct({ |
| @@ -30,16 +27,13 @@ export const decodeOptionalPayload = <A, I>( · 18 unmodified lines | ||
| 30 | 27 | return Effect.sync(() => undefined as A); |
| 31 | 28 | } |
| 32 | 29 | return Effect.fail( |
| 33 | − CodexError.CodexAppServerRequestError.invalidParams(`${method} does not accept params`, raw), | |
| 30 | + CodexError.CodexAppServerRequestError.unexpectedPayload(method, "decode-payload", raw), | |
| 34 | 31 | ); |
| 35 | 32 | } |
| 36 | 33 | |
| 37 | 34 | return Schema.decodeUnknownEffect(schema)(raw).pipe( |
| 38 | 35 | 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), | |
| 43 | 37 | ), |
| 44 | 38 | ); |
| 45 | 39 | }; |
| @@ -54,19 +48,13 @@ export const encodeOptionalPayload = <A, I>( · 8 unmodified lines | ||
| 54 | 48 | return Effect.sync(() => undefined); |
| 55 | 49 | } |
| 56 | 50 | return Effect.fail( |
| 57 | − CodexError.CodexAppServerRequestError.invalidParams( | |
| 58 | − `${method} does not accept params`, | |
| 59 | − payload, | |
| 60 | − ), | |
| 51 | + CodexError.CodexAppServerRequestError.unexpectedPayload(method, "encode-payload", payload), | |
| 61 | 52 | ); |
| 62 | 53 | } |
| 63 | 54 | |
| 64 | 55 | return Schema.encodeEffect(schema)(payload).pipe( |
| 65 | 56 | 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), | |
| 70 | 58 | ), |
| 71 | 59 | ); |
| 72 | 60 | }; |
| @@ -77,12 +65,12 @@ export const decodeNotificationPayload = <A, I>( · 4 unmodified lines | ||
| 77 | 65 | raw: unknown, |
| 78 | 66 | ): Effect.Effect<A, CodexError.CodexAppServerProtocolParseError> => |
| 79 | 67 | 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 | + ), | |
| 86 | 74 | ), |
| 87 | 75 | ); |
| 88 | 76 | |
| @@ -96,6 +84,8 @@ export const runHandler = Effect.fnUntraced(function* <A, B>( · 7 unmodified lines | ||
| 96 | 84 | } |
| 97 | 85 | |
| 98 | 86 | return yield* handler(payload).pipe( |
| 99 | − Effect.mapError((error) => CodexError.normalizeToRequestError(error)), | |
| 87 | + Effect.mapError((error) => | |
| 88 | + CodexError.CodexAppServerRequestError.fromAppServerError(error, method), | |
| 89 | + ), | |
| 100 | 90 | ); |
| 101 | 91 | }); |
| @@ -50,7 +50,7 @@ export const makeTerminationError = ( · 49 unmodified lines | ||
| 50 | 50 | Effect.match(handle.exitCode, { |
| 51 | 51 | onFailure: (cause) => |
| 52 | 52 | new CodexError.CodexAppServerTransportError({ |
| 53 | − detail: "Failed to determine Codex App Server process exit status", | |
| 53 | + operation: "read-process-exit-status", | |
| 54 | 54 | cause, |
| 55 | 55 | }), |
| 56 | 56 | onSuccess: (code) => new CodexError.CodexAppServerProcessExitedError({ code }), |
| @@ -1,4 +1,119 @@ | ||
| 1 | 1 | 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; | |
| 2 | 117 | |
| 3 | 118 | export interface CodexAppServerProtocolErrorShape { |
| 4 | 119 | readonly code: number; |
| @@ -37,24 +152,79 @@ export class CodexAppServerProcessExitedError extends Schema.TaggedErrorClass<Co · 32 unmodified lines | ||
| 37 | 152 | export class CodexAppServerProtocolParseError extends Schema.TaggedErrorClass<CodexAppServerProtocolParseError>()( |
| 38 | 153 | "CodexAppServerProtocolParseError", |
| 39 | 154 | { |
| 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), | |
| 41 | 161 | cause: Schema.optional(Schema.Defect()), |
| 42 | 162 | }, |
| 43 | 163 | ) { |
| 44 | 164 | 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 | + }); | |
| 46 | 195 | } |
| 47 | 196 | } |
| 48 | 197 | |
| 49 | 198 | export class CodexAppServerTransportError extends Schema.TaggedErrorClass<CodexAppServerTransportError>()( |
| 50 | 199 | "CodexAppServerTransportError", |
| 51 | 200 | { |
| 52 | − detail: Schema.String, | |
| 201 | + operation: CodexAppServerTransportOperation, | |
| 53 | 202 | cause: Schema.Defect(), |
| 54 | 203 | }, |
| 55 | 204 | ) { |
| 56 | 205 | 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."; | |
| 58 | 228 | } |
| 59 | 229 | } |
| 60 | 230 | |
| @@ -64,6 +234,13 @@ export class CodexAppServerRequestError extends Schema.TaggedErrorClass<CodexApp · 3 unmodified lines | ||
| 64 | 234 | code: Schema.Number, |
| 65 | 235 | errorMessage: Schema.String, |
| 66 | 236 | 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()), | |
| 67 | 244 | }, |
| 68 | 245 | ) { |
| 69 | 246 | override get message() { |
| @@ -78,6 +255,21 @@ export class CodexAppServerRequestError extends Schema.TaggedErrorClass<CodexApp · 8 unmodified lines | ||
| 78 | 255 | }); |
| 79 | 256 | } |
| 80 | 257 | |
| 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 | + | |
| 81 | 273 | static parseError(message = "Parse error", data?: unknown) { |
| 82 | 274 | return new CodexAppServerRequestError({ |
| 83 | 275 | code: -32700, |
| @@ -101,19 +293,62 @@ export class CodexAppServerRequestError extends Schema.TaggedErrorClass<CodexApp · 17 unmodified lines | ||
| 101 | 293 | }); |
| 102 | 294 | } |
| 103 | 295 | |
| 104 | − static invalidParams(message = "Invalid params", data?: unknown) { | |
| 296 | + static invalidParams( | |
| 297 | + message = "Invalid params", | |
| 298 | + data?: unknown, | |
| 299 | + diagnostics: CodexAppServerRequestDiagnostics = {}, | |
| 300 | + ) { | |
| 105 | 301 | return new CodexAppServerRequestError({ |
| 106 | 302 | code: -32602, |
| 107 | 303 | errorMessage: message, |
| 108 | 304 | ...(data !== undefined ? { data } : {}), |
| 305 | + ...diagnostics, | |
| 109 | 306 | }); |
| 110 | 307 | } |
| 111 | 308 | |
| 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 | + ) { | |
| 113 | 347 | return new CodexAppServerRequestError({ |
| 114 | 348 | code: -32603, |
| 115 | 349 | errorMessage: message, |
| 116 | 350 | ...(data !== undefined ? { data } : {}), |
| 351 | + ...diagnostics, | |
| 117 | 352 | }); |
| 118 | 353 | } |
| 119 | 354 | |
| @@ -140,13 +375,8 @@ export const CodexAppServerError = Schema.Union([ · 20 unmodified lines | ||
| 140 | 375 | CodexAppServerProcessExitedError, |
| 141 | 376 | CodexAppServerProtocolParseError, |
| 142 | 377 | CodexAppServerTransportError, |
| 378 | + CodexAppServerIdentifierGenerationError, | |
| 379 | + CodexAppServerInputStreamEndedError, | |
| 143 | 380 | ]); |
| 144 | 381 | |
| 145 | 382 | 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 | −} | |
| @@ -211,13 +211,70 @@ it.layer(NodeServices.layer)("effect-codex-app-server protocol", (it) => { · 210 unmodified lines | ||
| 211 | 211 | |
| 212 | 212 | const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip); |
| 213 | 213 | 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 | + ); | |
| 215 | 220 | |
| 216 | 221 | const circular: Record<string, unknown> = {}; |
| 217 | 222 | circular.self = circular; |
| 218 | 223 | const circularError = yield* transport.notify("x/test", circular).pipe(Effect.flip); |
| 219 | 224 | 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); | |
| 221 | 278 | }), |
| 222 | 279 | ); |
| 223 | 280 | }); |
| @@ -94,12 +94,8 @@ const encodeWireMessage = ( · 93 unmodified lines | ||
| 94 | 94 | ): Effect.Effect<string, CodexError.CodexAppServerProtocolParseError> => |
| 95 | 95 | encodeJsonString(message).pipe( |
| 96 | 96 | 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), | |
| 103 | 99 | ), |
| 104 | 100 | ); |
| 105 | 101 | |
| @@ -107,20 +103,19 @@ const decodeWireMessage = ( · 1 unmodified line | ||
| 107 | 103 | line: string, |
| 108 | 104 | ): Effect.Effect<unknown, CodexError.CodexAppServerProtocolParseError> => |
| 109 | 105 | 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), | |
| 116 | 108 | ), |
| 117 | 109 | ); |
| 118 | 110 | |
| 119 | −const normalizeIncomingError = (error: unknown, detail: string): CodexError.CodexAppServerError => | |
| 111 | +const normalizeIncomingError = ( | |
| 112 | + error: unknown, | |
| 113 | + operation: CodexError.CodexAppServerTransportOperation, | |
| 114 | +): CodexError.CodexAppServerError => | |
| 120 | 115 | isCodexAppServerError(error) |
| 121 | 116 | ? error |
| 122 | 117 | : new CodexError.CodexAppServerTransportError({ |
| 123 | − detail, | |
| 118 | + operation, | |
| 124 | 119 | cause: error, |
| 125 | 120 | }); |
| 126 | 121 | |
| @@ -262,7 +257,13 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa · 135 unmodified lines | ||
| 262 | 257 | ? options.onRequest(request).pipe( |
| 263 | 258 | Effect.matchEffect({ |
| 264 | 259 | 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 | + ), | |
| 266 | 267 | onSuccess: (result) => respond(request.id, result), |
| 267 | 268 | }), |
| 268 | 269 | ) |
| @@ -292,6 +293,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa · 23 unmodified lines | ||
| 292 | 293 | return Effect.fail( |
| 293 | 294 | new CodexError.CodexAppServerProtocolParseError({ |
| 294 | 295 | detail: "Received protocol message in an unknown shape", |
| 296 | + operation: "route-wire-message", | |
| 295 | 297 | }), |
| 296 | 298 | ); |
| 297 | 299 | }; |
| @@ -318,8 +320,13 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa · 20 unmodified lines | ||
| 318 | 320 | direction: "incoming", |
| 319 | 321 | stage: "decode_failed", |
| 320 | 322 | 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 }), | |
| 323 | 330 | }, |
| 324 | 331 | }), |
| 325 | 332 | ), |
| @@ -340,7 +347,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa · 14 unmodified lines | ||
| 340 | 347 | Effect.matchEffect({ |
| 341 | 348 | onFailure: (error) => |
| 342 | 349 | handleTermination(() => |
| 343 | − Effect.succeed(normalizeIncomingError(error, "Codex App Server input stream failed")), | |
| 350 | + Effect.succeed(normalizeIncomingError(error, "read-input-stream")), | |
| 344 | 351 | ), |
| 345 | 352 | onSuccess: () => |
| 346 | 353 | Ref.get(remainder).pipe( |
| @@ -351,12 +358,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa · 4 unmodified lines | ||
| 351 | 358 | handleTermination( |
| 352 | 359 | () => |
| 353 | 360 | 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({})), | |
| 360 | 362 | ), |
| 361 | 363 | }), |
| 362 | 364 | ), |