diff --git a/packages/plugins/agent-codex/src/app-server-client.test.ts b/packages/plugins/agent-codex/src/app-server-client.test.ts new file mode 100644 index 000000000..c2b5db7ea --- /dev/null +++ b/packages/plugins/agent-codex/src/app-server-client.test.ts @@ -0,0 +1,1067 @@ +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> { + return proc.stdinLines + .flatMap((line) => line.split("\n")) + .filter((line) => line.trim()) + .map((line) => JSON.parse(line) as Record); +} + +/** Find a request by method in stdin messages */ +function findRequest(proc: FakeProcess, method: string): Record | 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 { + 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 { + 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; + const clientInfo = params["clientInfo"] as Record; + expect(clientInfo["name"]).toBe("ao-agent-codex"); + expect(clientInfo["version"]).toBe("0.1.0"); + + 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 }> = []; + + 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]> = []; + client.on("notification", (method: string, params: Record) => { + 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]> = []; + client.on("approval", (id: string | number, method: string, params: Record) => { + 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; + 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); + }); + }); +}); diff --git a/packages/plugins/agent-codex/src/app-server-client.ts b/packages/plugins/agent-codex/src/app-server-client.ts new file mode 100644 index 000000000..a550000d9 --- /dev/null +++ b/packages/plugins/agent-codex/src/app-server-client.ts @@ -0,0 +1,504 @@ +/** + * Codex App-Server JSON-RPC Client + * + * Manages a `codex app-server` subprocess and communicates via + * newline-delimited JSON over stdin/stdout. Provides typed methods + * for thread management, turn execution, and conversation resume. + * + * Protocol reference: Codex app-server developer guide + * Implementation reference: codex-autorunner integrations/app_server/client.py + */ +import { spawn, type ChildProcess } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { createInterface, type Interface as ReadlineInterface } from "node:readline"; +import { EventEmitter } from "node:events"; + +// ============================================================================= +// Types +// ============================================================================= + +/** JSON-RPC request sent from client to server */ +export interface JsonRpcRequest { + jsonrpc: "2.0"; + id: string; + method: string; + params: Record; +} + +/** JSON-RPC response from server */ +export interface JsonRpcResponse { + id: string; + result?: Record; + error?: JsonRpcError; +} + +/** JSON-RPC error object */ +export interface JsonRpcError { + code: number; + message: string; + data?: unknown; +} + +/** JSON-RPC notification from server (no id) */ +export interface JsonRpcNotification { + method: string; + params: Record; +} + +/** Approval request from server (has id + method + params) */ +export interface JsonRpcApprovalRequest { + id: string | number; + method: string; + params: Record; +} + +/** Parsed message from the server — could be response, notification, or approval request */ +type ServerMessage = JsonRpcResponse | JsonRpcNotification | JsonRpcApprovalRequest; + +/** Callback for server notifications */ +export type NotificationHandler = (method: string, params: Record) => void; + +/** Callback for approval requests */ +export type ApprovalHandler = ( + id: string | number, + method: string, + params: Record, +) => Promise; + +/** Approval decision values */ +export type ApprovalDecision = + | "accept" + | "acceptForSession" + | "decline" + | "cancel"; + +/** Options for creating a CodexAppServerClient */ +export interface AppServerClientOptions { + /** Path to codex binary (default: "codex") */ + binaryPath?: string; + /** Working directory for the app-server process */ + cwd?: string; + /** Environment variables for the process */ + env?: Record; + /** Timeout for requests in ms (default: 60000) */ + requestTimeout?: number; + /** Handler for server notifications */ + onNotification?: NotificationHandler; + /** Handler for approval requests (auto-accepts if not provided) */ + onApproval?: ApprovalHandler; +} + +/** Thread start parameters */ +export interface ThreadStartParams { + model?: string; + modelProvider?: string; + cwd?: string; + /** Codex approval policy: untrusted (ask for all), on-request, or never */ + approvalPolicy?: "untrusted" | "on-request" | "never"; + /** Codex sandbox mode */ + sandbox?: "read-only" | "workspace-write" | "danger-full-access"; + /** Personality/instruction preset */ + personality?: string; +} + +/** Turn start parameters */ +export interface TurnStartParams { + threadId: string; + input: string; + cwd?: string; + model?: string; +} + +/** Pending request waiting for a response */ +interface PendingRequest { + resolve: (result: Record) => void; + reject: (error: Error) => void; + timer: ReturnType; +} + +// ============================================================================= +// Client Implementation +// ============================================================================= + +/** + * JSON-RPC client for Codex's app-server mode. + * + * Usage: + * ```ts + * const client = new CodexAppServerClient({ cwd: "/my/project" }); + * await client.connect(); + * + * const thread = await client.threadStart({ model: "o3-mini" }); + * const turn = await client.turnStart({ threadId: thread.id, input: "Fix the bug" }); + * + * await client.close(); + * ``` + */ +export class CodexAppServerClient extends EventEmitter { + private process: ChildProcess | null = null; + private readline: ReadlineInterface | null = null; + private pending = new Map(); + private initialized = false; + private closed = false; + private connecting = false; + + private readonly binaryPath: string; + private readonly cwd: string | undefined; + private readonly env: Record | undefined; + private readonly requestTimeout: number; + private readonly onNotification: NotificationHandler | undefined; + private readonly onApproval: ApprovalHandler | undefined; + + constructor(options: AppServerClientOptions = {}) { + super(); + this.binaryPath = options.binaryPath ?? "codex"; + this.cwd = options.cwd; + this.env = options.env; + this.requestTimeout = options.requestTimeout ?? 60_000; + this.onNotification = options.onNotification; + this.onApproval = options.onApproval; + } + + /** Whether the client is connected and initialized */ + get isConnected(): boolean { + return this.initialized && !this.closed && this.process !== null; + } + + /** + * Spawn the app-server process and perform the initialization handshake. + * Must be called before any other method. + */ + async connect(): Promise { + if (this.closed) throw new Error("Client is closed"); + if (this.initialized) throw new Error("Client is already connected"); + if (this.connecting) throw new Error("Client is already connecting"); + + this.connecting = true; + + try { + this.process = spawn(this.binaryPath, ["app-server"], { + cwd: this.cwd, + env: this.env ? { ...process.env, ...this.env } : undefined, + stdio: ["pipe", "pipe", "pipe"], + }); + + if (!this.process.stdout || !this.process.stdin) { + throw new Error("Failed to open stdio pipes for codex app-server"); + } + + // Drain stderr to prevent the child process from blocking when + // the pipe buffer fills up. + this.process.stderr?.resume(); + + // Set up line-based reading from stdout + this.readline = createInterface({ input: this.process.stdout }); + this.readline.on("line", (line) => this.handleLine(line)); + + // Handle process exit + this.process.once("exit", (code, signal) => { + this.handleProcessExit(code, signal); + }); + + this.process.once("error", (err) => { + this.handleProcessError(err); + }); + + await this.initialize(); + } catch (err) { + this.connecting = false; + await this.close(); + // Reset closed flag so the client can retry connect() after a + // transient handshake failure. The guard on line 172 ensures + // this.closed is always false when we reach this point. + this.closed = false; + throw err; + } + + this.connecting = false; + } + + /** + * Gracefully close the app-server process. + * Sends SIGTERM first, then SIGKILL after timeout. + */ + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.initialized = false; + + // Reject all pending requests + for (const [id, pending] of this.pending) { + clearTimeout(pending.timer); + pending.reject(new Error("Client closed")); + this.pending.delete(id); + } + + if (this.readline) { + this.readline.close(); + this.readline = null; + } + + if (this.process && this.process.exitCode === null) { + const proc = this.process; + + await new Promise((resolve) => { + const killTimer = setTimeout(() => { + try { + proc.kill("SIGKILL"); + } catch { + // Already dead + } + resolve(); + }, 5_000); + + proc.once("exit", () => { + clearTimeout(killTimer); + resolve(); + }); + + try { + proc.kill("SIGTERM"); + } catch { + clearTimeout(killTimer); + resolve(); + } + }); + } + + this.process = null; + } + + // --------------------------------------------------------------------------- + // Thread Management + // --------------------------------------------------------------------------- + + /** Create a new conversation thread */ + async threadStart(params: ThreadStartParams = {}): Promise> { + return this.sendRequest("thread/start", { ...params }); + } + + /** Resume an existing conversation thread by ID */ + async threadResume(threadId: string): Promise> { + return this.sendRequest("thread/resume", { threadId }); + } + + /** List threads (with optional cursor-based pagination) */ + async threadList(cursor?: string, limit?: number): Promise> { + const params: Record = {}; + if (cursor) params["cursor"] = cursor; + if (limit !== undefined) params["limit"] = limit; + return this.sendRequest("thread/list", params); + } + + /** Archive a thread */ + async threadArchive(threadId: string): Promise> { + return this.sendRequest("thread/archive", { threadId }); + } + + // --------------------------------------------------------------------------- + // Turn Management + // --------------------------------------------------------------------------- + + /** Start a new turn (send a message to the agent) */ + async turnStart(params: TurnStartParams): Promise> { + return this.sendRequest("turn/start", { + threadId: params.threadId, + input: [{ type: "text", text: params.input }], + ...(params.cwd ? { cwd: params.cwd } : {}), + ...(params.model ? { model: params.model } : {}), + }); + } + + /** Interrupt a running turn */ + async turnInterrupt(threadId: string, turnId: string): Promise> { + return this.sendRequest("turn/interrupt", { threadId, turnId }); + } + + // --------------------------------------------------------------------------- + // Model Discovery + // --------------------------------------------------------------------------- + + /** List available models */ + async modelList(cursor?: string, limit?: number): Promise> { + const params: Record = {}; + if (cursor) params["cursor"] = cursor; + if (limit !== undefined) params["limit"] = limit; + return this.sendRequest("model/list", params); + } + + // --------------------------------------------------------------------------- + // Low-level Protocol + // --------------------------------------------------------------------------- + + /** Send a JSON-RPC request and wait for the response */ + async sendRequest(method: string, params: Record = {}): Promise> { + if (!this.initialized && method !== "initialize") { + throw new Error("Client not initialized — call connect() first"); + } + if (this.closed) throw new Error("Client is closed"); + if (!this.process?.stdin?.writable) { + throw new Error("stdin not writable — process may have exited"); + } + + const id = randomUUID(); + const request: JsonRpcRequest = { jsonrpc: "2.0", id, method, params }; + + return new Promise>((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Request ${method} timed out after ${this.requestTimeout}ms`)); + }, this.requestTimeout); + + this.pending.set(id, { resolve, reject, timer }); + this.writeLine(JSON.stringify(request)); + }); + } + + /** Send a JSON-RPC notification (no response expected) */ + sendNotification(method: string, params: Record = {}): void { + if (this.closed) return; + if (!this.process?.stdin?.writable) return; + + this.writeLine(JSON.stringify({ jsonrpc: "2.0", method, params })); + } + + /** Respond to an approval request from the server */ + sendApprovalResponse(id: string | number, decision: ApprovalDecision): void { + if (this.closed) return; + if (!this.process?.stdin?.writable) return; + + this.writeLine(JSON.stringify({ jsonrpc: "2.0", id, result: { decision } })); + } + + // --------------------------------------------------------------------------- + // Internal + // --------------------------------------------------------------------------- + + private async initialize(): Promise { + const result = await this.sendRequest("initialize", { + clientInfo: { + name: "ao-agent-codex", + title: "Agent Orchestrator — Codex Plugin", + version: "0.1.0", + }, + }); + + // Send the initialized notification to complete the handshake + this.sendNotification("initialized", {}); + this.initialized = true; + this.emit("connected", result); + } + + private writeLine(line: string): void { + if (!this.process?.stdin?.writable) return; + this.process.stdin.write(line + "\n"); + } + + private handleLine(line: string): void { + const trimmed = line.trim(); + if (!trimmed) return; + + let msg: ServerMessage; + try { + const parsed: unknown = JSON.parse(trimmed); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return; + msg = parsed as ServerMessage; + } catch { + // Skip malformed lines + return; + } + + // Classify the message + if ("id" in msg && msg.id !== undefined) { + const id = String(msg.id); + + // Check if this is a response to a pending request + const pending = this.pending.get(id); + if (pending) { + this.pending.delete(id); + clearTimeout(pending.timer); + + if ("error" in msg && msg.error) { + pending.reject( + new Error(`JSON-RPC error ${msg.error.code}: ${msg.error.message}`), + ); + } else { + pending.resolve((msg as JsonRpcResponse).result ?? {}); + } + return; + } + + // If not a pending response, it's a server-initiated request (approval) + if ("method" in msg && typeof msg.method === "string") { + this.handleApprovalRequest(msg as JsonRpcApprovalRequest); + return; + } + } + + // Notification (no id, has method) + if ("method" in msg && typeof msg.method === "string" && !("id" in msg)) { + const notification = msg as JsonRpcNotification; + this.emit("notification", notification.method, notification.params); + if (this.onNotification) { + this.onNotification(notification.method, notification.params); + } + } + } + + private async handleApprovalRequest(request: JsonRpcApprovalRequest): Promise { + try { + this.emit("approval", request.id, request.method, request.params); + + if (this.onApproval) { + const decision = await this.onApproval(request.id, request.method, request.params); + this.sendApprovalResponse(request.id, decision); + } else { + // Default: auto-accept all approvals + this.sendApprovalResponse(request.id, "accept"); + } + } catch { + // On any error (listener throw or handler rejection), decline the request + this.sendApprovalResponse(request.id, "decline"); + } + } + + private handleProcessExit(code: number | null, signal: string | null): void { + this.initialized = false; + + // Close readline to release the event listener on the closed stdout stream + if (this.readline) { + this.readline.close(); + this.readline = null; + } + + // Reject all pending requests + const exitMsg = `codex app-server exited (code=${code}, signal=${signal})`; + for (const [id, pending] of this.pending) { + clearTimeout(pending.timer); + pending.reject(new Error(exitMsg)); + this.pending.delete(id); + } + + this.emit("exit", code, signal); + } + + private handleProcessError(err: Error): void { + this.initialized = false; + + // Close readline to release the event listener on the closed stdout stream + if (this.readline) { + this.readline.close(); + this.readline = null; + } + + // Reject all pending requests before emitting "error" — emit("error") + // with no listeners throws synchronously, which would skip cleanup. + for (const [id, pending] of this.pending) { + clearTimeout(pending.timer); + pending.reject(err); + this.pending.delete(id); + } + + this.emit("error", err); + } +} diff --git a/packages/plugins/agent-codex/src/index.test.ts b/packages/plugins/agent-codex/src/index.test.ts index 00dbeb119..363825244 100644 --- a/packages/plugins/agent-codex/src/index.test.ts +++ b/packages/plugins/agent-codex/src/index.test.ts @@ -9,14 +9,24 @@ const { mockWriteFile, mockMkdir, mockReadFile, + mockReaddir, mockRename, + mockStat, + mockLstat, + mockOpen, + mockCreateReadStream, mockHomedir, } = vi.hoisted(() => ({ mockExecFileAsync: vi.fn(), mockWriteFile: vi.fn().mockResolvedValue(undefined), mockMkdir: vi.fn().mockResolvedValue(undefined), mockReadFile: vi.fn(), + mockReaddir: vi.fn(), mockRename: vi.fn().mockResolvedValue(undefined), + mockStat: vi.fn(), + mockLstat: vi.fn(), + mockOpen: vi.fn(), + mockCreateReadStream: vi.fn(), mockHomedir: vi.fn(() => "/mock/home"), })); @@ -31,7 +41,11 @@ vi.mock("node:fs/promises", () => ({ writeFile: mockWriteFile, mkdir: mockMkdir, readFile: mockReadFile, + readdir: mockReaddir, rename: mockRename, + stat: mockStat, + lstat: mockLstat, + open: mockOpen, })); vi.mock("node:crypto", () => ({ @@ -40,13 +54,15 @@ vi.mock("node:crypto", () => ({ vi.mock("node:fs", () => ({ existsSync: vi.fn(() => false), + createReadStream: mockCreateReadStream, })); vi.mock("node:os", () => ({ homedir: mockHomedir, })); -import { create, manifest, default as defaultExport } from "./index.js"; +import { Readable } from "node:stream"; +import { create, manifest, default as defaultExport, resolveCodexBinary, _resetSessionFileCache } from "./index.js"; // --------------------------------------------------------------------------- // Test helpers @@ -108,9 +124,58 @@ function mockTmuxWithProcess(processName: string, found = true) { }); } +/** + * Create a mock file handle for `open()` that returns `content` from `read()`. + * Used by sessionFileMatchesCwd which reads only the first 4 KB. + */ +function makeFakeFileHandle(content: string) { + const buf = Buffer.from(content, "utf-8"); + return { + read: vi.fn().mockImplementation((buffer: Buffer, offset: number, length: number, _position: number) => { + const bytesToCopy = Math.min(length, buf.length); + buf.copy(buffer, offset, 0, bytesToCopy); + return Promise.resolve({ bytesRead: bytesToCopy, buffer }); + }), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +/** + * Set up mockOpen so that any `open(path, "r")` call returns a fake handle + * reading `content`. This is used by sessionFileMatchesCwd. + */ +function setupMockOpen(content: string) { + mockOpen.mockResolvedValue(makeFakeFileHandle(content)); +} + +/** + * Create a Readable stream from a string. Used to mock createReadStream + * for the streaming JSONL parser (streamCodexSessionData). + */ +function makeContentStream(content: string): Readable { + return Readable.from(Buffer.from(content, "utf-8")); +} + +/** + * Set up mockCreateReadStream to return a readable stream with the given content. + * Used by getSessionInfo/getRestoreCommand which now stream files line-by-line. + */ +function setupMockStream(content: string) { + mockCreateReadStream.mockReturnValue(makeContentStream(content)); +} + beforeEach(() => { vi.clearAllMocks(); + _resetSessionFileCache(); mockHomedir.mockReturnValue("/mock/home"); + // Default: open() returns a handle with empty content (no session_meta match). + // Session tests call setupMockOpen(content) to override. + mockOpen.mockResolvedValue(makeFakeFileHandle("")); + // Default: lstat rejects (no subdirectories). Session tests override as needed. + mockLstat.mockRejectedValue(new Error("ENOENT")); + // Default: createReadStream returns an empty stream. Session tests call + // setupMockStream(content) to override. + mockCreateReadStream.mockReturnValue(makeContentStream("")); }); // ========================================================================= @@ -145,12 +210,35 @@ describe("getLaunchCommand", () => { const agent = create(); it("generates base command", () => { - expect(agent.getLaunchCommand(makeLaunchConfig())).toBe("codex"); + expect(agent.getLaunchCommand(makeLaunchConfig())).toBe("'codex'"); }); - it("includes --full-auto when permissions=skip", () => { + it("includes --dangerously-bypass-approvals-and-sandbox when permissions=skip", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "skip" })); - expect(cmd).toContain("--full-auto"); + expect(cmd).toContain("--dangerously-bypass-approvals-and-sandbox"); + expect(cmd).not.toContain("--full-auto"); + }); + + it("includes --ask-for-approval never when permissions=auto-edit", () => { + // Cast needed: "auto-edit" not yet in AgentLaunchConfig type union + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ permissions: "auto-edit" as AgentLaunchConfig["permissions"] }), + ); + expect(cmd).toContain("--ask-for-approval never"); + }); + + it("includes --ask-for-approval untrusted when permissions=suggest", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ permissions: "suggest" as AgentLaunchConfig["permissions"] }), + ); + expect(cmd).toContain("--ask-for-approval untrusted"); + }); + + it("omits approval flags when permissions=default", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "default" })); + expect(cmd).not.toContain("--dangerously-bypass-approvals-and-sandbox"); + expect(cmd).not.toContain("--ask-for-approval"); + expect(cmd).not.toContain("--full-auto"); }); it("includes --model with shell-escaped value", () => { @@ -167,7 +255,7 @@ describe("getLaunchCommand", () => { const cmd = agent.getLaunchCommand( makeLaunchConfig({ permissions: "skip", model: "o3", prompt: "Go" }), ); - expect(cmd).toBe("codex --full-auto --model 'o3' -- 'Go'"); + expect(cmd).toBe("'codex' --dangerously-bypass-approvals-and-sandbox --model 'o3' -c model_reasoning_effort=high -- 'Go'"); }); it("escapes single quotes in prompt (POSIX shell escaping)", () => { @@ -203,9 +291,54 @@ describe("getLaunchCommand", () => { it("omits optional flags when not provided", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig()); - expect(cmd).not.toContain("--full-auto"); + expect(cmd).not.toContain("--dangerously-bypass-approvals-and-sandbox"); + expect(cmd).not.toContain("--ask-for-approval"); expect(cmd).not.toContain("--model"); expect(cmd).not.toContain("-c"); + expect(cmd).not.toContain("model_reasoning_effort"); + }); + + // -- Reasoning effort tests -- + describe("reasoning effort", () => { + it("adds model_reasoning_effort=high for o3 model", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig({ model: "o3" })); + expect(cmd).toContain("-c model_reasoning_effort=high"); + }); + + it("adds model_reasoning_effort=high for o3-mini model", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig({ model: "o3-mini" })); + expect(cmd).toContain("-c model_reasoning_effort=high"); + }); + + it("adds model_reasoning_effort=high for o4-mini model", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig({ model: "o4-mini" })); + expect(cmd).toContain("-c model_reasoning_effort=high"); + }); + + it("adds model_reasoning_effort=high for O3 (case-insensitive)", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig({ model: "O3" })); + expect(cmd).toContain("-c model_reasoning_effort=high"); + }); + + it("adds model_reasoning_effort=high for O4-MINI (case-insensitive)", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig({ model: "O4-MINI" })); + expect(cmd).toContain("-c model_reasoning_effort=high"); + }); + + it("does NOT add reasoning effort for gpt-4o model", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig({ model: "gpt-4o" })); + expect(cmd).not.toContain("model_reasoning_effort"); + }); + + it("does NOT add reasoning effort for gpt-4.1 model", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig({ model: "gpt-4.1" })); + expect(cmd).not.toContain("model_reasoning_effort"); + }); + + it("does NOT add reasoning effort when no model specified", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig()); + expect(cmd).not.toContain("model_reasoning_effort"); + }); }); }); @@ -443,54 +576,650 @@ describe("getActivityState", () => { it("returns exited when no runtimeHandle", async () => { const session = makeSession({ runtimeHandle: null }); const result = await agent.getActivityState(session); - expect(result).toEqual({ state: "exited" }); + expect(result?.state).toBe("exited"); + expect(result?.timestamp).toBeInstanceOf(Date); }); it("returns exited when process is not running", async () => { mockExecFileAsync.mockRejectedValue(new Error("tmux not running")); const session = makeSession({ runtimeHandle: makeTmuxHandle() }); const result = await agent.getActivityState(session); - expect(result).toEqual({ state: "exited" }); + expect(result?.state).toBe("exited"); + expect(result?.timestamp).toBeInstanceOf(Date); }); - it("returns null (unknown) when process is running", async () => { + it("returns null when process is running but no workspacePath", async () => { mockTmuxWithProcess("codex"); - const session = makeSession({ runtimeHandle: makeTmuxHandle() }); + const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: undefined }); expect(await agent.getActivityState(session)).toBeNull(); }); + it("returns null when process is running but no session file found", async () => { + mockTmuxWithProcess("codex"); + mockReaddir.mockRejectedValue(new Error("ENOENT")); + const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" }); + expect(await agent.getActivityState(session)).toBeNull(); + }); + + it("returns active when session file was recently modified", async () => { + mockTmuxWithProcess("codex"); + const content = '{"type":"session_meta","cwd":"/workspace/test"}\n'; + mockReaddir.mockResolvedValue(["sess.jsonl"]); + setupMockOpen(content); + // mtime = now (just modified) + mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() }); + + const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" }); + const result = await agent.getActivityState(session); + expect(result?.state).toBe("active"); + expect(result?.timestamp).toBeInstanceOf(Date); + }); + + it("returns idle when session file is stale", async () => { + mockTmuxWithProcess("codex"); + const content = '{"type":"session_meta","cwd":"/workspace/test"}\n'; + mockReaddir.mockResolvedValue(["sess.jsonl"]); + setupMockOpen(content); + // mtime = 10 minutes ago (past the 5-minute threshold) + const staleTime = Date.now() - 600_000; + mockStat.mockResolvedValue({ mtimeMs: staleTime, mtime: new Date(staleTime) }); + + const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" }); + const result = await agent.getActivityState(session); + expect(result?.state).toBe("idle"); + expect(result?.timestamp).toBeInstanceOf(Date); + }); + it("returns exited when process handle has dead PID", async () => { const killSpy = vi.spyOn(process, "kill").mockImplementation(() => { throw new Error("ESRCH"); }); const session = makeSession({ runtimeHandle: makeProcessHandle(999) }); const result = await agent.getActivityState(session); - expect(result).toEqual({ state: "exited" }); + expect(result?.state).toBe("exited"); + expect(result?.timestamp).toBeInstanceOf(Date); killSpy.mockRestore(); }); - - it("does not include timestamp in exited state", async () => { - const session = makeSession({ runtimeHandle: null }); - const result = await agent.getActivityState(session); - // The Codex implementation returns { state: "exited" } without timestamp - expect(result).toEqual({ state: "exited" }); - expect(result?.timestamp).toBeUndefined(); - }); }); // ========================================================================= -// getSessionInfo +// getSessionInfo — Codex JSONL parsing // ========================================================================= describe("getSessionInfo", () => { const agent = create(); - it("always returns null (not implemented)", async () => { - expect(await agent.getSessionInfo(makeSession())).toBeNull(); - expect(await agent.getSessionInfo(makeSession({ workspacePath: "/some/path" }))).toBeNull(); + // Helper to build JSONL content from lines + function jsonl(...lines: Record[]): string { + return lines.map((l) => JSON.stringify(l)).join("\n") + "\n"; + } + + it("returns null when workspacePath is null", async () => { + expect(await agent.getSessionInfo(makeSession({ workspacePath: null }))).toBeNull(); }); - it("returns null even with null workspacePath", async () => { - expect(await agent.getSessionInfo(makeSession({ workspacePath: null }))).toBeNull(); + it("returns null when workspacePath is undefined", async () => { + expect(await agent.getSessionInfo(makeSession({ workspacePath: undefined }))).toBeNull(); + }); + + it("returns null when ~/.codex/sessions/ directory does not exist", async () => { + mockReaddir.mockRejectedValue(new Error("ENOENT")); + expect(await agent.getSessionInfo(makeSession())).toBeNull(); + }); + + it("returns null when sessions directory is empty", async () => { + mockReaddir.mockResolvedValue([]); + expect(await agent.getSessionInfo(makeSession())).toBeNull(); + }); + + it("returns null when no session files match the workspace cwd", async () => { + mockReaddir.mockResolvedValue(["session-abc.jsonl"]); + const content = jsonl({ type: "session_meta", cwd: "/other/workspace", model: "gpt-4o" }); + setupMockOpen(content); + mockReadFile.mockResolvedValue(content); + expect(await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" }))).toBeNull(); + }); + + it("returns session info with cost and model when matching session found", async () => { + const sessionContent = jsonl( + { type: "session_meta", cwd: "/workspace/test", model: "o3-mini" }, + { type: "event_msg", msg: { type: "token_count", input_tokens: 1000, output_tokens: 500, cached_tokens: 200, reasoning_tokens: 100 } }, + { type: "event_msg", msg: { type: "token_count", input_tokens: 2000, output_tokens: 300, cached_tokens: 0, reasoning_tokens: 0 } }, + ); + + mockReaddir.mockResolvedValue(["session-123.jsonl"]); + setupMockOpen(sessionContent); + setupMockStream(sessionContent); + mockReadFile.mockResolvedValue(sessionContent); + mockStat.mockResolvedValue({ mtimeMs: 1000 }); + + const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })); + + expect(result).not.toBeNull(); + expect(result!.agentSessionId).toBe("session-123"); + expect(result!.summary).toBe("Codex session (o3-mini)"); + expect(result!.summaryIsFallback).toBe(true); + expect(result!.cost).toBeDefined(); + // cached_tokens/reasoning_tokens are subsets, not additive + // input: 1000 + 2000 = 3000 + // output: 500 + 300 = 800 + expect(result!.cost!.inputTokens).toBe(3000); + expect(result!.cost!.outputTokens).toBe(800); + expect(result!.cost!.estimatedCostUsd).toBeGreaterThan(0); + }); + + it("picks the most recently modified matching session file", async () => { + const oldContent = jsonl( + { type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" }, + ); + const newContent = jsonl( + { type: "session_meta", cwd: "/workspace/test", model: "o3" }, + ); + + mockReaddir.mockResolvedValue(["old-session.jsonl", "new-session.jsonl"]); + mockOpen.mockImplementation(async (path: string) => { + if (path.includes("old-session")) return makeFakeFileHandle(oldContent); + if (path.includes("new-session")) return makeFakeFileHandle(newContent); + throw new Error("ENOENT"); + }); + mockReadFile.mockImplementation((path: string) => { + if (path.includes("old-session")) return Promise.resolve(oldContent); + if (path.includes("new-session")) return Promise.resolve(newContent); + return Promise.reject(new Error("ENOENT")); + }); + mockCreateReadStream.mockImplementation((path: string) => { + if (path.includes("old-session")) return makeContentStream(oldContent); + if (path.includes("new-session")) return makeContentStream(newContent); + return makeContentStream(""); + }); + mockStat.mockImplementation((path: string) => { + if (path.includes("old-session")) return Promise.resolve({ mtimeMs: 1000 }); + if (path.includes("new-session")) return Promise.resolve({ mtimeMs: 2000 }); + return Promise.reject(new Error("ENOENT")); + }); + + const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })); + + expect(result).not.toBeNull(); + expect(result!.agentSessionId).toBe("new-session"); + expect(result!.summary).toBe("Codex session (o3)"); + }); + + it("handles corrupt/malformed JSONL lines gracefully", async () => { + const content = '{"type":"session_meta","cwd":"/workspace/test","model":"gpt-4o"}\n' + + "not valid json\n" + + '{"type":"event_msg","msg":{"type":"token_count","input_tokens":500,"output_tokens":200}}\n'; + + mockReaddir.mockResolvedValue(["sess.jsonl"]); + setupMockOpen(content); + setupMockStream(content); + mockReadFile.mockResolvedValue(content); + mockStat.mockResolvedValue({ mtimeMs: 1000 }); + + const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })); + + expect(result).not.toBeNull(); + expect(result!.cost!.inputTokens).toBe(500); + expect(result!.cost!.outputTokens).toBe(200); + }); + + it("returns null summary when no model in session_meta", async () => { + const content = jsonl( + { type: "session_meta", cwd: "/workspace/test" }, + { type: "event_msg", msg: { type: "token_count", input_tokens: 100, output_tokens: 50 } }, + ); + + mockReaddir.mockResolvedValue(["sess.jsonl"]); + setupMockOpen(content); + setupMockStream(content); + mockStat.mockResolvedValue({ mtimeMs: 1000 }); + + const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })); + + expect(result).not.toBeNull(); + expect(result!.summary).toBeNull(); + // Verify cost was actually parsed from the stream (not just defaulting to undefined) + expect(result!.cost).toBeDefined(); + expect(result!.cost!.inputTokens).toBe(100); + expect(result!.cost!.outputTokens).toBe(50); + }); + + it("returns undefined cost when no token_count events", async () => { + const content = jsonl( + { type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" }, + { type: "event_msg", msg: { type: "other_event" } }, + ); + + mockReaddir.mockResolvedValue(["sess.jsonl"]); + setupMockOpen(content); + setupMockStream(content); + mockStat.mockResolvedValue({ mtimeMs: 1000 }); + + const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })); + + expect(result).not.toBeNull(); + expect(result!.cost).toBeUndefined(); + // Verify model was actually parsed from the stream (not just defaulting to null) + expect(result!.summary).toContain("gpt-4o"); + }); + + it("handles unreadable session files gracefully", async () => { + mockReaddir.mockResolvedValue(["sess.jsonl"]); + // open() finds matching session_meta, but readFile fails for full parse + setupMockOpen(jsonl({ type: "session_meta", cwd: "/workspace/test" })); + mockStat.mockResolvedValue({ mtimeMs: 1000 }); + mockReadFile.mockRejectedValue(new Error("EACCES")); + mockCreateReadStream.mockImplementation(() => { throw new Error("EACCES"); }); + + expect(await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" }))).toBeNull(); + }); + + it("skips session files when stat throws", async () => { + const content = jsonl( + { type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" }, + ); + mockReaddir.mockResolvedValue(["sess.jsonl"]); + setupMockOpen(content); + mockReadFile.mockResolvedValue(content); + mockStat.mockRejectedValue(new Error("EACCES")); + + const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })); + // stat failed so no bestMatch can be established + expect(result).toBeNull(); + }); + + it("returns null when session JSONL has only empty/malformed lines", async () => { + mockReaddir.mockResolvedValue(["sess.jsonl"]); + // open() finds matching session_meta for cwd check + setupMockOpen(jsonl({ type: "session_meta", cwd: "/workspace/test" })); + // readFile (full parse) returns only garbage + mockReadFile.mockResolvedValue("not json\n\n \n"); + setupMockStream("not json\n\n \n"); + mockStat.mockResolvedValue({ mtimeMs: 1000 }); + + const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })); + // With streaming parser, garbage lines are skipped gracefully and a result + // is returned with null summary and undefined cost (no valid data extracted). + expect(result).not.toBeNull(); + expect(result!.summary).toBeNull(); + expect(result!.cost).toBeUndefined(); + }); + + it("finds session files in date-sharded subdirectories (YYYY/MM/DD)", async () => { + // Simulate ~/.codex/sessions/2026/02/24/rollout-abc.jsonl + mockReaddir.mockImplementation((dir: string) => { + if (dir.endsWith("sessions")) return Promise.resolve(["2026"]); + if (dir.endsWith("2026")) return Promise.resolve(["02"]); + if (dir.endsWith("02")) return Promise.resolve(["24"]); + if (dir.endsWith("24")) return Promise.resolve(["rollout-abc.jsonl"]); + return Promise.resolve([]); + }); + // lstat is used by collectJsonlFiles to check subdirectories (avoids symlink cycles) + mockLstat.mockResolvedValue({ isDirectory: () => true }); + // stat is used by findCodexSessionFile to get mtimeMs of matching JSONL files + mockStat.mockResolvedValue({ mtimeMs: 2000 }); + const content = jsonl( + { type: "session_meta", cwd: "/workspace/test", model: "o3-mini" }, + ); + setupMockOpen(content); + setupMockStream(content); + mockReadFile.mockResolvedValue(content); + + const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })); + expect(result).not.toBeNull(); + expect(result!.agentSessionId).toBe("rollout-abc"); + expect(result!.summary).toBe("Codex session (o3-mini)"); + }); + + it("ignores non-JSONL files in sessions directory", async () => { + mockReaddir.mockResolvedValue(["notes.txt", "config.json", "sess.jsonl"]); + const content = jsonl( + { type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" }, + ); + setupMockOpen(content); + setupMockStream(content); + mockReadFile.mockResolvedValue(content); + // Non-JSONL entries trigger lstat to check isDirectory() + mockLstat.mockResolvedValue({ isDirectory: () => false }); + // stat is used to get mtimeMs for matching JSONL files + mockStat.mockResolvedValue({ mtimeMs: 1000 }); + + const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })); + expect(result).not.toBeNull(); + // With streaming parser, readFile is no longer used for full parse; + // cwd check uses open(), data extraction uses createReadStream. + const readFileCalls = mockReadFile.mock.calls.filter( + (call: string[]) => typeof call[0] === "string" && call[0].includes("sessions/"), + ); + expect(readFileCalls.length).toBe(0); // streaming replaces readFile for full parse + }); +}); + +// ========================================================================= +// getRestoreCommand — conversation resume +// ========================================================================= +describe("getRestoreCommand", () => { + const agent = create(); + + function jsonl(...lines: Record[]): string { + return lines.map((l) => JSON.stringify(l)).join("\n") + "\n"; + } + + function makeProjectConfig(overrides: Record = {}) { + return { + name: "test-project", + repo: "owner/repo", + path: "/workspace/repo", + defaultBranch: "main", + sessionPrefix: "test", + ...overrides, + }; + } + + it("returns null when workspacePath is null", async () => { + const session = makeSession({ workspacePath: null }); + expect(await agent.getRestoreCommand!(session, makeProjectConfig())).toBeNull(); + }); + + it("returns null when workspacePath is undefined", async () => { + const session = makeSession({ workspacePath: undefined }); + expect(await agent.getRestoreCommand!(session, makeProjectConfig())).toBeNull(); + }); + + it("returns null when no matching session file found", async () => { + mockReaddir.mockRejectedValue(new Error("ENOENT")); + const session = makeSession({ workspacePath: "/workspace/test" }); + expect(await agent.getRestoreCommand!(session, makeProjectConfig())).toBeNull(); + }); + + it("returns null when session has no threadId", async () => { + const content = jsonl( + { type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" }, + { role: "user", content: "Some prompt" }, + ); + mockReaddir.mockResolvedValue(["sess.jsonl"]); + setupMockOpen(content); + setupMockStream(content); + mockStat.mockResolvedValue({ mtimeMs: 1000 }); + + const session = makeSession({ workspacePath: "/workspace/test" }); + // Native resume requires a threadId + expect(await agent.getRestoreCommand!(session, makeProjectConfig())).toBeNull(); + }); + + it("builds native resume command with codex resume ", async () => { + const content = jsonl( + { type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" }, + { threadId: "thread-abc-123" }, + ); + mockReaddir.mockResolvedValue(["sess.jsonl"]); + setupMockOpen(content); + setupMockStream(content); + mockReadFile.mockResolvedValue(content); + mockStat.mockResolvedValue({ mtimeMs: 1000 }); + + const session = makeSession({ workspacePath: "/workspace/test" }); + const cmd = await agent.getRestoreCommand!(session, makeProjectConfig()); + + expect(cmd).not.toBeNull(); + expect(cmd).toContain("'codex' resume"); + expect(cmd).toContain("thread-abc-123"); + }); + + it("includes --dangerously-bypass-approvals-and-sandbox from project config", async () => { + const content = jsonl( + { type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" }, + { threadId: "thread-1" }, + ); + mockReaddir.mockResolvedValue(["sess.jsonl"]); + setupMockOpen(content); + setupMockStream(content); + mockReadFile.mockResolvedValue(content); + mockStat.mockResolvedValue({ mtimeMs: 1000 }); + + const session = makeSession({ workspacePath: "/workspace/test" }); + const cmd = await agent.getRestoreCommand!(session, makeProjectConfig({ + agentConfig: { permissions: "skip" }, + })); + + expect(cmd).toContain("--dangerously-bypass-approvals-and-sandbox"); + }); + + it("includes --ask-for-approval never from project config", async () => { + const content = jsonl( + { type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" }, + { threadId: "thread-1" }, + ); + mockReaddir.mockResolvedValue(["sess.jsonl"]); + setupMockOpen(content); + setupMockStream(content); + mockReadFile.mockResolvedValue(content); + mockStat.mockResolvedValue({ mtimeMs: 1000 }); + + const session = makeSession({ workspacePath: "/workspace/test" }); + const cmd = await agent.getRestoreCommand!(session, makeProjectConfig({ + agentConfig: { permissions: "auto-edit" }, + })); + + expect(cmd).toContain("--ask-for-approval never"); + expect(cmd).not.toContain("--dangerously-bypass-approvals-and-sandbox"); + }); + + it("includes --ask-for-approval untrusted from project config", async () => { + const content = jsonl( + { type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" }, + { threadId: "thread-1" }, + ); + mockReaddir.mockResolvedValue(["sess.jsonl"]); + setupMockOpen(content); + setupMockStream(content); + mockReadFile.mockResolvedValue(content); + mockStat.mockResolvedValue({ mtimeMs: 1000 }); + + const session = makeSession({ workspacePath: "/workspace/test" }); + const cmd = await agent.getRestoreCommand!(session, makeProjectConfig({ + agentConfig: { permissions: "suggest" }, + })); + + expect(cmd).toContain("--ask-for-approval untrusted"); + }); + + it("places flags before positional threadId in resume command", async () => { + const content = jsonl( + { type: "session_meta", cwd: "/workspace/test", model: "o3-mini" }, + { threadId: "thread-order-test" }, + ); + mockReaddir.mockResolvedValue(["sess.jsonl"]); + setupMockOpen(content); + setupMockStream(content); + mockReadFile.mockResolvedValue(content); + mockStat.mockResolvedValue({ mtimeMs: 1000 }); + + const session = makeSession({ workspacePath: "/workspace/test" }); + const cmd = await agent.getRestoreCommand!(session, makeProjectConfig({ + agentConfig: { permissions: "auto-edit", model: "o3-mini" }, + })); + + expect(cmd).not.toBeNull(); + // threadId should come after all flags + const threadIdIdx = cmd!.indexOf("thread-order-test"); + const flagIdx = cmd!.indexOf("--ask-for-approval"); + const modelIdx = cmd!.indexOf("--model"); + expect(flagIdx).toBeLessThan(threadIdIdx); + expect(modelIdx).toBeLessThan(threadIdIdx); + }); + + it("includes model from project config (overrides session model)", async () => { + const content = jsonl( + { type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" }, + { threadId: "thread-1" }, + ); + mockReaddir.mockResolvedValue(["sess.jsonl"]); + setupMockOpen(content); + setupMockStream(content); + mockReadFile.mockResolvedValue(content); + mockStat.mockResolvedValue({ mtimeMs: 1000 }); + + const session = makeSession({ workspacePath: "/workspace/test" }); + const cmd = await agent.getRestoreCommand!(session, makeProjectConfig({ + agentConfig: { model: "o3-mini" }, + })); + + expect(cmd).toContain("--model 'o3-mini'"); + expect(cmd).toContain("-c model_reasoning_effort=high"); + }); + + it("falls back to session model when project config has no model", async () => { + const content = jsonl( + { type: "session_meta", cwd: "/workspace/test", model: "o4-mini" }, + { threadId: "thread-1" }, + ); + mockReaddir.mockResolvedValue(["sess.jsonl"]); + setupMockOpen(content); + setupMockStream(content); + mockReadFile.mockResolvedValue(content); + mockStat.mockResolvedValue({ mtimeMs: 1000 }); + + const session = makeSession({ workspacePath: "/workspace/test" }); + const cmd = await agent.getRestoreCommand!(session, makeProjectConfig()); + + expect(cmd).toContain("--model 'o4-mini'"); + expect(cmd).toContain("-c model_reasoning_effort=high"); + }); + + it("handles unreadable session files gracefully", async () => { + mockReaddir.mockResolvedValue(["sess.jsonl"]); + // open() finds matching session_meta for cwd check + setupMockOpen(jsonl({ type: "session_meta", cwd: "/workspace/test" })); + // readFile (full parse) fails + mockReadFile.mockRejectedValue(new Error("EACCES")); + mockCreateReadStream.mockImplementation(() => { throw new Error("EACCES"); }); + mockStat.mockResolvedValue({ mtimeMs: 1000 }); + + const session = makeSession({ workspacePath: "/workspace/test" }); + expect(await agent.getRestoreCommand!(session, makeProjectConfig())).toBeNull(); + }); +}); + +// ========================================================================= +// resolveCodexBinary +// ========================================================================= +describe("resolveCodexBinary", () => { + it("returns path from `which` when codex is found", async () => { + mockExecFileAsync.mockResolvedValue({ stdout: "/usr/local/bin/codex\n", stderr: "" }); + const result = await resolveCodexBinary(); + expect(result).toBe("/usr/local/bin/codex"); + expect(mockExecFileAsync).toHaveBeenCalledWith("which", ["codex"], { timeout: 10_000 }); + }); + + it("falls back to common locations when `which` fails", async () => { + mockExecFileAsync.mockRejectedValue(new Error("not found")); + mockStat.mockImplementation((path: string) => { + if (path === "/usr/local/bin/codex") { + return Promise.resolve({ mtimeMs: 1000 }); + } + return Promise.reject(new Error("ENOENT")); + }); + + const result = await resolveCodexBinary(); + expect(result).toBe("/usr/local/bin/codex"); + }); + + it("checks /opt/homebrew/bin/codex as fallback", async () => { + mockExecFileAsync.mockRejectedValue(new Error("not found")); + mockStat.mockImplementation((path: string) => { + if (path === "/opt/homebrew/bin/codex") { + return Promise.resolve({ mtimeMs: 1000 }); + } + return Promise.reject(new Error("ENOENT")); + }); + + const result = await resolveCodexBinary(); + expect(result).toBe("/opt/homebrew/bin/codex"); + }); + + it("checks ~/.cargo/bin/codex as fallback (Rust-based codex)", async () => { + mockExecFileAsync.mockRejectedValue(new Error("not found")); + mockStat.mockImplementation((path: string) => { + if (path === "/mock/home/.cargo/bin/codex") { + return Promise.resolve({ mtimeMs: 1000 }); + } + return Promise.reject(new Error("ENOENT")); + }); + + const result = await resolveCodexBinary(); + expect(result).toBe("/mock/home/.cargo/bin/codex"); + }); + + it("checks ~/.npm/bin/codex as fallback", async () => { + mockExecFileAsync.mockRejectedValue(new Error("not found")); + mockStat.mockImplementation((path: string) => { + if (path === "/mock/home/.npm/bin/codex") { + return Promise.resolve({ mtimeMs: 1000 }); + } + return Promise.reject(new Error("ENOENT")); + }); + + const result = await resolveCodexBinary(); + expect(result).toBe("/mock/home/.npm/bin/codex"); + }); + + it("returns 'codex' when not found anywhere", async () => { + mockExecFileAsync.mockRejectedValue(new Error("not found")); + mockStat.mockRejectedValue(new Error("ENOENT")); + + const result = await resolveCodexBinary(); + expect(result).toBe("codex"); + }); + + it("returns 'codex' when `which` returns empty stdout", async () => { + mockExecFileAsync.mockResolvedValue({ stdout: "", stderr: "" }); + mockStat.mockRejectedValue(new Error("ENOENT")); + + const result = await resolveCodexBinary(); + expect(result).toBe("codex"); + }); +}); + +// ========================================================================= +// postLaunchSetup — binary resolution +// ========================================================================= +describe("postLaunchSetup", () => { + it("has postLaunchSetup method", () => { + const agent = create(); + expect(typeof agent.postLaunchSetup).toBe("function"); + }); + + it("runs setup when session has workspacePath", async () => { + const agent = create(); + // which fails, stat fails → resolves to "codex" + mockExecFileAsync.mockRejectedValue(new Error("not found")); + mockStat.mockRejectedValue(new Error("ENOENT")); + mockReadFile.mockRejectedValue(new Error("ENOENT")); + await agent.postLaunchSetup!(makeSession({ workspacePath: "/workspace/test" })); + expect(mockMkdir).toHaveBeenCalled(); + }); + + it("returns early when session has no workspacePath", async () => { + const agent = create(); + mockExecFileAsync.mockRejectedValue(new Error("not found")); + mockStat.mockRejectedValue(new Error("ENOENT")); + await agent.postLaunchSetup!(makeSession({ workspacePath: undefined })); + expect(mockMkdir).not.toHaveBeenCalled(); + }); + + it("resolves binary and uses it in getLaunchCommand after postLaunchSetup", async () => { + const agent = create(); + mockExecFileAsync.mockResolvedValue({ stdout: "/opt/bin/codex\n", stderr: "" }); + mockReadFile.mockRejectedValue(new Error("ENOENT")); + + // Before postLaunchSetup, binary is "codex" + expect(agent.getLaunchCommand(makeLaunchConfig())).toBe("'codex'"); + + // After postLaunchSetup resolves the binary + await agent.postLaunchSetup!(makeSession({ workspacePath: "/workspace/test" })); + + // Now getLaunchCommand should use the resolved binary + expect(agent.getLaunchCommand(makeLaunchConfig())).toBe("'/opt/bin/codex'"); }); }); @@ -747,28 +1476,6 @@ describe("setupWorkspaceHooks", () => { }); }); -// ========================================================================= -// postLaunchSetup -// ========================================================================= -describe("postLaunchSetup", () => { - const agent = create(); - - it("has postLaunchSetup method", () => { - expect(typeof agent.postLaunchSetup).toBe("function"); - }); - - it("runs setup when session has workspacePath", async () => { - mockReadFile.mockRejectedValue(new Error("ENOENT")); - await agent.postLaunchSetup!(makeSession({ workspacePath: "/workspace/test" })); - expect(mockMkdir).toHaveBeenCalled(); - }); - - it("returns early when session has no workspacePath", async () => { - await agent.postLaunchSetup!(makeSession({ workspacePath: undefined })); - expect(mockMkdir).not.toHaveBeenCalled(); - }); -}); - // ========================================================================= // Shell wrapper content verification // ========================================================================= diff --git a/packages/plugins/agent-codex/src/index.ts b/packages/plugins/agent-codex/src/index.ts index 916d0f293..0ebb69039 100644 --- a/packages/plugins/agent-codex/src/index.ts +++ b/packages/plugins/agent-codex/src/index.ts @@ -1,19 +1,24 @@ import { + DEFAULT_READY_THRESHOLD_MS, shellEscape, type Agent, type AgentSessionInfo, type AgentLaunchConfig, type ActivityState, type ActivityDetection, + type CostEstimate, type PluginModule, + type ProjectConfig, type RuntimeHandle, type Session, type WorkspaceHooksConfig, } from "@composio/ao-core"; import { execFile } from "node:child_process"; -import { writeFile, mkdir, readFile, rename } from "node:fs/promises"; +import { createReadStream } from "node:fs"; +import { writeFile, mkdir, readFile, readdir, rename, stat, lstat, open } from "node:fs/promises"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { basename, join } from "node:path"; +import { createInterface } from "node:readline"; import { promisify } from "node:util"; import { randomBytes } from "node:crypto"; @@ -270,25 +275,304 @@ async function setupCodexWorkspace(workspacePath: string): Promise { } } +// ============================================================================= +// Codex Session JSONL Parsing (for getSessionInfo) +// ============================================================================= + +/** Codex session directory: ~/.codex/sessions/ */ +const CODEX_SESSIONS_DIR = join(homedir(), ".codex", "sessions"); + +/** Typed representation of a line in a Codex JSONL session file */ +interface CodexJsonlLine { + type?: string; + cwd?: string; + model?: string; + // Thread ID from thread_started notifications + threadId?: string; + // User message content (from user input events) + content?: string; + role?: string; + // event_msg with token_count subtype + msg?: { + type?: string; + input_tokens?: number; + output_tokens?: number; + cached_tokens?: number; + reasoning_tokens?: number; + }; +} + +/** + * Collect all JSONL files under a directory, recursively. + * Codex stores sessions in date-sharded directories: + * ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl + * + * Uses lstat (not stat) so symlinks to directories are never followed, + * preventing infinite loops from symlink cycles. Max depth is capped at 4 + * (YYYY/MM/DD + 1 buffer) as an additional safety guard. + */ +const MAX_SESSION_SCAN_DEPTH = 4; + +async function collectJsonlFiles(dir: string, depth = 0): Promise { + if (depth > MAX_SESSION_SCAN_DEPTH) return []; + + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + return []; + } + + const results: string[] = []; + for (const entry of entries) { + const fullPath = join(dir, entry); + if (entry.endsWith(".jsonl")) { + results.push(fullPath); + } else { + // Recurse into subdirectories (YYYY/MM/DD structure). + // Use lstat to avoid following symlinks that could create cycles. + try { + const s = await lstat(fullPath); + if (s.isDirectory()) { + const nested = await collectJsonlFiles(fullPath, depth + 1); + results.push(...nested); + } + } catch { + // Skip inaccessible entries + } + } + } + return results; +} + +/** + * Check if the first few lines of a JSONL file contain a session_meta + * entry matching the given workspace path. Reads only the first 4 KB + * to avoid loading large rollout files into memory. + */ +async function sessionFileMatchesCwd( + filePath: string, + workspacePath: string, +): Promise { + try { + // Read only the first 4 KB — session_meta is always in the first few lines. + // Avoids loading large rollout files (100 MB+) into memory. + const handle = await open(filePath, "r"); + let content: string; + try { + const buffer = Buffer.allocUnsafe(4096); + const { bytesRead } = await handle.read(buffer, 0, 4096, 0); + content = buffer.subarray(0, bytesRead).toString("utf-8"); + } finally { + await handle.close(); + } + const lines = content.split("\n").slice(0, 10); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const parsed: unknown = JSON.parse(trimmed); + if ( + typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) && + (parsed as CodexJsonlLine).type === "session_meta" && + (parsed as CodexJsonlLine).cwd === workspacePath + ) { + return true; + } + } catch { + // Skip malformed lines + } + } + } catch { + // Unreadable file + } + return false; +} + +/** + * Find Codex session files whose `session_meta` cwd matches the given workspace path. + * Recursively scans ~/.codex/sessions/ (date-sharded: YYYY/MM/DD/rollout-*.jsonl). + * Returns the path to the most recently modified matching file, or null. + */ +async function findCodexSessionFile(workspacePath: string): Promise { + const jsonlFiles = await collectJsonlFiles(CODEX_SESSIONS_DIR); + if (jsonlFiles.length === 0) return null; + + let bestMatch: { path: string; mtime: number } | null = null; + + for (const filePath of jsonlFiles) { + const matches = await sessionFileMatchesCwd(filePath, workspacePath); + if (matches) { + try { + const s = await stat(filePath); + if (!bestMatch || s.mtimeMs > bestMatch.mtime) { + bestMatch = { path: filePath, mtime: s.mtimeMs }; + } + } catch { + // Skip if stat fails + } + } + } + + return bestMatch?.path ?? null; +} + +/** Aggregated data extracted from a Codex session file via streaming */ +interface CodexSessionData { + model: string | null; + threadId: string | null; + inputTokens: number; + outputTokens: number; +} + +/** + * Stream a Codex JSONL session file line-by-line and aggregate the data + * we need (model, threadId, token counts) without loading the entire file + * into memory. This is critical because Codex rollout files can be 100 MB+. + */ +async function streamCodexSessionData(filePath: string): Promise { + try { + const data: CodexSessionData = { model: null, threadId: null, inputTokens: 0, outputTokens: 0 }; + const rl = createInterface({ + input: createReadStream(filePath, { encoding: "utf-8" }), + crlfDelay: Infinity, + }); + + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const parsed: unknown = JSON.parse(trimmed); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) continue; + const entry = parsed as CodexJsonlLine; + + if (entry.type === "session_meta" && typeof entry.model === "string") { + data.model = entry.model; + } + if (typeof entry.threadId === "string" && entry.threadId) { + data.threadId = entry.threadId; + } + if (entry.type === "event_msg" && entry.msg?.type === "token_count") { + data.inputTokens += entry.msg.input_tokens ?? 0; + data.outputTokens += entry.msg.output_tokens ?? 0; + } + } catch { + // Skip malformed lines + } + } + + return data; + } catch { + return null; + } +} + +// ============================================================================= +// Binary Resolution +// ============================================================================= + +/** + * Resolve the Codex CLI binary path. + * Checks (in order): which, common fallback locations. + * Returns "codex" as final fallback (let the shell resolve it at runtime). + */ +export async function resolveCodexBinary(): Promise { + // 1. Try `which codex` + try { + const { stdout } = await execFileAsync("which", ["codex"], { timeout: 10_000 }); + const resolved = stdout.trim(); + if (resolved) return resolved; + } catch { + // Not found via which + } + + // 2. Check common locations (npm global, Homebrew, Cargo — Codex is now Rust-based) + const home = homedir(); + const candidates = [ + "/usr/local/bin/codex", + "/opt/homebrew/bin/codex", + join(home, ".cargo", "bin", "codex"), + join(home, ".npm", "bin", "codex"), + ]; + + for (const candidate of candidates) { + try { + await stat(candidate); + return candidate; + } catch { + // Not found at this location + } + } + + // 3. Fallback: let the shell resolve it + return "codex"; +} + // ============================================================================= // Agent Implementation // ============================================================================= +/** Append approval-policy flags to a command parts array */ +function appendApprovalFlags(parts: string[], permissions: string | undefined): void { + if (permissions === "skip") { + parts.push("--dangerously-bypass-approvals-and-sandbox"); + } else if (permissions === "auto-edit") { + parts.push("--ask-for-approval", "never"); + } else if (permissions === "suggest") { + parts.push("--ask-for-approval", "untrusted"); + } +} + +/** Append model and reasoning flags to a command parts array */ +function appendModelFlags(parts: string[], model: string | undefined): void { + if (!model) return; + parts.push("--model", shellEscape(model)); + + // Auto-detect o-series models and enable reasoning via config override. + // Codex does not have a --reasoning flag; reasoning is controlled via + // the model_reasoning_effort config key. + if (/^o[34]/i.test(model)) { + parts.push("-c", "model_reasoning_effort=high"); + } +} + +/** TTL for session file path cache (ms). Prevents redundant filesystem scans + * when getActivityState and getSessionInfo are called in the same refresh cycle. */ +const SESSION_FILE_CACHE_TTL_MS = 30_000; + +/** Module-level session file cache shared across the agent instance lifetime. + * Keyed by workspace path, stores the resolved file path and an expiry timestamp. */ +const sessionFileCache = new Map(); + +/** Find session file with caching to avoid double scans per refresh cycle */ +async function findCodexSessionFileCached(workspacePath: string): Promise { + const cached = sessionFileCache.get(workspacePath); + if (cached && Date.now() < cached.expiry) { + return cached.path; + } + const result = await findCodexSessionFile(workspacePath); + sessionFileCache.set(workspacePath, { path: result, expiry: Date.now() + SESSION_FILE_CACHE_TTL_MS }); + return result; +} + function createCodexAgent(): Agent { + /** Cached resolved binary path (populated by init or first getLaunchCommand) */ + let resolvedBinary: string | null = null; + /** Guard against concurrent resolveCodexBinary() calls */ + let resolvingBinary: Promise | null = null; + return { name: "codex", processName: "codex", getLaunchCommand(config: AgentLaunchConfig): string { - const parts: string[] = ["codex"]; + const binary = resolvedBinary ?? "codex"; + const parts: string[] = [shellEscape(binary)]; - if (config.permissions === "skip") { - parts.push("--full-auto"); - } - - if (config.model) { - parts.push("--model", shellEscape(config.model)); - } + appendApprovalFlags(parts, config.permissions as string | undefined); + appendModelFlags(parts, config.model); if (config.systemPromptFile) { // Codex reads developer instructions from a file via config override @@ -342,20 +626,38 @@ function createCodexAgent(): Agent { return "active"; }, - async getActivityState(session: Session, _readyThresholdMs?: number): Promise { - // Check if process is running first - if (!session.runtimeHandle) return { state: "exited" }; - const running = await this.isProcessRunning(session.runtimeHandle); - if (!running) return { state: "exited" }; + async getActivityState(session: Session, readyThresholdMs?: number): Promise { + const threshold = readyThresholdMs ?? DEFAULT_READY_THRESHOLD_MS; - // NOTE: Codex stores rollout files in a global ~/.codex/sessions/ directory - // without workspace-specific scoping. When multiple Codex sessions run in - // parallel, we cannot reliably determine which rollout file belongs to which - // session. Until Codex provides per-workspace session tracking, we return - // null (unknown) rather than guessing. See issue #13 for details. - // - // TODO: Implement proper per-session activity detection when Codex supports it. - return null; + // Check if process is running first + const exitedAt = new Date(); + if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt }; + const running = await this.isProcessRunning(session.runtimeHandle); + if (!running) return { state: "exited", timestamp: exitedAt }; + + // Use session file mtime as a proxy for activity. Codex continuously + // appends to its rollout JSONL file while working, so a recently + // modified file means the agent is active. + if (!session.workspacePath) return null; + + const sessionFile = await findCodexSessionFileCached(session.workspacePath); + if (!sessionFile) return null; + + try { + const s = await stat(sessionFile); + const timestamp = s.mtime; + const ageMs = Date.now() - s.mtimeMs; + + if (ageMs <= threshold) { + // File was recently modified — agent is actively working + return { state: "active", timestamp }; + } + + // File is stale — agent finished or is idle + return { state: "idle", timestamp }; + } catch { + return null; + } }, async isProcessRunning(handle: RuntimeHandle): Promise { @@ -409,9 +711,63 @@ function createCodexAgent(): Agent { } }, - async getSessionInfo(_session: Session): Promise { - // Codex doesn't have JSONL session files for introspection yet - return null; + async getSessionInfo(session: Session): Promise { + if (!session.workspacePath) return null; + + const sessionFile = await findCodexSessionFileCached(session.workspacePath); + if (!sessionFile) return null; + + // Stream the file line-by-line to avoid loading potentially huge + // rollout files (100 MB+) entirely into memory. + const data = await streamCodexSessionData(sessionFile); + if (!data) return null; + + const agentSessionId = basename(sessionFile, ".jsonl"); + + const cost: CostEstimate | undefined = + data.inputTokens === 0 && data.outputTokens === 0 + ? undefined + : { + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + estimatedCostUsd: + (data.inputTokens / 1_000_000) * 2.5 + (data.outputTokens / 1_000_000) * 10.0, + }; + + return { + summary: data.model ? `Codex session (${data.model})` : null, + summaryIsFallback: true, + agentSessionId, + cost, + }; + }, + + async getRestoreCommand(session: Session, project: ProjectConfig): Promise { + if (!session.workspacePath) return null; + + // Find the Codex session file for this workspace + const sessionFile = await findCodexSessionFileCached(session.workspacePath); + if (!sessionFile) return null; + + // Stream the file line-by-line to avoid loading potentially huge + // rollout files (100 MB+) entirely into memory. + const data = await streamCodexSessionData(sessionFile); + if (!data?.threadId) return null; + + // Use Codex's native `resume` subcommand for proper conversation resume. + // This restores the full thread state, not just a text prompt re-injection. + // Flags are placed before the positional threadId for CLI parser compatibility. + const binary = resolvedBinary ?? "codex"; + const parts: string[] = [shellEscape(binary), "resume"]; + + appendApprovalFlags(parts, project.agentConfig?.permissions as string | undefined); + const effectiveModel = (project.agentConfig?.model ?? data.model) as string | undefined; + appendModelFlags(parts, effectiveModel ?? undefined); + + // Positional threadId goes last, after all flags + parts.push(shellEscape(data.threadId)); + + return parts.join(" "); }, async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise { @@ -419,6 +775,18 @@ function createCodexAgent(): Agent { }, async postLaunchSetup(session: Session): Promise { + // Resolve binary path on first launch (cached for subsequent calls). + // Uses a promise guard to prevent concurrent calls from racing. + if (!resolvedBinary) { + if (!resolvingBinary) { + resolvingBinary = resolveCodexBinary(); + } + try { + resolvedBinary = await resolvingBinary; + } finally { + resolvingBinary = null; + } + } if (!session.workspacePath) return; await setupCodexWorkspace(session.workspacePath); }, @@ -433,4 +801,19 @@ export function create(): Agent { return createCodexAgent(); } +/** @internal Clear the session file cache. Exported for testing only. */ +export function _resetSessionFileCache(): void { + sessionFileCache.clear(); +} + +export { CodexAppServerClient } from "./app-server-client.js"; +export type { + AppServerClientOptions, + ThreadStartParams, + TurnStartParams, + NotificationHandler, + ApprovalHandler, + ApprovalDecision, +} from "./app-server-client.js"; + export default { manifest, create } satisfies PluginModule;