Skip to content
Merged
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
3 changes: 2 additions & 1 deletion apps/desktop/src/electron/ElectronProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ describe("ElectronProtocol", () => {
assert.equal(yield* Effect.promise(() => response.text()), "ok");
assert.include(
response.headers.get("content-security-policy") ?? "",
"script-src 'self' 'unsafe-inline' https://clerk.t3.codes https://challenges.cloudflare.com",
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://clerk.t3.codes https://challenges.cloudflare.com",
);
assert.include(
response.headers.get("content-security-policy") ?? "",
Expand Down Expand Up @@ -212,6 +212,7 @@ describe("ElectronProtocol", () => {
assert.deepEqual(directives["script-src"], [
"'self'",
"'unsafe-inline'",
"'wasm-unsafe-eval'",
"https://clerk.t3.codes",
"https://challenges.cloudflare.com",
]);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/electron/ElectronProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat
const scriptSources = [
"'self'",
"'unsafe-inline'",
"'wasm-unsafe-eval'",
...(clerkOrigin ? [clerkOrigin] : []),
"https://challenges.cloudflare.com",
];
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
for (const stream of [process.stdout, process.stderr]) {
stream.on("error", (err: NodeJS.ErrnoException) => {
if (err.code !== "EPIPE") throw err;
});
}

import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import * as NodeServices from "@effect/platform-node/NodeServices";
Expand Down
8 changes: 6 additions & 2 deletions apps/server/src/provider/Layers/CodexAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import * as CodexErrors from "effect-codex-app-server/errors";
import * as EffectCodexSchema from "effect-codex-app-server/schema";

import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
import { extractProviderErrorMessage } from "@t3tools/shared/providerError";
import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts";
import * as McpProviderSession from "../../mcp/McpProviderSession.ts";

Expand Down Expand Up @@ -772,7 +773,8 @@ function mapToRuntimeEvents(
if (!payload) {
return [];
}
const errorMessage = trimText(payload.turn.error?.message);
const rawErrorMessage = trimText(payload.turn.error?.message);
const errorMessage = rawErrorMessage ? extractProviderErrorMessage(rawErrorMessage) : undefined;
return [
{
...runtimeEventBase(event, canonicalThreadId),
Expand Down Expand Up @@ -1249,7 +1251,9 @@ function mapToRuntimeEvents(

if (event.method === "error") {
const payload = readPayload(EffectCodexSchema.V2ErrorNotification, event.payload);
const message = payload?.error.message ?? event.message ?? "Provider runtime error";
const message = extractProviderErrorMessage(
payload?.error.message ?? event.message ?? "Provider runtime error",
);
const willRetry = payload?.willRetry === true;
return [
{
Expand Down
207 changes: 207 additions & 0 deletions apps/server/src/provider/Layers/CodexInterruptResolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import * as NodeAssert from "node:assert/strict";

import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as TestClock from "effect/testing/TestClock";
import { describe, it } from "@effect/vitest";
import { TurnId } from "@t3tools/contracts";
import type * as CodexRpc from "effect-codex-app-server/rpc";
import type * as EffectCodexSchema from "effect-codex-app-server/schema";

import {
findActiveCodexTurnId,
resolveCodexInterruptTurnId,
shouldReplaceActiveCodexTurnCandidate,
} from "./CodexSessionRuntime.ts";

function makeThreadReadResponse(
turns: EffectCodexSchema.V2ThreadReadResponse["thread"]["turns"],
): EffectCodexSchema.V2ThreadReadResponse {
return {
thread: {
cliVersion: "0.0.0-test",
createdAt: 1,
cwd: "/tmp/project",
ephemeral: false,
id: "provider-thread-1",
modelProvider: "openai",
preview: "test thread",
sessionId: "session-1",
source: "appServer",
status: { type: "active", activeFlags: [] },
turns,
updatedAt: 2,
},
};
}

describe("findActiveCodexTurnId", () => {
it("selects the most recently started in-progress turn", () => {
const snapshot = makeThreadReadResponse([
{ id: "turn-active-new", status: "inProgress", startedAt: 30, items: [] },
{ id: "turn-completed", status: "completed", startedAt: 20, items: [] },
{ id: "turn-active-old", status: "inProgress", startedAt: 10, items: [] },
]);

NodeAssert.equal(findActiveCodexTurnId(snapshot), "turn-active-new");
});

it("selects a later in-progress turn without a start timestamp", () => {
const snapshot = makeThreadReadResponse([
{ id: "turn-active-old", status: "inProgress", startedAt: 10, items: [] },
{ id: "turn-active-new", status: "inProgress", items: [] },
]);

NodeAssert.equal(findActiveCodexTurnId(snapshot), "turn-active-new");
});

it("selects a later timestamped turn after one without a timestamp", () => {
const snapshot = makeThreadReadResponse([
{ id: "turn-active-old", status: "inProgress", items: [] },
{ id: "turn-active-new", status: "inProgress", startedAt: 10, items: [] },
]);

NodeAssert.equal(findActiveCodexTurnId(snapshot), "turn-active-new");
});

it("returns undefined when no turn is active", () => {
const response = makeThreadReadResponse([]);
NodeAssert.equal(findActiveCodexTurnId(response), undefined);
});
});

describe("resolveCodexInterruptTurnId", () => {
it.effect("requests turns when resolving an interrupt without a projected turn id", () => {
let requestedParams: CodexRpc.ClientRequestParamsByMethod["thread/read"] | undefined;
let readThreadCallCount = 0;

return Effect.gen(function* () {
const turnId = yield* resolveCodexInterruptTurnId({
providerThreadId: "provider-thread-1",
requestedTurnId: undefined,
readSessionActiveTurnId: Effect.succeed(undefined),
readThread: (params) => {
readThreadCallCount += 1;
requestedParams = params;
return Effect.succeed(
makeThreadReadResponse([
{ id: "turn-active", status: "inProgress", startedAt: 10, items: [] },
]),
);
},
});

NodeAssert.deepStrictEqual(requestedParams, {
threadId: "provider-thread-1",
includeTurns: true,
});
NodeAssert.equal(readThreadCallCount, 1);
NodeAssert.equal(turnId, "turn-active");
});
});

it.effect("does not revive a stale projected turn after a successful empty read", () =>
Effect.gen(function* () {
const turnId = yield* resolveCodexInterruptTurnId({
providerThreadId: "provider-thread-1",
requestedTurnId: undefined,
readSessionActiveTurnId: Effect.succeed(TurnId.make("turn-stale")),
readThread: () => Effect.succeed(makeThreadReadResponse([])),
});

NodeAssert.equal(turnId, undefined);
}),
);

it.effect("falls back to the projected turn when the live lookup fails", () =>
Effect.gen(function* () {
const projectedTurnId = TurnId.make("turn-projected");
const turnId = yield* resolveCodexInterruptTurnId({
providerThreadId: "provider-thread-1",
requestedTurnId: undefined,
readSessionActiveTurnId: Effect.succeed(projectedTurnId),
readThread: () => Effect.fail("lookup failed"),
});

NodeAssert.equal(turnId, projectedTurnId);
}),
);

it.effect("bounds the live lookup and falls back to the projected turn on timeout", () =>
Effect.gen(function* () {
const projectedTurnId = TurnId.make("turn-projected");
const lookupStarted = yield* Deferred.make<void>();
const resolution = yield* resolveCodexInterruptTurnId({
providerThreadId: "provider-thread-1",
requestedTurnId: undefined,
readSessionActiveTurnId: Effect.succeed(projectedTurnId),
readThread: () =>
Effect.gen(function* () {
yield* Deferred.succeed(lookupStarted, undefined);
return yield* Effect.never;
}),
}).pipe(Effect.forkScoped);

yield* Deferred.await(lookupStarted);
yield* TestClock.adjust("2 seconds");
NodeAssert.equal(yield* Fiber.join(resolution), projectedTurnId);
}),
);

it.effect("reads the projected fallback after a live lookup times out", () =>
Effect.gen(function* () {
let projectedTurnId = TurnId.make("turn-old");
const lookupStarted = yield* Deferred.make<void>();
const resolution = yield* resolveCodexInterruptTurnId({
providerThreadId: "provider-thread-1",
requestedTurnId: undefined,
readSessionActiveTurnId: Effect.sync(() => projectedTurnId),
readThread: () =>
Effect.gen(function* () {
yield* Deferred.succeed(lookupStarted, undefined);
return yield* Effect.never;
}),
}).pipe(Effect.forkScoped);

yield* Deferred.await(lookupStarted);
// Mutate after the live lookup starts to verify that the fallback is
// evaluated lazily after the timeout instead of captured up front.
projectedTurnId = TurnId.make("turn-current");
yield* TestClock.adjust("2 seconds");
NodeAssert.equal(yield* Fiber.join(resolution), projectedTurnId);
}),
);
});

describe("shouldReplaceActiveCodexTurnCandidate", () => {
it("selects the first candidate", () => {
NodeAssert.equal(shouldReplaceActiveCodexTurnCandidate({ startedAt: 10 }, undefined), true);
});

it("orders timestamped turns by start time and lets a later equal entry win", () => {
NodeAssert.equal(
shouldReplaceActiveCodexTurnCandidate({ startedAt: 20 }, { startedAt: 10 }),
true,
);
NodeAssert.equal(
shouldReplaceActiveCodexTurnCandidate({ startedAt: 10 }, { startedAt: 20 }),
false,
);
NodeAssert.equal(
shouldReplaceActiveCodexTurnCandidate({ startedAt: 10 }, { startedAt: 10 }),
true,
);
});

it("lets the later provider entry win when either timestamp is absent", () => {
for (const [candidate, selected] of [
[{}, { startedAt: 10 }],
[{ startedAt: null }, { startedAt: 10 }],
[{ startedAt: 10 }, {}],
[{ startedAt: 10 }, { startedAt: null }],
] as const) {
NodeAssert.equal(shouldReplaceActiveCodexTurnCandidate(candidate, selected), true);
}
});
});
3 changes: 1 addition & 2 deletions apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import * as NodeAssert from "node:assert/strict";

import { it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import { describe } from "vite-plus/test";
import { describe, it } from "@effect/vitest";
import { DEFAULT_MODEL, ThreadId } from "@t3tools/contracts";
import * as CodexErrors from "effect-codex-app-server/errors";
import * as CodexRpc from "effect-codex-app-server/rpc";
Expand Down
Loading
Loading