Skip to content
119 changes: 119 additions & 0 deletions packages/effect-codex-app-server/src/_internal/shared.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";

import * as CodexError from "../errors.ts";
import * as Shared from "./shared.ts";

it.effect("preserves schema decode diagnostics without deriving the message from the cause", () =>
Effect.gen(function* () {
const error = yield* Shared.decodeOptionalPayload("thread/start", Schema.String, 42).pipe(
Effect.flip,
);

assert.instanceOf(error, CodexError.CodexAppServerRequestError);
assert.equal(error.code, -32602);
assert.equal(error.method, "thread/start");
assert.equal(error.operation, "decode-payload");
assert.equal(
error.message,
"Invalid payload for method 'thread/start' during 'decode-payload'",
);
assert.isTrue(Schema.isSchemaError(error.cause));

const protocolError = error.toProtocolError();
assert.equal(protocolError.code, -32602);
assert.equal(protocolError.message, error.message);
assert.property(protocolError, "data");
assert.notProperty(protocolError, "method");
assert.notProperty(protocolError, "operation");
assert.notProperty(protocolError, "cause");
}),
);

it.effect("preserves schema encode diagnostics", () =>
Effect.gen(function* () {
const error = yield* Shared.encodeOptionalPayload(
"thread/start",
Schema.Number,
"not-a-number" as never,
).pipe(Effect.flip);

assert.equal(error.method, "thread/start");
assert.equal(error.operation, "encode-payload");
assert.equal(
error.message,
"Invalid payload for method 'thread/start' during 'encode-payload'",
);
assert.isTrue(Schema.isSchemaError(error.cause));
}),
);

it.effect("does not invent a cause when a method has no payload schema", () =>
Effect.gen(function* () {
const error = yield* Shared.decodeOptionalPayload<never, never>(
"initialized",
undefined,
"unexpected",
).pipe(Effect.flip);

assert.equal(error.method, "initialized");
assert.equal(error.operation, "decode-payload");
assert.isUndefined(error.cause);
}),
);

it.effect("retains the request-handler error as the internal error cause", () =>
Effect.gen(function* () {
const rootCause = new Error("socket closed");
const source = new CodexError.CodexAppServerTransportError({
detail: "Codex App Server transport failed",
cause: rootCause,
});
const error = yield* Shared.runHandler(
(_payload: void) => Effect.fail(source),
undefined,
"thread/start",
).pipe(Effect.flip);

assert.equal(error.code, -32603);
assert.equal(error.method, "thread/start");
assert.equal(error.operation, "handle-request");
assert.equal(
error.message,
"Codex App Server request handler failed for method 'thread/start'",
);
assert.strictEqual(error.cause, source);
assert.strictEqual(source.cause, rootCause);
assert.notInclude(error.message, source.message);
}),
);

it.effect("passes request errors through without adding a wrapper", () =>
Effect.gen(function* () {
const source = CodexError.CodexAppServerRequestError.invalidParams("Invalid thread id");
const error = yield* Shared.runHandler(
(_payload: void) => Effect.fail(source),
undefined,
"thread/start",
).pipe(Effect.flip);

assert.strictEqual(error, source);
}),
);

it.effect("retains the full notification payload decode cause chain", () =>
Effect.gen(function* () {
const error = yield* Shared.decodeNotificationPayload(
"item/agentMessage/delta",
Schema.String,
42,
).pipe(Effect.flip);

assert.equal(error.method, "item/agentMessage/delta");
assert.equal(error.operation, "decode-notification-payload");
assert.equal(error.detail, "Invalid notification payload");
assert.instanceOf(error.cause, CodexError.CodexAppServerRequestError);
assert.isTrue(Schema.isSchemaError(error.cause.cause));
}),
);
34 changes: 25 additions & 9 deletions packages/effect-codex-app-server/src/_internal/shared.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import * as SchemaIssue from "effect/SchemaIssue";

import * as CodexError from "../errors.ts";

const formatSchemaIssue = SchemaIssue.makeFormatterDefault();

export const JsonRpcId = Schema.Union([Schema.Number, Schema.String]);

