agent-orchestrator/packages/plugins/agent-codex/src/app-server-client.test.ts

1068 lines
37 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter, PassThrough, Writable } from "node:stream";
// ---------------------------------------------------------------------------
// Hoisted mocks
// ---------------------------------------------------------------------------
const { mockSpawn } = vi.hoisted(() => ({
mockSpawn: vi.fn(),
}));
vi.mock("node:child_process", () => ({
spawn: mockSpawn,
}));
import { CodexAppServerClient, type ApprovalDecision } from "./app-server-client.js";
// ---------------------------------------------------------------------------
// Helpers: fake child process
// ---------------------------------------------------------------------------
class FakeProcess extends EventEmitter {
stdin = new Writable({
write(_chunk, _encoding, callback) {
callback();
},
});
stdout = new PassThrough();
stderr = new PassThrough();
exitCode: number | null = null;
pid = 12345;
/** Lines written to stdin (captured for assertions) */
stdinLines: string[] = [];
constructor() {
super();
// Capture stdin writes
const origWrite = this.stdin.write.bind(this.stdin);
this.stdin.write = ((chunk: string | Buffer, ...args: unknown[]) => {
this.stdinLines.push(chunk.toString());
return origWrite(chunk, ...args);
}) as typeof this.stdin.write;
}
/** Simulate the server sending a line on stdout */
sendLine(data: string): void {
this.stdout.write(data + "\n");
}
/** Simulate process exit */
simulateExit(code: number | null = 0, signal: string | null = null): void {
this.exitCode = code;
this.emit("exit", code, signal);
}
kill(_signal?: string): boolean {
return true;
}
}
function createFakeProcess(): FakeProcess {
const proc = new FakeProcess();
mockSpawn.mockReturnValue(proc);
return proc;
}
/** Parse JSON-RPC messages written to stdin */
function parseStdinMessages(proc: FakeProcess): Array<Record<string, unknown>> {
return proc.stdinLines
.flatMap((line) => line.split("\n"))
.filter((line) => line.trim())
.map((line) => JSON.parse(line) as Record<string, unknown>);
}
/** Find a request by method in stdin messages */
function findRequest(proc: FakeProcess, method: string): Record<string, unknown> | undefined {
return parseStdinMessages(proc).find((msg) => msg["method"] === method);
}
beforeEach(() => {
vi.clearAllMocks();
});
// ---------------------------------------------------------------------------
// Shared helper: connect a client to a fake process (handshake)
// ---------------------------------------------------------------------------
async function connectClient(
client: CodexAppServerClient,
proc: FakeProcess,
): Promise<void> {
const connectPromise = client.connect();
await new Promise((r) => setTimeout(r, 10));
const initReq = findRequest(proc, "initialize");
if (initReq) {
proc.sendLine(JSON.stringify({ id: initReq["id"], result: {} }));
}
await connectPromise;
}
async function closeClient(
client: CodexAppServerClient,
proc: FakeProcess,
): Promise<void> {
const closePromise = client.close();
proc.simulateExit(0);
await closePromise;
}
// =========================================================================
// Construction & Connection
// =========================================================================
describe("CodexAppServerClient", () => {
describe("constructor", () => {
it("creates client with default options", () => {
const client = new CodexAppServerClient();
expect(client.isConnected).toBe(false);
});
it("accepts custom options", () => {
const client = new CodexAppServerClient({
binaryPath: "/custom/codex",
cwd: "/my/project",
requestTimeout: 5000,
});
expect(client.isConnected).toBe(false);
});
});
describe("connect", () => {
it("spawns codex app-server and verifies spawn args", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 500 });
await connectClient(client, proc);
expect(client.isConnected).toBe(true);
expect(mockSpawn).toHaveBeenCalledWith("codex", ["app-server"], expect.any(Object));
// Should have sent initialize request and initialized notification
const initReq = findRequest(proc, "initialize");
expect(initReq).toBeDefined();
expect(initReq!["jsonrpc"]).toBe("2.0");
const initializedNotif = findRequest(proc, "initialized");
expect(initializedNotif).toBeDefined();
expect(initializedNotif!["jsonrpc"]).toBe("2.0");
await closeClient(client, proc);
});
it("uses custom binary path", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ binaryPath: "/opt/codex", requestTimeout: 500 });
await connectClient(client, proc);
expect(mockSpawn).toHaveBeenCalledWith("/opt/codex", ["app-server"], expect.any(Object));
await closeClient(client, proc);
});
it("throws if already connected", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 500 });
await connectClient(client, proc);
await expect(client.connect()).rejects.toThrow("already connected");
await closeClient(client, proc);
});
it("throws if client is closed", async () => {
const client = new CodexAppServerClient();
await client.close();
await expect(client.connect()).rejects.toThrow("closed");
});
it("forwards cwd and env to spawn", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({
cwd: "/my/project",
env: { OPENAI_API_KEY: "sk-test" },
requestTimeout: 500,
});
await connectClient(client, proc);
expect(mockSpawn).toHaveBeenCalledWith("codex", ["app-server"], expect.objectContaining({
cwd: "/my/project",
stdio: ["pipe", "pipe", "pipe"],
}));
// env should merge with process.env
const spawnCall = mockSpawn.mock.calls[0];
expect(spawnCall[2].env).toBeDefined();
expect(spawnCall[2].env.OPENAI_API_KEY).toBe("sk-test");
await closeClient(client, proc);
});
it("emits 'connected' event after handshake", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 500 });
const events: unknown[] = [];
client.on("connected", (result: unknown) => events.push(result));
await connectClient(client, proc);
expect(events).toHaveLength(1);
await closeClient(client, proc);
});
it("sends clientInfo in initialize request", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 500 });
await connectClient(client, proc);
const initReq = findRequest(proc, "initialize");
expect(initReq).toBeDefined();
const params = initReq!["params"] as Record<string, unknown>;
const clientInfo = params["clientInfo"] as Record<string, unknown>;
expect(clientInfo["name"]).toBe("ao-agent-codex");
expect(clientInfo["version"]).toBe("0.1.1");
await closeClient(client, proc);
});
});
describe("close", () => {
it("sends SIGTERM to the process", async () => {
const proc = createFakeProcess();
const killSpy = vi.spyOn(proc, "kill");
const client = new CodexAppServerClient({ requestTimeout: 500 });
await connectClient(client, proc);
await closeClient(client, proc);
expect(killSpy).toHaveBeenCalledWith("SIGTERM");
expect(client.isConnected).toBe(false);
});
it("is idempotent — calling close twice is safe", async () => {
const client = new CodexAppServerClient();
await client.close();
await client.close(); // Should not throw
});
it("rejects pending requests on close", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 500 });
await connectClient(client, proc);
// Send a request that won't be answered
const requestPromise = client.threadList();
await closeClient(client, proc);
await expect(requestPromise).rejects.toThrow("closed");
});
});
// =========================================================================
// Request / Response
// =========================================================================
describe("sendRequest", () => {
let proc: FakeProcess;
let client: CodexAppServerClient;
beforeEach(async () => {
proc = createFakeProcess();
client = new CodexAppServerClient({ requestTimeout: 500 });
await connectClient(client, proc);
});
afterEach(async () => {
await closeClient(client, proc);
});
it("correlates responses by id", async () => {
const promise = client.sendRequest("thread/list", {});
await new Promise((r) => setTimeout(r, 10));
const msgs = parseStdinMessages(proc);
const req = msgs.find((m) => m["method"] === "thread/list");
expect(req).toBeDefined();
proc.sendLine(JSON.stringify({
id: req!["id"],
result: { threads: [{ id: "t-1" }] },
}));
const result = await promise;
expect(result).toEqual({ threads: [{ id: "t-1" }] });
});
it("rejects on JSON-RPC error response", async () => {
const promise = client.sendRequest("thread/start", {});
await new Promise((r) => setTimeout(r, 10));
const msgs = parseStdinMessages(proc);
const req = msgs.find((m) => m["method"] === "thread/start");
proc.sendLine(JSON.stringify({
id: req!["id"],
error: { code: -32600, message: "Invalid params" },
}));
await expect(promise).rejects.toThrow("Invalid params");
});
it("times out if no response received", async () => {
// requestTimeout is 500ms
const promise = client.sendRequest("thread/start", {});
await expect(promise).rejects.toThrow("timed out");
}, 2000);
it("throws if client is not initialized", async () => {
const uninitClient = new CodexAppServerClient();
await expect(uninitClient.sendRequest("thread/list", {})).rejects.toThrow("not initialized");
});
it("throws after client is closed", async () => {
await closeClient(client, proc);
// After close, initialized=false so "not initialized" check fires first
await expect(client.sendRequest("thread/list", {})).rejects.toThrow();
// Prevent afterEach from double-closing
proc = createFakeProcess();
client = new CodexAppServerClient({ requestTimeout: 500 });
await connectClient(client, proc);
});
it("resolves to empty object when response has no result field", async () => {
const promise = client.sendRequest("thread/list", {});
await new Promise((r) => setTimeout(r, 10));
const msgs = parseStdinMessages(proc);
const req = msgs.find((m) => m["method"] === "thread/list");
// Response with id but no result field
proc.sendLine(JSON.stringify({ id: req!["id"] }));
const result = await promise;
expect(result).toEqual({});
});
});
// =========================================================================
// Thread Management
// =========================================================================
describe("thread management", () => {
let proc: FakeProcess;
let client: CodexAppServerClient;
beforeEach(async () => {
proc = createFakeProcess();
client = new CodexAppServerClient({ requestTimeout: 500 });
const connectPromise = client.connect();
await new Promise((r) => setTimeout(r, 10));
const initReq = findRequest(proc, "initialize");
proc.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
await connectPromise;
});
afterEach(async () => {
const closePromise = client.close();
proc.simulateExit(0);
await closePromise;
});
it("threadStart sends thread/start request with params", async () => {
const promise = client.threadStart({ model: "o3-mini", cwd: "/project" });
await new Promise((r) => setTimeout(r, 10));
const req = findRequest(proc, "thread/start");
expect(req).toBeDefined();
expect(req!["params"]).toEqual({ model: "o3-mini", cwd: "/project" });
proc.sendLine(JSON.stringify({ id: req!["id"], result: { id: "thread-abc" } }));
const result = await promise;
expect(result).toEqual({ id: "thread-abc" });
});
it("threadResume sends thread/resume request with threadId", async () => {
const promise = client.threadResume("thread-123");
await new Promise((r) => setTimeout(r, 10));
const req = findRequest(proc, "thread/resume");
expect(req).toBeDefined();
expect(req!["params"]).toEqual({ threadId: "thread-123" });
proc.sendLine(JSON.stringify({ id: req!["id"], result: { id: "thread-123" } }));
const result = await promise;
expect(result).toEqual({ id: "thread-123" });
});
it("threadList sends thread/list request", async () => {
const promise = client.threadList("cursor-1", 10);
await new Promise((r) => setTimeout(r, 10));
const req = findRequest(proc, "thread/list");
expect(req).toBeDefined();
expect(req!["params"]).toEqual({ cursor: "cursor-1", limit: 10 });
proc.sendLine(JSON.stringify({ id: req!["id"], result: { threads: [] } }));
await promise;
});
it("threadStart sends thread/start with empty params by default", async () => {
const promise = client.threadStart();
await new Promise((r) => setTimeout(r, 10));
const req = findRequest(proc, "thread/start");
expect(req).toBeDefined();
expect(req!["params"]).toEqual({});
proc.sendLine(JSON.stringify({ id: req!["id"], result: { id: "thread-default" } }));
const result = await promise;
expect(result).toEqual({ id: "thread-default" });
});
it("threadArchive sends thread/archive request", async () => {
const promise = client.threadArchive("thread-old");
await new Promise((r) => setTimeout(r, 10));
const req = findRequest(proc, "thread/archive");
expect(req!["params"]).toEqual({ threadId: "thread-old" });
proc.sendLine(JSON.stringify({ id: req!["id"], result: {} }));
await promise;
});
});
// =========================================================================
// Turn Management
// =========================================================================
describe("turn management", () => {
let proc: FakeProcess;
let client: CodexAppServerClient;
beforeEach(async () => {
proc = createFakeProcess();
client = new CodexAppServerClient({ requestTimeout: 500 });
const connectPromise = client.connect();
await new Promise((r) => setTimeout(r, 10));
const initReq = findRequest(proc, "initialize");
proc.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
await connectPromise;
});
afterEach(async () => {
const closePromise = client.close();
proc.simulateExit(0);
await closePromise;
});
it("turnStart sends turn/start with text input", async () => {
const promise = client.turnStart({
threadId: "t-1",
input: "Fix the bug in auth.ts",
});
await new Promise((r) => setTimeout(r, 10));
const req = findRequest(proc, "turn/start");
expect(req).toBeDefined();
expect(req!["params"]).toEqual({
threadId: "t-1",
input: [{ type: "text", text: "Fix the bug in auth.ts" }],
});
proc.sendLine(JSON.stringify({ id: req!["id"], result: { turnId: "turn-1" } }));
const result = await promise;
expect(result).toEqual({ turnId: "turn-1" });
});
it("turnStart includes optional cwd and model params", async () => {
const promise = client.turnStart({
threadId: "t-1",
input: "Fix it",
cwd: "/custom/dir",
model: "o3-mini",
});
await new Promise((r) => setTimeout(r, 10));
const req = findRequest(proc, "turn/start");
expect(req).toBeDefined();
expect(req!["params"]).toEqual({
threadId: "t-1",
input: [{ type: "text", text: "Fix it" }],
cwd: "/custom/dir",
model: "o3-mini",
});
proc.sendLine(JSON.stringify({ id: req!["id"], result: { turnId: "turn-2" } }));
await promise;
});
it("turnInterrupt sends turn/interrupt", async () => {
const promise = client.turnInterrupt("t-1", "turn-1");
await new Promise((r) => setTimeout(r, 10));
const req = findRequest(proc, "turn/interrupt");
expect(req!["params"]).toEqual({ threadId: "t-1", turnId: "turn-1" });
proc.sendLine(JSON.stringify({ id: req!["id"], result: {} }));
await promise;
});
});
// =========================================================================
// Notifications
// =========================================================================
describe("notifications", () => {
it("routes server notifications to handler", async () => {
const proc = createFakeProcess();
const notifications: Array<{ method: string; params: Record<string, unknown> }> = [];
const client = new CodexAppServerClient({
requestTimeout: 500,
onNotification: (method, params) => {
notifications.push({ method, params });
},
});
const connectPromise = client.connect();
await new Promise((r) => setTimeout(r, 10));
const initReq = findRequest(proc, "initialize");
proc.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
await connectPromise;
// Server sends a notification
proc.sendLine(JSON.stringify({
method: "turn/completed",
params: { threadId: "t-1", turnId: "turn-1" },
}));
await new Promise((r) => setTimeout(r, 10));
expect(notifications).toHaveLength(1);
expect(notifications[0]).toEqual({
method: "turn/completed",
params: { threadId: "t-1", turnId: "turn-1" },
});
const closePromise = client.close();
proc.simulateExit(0);
await closePromise;
});
it("emits notification events on the EventEmitter", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 500 });
const connectPromise = client.connect();
await new Promise((r) => setTimeout(r, 10));
const initReq = findRequest(proc, "initialize");
proc.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
await connectPromise;
const events: Array<[string, Record<string, unknown>]> = [];
client.on("notification", (method: string, params: Record<string, unknown>) => {
events.push([method, params]);
});
proc.sendLine(JSON.stringify({
method: "thread/tokenUsage/updated",
params: { inputTokens: 100 },
}));
await new Promise((r) => setTimeout(r, 10));
expect(events).toHaveLength(1);
expect(events[0]![0]).toBe("thread/tokenUsage/updated");
const closePromise = client.close();
proc.simulateExit(0);
await closePromise;
});
});
// =========================================================================
// Approval Requests
// =========================================================================
describe("approval handling", () => {
it("auto-accepts approvals when no handler is provided", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 500 });
const connectPromise = client.connect();
await new Promise((r) => setTimeout(r, 10));
const initReq = findRequest(proc, "initialize");
proc.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
await connectPromise;
// Server sends approval request
proc.sendLine(JSON.stringify({
id: 0,
method: "item/fileChange/requestApproval",
params: { path: "test.ts" },
}));
await new Promise((r) => setTimeout(r, 10));
// Should have auto-responded with accept
const approvalResponse = proc.stdinLines.find((l) => l.includes('"accept"'));
expect(approvalResponse).toBeDefined();
const closePromise = client.close();
proc.simulateExit(0);
await closePromise;
});
it("falls back to 'decline' when approval handler throws", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({
requestTimeout: 500,
onApproval: async () => {
throw new Error("handler crashed");
},
});
await connectClient(client, proc);
proc.sendLine(JSON.stringify({
id: 99,
method: "item/fileChange/requestApproval",
params: { path: "dangerous.ts" },
}));
await new Promise((r) => setTimeout(r, 10));
// Should have responded with "decline" due to handler error
const declineResponse = proc.stdinLines.find((l) => l.includes('"decline"'));
expect(declineResponse).toBeDefined();
await closeClient(client, proc);
});
it("emits 'approval' event for approval requests", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 500 });
const events: Array<[string | number, string, Record<string, unknown>]> = [];
client.on("approval", (id: string | number, method: string, params: Record<string, unknown>) => {
events.push([id, method, params]);
});
await connectClient(client, proc);
proc.sendLine(JSON.stringify({
id: 7,
method: "item/commandExecution/requestApproval",
params: { command: ["ls"] },
}));
await new Promise((r) => setTimeout(r, 10));
expect(events).toHaveLength(1);
expect(events[0]![1]).toBe("item/commandExecution/requestApproval");
await closeClient(client, proc);
});
it("delegates to custom approval handler", async () => {
const proc = createFakeProcess();
const decisions: ApprovalDecision[] = [];
const client = new CodexAppServerClient({
requestTimeout: 500,
onApproval: async (_id, _method, _params) => {
decisions.push("decline");
return "decline";
},
});
const connectPromise = client.connect();
await new Promise((r) => setTimeout(r, 10));
const initReq = findRequest(proc, "initialize");
proc.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
await connectPromise;
proc.sendLine(JSON.stringify({
id: 42,
method: "item/commandExecution/requestApproval",
params: { command: ["rm", "-rf", "/"] },
}));
await new Promise((r) => setTimeout(r, 10));
expect(decisions).toEqual(["decline"]);
const closePromise = client.close();
proc.simulateExit(0);
await closePromise;
});
});
// =========================================================================
// Process lifecycle
// =========================================================================
describe("process lifecycle", () => {
it("rejects pending requests when process exits", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 5000 });
const connectPromise = client.connect();
await new Promise((r) => setTimeout(r, 10));
const initReq = findRequest(proc, "initialize");
proc.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
await connectPromise;
// Start a request
const requestPromise = client.threadList();
await new Promise((r) => setTimeout(r, 10));
// Process exits unexpectedly
proc.simulateExit(1, null);
await expect(requestPromise).rejects.toThrow("exited");
expect(client.isConnected).toBe(false);
});
it("emits exit event on process exit", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 500 });
const connectPromise = client.connect();
await new Promise((r) => setTimeout(r, 10));
const initReq = findRequest(proc, "initialize");
proc.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
await connectPromise;
const exits: Array<[number | null, string | null]> = [];
client.on("exit", (code: number | null, signal: string | null) => {
exits.push([code, signal]);
});
proc.simulateExit(0, null);
expect(exits).toEqual([[0, null]]);
});
it("emits error and rejects pending on process spawn error", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 5000 });
await connectClient(client, proc);
const errors: Error[] = [];
client.on("error", (err: Error) => errors.push(err));
// Start a request
const requestPromise = client.threadList();
await new Promise((r) => setTimeout(r, 10));
// Process emits error
proc.emit("error", new Error("spawn ENOENT"));
await expect(requestPromise).rejects.toThrow("spawn ENOENT");
expect(errors).toHaveLength(1);
expect(errors[0]!.message).toBe("spawn ENOENT");
expect(client.isConnected).toBe(false);
});
it("rejects pending requests before emitting error (no listener crash safe)", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 5000 });
await connectClient(client, proc);
// Do NOT attach an error listener — emit("error") will throw
// Start a request that will be pending
const requestPromise = client.threadList();
await new Promise((r) => setTimeout(r, 10));
// Process emits error — without a listener, emit("error") throws.
// Pending requests must still be rejected before that throw.
try {
proc.emit("error", new Error("spawn ENOENT"));
} catch {
// Expected: uncaught error event from EventEmitter
}
await expect(requestPromise).rejects.toThrow("spawn ENOENT");
});
it("handles malformed JSON lines gracefully", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 500 });
const connectPromise = client.connect();
await new Promise((r) => setTimeout(r, 10));
const initReq = findRequest(proc, "initialize");
proc.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
await connectPromise;
// Send garbage — should not crash
proc.sendLine("not valid json {{{");
proc.sendLine("");
proc.sendLine(" ");
// Client should still be connected
expect(client.isConnected).toBe(true);
const closePromise = client.close();
proc.simulateExit(0);
await closePromise;
});
});
// =========================================================================
// sendNotification & sendApprovalResponse
// =========================================================================
describe("sendNotification", () => {
it("sends a notification without id (no response expected)", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 500 });
await connectClient(client, proc);
client.sendNotification("custom/event", { key: "value" });
await new Promise((r) => setTimeout(r, 10));
const msg = parseStdinMessages(proc).find((m) => m["method"] === "custom/event");
expect(msg).toBeDefined();
expect(msg!["params"]).toEqual({ key: "value" });
expect(msg!["id"]).toBeUndefined();
await closeClient(client, proc);
});
it("is a no-op on closed client", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 500 });
await connectClient(client, proc);
await closeClient(client, proc);
// Should not throw
client.sendNotification("custom/event", {});
});
});
describe("sendApprovalResponse", () => {
it("sends approval response with decision", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 500 });
await connectClient(client, proc);
client.sendApprovalResponse(42, "acceptForSession");
await new Promise((r) => setTimeout(r, 10));
const response = proc.stdinLines.find((l) => l.includes('"acceptForSession"'));
expect(response).toBeDefined();
const parsed = JSON.parse(response!) as Record<string, unknown>;
expect(parsed["id"]).toBe(42);
expect(parsed["result"]).toEqual({ decision: "acceptForSession" });
await closeClient(client, proc);
});
it("is a no-op on closed client", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 500 });
await connectClient(client, proc);
await closeClient(client, proc);
// Should not throw
client.sendApprovalResponse("req-1", "cancel");
});
});
// =========================================================================
// handleLine edge cases
// =========================================================================
describe("handleLine edge cases", () => {
it("ignores JSON arrays", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 500 });
await connectClient(client, proc);
// Send a JSON array — should be silently ignored
proc.sendLine(JSON.stringify([1, 2, 3]));
await new Promise((r) => setTimeout(r, 10));
expect(client.isConnected).toBe(true);
await closeClient(client, proc);
});
it("ignores JSON primitives (string, number)", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 500 });
await connectClient(client, proc);
proc.sendLine(JSON.stringify("hello"));
proc.sendLine(JSON.stringify(42));
proc.sendLine(JSON.stringify(null));
await new Promise((r) => setTimeout(r, 10));
expect(client.isConnected).toBe(true);
await closeClient(client, proc);
});
it("ignores messages with unknown id and no method", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 500 });
await connectClient(client, proc);
// Message with id that doesn't match any pending request and no method
proc.sendLine(JSON.stringify({ id: "unknown-id-999", result: { data: "orphan" } }));
await new Promise((r) => setTimeout(r, 10));
expect(client.isConnected).toBe(true);
await closeClient(client, proc);
});
});
// =========================================================================
// Model Discovery
// =========================================================================
describe("model discovery", () => {
it("modelList sends model/list request", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 500 });
const connectPromise = client.connect();
await new Promise((r) => setTimeout(r, 10));
const initReq = findRequest(proc, "initialize");
proc.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
await connectPromise;
const promise = client.modelList();
await new Promise((r) => setTimeout(r, 10));
const req = findRequest(proc, "model/list");
expect(req).toBeDefined();
proc.sendLine(JSON.stringify({
id: req!["id"],
result: { models: [{ id: "o3-mini" }, { id: "gpt-4o" }] },
}));
const result = await promise;
expect(result).toEqual({ models: [{ id: "o3-mini" }, { id: "gpt-4o" }] });
const closePromise = client.close();
proc.simulateExit(0);
await closePromise;
});
});
// =========================================================================
// Concurrency & resource cleanup (review fixes)
// =========================================================================
describe("concurrency guards", () => {
it("throws if connect() is called while already connecting", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 500 });
// Start connect but don't complete the handshake
const connectPromise = client.connect();
// Second connect() should throw immediately
await expect(client.connect()).rejects.toThrow("already connecting");
// Complete the handshake to clean up
await new Promise((r) => setTimeout(r, 10));
const initReq = findRequest(proc, "initialize");
proc.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
await connectPromise;
await closeClient(client, proc);
});
it("cleans up process when initialize handshake fails", async () => {
const proc = createFakeProcess();
// Make kill trigger exit so close() doesn't hang
vi.spyOn(proc, "kill").mockImplementation((_signal?: string) => {
proc.simulateExit(1, "SIGTERM");
return true;
});
const client = new CodexAppServerClient({ requestTimeout: 200 });
// Start connect — don't answer the initialize request so it times out
await expect(client.connect()).rejects.toThrow("timed out");
// Client should have cleaned up — not connected, not connecting
expect(client.isConnected).toBe(false);
});
it("allows retry after a failed connect (does not permanently close)", async () => {
// First attempt: spawn a process that times out during handshake
const proc1 = createFakeProcess();
vi.spyOn(proc1, "kill").mockImplementation((_signal?: string) => {
proc1.simulateExit(1, "SIGTERM");
return true;
});
const client = new CodexAppServerClient({ requestTimeout: 200 });
await expect(client.connect()).rejects.toThrow("timed out");
expect(client.isConnected).toBe(false);
// Second attempt should NOT throw "Client is closed" — it should be retryable
const proc2 = createFakeProcess();
const connectPromise = client.connect();
await new Promise((r) => setTimeout(r, 10));
const initReq = findRequest(proc2, "initialize");
proc2.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
await connectPromise;
expect(client.isConnected).toBe(true);
await closeClient(client, proc2);
});
it("drains stderr without blocking the child process", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 500 });
await connectClient(client, proc);
// Write a large amount of data to stderr — should not block
const largeChunk = "x".repeat(65536);
proc.stderr.write(largeChunk);
await new Promise((r) => setTimeout(r, 10));
expect(client.isConnected).toBe(true);
await closeClient(client, proc);
});
it("resets connecting flag if spawn throws synchronously", async () => {
// Make spawn throw (e.g. EMFILE, ENOMEM)
mockSpawn.mockImplementationOnce(() => {
throw new Error("spawn EMFILE");
});
const client = new CodexAppServerClient({ requestTimeout: 500 });
await expect(client.connect()).rejects.toThrow("spawn EMFILE");
// connecting flag should be reset — a retry should not throw "already connecting"
const proc = createFakeProcess();
const connectPromise = client.connect();
await new Promise((r) => setTimeout(r, 10));
const initReq = findRequest(proc, "initialize");
proc.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
await connectPromise;
expect(client.isConnected).toBe(true);
await closeClient(client, proc);
});
});
describe("readline cleanup on process exit/error", () => {
it("closes readline on unexpected process exit", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 500 });
await connectClient(client, proc);
// Spy on readline close — the client stores it privately so we check
// indirectly: after exit, sending another line should not cause errors
proc.simulateExit(1, null);
await new Promise((r) => setTimeout(r, 10));
// Client should be disconnected
expect(client.isConnected).toBe(false);
// Sending data on stdout should not throw (readline is closed)
expect(() => proc.sendLine('{"id":"x","result":{}}')).not.toThrow();
});
it("closes readline on process error", async () => {
const proc = createFakeProcess();
const client = new CodexAppServerClient({ requestTimeout: 500 });
await connectClient(client, proc);
// Catch the error event on the client to prevent unhandled error
const errors: Error[] = [];
client.on("error", (err: Error) => errors.push(err));
proc.emit("error", new Error("process crashed"));
await new Promise((r) => setTimeout(r, 10));
expect(client.isConnected).toBe(false);
expect(errors).toHaveLength(1);
});
});
});