Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import * as NodeAssert from "node:assert/strict";

import { it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Cause from "effect/Cause";
import * as Deferred from "effect/Deferred";
import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import * as Schema from "effect/Schema";
import * as TestClock from "effect/testing/TestClock";
import { describe } from "vite-plus/test";
import { DEFAULT_MODEL, ThreadId } from "@t3tools/contracts";
import * as CodexErrors from "effect-codex-app-server/errors";
Expand All @@ -24,6 +29,7 @@ import {
readCodexThread,
rollbackCodexThread,
toMcpElicitationResponse,
uploadCodexFeedback,
} from "./CodexSessionRuntime.ts";
const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError);

Expand Down Expand Up @@ -1069,3 +1075,36 @@ describe("openCodexThread", () => {
}),
);
});

describe("uploadCodexFeedback", () => {
it.effect("fails a wedged feedback/upload instead of awaiting forever", () =>
Effect.gen(function* () {
const entered = yield* Deferred.make<void>();
const client: Parameters<typeof uploadCodexFeedback>[0] = {
raw: {
request: () => Effect.die("raw requests are unused by uploadCodexFeedback"),
},
request: () => Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never)),
};
const fiber = yield* uploadCodexFeedback(client, "thread-1", "crash report").pipe(
Effect.forkChild,
);
// Spawn gate: the bound must not trip before the request is parked.
yield* Deferred.await(entered);
yield* TestClock.adjust("61 seconds");
const polled = yield* Effect.sync(() => fiber.pollUnsafe());
NodeAssert.ok(
polled !== undefined,
"a wedged app server must not park the feedback upload forever",
);
const exit = polled;
NodeAssert.ok(Exit.isFailure(exit));
const failure = Cause.findErrorOption(exit.cause);
NodeAssert.ok(failure._tag === "Some");
NodeAssert.ok(isCodexAppServerRequestError(failure.value));
NodeAssert.equal(failure.value.method, "feedback/upload");
NodeAssert.match(failure.value.errorMessage, /timed out/);
yield* Fiber.interrupt(fiber);
}),
);
});
40 changes: 34 additions & 6 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { normalizeModelSlug } from "@t3tools/shared/model";
import * as Crypto from "effect/Crypto";
import * as DateTime from "effect/DateTime";
import * as Deferred from "effect/Deferred";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";
Expand Down Expand Up @@ -1296,6 +1297,38 @@ export const rollbackCodexThread = Effect.fn("rollbackCodexThread")(function* (
return { threadId, turns: snapshot.turns.slice(0, retainedCount) };
});

// The transport awaits an unbounded Deferred per request, so a wedged app
// server would park the feedback upload forever: the upload carries the
// session logs and the client would spin with no way to report the outcome.
// Bounding the wait turns the wedge into a clear request error.
const FEEDBACK_UPLOAD_TIMEOUT = Duration.seconds(60);

export const uploadCodexFeedback = Effect.fn("uploadCodexFeedback")(function* (
client: CodexHistoryClient,
threadId: string,
reason: string | undefined,
): Effect.fn.Return<EffectCodexSchema.V2FeedbackUploadResponse, CodexErrors.CodexAppServerError> {
return yield* client
.request("feedback/upload", {
classification: "bug",
includeLogs: true,
...(reason ? { reason } : {}),
threadId,
})
.pipe(
Effect.timeout(FEEDBACK_UPLOAD_TIMEOUT),
Effect.catchTag("TimeoutError", () =>
Effect.fail(
CodexErrors.CodexAppServerRequestError.internalError(
"Codex App Server request 'feedback/upload' timed out waiting for a response.",
undefined,
{ method: "feedback/upload", operation: "receive-response" },
),
),
),
);
});

export const makeCodexSessionRuntime = (
options: CodexSessionRuntimeOptions,
): Effect.Effect<
Expand Down Expand Up @@ -2671,12 +2704,7 @@ export const makeCodexSessionRuntime = (
uploadFeedback: (reason) =>
Effect.gen(function* () {
const providerThreadId = yield* readProviderThreadId;
return yield* client.request("feedback/upload", {
classification: "bug",
includeLogs: true,
...(reason ? { reason } : {}),
threadId: providerThreadId,
});
return yield* uploadCodexFeedback(client, providerThreadId, reason);
}),
respondToRequest: (requestId, decision) =>
Effect.gen(function* () {
Expand Down
Loading