export const JsonRpcError = Schema.Struct({
Expand All @@ -30,15 +27,24 @@ export const decodeOptionalPayload = <A, I>(
return Effect.sync(() => undefined as A);
}
return Effect.fail(
CodexError.CodexAppServerRequestError.invalidParams(`${method} does not accept params`, raw),
CodexError.CodexAppServerRequestError.invalidParams(
`Method '${method}' does not accept a payload during 'decode-payload'`,
raw,
{ method, operation: "decode-payload" },
),
);
}

return Schema.decodeUnknownEffect(schema)(raw).pipe(
Effect.mapError((error) =>
CodexError.CodexAppServerRequestError.invalidParams(
`Invalid ${method} payload: ${formatSchemaIssue(error.issue)}`,
`Invalid payload for method '${method}' during 'decode-payload'`,
{ issue: error.issue },
{
method,
operation: "decode-payload",
cause: error,
},
),
),
);
Expand All @@ -55,17 +61,23 @@ export const encodeOptionalPayload = <A, I>(
}
return Effect.fail(
CodexError.CodexAppServerRequestError.invalidParams(
`${method} does not accept params`,
`Method '${method}' does not accept a payload during 'encode-payload'`,
payload,
{ method, operation: "encode-payload" },
),
);
}

return Schema.encodeEffect(schema)(payload).pipe(
Effect.mapError((error) =>
CodexError.CodexAppServerRequestError.invalidParams(
`Invalid ${method} payload: ${formatSchemaIssue(error.issue)}`,
`Invalid payload for method '${method}' during 'encode-payload'`,
{ issue: error.issue },
{
method,
operation: "encode-payload",
cause: error,
},
),
),
);
Expand All @@ -80,7 +92,9 @@ export const decodeNotificationPayload = <A, I>(
Effect.mapError(
(error) =>
new CodexError.CodexAppServerProtocolParseError({
detail: error.message,
detail: "Invalid notification payload",
method,
operation: "decode-notification-payload",
cause: error,
}),
),
Expand All @@ -96,6 +110,8 @@ export const runHandler = Effect.fnUntraced(function* <A, B>(
}

return yield* handler(payload).pipe(
Effect.mapError((error) => CodexError.normalizeToRequestError(error)),
Effect.mapError((error) =>
CodexError.CodexAppServerRequestError.fromAppServerError(error, method),
),
);
});
75 changes: 65 additions & 10 deletions packages/effect-codex-app-server/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
import * as Schema from "effect/Schema";

export const CodexAppServerRequestOperation = Schema.Literals([
"decode-payload",
"encode-payload",
"handle-request",
]);
export type CodexAppServerRequestOperation = typeof CodexAppServerRequestOperation.Type;

export interface CodexAppServerRequestDiagnostics {
readonly method?: string;
readonly operation?: CodexAppServerRequestOperation;
readonly cause?: unknown;
}

export const CodexAppServerProtocolParseOperation = Schema.Literals([
"encode-wire-message",
"decode-wire-message",
"route-wire-message",
"decode-notification-payload",
]);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

export interface CodexAppServerProtocolErrorShape {
readonly code: number;
readonly message: string;
Expand Down Expand Up @@ -38,11 +58,15 @@ export class CodexAppServerProtocolParseError extends Schema.TaggedErrorClass<Co
"CodexAppServerProtocolParseError",
{
detail: Schema.String,
method: Schema.optionalKey(Schema.String),
operation: Schema.optionalKey(CodexAppServerProtocolParseOperation),
cause: Schema.optional(Schema.Defect()),
},
) {
override get message() {
return `Failed to parse Codex App Server protocol message: ${this.detail}`;
const operation = this.operation === undefined ? "" : ` during '${this.operation}'`;
const method = this.method === undefined ? "" : ` for method '${this.method}'`;
return `Failed to parse Codex App Server protocol message${operation}${method}: ${this.detail}`;
}
}

Expand All @@ -58,12 +82,24 @@ export class CodexAppServerTransportError extends Schema.TaggedErrorClass<CodexA
}
}

