import { EventEmitter } from "node:events"; import { PassThrough, Writable } from "node:stream"; import { afterEach, describe, expect, it, vi } from "vitest"; import { CodexAppServerClient, listCodexAppServerModels, resetSharedCodexAppServerClientForTests, } from "./client.js"; function createClientHarness() { const stdout = new PassThrough(); const stderr = new PassThrough(); const writes: string[] = []; const stdin = new Writable({ write(chunk, _encoding, callback) { writes.push(chunk.toString()); callback(); }, }); const process = Object.assign(new EventEmitter(), { stdin, stdout, stderr, killed: false, kill: vi.fn(() => { process.killed = true; }), }); const client = CodexAppServerClient.fromTransportForTests(process); return { client, writes, send(message: unknown) { stdout.write(`${JSON.stringify(message)}\n`); }, }; } describe("CodexAppServerClient", () => { const clients: CodexAppServerClient[] = []; afterEach(() => { resetSharedCodexAppServerClientForTests(); vi.restoreAllMocks(); for (const client of clients) { client.close(); } clients.length = 0; }); it("routes request responses by id", async () => { const harness = createClientHarness(); clients.push(harness.client); const request = harness.client.request("model/list", {}); const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number; method?: string }; harness.send({ id: outbound.id, result: { models: [] } }); await expect(request).resolves.toEqual({ models: [] }); expect(outbound.method).toBe("model/list"); }); it("initializes with the required client version", async () => { const harness = createClientHarness(); clients.push(harness.client); const initializing = harness.client.initialize(); const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number; method?: string; params?: { clientInfo?: { name?: string; title?: string; version?: string } }; }; harness.send({ id: outbound.id, result: {} }); await expect(initializing).resolves.toBeUndefined(); expect(outbound).toMatchObject({ method: "initialize", params: { clientInfo: { name: "openclaw", title: "OpenClaw", version: expect.any(String), }, }, }); expect(outbound.params?.clientInfo?.version).not.toBe(""); expect(JSON.parse(harness.writes[1] ?? "{}")).toEqual({ method: "initialized" }); }); it("answers server-initiated requests with the registered handler result", async () => { const harness = createClientHarness(); clients.push(harness.client); harness.client.addRequestHandler((request) => { if (request.method === "item/tool/call") { return { contentItems: [{ type: "inputText", text: "ok" }], success: true }; } return undefined; }); harness.send({ id: "srv-1", method: "item/tool/call", params: { tool: "message" } }); await vi.waitFor(() => expect(harness.writes.length).toBe(1)); expect(JSON.parse(harness.writes[0] ?? "{}")).toEqual({ id: "srv-1", result: { contentItems: [{ type: "inputText", text: "ok" }], success: true }, }); }); it("fails closed for unhandled native app-server approvals", async () => { const harness = createClientHarness(); clients.push(harness.client); harness.send({ id: "approval-1", method: "item/commandExecution/requestApproval", params: { threadId: "thread-1", turnId: "turn-1", itemId: "cmd-1", command: "pnpm test" }, }); await vi.waitFor(() => expect(harness.writes.length).toBe(1)); expect(JSON.parse(harness.writes[0] ?? "{}")).toEqual({ id: "approval-1", result: { decision: "decline" }, }); }); it("fails closed with legacy review decisions for legacy app-server approvals", async () => { const harness = createClientHarness(); clients.push(harness.client); harness.send({ id: "approval-legacy", method: "execCommandApproval", params: { conversationId: "thread-1", callId: "cmd-1", command: ["pnpm", "test"] }, }); await vi.waitFor(() => expect(harness.writes.length).toBe(1)); expect(JSON.parse(harness.writes[0] ?? "{}")).toEqual({ id: "approval-legacy", result: { decision: "denied" }, }); }); it("lists app-server models through the typed helper", async () => { const harness = createClientHarness(); clients.push(harness.client); const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client); const listPromise = listCodexAppServerModels({ limit: 12, timeoutMs: 1000 }); const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number }; harness.send({ id: initialize.id, result: {} }); await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(3)); const list = JSON.parse(harness.writes[2] ?? "{}") as { id?: number; method?: string }; expect(list.method).toBe("model/list"); harness.send({ id: list.id, result: { data: [ { id: "gpt-5.4", model: "gpt-5.4", displayName: "gpt-5.4", inputModalities: ["text", "image"], supportedReasoningEfforts: [ { reasoningEffort: "low", description: "fast" }, { reasoningEffort: "xhigh", description: "deep" }, ], defaultReasoningEffort: "medium", isDefault: true, }, ], nextCursor: null, }, }); await expect(listPromise).resolves.toEqual({ models: [ { id: "gpt-5.4", model: "gpt-5.4", displayName: "gpt-5.4", inputModalities: ["text", "image"], supportedReasoningEfforts: ["low", "xhigh"], defaultReasoningEffort: "medium", isDefault: true, }, ], }); startSpy.mockRestore(); }); });