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
7 changes: 6 additions & 1 deletion apps/server/src/codexAppServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,9 +687,14 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
throw new Error(`${threadOpenMethod} response did not include a thread id.`);
}
const providerThreadId = threadIdRaw;
const resolvedModel =
normalizeCodexModelSlug(this.readString(this.readObject(threadOpenRecord, "thread"), "model")) ??
normalizeCodexModelSlug(this.readString(threadOpenRecord, "model")) ??
context.session.model;

this.updateSession(context, {
status: "ready",
model: resolvedModel,
resumeCursor: { threadId: providerThreadId },
});
this.emitLifecycleEvent(
Expand Down Expand Up @@ -1521,7 +1526,7 @@ function readCodexProviderOptions(input: CodexAppServerStartSessionInput): {
};
}

function assertSupportedCodexCliVersion(input: {
export function assertSupportedCodexCliVersion(input: {
readonly binaryPath: string;
readonly cwd: string;
readonly homePath?: string;
Expand Down
298 changes: 298 additions & 0 deletions apps/server/src/codexImport/Layers/CodexImportBrowser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";

import { Effect, Layer, ManagedRuntime } from "effect";
import { afterEach, describe, expect, it, vi } from "vitest";

const { spawnMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
}));

vi.mock("../../codexAppServerManager.ts", () => ({
assertSupportedCodexCliVersion: vi.fn(),
buildCodexInitializeParams: vi.fn(() => ({ clientInfo: { name: "test" } })),
}));
vi.mock("node:child_process", () => ({
spawn: spawnMock,
}));

import { ServerConfig } from "../../config.ts";
import { CodexImportBrowser } from "../Services/CodexImportBrowser.ts";
import { CodexImportBrowserLive } from "./CodexImportBrowser.ts";

type FakeChildMode = "success" | "exit-on-initialize" | "error-on-initialize";
type ThreadListPage = {
readonly cursor: string | null;
readonly result: {
readonly data: ReadonlyArray<Record<string, unknown>>;
readonly nextCursor: string | null;
};
};

class FakeChild extends EventEmitter {
readonly stdout = new PassThrough();
readonly stderr = new PassThrough();
readonly stdin: { write: (chunk: string) => boolean };
killed = false;
constructor(
private readonly mode: FakeChildMode,
private readonly threadListPages: ReadonlyArray<ThreadListPage> = [
{ cursor: null, result: { data: [], nextCursor: null } },
],
private readonly options: {
readonly errorMethods?: Readonly<Record<string, string>>;
readonly ignoredMethods?: ReadonlyArray<string>;
} = {},
) {
super();
this.stdin = {
write: (chunk: string) => {
const message = JSON.parse(chunk.trim()) as {
id?: string;
method?: string;
params?: Record<string, unknown>;
};
if (this.options.ignoredMethods?.includes(message.method ?? "")) {
return true;
}
const errorMessage =
message.method ? this.options.errorMethods?.[message.method] : undefined;
if (errorMessage && message.id) {
queueMicrotask(() => {
this.stdout.write(
`${JSON.stringify({ id: message.id, error: { message: errorMessage } })}\n`,
);
});
return true;
}
if (message.method === "initialize" && message.id) {
queueMicrotask(() => {
if (this.mode === "success") {
this.stdout.write(`${JSON.stringify({ id: message.id, result: {} })}\n`);
} else if (this.mode === "exit-on-initialize") {
this.emit("exit", 1);
} else {
this.emit("error", new Error("spawn failed"));
}
});
return true;
}
if (message.method === "thread/list" && message.id) {
queueMicrotask(() => {
const requestedCursor =
typeof message.params?.cursor === "string" ? message.params.cursor : null;
const page =
this.threadListPages.find((candidate) => candidate.cursor === requestedCursor) ??
this.threadListPages[this.threadListPages.length - 1]!;
this.stdout.write(
`${JSON.stringify({
id: message.id,
result: page.result,
})}\n`,
);
});
}
if (message.method === "thread/read" && message.id) {
queueMicrotask(() => {
this.stdout.write(
`${JSON.stringify({
id: message.id,
result: {
thread: {
id: String(message.params?.threadId ?? "thread-1"),
cwd: "/tmp/repo",
source: "cli",
preview: "",
createdAt: 0,
updatedAt: 0,
turns: [],
},
},
})}\n`,
);
});
}
if (message.method === "thread/archive" && message.id) {
queueMicrotask(() => {
this.stdout.write(`${JSON.stringify({ id: message.id, result: {} })}\n`);
});
}
return true;
},
};
}

kill() {
this.killed = true;
return true;
}
}

describe("CodexImportBrowser", () => {
let runtime: ManagedRuntime.ManagedRuntime<any, any> | null = null;

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

async function makeBrowser() {
const layer = Layer.provide(
CodexImportBrowserLive,
Layer.succeed(ServerConfig, {
cwd: "/tmp",
stateDir: "/tmp/state",
mode: "web",
autoBootstrapProjectFromCwd: false,
logWebSocketEvents: false,
port: 0,
host: undefined,
authToken: undefined,
keybindingsConfigPath: "/tmp/state/keybindings.json",
staticDir: undefined,
devUrl: undefined,
noBrowser: true,
}),
);
runtime = ManagedRuntime.make(layer);
return runtime.runPromise(Effect.service(CodexImportBrowser));
}

it("reuses a single app-server process for a withSession operation", async () => {
const child = new FakeChild("success");
spawnMock.mockReturnValue(child);
const browser = await makeBrowser();

await runtime!.runPromise(
browser.withSession({ binaryPath: undefined, homePath: undefined }, (session) =>
Effect.gen(function* () {
yield* session.listThreads();
yield* session.readThread("thread-1");
yield* session.archiveThread("thread-1");
}),
),
);

expect(spawnMock).toHaveBeenCalledTimes(1);
expect(child.killed).toBe(true);
});

it("paginates through all thread/list pages", async () => {
const child = new FakeChild("success", [
{
cursor: null,
result: {
data: [
{
id: "thread-1",
cwd: "/tmp/repo-a",
source: "cli",
preview: "one",
createdAt: 1,
updatedAt: 2,
},
],
nextCursor: "cursor-2",
},
},
{
cursor: "cursor-2",
result: {
data: [
{
id: "thread-2",
cwd: "/tmp/repo-b",
source: "vscode",
preview: "two",
createdAt: 3,
updatedAt: 4,
},
],
nextCursor: null,
},
},
]);
spawnMock.mockReturnValue(child);
const browser = await makeBrowser();

const threads = await runtime!.runPromise(
browser.listThreads({ binaryPath: undefined, homePath: undefined }),
);

expect(threads.map((thread) => thread.id)).toEqual(["thread-1", "thread-2"]);
expect(child.killed).toBe(true);
});

it("cleans up the child when initialize fails after spawn", async () => {
const child = new FakeChild("exit-on-initialize");
spawnMock.mockReturnValue(child);
const browser = await makeBrowser();

await expect(
runtime!.runPromise(
browser.listThreads({ binaryPath: undefined, homePath: undefined }),
),
).rejects.toThrow("Codex import client exited unexpectedly.");
expect(child.killed).toBe(true);
});

it("surfaces JSON-RPC request errors from the app-server", async () => {
const child = new FakeChild("success", undefined, {
errorMethods: {
"thread/archive": "archive rejected",
},
});
spawnMock.mockReturnValue(child);
const browser = await makeBrowser();

await expect(
runtime!.runPromise(
browser.archiveThread({ binaryPath: undefined, homePath: undefined }, "thread-1"),
),
).rejects.toThrow("archive rejected");
expect(child.killed).toBe(true);
});

it("times out requests that never receive a response", async () => {
vi.useFakeTimers();
try {
const child = new FakeChild("success", undefined, {
ignoredMethods: ["thread/list"],
});
spawnMock.mockReturnValue(child);
const browser = await makeBrowser();

const observedError = runtime!
.runPromise(
browser.listThreads({ binaryPath: undefined, homePath: undefined }),
)
.catch((error) => error);
await vi.advanceTimersByTimeAsync(30_000);

const error = await observedError;
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe(
"Timed out waiting for Codex import response to thread/list.",
);
expect(child.killed).toBe(true);
} finally {
vi.useRealTimers();
}
});

it("surfaces child process errors as browser errors instead of unhandled failures", async () => {
const child = new FakeChild("error-on-initialize");
spawnMock.mockReturnValue(child);
const browser = await makeBrowser();

await expect(
runtime!.runPromise(
browser.listThreads({ binaryPath: undefined, homePath: undefined }),
),
).rejects.toThrow("spawn failed");
expect(child.killed).toBe(true);
});
});
Loading