export class CodexAppServerInputStreamEndedError extends Schema.TaggedErrorClass<CodexAppServerInputStreamEndedError>()(
"CodexAppServerInputStreamEndedError",
{},
) {
override get message() {
return "Codex App Server input stream ended.";
}
}

export class CodexAppServerRequestError extends Schema.TaggedErrorClass<CodexAppServerRequestError>()(
"CodexAppServerRequestError",
{
code: Schema.Number,
errorMessage: Schema.String,
data: Schema.optional(Schema.Unknown),
method: Schema.optionalKey(Schema.String),
operation: Schema.optionalKey(CodexAppServerRequestOperation),
cause: Schema.optionalKey(Schema.Defect()),
},
) {
override get message() {
Expand All @@ -78,6 +114,21 @@ export class CodexAppServerRequestError extends Schema.TaggedErrorClass<CodexApp
});
}

static fromAppServerError(error: CodexAppServerError, method: string) {
if (error._tag === "CodexAppServerRequestError") {
return error;
}
return CodexAppServerRequestError.internalError(
`Codex App Server request handler failed for method '${method}'`,
undefined,
{
method,
operation: "handle-request",
cause: error,
},
);
}

static parseError(message = "Parse error", data?: unknown) {
return new CodexAppServerRequestError({
code: -32700,
Expand All @@ -101,19 +152,29 @@ export class CodexAppServerRequestError extends Schema.TaggedErrorClass<CodexApp
});
}

static invalidParams(message = "Invalid params", data?: unknown) {
static invalidParams(
message = "Invalid params",
data?: unknown,
diagnostics: CodexAppServerRequestDiagnostics = {},
) {
return new CodexAppServerRequestError({
code: -32602,
errorMessage: message,
...(data !== undefined ? { data } : {}),
...diagnostics,
});
}

static internalError(message = "Internal error", data?: unknown) {
static internalError(
message = "Internal error",
data?: unknown,
diagnostics: CodexAppServerRequestDiagnostics = {},
) {
return new CodexAppServerRequestError({
code: -32603,
errorMessage: message,
...(data !== undefined ? { data } : {}),
...diagnostics,
});
}

Expand All @@ -140,13 +201,7 @@ export const CodexAppServerError = Schema.Union([
CodexAppServerProcessExitedError,
CodexAppServerProtocolParseError,
CodexAppServerTransportError,
CodexAppServerInputStreamEndedError,
]);

export type CodexAppServerError = typeof CodexAppServerError.Type;
const isCodexAppServerRequestError = Schema.is(CodexAppServerRequestError);

export function normalizeToRequestError(error: CodexAppServerError): CodexAppServerRequestError {
return isCodexAppServerRequestError(error)
? error
: CodexAppServerRequestError.internalError(error.message);
}
20 changes: 20 additions & 0 deletions packages/effect-codex-app-server/src/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,13 +211,33 @@ it.layer(NodeServices.layer)("effect-codex-app-server protocol", (it) => {

const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip);
assert.instanceOf(bigintError, CodexError.CodexAppServerProtocolParseError);
assert.equal(bigintError.operation, "encode-wire-message");
assert.equal(bigintError.detail, "Failed to encode Codex App Server message");

const circular: Record<string, unknown> = {};
circular.self = circular;
const circularError = yield* transport.notify("x/test", circular).pipe(Effect.flip);
assert.instanceOf(circularError, CodexError.CodexAppServerProtocolParseError);
assert.equal(circularError.operation, "encode-wire-message");
assert.equal(circularError.detail, "Failed to encode Codex App Server message");
}),
);

it.effect("classifies an input stream ending without inventing a cause", () =>
Effect.gen(function* () {
const { stdio, input } = yield* makeInMemoryStdio();
const termination = yield* Deferred.make<CodexError.CodexAppServerError>();
yield* CodexProtocol.makeCodexAppServerPatchedProtocol({
stdio,
onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid),
});

yield* Queue.end(input);

const error = yield* Deferred.await(termination);
assert.instanceOf(error, CodexError.CodexAppServerInputStreamEndedError);
assert.equal(error.message, "Codex App Server input stream ended.");
assert.equal("cause" in error, false);
}),
);
});
Loading
Loading