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
47 changes: 42 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:

jobs:
quality:
name: Lint, Typecheck, Test, Browser Test, Build
name: Lint, Typecheck, Browser Test, Build
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
Expand Down Expand Up @@ -51,21 +51,58 @@ jobs:
- name: Typecheck
run: bun run typecheck

- name: Test
run: bun run test

- name: Install browser test runtime
run: |
cd apps/web
bunx playwright install --with-deps chromium

- name: Browser test
run: bun run --cwd apps/web test:browser

- name: Build desktop pipeline
run: bun run build:desktop

- name: Verify preload bundle output
run: |
test -f apps/desktop/dist-electron/preload.js
grep -nE "desktopBridge|getWsUrl|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.js

test:
name: Test (${{ matrix.label }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- label: Linux
runner: blacksmith-4vcpu-ubuntu-2404
- label: Windows
runner: blacksmith-4vcpu-windows-2025
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: package.json

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version-file: package.json

- name: Cache Bun and Turbo
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
.turbo
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}-${{ hashFiles('turbo.json') }}
restore-keys: |
${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}-

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Test
run: bun run test
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
type TestProviderAdapterHarness,
} from "./TestProviderAdapter.integration.ts";
import { ServerConfig } from "../src/config.ts";
import { removeDirectoryBestEffort } from "../src/testUtils/removeDirectoryBestEffort.ts";

