| author | |
| committer | |
| log | 430ece4c0aa49ede37bb3bd29f451fde97b1b64a |
| tree | 38f991aaf6086bdffb603ba7cb71f40c91808caf |
| parent | 4238a0e769c9f149d4083968823298ace1e2bd0b |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ | ... | @@ -1,8 +1,9 @@ |
| 1 | import * as NodeAssert from "node:assert/strict"; | 1 | import * as NodeAssert from "node:assert/strict"; |
| 2 | 2 | ||
| 3 | import { it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schema from "effect/Schema"; | 5 | import * as Schema from "effect/Schema"; |
| 5 | import { describe, it } from "vite-plus/test"; | 6 | import { describe } from "vite-plus/test"; |
| 6 | import { ThreadId } from "@t3tools/contracts"; | 7 | import { ThreadId } from "@t3tools/contracts"; |
| 7 | import * as CodexErrors from "effect-codex-app-server/errors"; | 8 | import * as CodexErrors from "effect-codex-app-server/errors"; |
| 8 | import * as CodexRpc from "effect-codex-app-server/rpc"; | 9 | import * as CodexRpc from "effect-codex-app-server/rpc"; |
| ... | @@ -19,6 +20,23 @@ import { | ... | @@ -19,6 +20,23 @@ import { |
| 19 | } from "./CodexSessionRuntime.ts"; | 20 | } from "./CodexSessionRuntime.ts"; |
| 20 | const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); | 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 | function makeThreadOpenResponse( | 40 | function makeThreadOpenResponse( |
| 23 | threadId: string, | 41 | threadId: string, |
| 24 | ): CodexRpc.ClientRequestResponsesByMethod["thread/start"] { | 42 | ): CodexRpc.ClientRequestResponsesByMethod["thread/start"] { |
| ... | @@ -43,6 +61,32 @@ function makeThreadOpenResponse( | ... | @@ -43,6 +61,32 @@ function makeThreadOpenResponse( |
| 43 | } | 61 | } |
| 44 | 62 | ||
| 45 | describe("buildTurnStartParams", () => { | 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 | it("includes plan collaboration mode when requested", () => { | 90 | it("includes plan collaboration mode when requested", () => { |
| 47 | const params = Effect.runSync( | 91 | const params = Effect.runSync( |
| 48 | buildTurnStartParams({ | 92 | buildTurnStartParams({ |
| ... | @@ -223,29 +267,29 @@ describe("isRecoverableThreadResumeError", () => { | ... | @@ -223,29 +267,29 @@ describe("isRecoverableThreadResumeError", () => { |
| 223 | }); | 267 | }); |
| 224 | 268 | ||
| 225 | describe("openCodexThread", () => { | 269 | describe("openCodexThread", () => { |
| 226 | it("falls back to thread/start when resume fails recoverably", async () => { | 270 | it.effect("falls back to thread/start when resume fails recoverably", () => |
| 227 | const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = []; | 271 | Effect.gen(function* () { |
| 228 | const started = makeThreadOpenResponse("fresh-thread"); | 272 | const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = []; |
| 229 | const client = { | 273 | const started = makeThreadOpenResponse("fresh-thread"); |
| 230 | request: <M extends "thread/start" | "thread/resume">( | 274 | const client = { |
| 231 | method: M, | 275 | request: <M extends "thread/start" | "thread/resume">( |
| 232 | payload: CodexRpc.ClientRequestParamsByMethod[M], | 276 | method: M, |
| 233 | ) => { | 277 | payload: CodexRpc.ClientRequestParamsByMethod[M], |
| 234 | calls.push({ method, payload }); | 278 | ) => { |
| 235 | if (method === "thread/resume") { | 279 | calls.push({ method, payload }); |
| 236 | return Effect.fail( | 280 | if (method === "thread/resume") { |
| 237 | new CodexErrors.CodexAppServerRequestError({ | 281 | return Effect.fail( |
| 238 | code: -32603, | 282 | new CodexErrors.CodexAppServerRequestError({ |
| 239 | errorMessage: "thread not found", | 283 | code: -32603, |
| 240 | }), | 284 | errorMessage: "thread not found", |
| 241 | ); | 285 | }), |
| 242 | } | 286 | ); |
| 243 | return Effect.succeed(started as CodexRpc.ClientRequestResponsesByMethod[M]); | 287 | } |
| 244 | }, | 288 | return Effect.succeed(started as CodexRpc.ClientRequestResponsesByMethod[M]); |
| 245 | }; | 289 | }, |
| 290 | }; | ||
| 246 | 291 | ||
| 247 | const opened = await Effect.runPromise( | 292 | const opened = yield* openCodexThread({ |
| 248 | openCodexThread({ | ||
| 249 | client, | 293 | client, |
| 250 | threadId: ThreadId.make("thread-1"), | 294 | threadId: ThreadId.make("thread-1"), |
| 251 | runtimeMode: "full-access", | 295 | runtimeMode: "full-access", |
| ... | @@ -253,51 +297,49 @@ describe("openCodexThread", () => { | ... | @@ -253,51 +297,49 @@ describe("openCodexThread", () => { |
| 253 | requestedModel: "gpt-5.3-codex", | 297 | requestedModel: "gpt-5.3-codex", |
| 254 | serviceTier: undefined, | 298 | serviceTier: undefined, |
| 255 | resumeThreadId: "stale-thread", | 299 | resumeThreadId: "stale-thread", |
| 256 | }), | 300 | }); |
| 257 | ); | ||
| 258 | 301 | ||
| 259 | NodeAssert.equal(opened.thread.id, "fresh-thread"); | 302 | NodeAssert.equal(opened.thread.id, "fresh-thread"); |
| 260 | NodeAssert.deepStrictEqual( | 303 | NodeAssert.deepStrictEqual( |
| 261 | calls.map((call) => call.method), | 304 | calls.map((call) => call.method), |
| 262 | ["thread/resume", "thread/start"], | 305 | ["thread/resume", "thread/start"], |
| 263 | ); | 306 | ); |
| 264 | }); | 307 | }), |
| 308 | ); | ||
| 265 | 309 | ||
| 266 | it("propagates non-recoverable resume failures", async () => { | 310 | it.effect("propagates non-recoverable resume failures", () => |
| 267 | const client = { | 311 | Effect.gen(function* () { |
| 268 | request: <M extends "thread/start" | "thread/resume">( | 312 | const client = { |
| 269 | method: M, | 313 | request: <M extends "thread/start" | "thread/resume">( |
| 270 | _payload: CodexRpc.ClientRequestParamsByMethod[M], | 314 | method: M, |
| 271 | ) => { | 315 | _payload: CodexRpc.ClientRequestParamsByMethod[M], |
| 272 | if (method === "thread/resume") { | 316 | ) => { |
| 273 | return Effect.fail( | 317 | if (method === "thread/resume") { |
| 274 | new CodexErrors.CodexAppServerRequestError({ | 318 | return Effect.fail( |
| 275 | code: -32603, | 319 | new CodexErrors.CodexAppServerRequestError({ |
| 276 | errorMessage: "timed out waiting for server", | 320 | code: -32603, |
| 277 | }), | 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 | } | 328 | }, |
| 280 | return Effect.succeed( | 329 | }; |
| 281 | makeThreadOpenResponse("fresh-thread") as CodexRpc.ClientRequestResponsesByMethod[M], | ||
| 282 | ); | ||
| 283 | }, | ||
| 284 | }; | ||
| 285 | 330 | ||
| 286 | await NodeAssert.rejects( | 331 | const error = yield* openCodexThread({ |
| 287 | Effect.runPromise( | 332 | client, |
| 288 | openCodexThread({ | 333 | threadId: ThreadId.make("thread-1"), |
| 289 | client, | 334 | runtimeMode: "full-access", |
| 290 | threadId: ThreadId.make("thread-1"), | 335 | cwd: "/tmp/project", |
| 291 | runtimeMode: "full-access", | 336 | requestedModel: "gpt-5.3-codex", |
| 292 | cwd: "/tmp/project", | 337 | serviceTier: undefined, |
| 293 | requestedModel: "gpt-5.3-codex", | 338 | resumeThreadId: "stale-thread", |
| 294 | serviceTier: undefined, | 339 | }).pipe(Effect.flip); |
| 295 | resumeThreadId: "stale-thread", | 340 | |
| 296 | }), | 341 | NodeAssert.ok(isCodexAppServerRequestError(error)); |
| 297 | ), | 342 | NodeAssert.equal(error.errorMessage, "timed out waiting for server"); |
| 298 | (error: unknown) => | 343 | }), |
| 299 | isCodexAppServerRequestError(error) && | 344 | ); |
| 300 | error.errorMessage === "timed out waiting for server", | ||
| 301 | ); | ||
| 302 | }); | ||
| 303 | }); | 345 | }); |
apps/server/src/provider/Layers/CodexSessionRuntime.ts+30-29| ... | @@ -26,10 +26,9 @@ import * as Exit from "effect/Exit"; | ... | @@ -26,10 +26,9 @@ import * as Exit from "effect/Exit"; |
| 26 | import * as Layer from "effect/Layer"; | 26 | import * as Layer from "effect/Layer"; |
| 27 | import * as Queue from "effect/Queue"; | 27 | import * as Queue from "effect/Queue"; |
| 28 | import * as Ref from "effect/Ref"; | 28 | import * as Ref from "effect/Ref"; |
| 29 | import * as Scope from "effect/Scope"; | ||
| 30 | import * as Schema from "effect/Schema"; | 29 | import * as Schema from "effect/Schema"; |
| 30 | import * as Scope from "effect/Scope"; | ||
| 31 | import * as Stream from "effect/Stream"; | 31 | import * as Stream from "effect/Stream"; |
| 32 | import * as SchemaIssue from "effect/SchemaIssue"; | ||
| 33 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 32 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 34 | import * as CodexClient from "effect-codex-app-server/client"; | 33 | import * as CodexClient from "effect-codex-app-server/client"; |
| 35 | import * as CodexErrors from "effect-codex-app-server/errors"; | 34 | import * as CodexErrors from "effect-codex-app-server/errors"; |
| ... | @@ -89,7 +88,6 @@ const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffe | ... | @@ -89,7 +88,6 @@ const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffe |
| 89 | 88 | ||
| 90 | export type CodexTurnStartParamsWithCollaborationMode = | 89 | export type CodexTurnStartParamsWithCollaborationMode = |
| 91 | typeof CodexTurnStartParamsWithCollaborationMode.Type; | 90 | typeof CodexTurnStartParamsWithCollaborationMode.Type; |
| 92 | const formatSchemaIssue = SchemaIssue.makeFormatterDefault(); | ||
| 93 | 91 | ||
| 94 | export type CodexResumeCursor = typeof CodexResumeCursorSchema.Type; | 92 | export type CodexResumeCursor = typeof CodexResumeCursorSchema.Type; |
| 95 | type CodexServiceTier = NonNullable<EffectCodexSchema.V2ThreadStartParams["serviceTier"]>; | 93 | type CodexServiceTier = NonNullable<EffectCodexSchema.V2ThreadStartParams["serviceTier"]>; |
| ... | @@ -390,7 +388,13 @@ export function buildTurnStartParams(input: { | ... | @@ -390,7 +388,13 @@ export function buildTurnStartParams(input: { |
| 390 | ...(input.effort ? { effort: input.effort } : {}), | 388 | ...(input.effort ? { effort: input.effort } : {}), |
| 391 | ...(collaborationMode ? { collaborationMode } : {}), | 389 | ...(collaborationMode ? { collaborationMode } : {}), |
| 392 | }).pipe( | 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: { | ... | @@ -468,7 +472,7 @@ export const openCodexThread = (input: { |
| 468 | requestedRuntimeMode: input.runtimeMode, | 472 | requestedRuntimeMode: input.runtimeMode, |
| 469 | resumeThreadId, | 473 | resumeThreadId, |
| 470 | recoverable: true, | 474 | recoverable: true, |
| 471 | cause: error.message, | 475 | cause: error, |
| 472 | }).pipe(Effect.andThen(input.client.request("thread/start", startParams))), | 476 | }).pipe(Effect.andThen(input.client.request("thread/start", startParams))), |
| 473 | ), | 477 | ), |
| 474 | ); | 478 | ); |
| ... | @@ -658,16 +662,6 @@ function toCodexUserInputAnswers( | ... | @@ -658,16 +662,6 @@ function toCodexUserInputAnswers( |
| 658 | ).pipe(Effect.map((entries) => Object.fromEntries(entries))); | 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 | function currentProviderThreadId(session: ProviderSession): string | undefined { | 665 | function currentProviderThreadId(session: ProviderSession): string | undefined { |
| 672 | return readResumeCursorThreadId(session.resumeCursor); | 666 | return readResumeCursorThreadId(session.resumeCursor); |
| 673 | } | 667 | } |
| ... | @@ -760,15 +754,16 @@ export const makeCodexSessionRuntime = ( | ... | @@ -760,15 +754,16 @@ export const makeCodexSessionRuntime = ( |
| 760 | ); | 754 | ); |
| 761 | const serverNotifications = yield* Queue.unbounded<CodexServerNotification>(); | 755 | const serverNotifications = yield* Queue.unbounded<CodexServerNotification>(); |
| 762 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); | 756 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 763 | const randomUUIDv4 = crypto.randomUUIDv4.pipe( | 757 | const randomUUIDv4 = (purpose: CodexErrors.CodexAppServerIdentifierPurpose) => |
| 764 | Effect.mapError( | 758 | crypto.randomUUIDv4.pipe( |
| 765 | (cause) => | 759 | Effect.mapError( |
| 766 | new CodexErrors.CodexAppServerTransportError({ | 760 | (cause) => |
| 767 | detail: "Failed to generate Codex runtime identifier.", | 761 | new CodexErrors.CodexAppServerIdentifierGenerationError({ |
| 768 | cause, | 762 | purpose, |
| 769 | }), | 763 | cause, |
| 770 | ), | 764 | }), |
| 771 | ); | 765 | ), |
| 766 | ); | ||
| 772 | 767 | ||
| 773 | const sessionCreatedAt = yield* nowIso; | 768 | const sessionCreatedAt = yield* nowIso; |
| 774 | const initialSession = { | 769 | const initialSession = { |
| ... | @@ -788,7 +783,7 @@ export const makeCodexSessionRuntime = ( | ... | @@ -788,7 +783,7 @@ export const makeCodexSessionRuntime = ( |
| 788 | 783 | ||
| 789 | const emitEvent = (event: Omit<ProviderEvent, "id" | "provider" | "createdAt">) => | 784 | const emitEvent = (event: Omit<ProviderEvent, "id" | "provider" | "createdAt">) => |
| 790 | Effect.gen(function* () { | 785 | Effect.gen(function* () { |
| 791 | const id = yield* randomUUIDv4; | 786 | const id = yield* randomUUIDv4("provider-event"); |
| 792 | return yield* offerEvent({ | 787 | return yield* offerEvent({ |
| 793 | id: EventId.make(id), | 788 | id: EventId.make(id), |
| 794 | provider: PROVIDER, | 789 | provider: PROVIDER, |
| ... | @@ -956,7 +951,7 @@ export const makeCodexSessionRuntime = ( | ... | @@ -956,7 +951,7 @@ export const makeCodexSessionRuntime = ( |
| 956 | 951 | ||
| 957 | yield* client.handleServerRequest("item/commandExecution/requestApproval", (payload) => | 952 | yield* client.handleServerRequest("item/commandExecution/requestApproval", (payload) => |
| 958 | Effect.gen(function* () { | 953 | Effect.gen(function* () { |
| 959 | const requestId = ApprovalRequestId.make(yield* randomUUIDv4); | 954 | const requestId = ApprovalRequestId.make(yield* randomUUIDv4("command-approval-request")); |
| 960 | const turnId = TurnId.make(payload.turnId); | 955 | const turnId = TurnId.make(payload.turnId); |
| 961 | const itemId = ProviderItemId.make(payload.itemId); | 956 | const itemId = ProviderItemId.make(payload.itemId); |
| 962 | const decision = yield* Deferred.make<ProviderApprovalDecision>(); | 957 | const decision = yield* Deferred.make<ProviderApprovalDecision>(); |
| ... | @@ -1012,7 +1007,9 @@ export const makeCodexSessionRuntime = ( | ... | @@ -1012,7 +1007,9 @@ export const makeCodexSessionRuntime = ( |
| 1012 | 1007 | ||
| 1013 | yield* client.handleServerRequest("item/fileChange/requestApproval", (payload) => | 1008 | yield* client.handleServerRequest("item/fileChange/requestApproval", (payload) => |
| 1014 | Effect.gen(function* () { | 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 | const turnId = TurnId.make(payload.turnId); | 1013 | const turnId = TurnId.make(payload.turnId); |
| 1017 | const itemId = ProviderItemId.make(payload.itemId); | 1014 | const itemId = ProviderItemId.make(payload.itemId); |
| 1018 | const decision = yield* Deferred.make<ProviderApprovalDecision>(); | 1015 | const decision = yield* Deferred.make<ProviderApprovalDecision>(); |
| ... | @@ -1068,7 +1065,7 @@ export const makeCodexSessionRuntime = ( | ... | @@ -1068,7 +1065,7 @@ export const makeCodexSessionRuntime = ( |
| 1068 | 1065 | ||
| 1069 | yield* client.handleServerRequest("item/tool/requestUserInput", (payload) => | 1066 | yield* client.handleServerRequest("item/tool/requestUserInput", (payload) => |
| 1070 | Effect.gen(function* () { | 1067 | Effect.gen(function* () { |
| 1071 | const requestId = ApprovalRequestId.make(yield* randomUUIDv4); | 1068 | const requestId = ApprovalRequestId.make(yield* randomUUIDv4("user-input-request")); |
| 1072 | const turnId = TurnId.make(payload.turnId); | 1069 | const turnId = TurnId.make(payload.turnId); |
| 1073 | const itemId = ProviderItemId.make(payload.itemId); | 1070 | const itemId = ProviderItemId.make(payload.itemId); |
| 1074 | const answers = yield* Deferred.make<ProviderUserInputAnswers>(); | 1071 | const answers = yield* Deferred.make<ProviderUserInputAnswers>(); |
| ... | @@ -1293,7 +1290,11 @@ export const makeCodexSessionRuntime = ( | ... | @@ -1293,7 +1290,11 @@ export const makeCodexSessionRuntime = ( |
| 1293 | const rawResponse = yield* client.raw.request("turn/start", params); | 1290 | const rawResponse = yield* client.raw.request("turn/start", params); |
| 1294 | const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( | 1291 | const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( |
| 1295 | Effect.mapError((error) => | 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 | const turnId = TurnId.make(response.turn.id); | 1300 | const turnId = TurnId.make(response.turn.id); |
packages/effect-codex-app-server/src/_internal/shared.test.ts created+151| ... | @@ -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 @@ | ... | @@ -1,11 +1,8 @@ |
| 1 | import * as Effect from "effect/Effect"; | 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 3 | import * as SchemaIssue from "effect/SchemaIssue"; | ||
| 4 | 3 | ||
| 5 | import * as CodexError from "../errors.ts"; | 4 | import * as CodexError from "../errors.ts"; |
| 6 | 5 | ||
| 7 | const formatSchemaIssue = SchemaIssue.makeFormatterDefault(); | ||
| 8 | |||
| 9 | export const JsonRpcId = Schema.Union([Schema.Number, Schema.String]); | 6 | export const JsonRpcId = Schema.Union([Schema.Number, Schema.String]); |
| 10 | 7 | ||
| 11 | export const JsonRpcError = Schema.Struct({ | 8 | export const JsonRpcError = Schema.Struct({ |
| ... | @@ -30,16 +27,13 @@ export const decodeOptionalPayload = <A, I>( | ... | @@ -30,16 +27,13 @@ export const decodeOptionalPayload = <A, I>( |
| 30 | return Effect.sync(() => undefined as A); | 27 | return Effect.sync(() => undefined as A); |
| 31 | } | 28 | } |
| 32 | return Effect.fail( | 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 | return Schema.decodeUnknownEffect(schema)(raw).pipe( | 34 | return Schema.decodeUnknownEffect(schema)(raw).pipe( |
| 38 | Effect.mapError((error) => | 35 | Effect.mapError((error) => |
| 39 | CodexError.CodexAppServerRequestError.invalidParams( | 36 | CodexError.CodexAppServerRequestError.invalidPayload(method, "decode-payload", error), |
| 40 | `Invalid ${method} payload: ${formatSchemaIssue(error.issue)}`, | ||
| 41 | { issue: error.issue }, | ||
| 42 | ), | ||
| 43 | ), | 37 | ), |
| 44 | ); | 38 | ); |
| 45 | }; | 39 | }; |
| ... | @@ -54,19 +48,13 @@ export const encodeOptionalPayload = <A, I>( | ... | @@ -54,19 +48,13 @@ export const encodeOptionalPayload = <A, I>( |
| 54 | return Effect.sync(() => undefined); | 48 | return Effect.sync(() => undefined); |
| 55 | } | 49 | } |
| 56 | return Effect.fail( | 50 | return Effect.fail( |
| 57 | CodexError.CodexAppServerRequestError.invalidParams( | 51 | CodexError.CodexAppServerRequestError.unexpectedPayload(method, "encode-payload", payload), |
| 58 | `${method} does not accept params`, | ||
| 59 | payload, | ||
| 60 | ), | ||
| 61 | ); | 52 | ); |
| 62 | } | 53 | } |
| 63 | 54 | ||
| 64 | return Schema.encodeEffect(schema)(payload).pipe( | 55 | return Schema.encodeEffect(schema)(payload).pipe( |
| 65 | Effect.mapError((error) => | 56 | Effect.mapError((error) => |
| 66 | CodexError.CodexAppServerRequestError.invalidParams( | 57 | CodexError.CodexAppServerRequestError.invalidPayload(method, "encode-payload", error), |
| 67 | `Invalid ${method} payload: ${formatSchemaIssue(error.issue)}`, | ||
| 68 | { issue: error.issue }, | ||
| 69 | ), | ||
| 70 | ), | 58 | ), |
| 71 | ); | 59 | ); |
| 72 | }; | 60 | }; |
| ... | @@ -77,12 +65,12 @@ export const decodeNotificationPayload = <A, I>( | ... | @@ -77,12 +65,12 @@ export const decodeNotificationPayload = <A, I>( |
| 77 | raw: unknown, | 65 | raw: unknown, |
| 78 | ): Effect.Effect<A, CodexError.CodexAppServerProtocolParseError> => | 66 | ): Effect.Effect<A, CodexError.CodexAppServerProtocolParseError> => |
| 79 | decodeOptionalPayload(method, schema, raw).pipe( | 67 | decodeOptionalPayload(method, schema, raw).pipe( |
| 80 | Effect.mapError( | 68 | Effect.mapError((error) => |
| 81 | (error) => | 69 | CodexError.CodexAppServerProtocolParseError.fromRequestError( |
| 82 | new CodexError.CodexAppServerProtocolParseError({ | 70 | "decode-notification-payload", |
| 83 | detail: error.message, | 71 | method, |
| 84 | cause: error, | 72 | error, |
| 85 | }), | 73 | ), |
| 86 | ), | 74 | ), |
| 87 | ); | 75 | ); |
| 88 | 76 | ||
| ... | @@ -96,6 +84,8 @@ export const runHandler = Effect.fnUntraced(function* <A, B>( | ... | @@ -96,6 +84,8 @@ export const runHandler = Effect.fnUntraced(function* <A, B>( |
| 96 | } | 84 | } |
| 97 | 85 | ||
| 98 | return yield* handler(payload).pipe( | 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 | }); |
packages/effect-codex-app-server/src/_internal/stdio.ts+1-1| ... | @@ -50,7 +50,7 @@ export const makeTerminationError = ( | ... | @@ -50,7 +50,7 @@ export const makeTerminationError = ( |
| 50 | Effect.match(handle.exitCode, { | 50 | Effect.match(handle.exitCode, { |
| 51 | onFailure: (cause) => | 51 | onFailure: (cause) => |
| 52 | new CodexError.CodexAppServerTransportError({ | 52 | new CodexError.CodexAppServerTransportError({ |
| 53 | detail: "Failed to determine Codex App Server process exit status", | 53 | operation: "read-process-exit-status", |
| 54 | cause, | 54 | cause, |
| 55 | }), | 55 | }), |
| 56 | onSuccess: (code) => new CodexError.CodexAppServerProcessExitedError({ code }), | 56 | onSuccess: (code) => new CodexError.CodexAppServerProcessExitedError({ code }), |
packages/effect-codex-app-server/src/errors.ts+243-13| ... | @@ -1,4 +1,119 @@ | ... | @@ -1,4 +1,119 @@ |
| 1 | import * as Schema from "effect/Schema"; | 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 | export interface CodexAppServerProtocolErrorShape { | 118 | export interface CodexAppServerProtocolErrorShape { |
| 4 | readonly code: number; | 119 | readonly code: number; |
| ... | @@ -37,24 +152,79 @@ export class CodexAppServerProcessExitedError extends Schema.TaggedErrorClass<Co | ... | @@ -37,24 +152,79 @@ export class CodexAppServerProcessExitedError extends Schema.TaggedErrorClass<Co |
| 37 | export class CodexAppServerProtocolParseError extends Schema.TaggedErrorClass<CodexAppServerProtocolParseError>()( | 152 | export class CodexAppServerProtocolParseError extends Schema.TaggedErrorClass<CodexAppServerProtocolParseError>()( |
| 38 | "CodexAppServerProtocolParseError", | 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 | cause: Schema.optional(Schema.Defect()), | 161 | cause: Schema.optional(Schema.Defect()), |
| 42 | }, | 162 | }, |
| 43 | ) { | 163 | ) { |
| 44 | override get message() { | 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 | export class CodexAppServerTransportError extends Schema.TaggedErrorClass<CodexAppServerTransportError>()( | 198 | export class CodexAppServerTransportError extends Schema.TaggedErrorClass<CodexAppServerTransportError>()( |
| 50 | "CodexAppServerTransportError", | 199 | "CodexAppServerTransportError", |
| 51 | { | 200 | { |
| 52 | detail: Schema.String, | 201 | operation: CodexAppServerTransportOperation, |
| 53 | cause: Schema.Defect(), | 202 | cause: Schema.Defect(), |
| 54 | }, | 203 | }, |
| 55 | ) { | 204 | ) { |
| 56 | override get message() { | 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 | ... | @@ -64,6 +234,13 @@ export class CodexAppServerRequestError extends Schema.TaggedErrorClass<CodexApp |
| 64 | code: Schema.Number, | 234 | code: Schema.Number, |
| 65 | errorMessage: Schema.String, | 235 | errorMessage: Schema.String, |
| 66 | data: Schema.optional(Schema.Unknown), | 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 | override get message() { | 246 | override get message() { |
| ... | @@ -78,6 +255,21 @@ export class CodexAppServerRequestError extends Schema.TaggedErrorClass<CodexApp | ... | @@ -78,6 +255,21 @@ export class CodexAppServerRequestError extends Schema.TaggedErrorClass<CodexApp |
| 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 | static parseError(message = "Parse error", data?: unknown) { | 273 | static parseError(message = "Parse error", data?: unknown) { |
| 82 | return new CodexAppServerRequestError({ | 274 | return new CodexAppServerRequestError({ |
| 83 | code: -32700, | 275 | code: -32700, |
| ... | @@ -101,19 +293,62 @@ export class CodexAppServerRequestError extends Schema.TaggedErrorClass<CodexApp | ... | @@ -101,19 +293,62 @@ export class CodexAppServerRequestError extends Schema.TaggedErrorClass<CodexApp |
| 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 | return new CodexAppServerRequestError({ | 301 | return new CodexAppServerRequestError({ |
| 106 | code: -32602, | 302 | code: -32602, |
| 107 | errorMessage: message, | 303 | errorMessage: message, |
| 108 | ...(data !== undefined ? { data } : {}), | 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 | return new CodexAppServerRequestError({ | 347 | return new CodexAppServerRequestError({ |
| 114 | code: -32603, | 348 | code: -32603, |
| 115 | errorMessage: message, | 349 | errorMessage: message, |
| 116 | ...(data !== undefined ? { data } : {}), | 350 | ...(data !== undefined ? { data } : {}), |
| 351 | ...diagnostics, | ||
| 117 | }); | 352 | }); |
| 118 | } | 353 | } |
| 119 | 354 | ||
| ... | @@ -140,13 +375,8 @@ export const CodexAppServerError = Schema.Union([ | ... | @@ -140,13 +375,8 @@ export const CodexAppServerError = Schema.Union([ |
| 140 | CodexAppServerProcessExitedError, | 375 | CodexAppServerProcessExitedError, |
| 141 | CodexAppServerProtocolParseError, | 376 | CodexAppServerProtocolParseError, |
| 142 | CodexAppServerTransportError, | 377 | CodexAppServerTransportError, |
| 378 | CodexAppServerIdentifierGenerationError, | ||
| 379 | CodexAppServerInputStreamEndedError, | ||
| 143 | ]); | 380 | ]); |
| 144 | 381 | ||
| 145 | export type CodexAppServerError = typeof CodexAppServerError.Type; | 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 | } |
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) => { | ... | @@ -211,13 +211,70 @@ it.layer(NodeServices.layer)("effect-codex-app-server protocol", (it) => { |
| 211 | 211 | ||
| 212 | const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip); | 212 | const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip); |
| 213 | assert.instanceOf(bigintError, CodexError.CodexAppServerProtocolParseError); | 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 | const circular: Record<string, unknown> = {}; | 221 | const circular: Record<string, unknown> = {}; |
| 217 | circular.self = circular; | 222 | circular.self = circular; |
| 218 | const circularError = yield* transport.notify("x/test", circular).pipe(Effect.flip); | 223 | const circularError = yield* transport.notify("x/test", circular).pipe(Effect.flip); |
| 219 | assert.instanceOf(circularError, CodexError.CodexAppServerProtocolParseError); | 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 | }); |
packages/effect-codex-app-server/src/protocol.ts+26-24| ... | @@ -94,12 +94,8 @@ const encodeWireMessage = ( | ... | @@ -94,12 +94,8 @@ const encodeWireMessage = ( |
| 94 | ): Effect.Effect<string, CodexError.CodexAppServerProtocolParseError> => | 94 | ): Effect.Effect<string, CodexError.CodexAppServerProtocolParseError> => |
| 95 | encodeJsonString(message).pipe( | 95 | encodeJsonString(message).pipe( |
| 96 | Effect.map((encoded) => `${encoded}\n`), | 96 | Effect.map((encoded) => `${encoded}\n`), |
| 97 | Effect.mapError( | 97 | Effect.mapError((cause) => |
| 98 | (cause) => | 98 | CodexError.CodexAppServerProtocolParseError.fromSchemaError("encode-wire-message", cause), |
| 99 | new CodexError.CodexAppServerProtocolParseError({ | ||
| 100 | detail: "Failed to encode Codex App Server message", | ||
| 101 | cause, | ||
| 102 | }), | ||
| 103 | ), | 99 | ), |
| 104 | ); | 100 | ); |
| 105 | 101 | ||
| ... | @@ -107,20 +103,19 @@ const decodeWireMessage = ( | ... | @@ -107,20 +103,19 @@ const decodeWireMessage = ( |
| 107 | line: string, | 103 | line: string, |
| 108 | ): Effect.Effect<unknown, CodexError.CodexAppServerProtocolParseError> => | 104 | ): Effect.Effect<unknown, CodexError.CodexAppServerProtocolParseError> => |
| 109 | decodeJsonString(line).pipe( | 105 | decodeJsonString(line).pipe( |
| 110 | Effect.mapError( | 106 | Effect.mapError((cause) => |
| 111 | (cause) => | 107 | CodexError.CodexAppServerProtocolParseError.fromSchemaError("decode-wire-message", cause), |
| 112 | new CodexError.CodexAppServerProtocolParseError({ | ||
| 113 | detail: "Failed to decode Codex App Server wire message", | ||
| 114 | cause, | ||
| 115 | }), | ||
| 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 | isCodexAppServerError(error) | 115 | isCodexAppServerError(error) |
| 121 | ? error | 116 | ? error |
| 122 | : new CodexError.CodexAppServerTransportError({ | 117 | : new CodexError.CodexAppServerTransportError({ |
| 123 | detail, | 118 | operation, |
| 124 | cause: error, | 119 | cause: error, |
| 125 | }); | 120 | }); |
| 126 | 121 | ||
| ... | @@ -262,7 +257,13 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa | ... | @@ -262,7 +257,13 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa |
| 262 | ? options.onRequest(request).pipe( | 257 | ? options.onRequest(request).pipe( |
| 263 | Effect.matchEffect({ | 258 | Effect.matchEffect({ |
| 264 | onFailure: (error) => | 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 | onSuccess: (result) => respond(request.id, result), | 267 | onSuccess: (result) => respond(request.id, result), |
| 267 | }), | 268 | }), |
| 268 | ) | 269 | ) |
| ... | @@ -292,6 +293,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa | ... | @@ -292,6 +293,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa |
| 292 | return Effect.fail( | 293 | return Effect.fail( |
| 293 | new CodexError.CodexAppServerProtocolParseError({ | 294 | new CodexError.CodexAppServerProtocolParseError({ |
| 294 | detail: "Received protocol message in an unknown shape", | 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 | ... | @@ -318,8 +320,13 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa |
| 318 | direction: "incoming", | 320 | direction: "incoming", |
| 319 | stage: "decode_failed", | 321 | stage: "decode_failed", |
| 320 | payload: { | 322 | payload: { |
| 321 | detail: error.detail, | 323 | operation: error.operation, |
| 322 | cause: error.cause, | 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 | ... | @@ -340,7 +347,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa |
| 340 | Effect.matchEffect({ | 347 | Effect.matchEffect({ |
| 341 | onFailure: (error) => | 348 | onFailure: (error) => |
| 342 | handleTermination(() => | 349 | handleTermination(() => |
| 343 | Effect.succeed(normalizeIncomingError(error, "Codex App Server input stream failed")), | 350 | Effect.succeed(normalizeIncomingError(error, "read-input-stream")), |
| 344 | ), | 351 | ), |
| 345 | onSuccess: () => | 352 | onSuccess: () => |
| 346 | Ref.get(remainder).pipe( | 353 | Ref.get(remainder).pipe( |
| ... | @@ -351,12 +358,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa | ... | @@ -351,12 +358,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa |
| 351 | handleTermination( | 358 | handleTermination( |
| 352 | () => | 359 | () => |
| 353 | options.terminationError ?? | 360 | options.terminationError ?? |
| 354 | Effect.succeed( | 361 | Effect.succeed(new CodexError.CodexAppServerInputStreamEndedError({})), |
| 355 | new CodexError.CodexAppServerTransportError({ | ||
| 356 | detail: "Codex App Server input stream ended", | ||
| 357 | cause: new Error("Codex App Server input stream ended"), | ||
| 358 | }), | ||
| 359 | ), | ||
| 360 | ), | 362 | ), |
| 361 | }), | 363 | }), |
| 362 | ), | 364 | ), |