Skip to content
17 changes: 17 additions & 0 deletions apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ import {
} from "./CodexSessionRuntime.ts";
const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError);

describe("CodexSessionRuntimeIdentifierGenerationError", () => {
it("retains identifier purpose and the random source failure", () => {
const cause = new Error("random source unavailable");
const error = new CodexErrors.CodexAppServerIdentifierGenerationError({
purpose: "provider-event",
cause,
});

NodeAssert.equal(error.purpose, "provider-event");
NodeAssert.strictEqual(error.cause, cause);
NodeAssert.equal(
error.message,
"Failed to generate Codex App Server identifier for provider-event.",
);
});
});

function makeThreadOpenResponse(
threadId: string,
): CodexRpc.ClientRequestResponsesByMethod["thread/start"] {
Expand Down
29 changes: 16 additions & 13 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -760,15 +760,16 @@ export const makeCodexSessionRuntime = (
);
const serverNotifications = yield* Queue.unbounded<CodexServerNotification>();
const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
const randomUUIDv4 = crypto.randomUUIDv4.pipe(
Effect.mapError(
(cause) =>
new CodexErrors.CodexAppServerTransportError({
detail: "Failed to generate Codex runtime identifier.",
cause,
}),
),
);
const randomUUIDv4 = (purpose: CodexErrors.CodexAppServerIdentifierPurpose) =>
crypto.randomUUIDv4.pipe(
Effect.mapError(
(cause) =>
new CodexErrors.CodexAppServerIdentifierGenerationError({
purpose,
cause,
}),
),
);

const sessionCreatedAt = yield* nowIso;
const initialSession = {
Expand All @@ -788,7 +789,7 @@ export const makeCodexSessionRuntime = (

const emitEvent = (event: Omit<ProviderEvent, "id" | "provider" | "createdAt">) =>
Effect.gen(function* () {
const id = yield* randomUUIDv4;
const id = yield* randomUUIDv4("provider-event");
return yield* offerEvent({
id: EventId.make(id),
provider: PROVIDER,
Expand Down Expand Up @@ -956,7 +957,7 @@ export const makeCodexSessionRuntime = (

yield* client.handleServerRequest("item/commandExecution/requestApproval", (payload) =>
Effect.gen(function* () {
const requestId = ApprovalRequestId.make(yield* randomUUIDv4);
const requestId = ApprovalRequestId.make(yield* randomUUIDv4("command-approval-request"));
const turnId = TurnId.make(payload.turnId);
const itemId = ProviderItemId.make(payload.itemId);
const decision = yield* Deferred.make<ProviderApprovalDecision>();
Expand Down Expand Up @@ -1012,7 +1013,9 @@ export const makeCodexSessionRuntime = (

yield* client.handleServerRequest("item/fileChange/requestApproval", (payload) =>
Effect.gen(function* () {
const requestId = ApprovalRequestId.make(yield* randomUUIDv4);
const requestId = ApprovalRequestId.make(
yield* randomUUIDv4("file-change-approval-request"),
);
const turnId = TurnId.make(payload.turnId);
const itemId = ProviderItemId.make(payload.itemId);
const decision = yield* Deferred.make<ProviderApprovalDecision>();
Expand Down Expand Up @@ -1068,7 +1071,7 @@ export const makeCodexSessionRuntime = (

yield* client.handleServerRequest("item/tool/requestUserInput", (payload) =>
Effect.gen(function* () {
const requestId = ApprovalRequestId.make(yield* randomUUIDv4);
const requestId = ApprovalRequestId.make(yield* randomUUIDv4("user-input-request"));
const turnId = TurnId.make(payload.turnId);
const itemId = ProviderItemId.make(payload.itemId);
const answers = yield* Deferred.make<ProviderUserInputAnswers>();
Expand Down
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({
operation: "read-input-stream",
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),
),
);
});
2 changes: 1 addition & 1 deletion packages/effect-codex-app-server/src/_internal/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export const makeTerminationError = (
Effect.match(handle.exitCode, {
onFailure: (cause) =>
new CodexError.CodexAppServerTransportError({
detail: "Failed to determine Codex App Server process exit status",
operation: "read-process-exit-status",
cause,
}),
onSuccess: (code) => new CodexError.CodexAppServerProcessExitedError({ code }),
Expand Down
Loading
Loading