function runGit(cwd: string, args: ReadonlyArray<string>) {
return execFileSync("git", args, {
Expand All @@ -70,6 +71,7 @@ function initializeGitWorkspace(cwd: string) {
runGit(cwd, ["init", "--initial-branch=main"]);
runGit(cwd, ["config", "user.email", "test@example.com"]);
runGit(cwd, ["config", "user.name", "Test User"]);
runGit(cwd, ["config", "core.autocrlf", "false"]);
fs.writeFileSync(path.join(cwd, "README.md"), "v1\n", "utf8");
runGit(cwd, ["add", "."]);
runGit(cwd, ["commit", "-m", "Initial"]);
Expand Down Expand Up @@ -387,9 +389,7 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {

yield* shutdown.pipe(
Effect.ensuring(
Effect.sync(() => {
fs.rmSync(rootDir, { recursive: true, force: true });
}),
Effect.promise(() => removeDirectoryBestEffort(rootDir)),
),
);
});
Expand Down
200 changes: 196 additions & 4 deletions apps/server/src/codexAppServerManager.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,43 @@
import { describe, expect, it, vi } from "vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { Cause, Effect, ManagedRuntime, ServiceMap } from "effect";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProviderSessionId } from "@t3tools/contracts";

import {
CodexAppServerManager,
classifyCodexStderrLine,
isRecoverableThreadResumeError,
messageFromCodexProcessCause,
normalizeCodexModelSlug,
} from "./codexAppServerManager";

const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);

let runtime: ManagedRuntime.ManagedRuntime<NodeServices.NodeServices, never> | null = null;
let services: ServiceMap.ServiceMap<NodeServices.NodeServices> | null = null;

beforeEach(async () => {
runtime = ManagedRuntime.make(NodeServices.layer);
services = await runtime.services();
});

afterEach(async () => {
services = null;
if (runtime) {
await runtime.dispose();
}
runtime = null;
});

function createManager() {
if (!services) {
throw new Error("Test runtime services not initialized.");
}
return new CodexAppServerManager(services);
}

function createSendTurnHarness() {
const manager = new CodexAppServerManager();
const manager = createManager();
const context = {
session: {
sessionId: "sess_1",
Expand Down Expand Up @@ -47,7 +73,7 @@ function createSendTurnHarness() {
}

function createThreadControlHarness() {
const manager = new CodexAppServerManager();
const manager = createManager();
const context = {
session: {
sessionId: "sess_1",
Expand Down Expand Up @@ -76,6 +102,58 @@ function createThreadControlHarness() {
return { manager, context, requireSession, sendRequest, updateSession };
}

function createMinimalSessionContext(sessionId = asSessionId("sess_1")) {
return {
session: {
sessionId,
provider: "codex",
status: "ready",
threadId: "thread_1",
createdAt: "2026-02-10T00:00:00.000Z",
updatedAt: "2026-02-10T00:00:00.000Z",
},
child: {
stdin: {},
isRunning: Effect.succeed(true),
},
scope: {},
scopeClosePromise: null,
pending: new Map(),
pendingApprovals: new Map(),
nextRequestId: 1,
stopping: false,
} as unknown as {
session: {
sessionId: ProviderSessionId;
provider: "codex";
status: "ready" | "closed";
threadId: string;
createdAt: string;
updatedAt: string;
activeTurnId?: string;
lastError?: string;
};
child: {
stdin: unknown;
isRunning: Effect.Effect<boolean, never>;
};
scope: unknown;
scopeClosePromise: Promise<void> | null;
pending: Map<
string,
{
method: string;
timeout: ReturnType<typeof setTimeout>;
resolve: (value: unknown) => void;
reject: (error: Error) => void;
}
>;
pendingApprovals: Map<string, unknown>;
nextRequestId: number;
stopping: boolean;
};
}

describe("classifyCodexStderrLine", () => {
it("ignores empty lines", () => {
expect(classifyCodexStderrLine(" ")).toBeNull();
Expand Down Expand Up @@ -146,9 +224,123 @@ describe("isRecoverableThreadResumeError", () => {
});
});

describe("messageFromCodexProcessCause", () => {
it("extracts the underlying error message from an effect cause", () => {
expect(messageFromCodexProcessCause(Cause.fail(new Error("boom")))).toBe("boom");
});
});

describe("constructor", () => {
it("supports the optional no-services path without unsafe casting", () => {
const manager = new CodexAppServerManager();
expect(() => manager.stopAll()).not.toThrow();
});
});

describe("session lifecycle guards", () => {
it("rejects writes when the codex process is not running", async () => {
const manager = createManager();
const context = createMinimalSessionContext();
context.child.isRunning = Effect.succeed(false);
const runPromise = vi
.spyOn(manager as unknown as { runPromise: (...args: unknown[]) => Promise<unknown> }, "runPromise")
.mockResolvedValue(false);

await expect(
(
manager as unknown as { writeMessage: (ctx: unknown, message: unknown) => Promise<void> }
).writeMessage(context, {
method: "initialized",
}),
).rejects.toThrow("Cannot write to codex app-server stdin.");
expect(runPromise).toHaveBeenCalledTimes(1);
});

it("closes session scope on unexpected process exit", async () => {
const manager = createManager();
const context = createMinimalSessionContext();
const pendingReject = vi.fn();
context.pending.set("1", {
method: "thread/start",
timeout: setTimeout(() => undefined, 1000),
resolve: () => undefined,
reject: pendingReject,
});
(
manager as unknown as {
sessions: Map<ProviderSessionId, unknown>;
}
).sessions.set(context.session.sessionId, context);
const runPromise = vi
.spyOn(manager as unknown as { runPromise: (...args: unknown[]) => Promise<unknown> }, "runPromise")
.mockResolvedValue(undefined);

await (
manager as unknown as {
handleUnexpectedProcessExit: (
ctx: unknown,
outcome: { kind: "failure"; message: string } | { kind: "exit"; code: number },
) => Promise<void>;
}
).handleUnexpectedProcessExit(context, { kind: "exit", code: 1 });

expect(pendingReject).toHaveBeenCalledWith(
expect.objectContaining({
message: "Session terminated before request completed.",
}),
);
expect(context.session.status).toBe("closed");
expect(context.session.lastError).toBe("codex app-server exited (code=1).");
expect(runPromise).toHaveBeenCalledTimes(1);
expect(
(
manager as unknown as {
sessions: Map<ProviderSessionId, unknown>;
}
).sessions.has(context.session.sessionId),
).toBe(false);
});

it("waits for active scope closes before disposing owned runtime", async () => {
const manager = new CodexAppServerManager();
const context = createMinimalSessionContext();
(
manager as unknown as {
sessions: Map<ProviderSessionId, unknown>;
}
).sessions.set(context.session.sessionId, context);
let resolveClose: (() => void) | undefined;
const closePromise = new Promise<void>((resolve) => {
resolveClose = resolve;
});
vi.spyOn(
manager as unknown as { runPromise: (...args: unknown[]) => Promise<unknown> },
"runPromise",
).mockReturnValue(closePromise);
const ownedRuntime = (
manager as unknown as {
ownedRuntime: { dispose: () => Promise<void> } | null;
}
).ownedRuntime;
if (!ownedRuntime) {
throw new Error("Expected manager to own a runtime.");
}
const disposeSpy = vi.spyOn(ownedRuntime, "dispose").mockResolvedValue(undefined);

manager.stopAll();

expect(disposeSpy).not.toHaveBeenCalled();
resolveClose?.();
await closePromise;
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(disposeSpy).toHaveBeenCalledTimes(1);
});
});

describe("startSession", () => {
it("emits session/startFailed when resolving cwd throws before process launch", async () => {
const manager = new CodexAppServerManager();
const manager = createManager();
const events: Array<{ method: string; kind: string; message?: string }> = [];
manager.on("event", (event) => {
events.push({
Expand Down
Loading