murashit_codex-panel/tests/app-server/app-server-client.test.ts

763 lines
26 KiB
TypeScript
Raw Normal View History

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AppServerClientHandlers, AppServerServerRequestResponder } from "../../src/app-server/connection/client";
2026-06-24 05:44:16 +00:00
import { AppServerClient } from "../../src/app-server/connection/client";
import type { RpcOutboundMessage } from "../../src/app-server/connection/rpc-messages";
2026-06-24 05:44:16 +00:00
import type { AppServerTransport, AppServerTransportHandlers } from "../../src/app-server/connection/transport";
import type { InitializeParams } from "../../src/generated/app-server/InitializeParams";
import type { InitializeResponse } from "../../src/generated/app-server/InitializeResponse";
import type { ServerRequest } from "../../src/generated/app-server/ServerRequest";
2026-05-12 15:05:56 +00:00
const TEST_INITIALIZE_PARAMS: InitializeParams = {
clientInfo: {
name: "test_client",
title: "Test Client",
version: "0.0.0",
},
capabilities: {
experimentalApi: false,
requestAttestation: false,
},
};
2026-05-12 15:05:56 +00:00
class FakeTransport implements AppServerTransport {
readonly sent: RpcOutboundMessage[] = [];
running = false;
sendError: Error | null = null;
2026-05-12 15:05:56 +00:00
constructor(private readonly handlers: AppServerTransportHandlers) {}
start(): void {
this.running = true;
}
send(message: RpcOutboundMessage): void {
if (this.sendError) throw this.sendError;
2026-05-12 15:05:56 +00:00
this.sent.push(message);
}
stop(): void {
this.running = false;
}
isRunning(): boolean {
return this.running;
}
emitLine(message: unknown): void {
this.handlers.onLine(JSON.stringify(message));
}
2026-05-28 11:53:20 +00:00
emitRawLine(line: string): void {
this.handlers.onLine(line);
}
emitLog(message: string): void {
this.handlers.onLog(message);
}
2026-05-28 11:53:20 +00:00
emitExit(code: number | null = 0, signal: NodeJS.Signals | null = null): void {
this.running = false;
this.handlers.onExit(code, signal);
}
emitError(error: Error): void {
this.handlers.onError(error);
}
2026-05-12 15:05:56 +00:00
}
async function connectedClient(): Promise<{ client: AppServerClient; transport: FakeTransport }> {
let transport!: FakeTransport;
const client = createTestClient({
handlers: {
2026-05-12 15:05:56 +00:00
onNotification: () => undefined,
onServerRequest: () => undefined,
onLog: () => undefined,
onExit: () => undefined,
},
transportFactory: (handlers) => {
2026-05-12 15:05:56 +00:00
transport = new FakeTransport(handlers);
return transport;
},
});
2026-05-12 15:05:56 +00:00
const connecting = client.connect();
transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
await connecting;
return { client, transport };
}
function createTestClient(options: {
handlers: AppServerClientHandlers;
requestTimeoutMs?: number;
transportFactory: (handlers: AppServerTransportHandlers) => AppServerTransport;
initializeParams?: InitializeParams;
}): AppServerClient {
return new AppServerClient({
codexPath: "/bin/codex",
cwd: "/vault",
handlers: options.handlers,
initializeParams: options.initializeParams ?? TEST_INITIALIZE_PARAMS,
requestTimeoutMs: options.requestTimeoutMs ?? 500,
transportFactory: options.transportFactory,
});
}
function expectTransport(transport: FakeTransport | undefined): FakeTransport {
if (!transport) throw new Error("Expected app-server transport.");
return transport;
}
2026-05-23 00:15:13 +00:00
function latestSent(transport: FakeTransport): RpcOutboundMessage {
const message = transport.sent.at(-1);
if (!message) throw new Error("Expected an outbound app-server message.");
return message;
}
async function expectRequest(
transport: FakeTransport,
request: Promise<unknown>,
expected: Partial<RpcOutboundMessage>,
result: unknown,
): Promise<void> {
const sent = latestSent(transport);
expect(sent).toMatchObject(expected);
if (!("id" in sent) || typeof sent.id !== "number") throw new Error("Expected an app-server request id.");
transport.emitLine({ id: sent.id, result });
await request;
}
function listModels(client: AppServerClient): Promise<unknown> {
return client.request("model/list", { includeHidden: false, limit: 100 });
}
function readFile(client: AppServerClient, path: string, options: { timeoutMs?: number } = {}): Promise<unknown> {
return client.request("fs/readFile", { path }, options);
}
2026-05-12 15:05:56 +00:00
describe("AppServerClient", () => {
2026-05-12 23:39:00 +00:00
beforeEach(() => {
vi.stubGlobal("window", {
clearTimeout,
setTimeout,
});
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
2026-05-12 15:05:56 +00:00
it("routes responses, notifications, and server requests", async () => {
let transport: FakeTransport;
const getTransport = () => transport;
const notifications: string[] = [];
const serverRequests: ServerRequest[] = [];
const serverRequestResponders: AppServerServerRequestResponder[] = [];
const client = createTestClient({
initializeParams: {
clientInfo: {
name: "custom_client",
title: "Custom Client",
version: "1.2.3",
},
capabilities: {
experimentalApi: true,
requestAttestation: false,
},
},
handlers: {
2026-05-12 15:05:56 +00:00
onNotification: (notification) => notifications.push(notification.method),
onServerRequest: (request, responder) => {
serverRequests.push(request);
serverRequestResponders.push(responder);
},
2026-05-12 15:05:56 +00:00
onLog: () => undefined,
onExit: () => undefined,
},
transportFactory: (handlers) => {
2026-05-12 15:05:56 +00:00
transport = new FakeTransport(handlers);
return transport;
},
});
2026-05-12 15:05:56 +00:00
const connecting = client.connect();
expect(getTransport().sent[0]).toMatchObject({
id: 1,
method: "initialize",
params: {
2026-05-23 00:15:13 +00:00
clientInfo: {
name: "custom_client",
title: "Custom Client",
version: "1.2.3",
2026-05-23 00:15:13 +00:00
},
capabilities: {
experimentalApi: true,
requestAttestation: false,
},
},
});
2026-05-12 15:05:56 +00:00
getTransport().emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
await expect(connecting).resolves.toMatchObject({ codexHome: "/tmp/codex" });
expect(getTransport().sent[1]).toEqual({ method: "initialized" });
getTransport().emitLine({ method: "warning", params: { message: "careful" } });
expect(notifications).toEqual(["warning"]);
getTransport().emitLine({
id: 99,
method: "item/commandExecution/requestApproval",
params: {
command: "npm run build",
cwd: "/vault",
threadId: "thread",
turnId: "turn",
itemId: "command",
startedAtMs: 1,
reason: null,
commandActions: [],
proposedExecpolicyAmendment: null,
proposedNetworkPolicyAmendments: [],
},
});
expect(serverRequests[0]?.method).toBe("item/commandExecution/requestApproval");
serverRequestResponders[0]?.reject(-32601, "Request not handled.");
expect(latestSent(getTransport())).toEqual({ id: 99, error: { code: -32601, message: "Request not handled." } });
2026-05-12 15:05:56 +00:00
});
it("logs malformed JSON-RPC envelopes without throwing from the transport callback", async () => {
const logs: string[] = [];
const notifications = vi.fn();
const serverRequests = vi.fn();
let transport!: FakeTransport;
const client = createTestClient({
handlers: {
onNotification: notifications,
onServerRequest: serverRequests,
onLog: (message) => logs.push(message),
onExit: () => undefined,
},
transportFactory: (handlers) => {
transport = new FakeTransport(handlers);
return transport;
},
});
const connecting = client.connect();
transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
await connecting;
for (const message of [null, 42, "text", [], {}, { method: "warning" }, { id: 4 }, { id: 5, method: "request", params: null }]) {
expect(() => transport.emitLine(message)).not.toThrow();
}
expect(notifications).not.toHaveBeenCalled();
expect(serverRequests).not.toHaveBeenCalled();
expect(logs).toHaveLength(8);
expect(logs.every((message) => message.startsWith("Invalid app-server JSON-RPC message:"))).toBe(true);
});
it("logs invalid raw JSON without throwing from the transport callback", async () => {
const logs: string[] = [];
let transport!: FakeTransport;
const client = createTestClient({
handlers: {
onNotification: () => undefined,
onServerRequest: () => undefined,
onLog: (message) => logs.push(message),
onExit: () => undefined,
},
transportFactory: (handlers) => {
transport = new FakeTransport(handlers);
return transport;
},
});
const connecting = client.connect();
transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
await connecting;
expect(() => transport.emitRawLine("{not JSON")).not.toThrow();
expect(logs).toHaveLength(1);
expect(logs[0]).toContain("Invalid app-server JSON");
});
it("contains notification handler failures at the JSON-RPC boundary", async () => {
const logs: string[] = [];
let transport!: FakeTransport;
const client = createTestClient({
handlers: {
onNotification: () => {
throw new Error("notification failed");
},
onServerRequest: () => undefined,
onLog: (message) => logs.push(message),
onExit: () => undefined,
},
transportFactory: (handlers) => {
transport = new FakeTransport(handlers);
return transport;
},
});
const connecting = client.connect();
transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
await connecting;
expect(() => transport.emitLine({ method: "warning", params: { message: "careful" } })).not.toThrow();
expect(logs).toEqual(["App-server notification handler failed: notification failed"]);
});
it("contains server-request handler failures and sends an internal-error response", async () => {
const logs: string[] = [];
let transport!: FakeTransport;
const client = createTestClient({
handlers: {
onNotification: () => undefined,
onServerRequest: () => {
throw new Error("request failed");
},
onLog: (message) => logs.push(message),
onExit: () => undefined,
},
transportFactory: (handlers) => {
transport = new FakeTransport(handlers);
return transport;
},
});
const connecting = client.connect();
transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
await connecting;
expect(() => transport.emitLine({ id: 12, method: "currentTime/read", params: { threadId: "thread" } })).not.toThrow();
expect(logs).toEqual(["App-server request handler failed: request failed"]);
expect(latestSent(transport)).toEqual({
id: 12,
error: { code: -32603, message: "Codex Panel failed to handle the app-server request." },
});
});
it("rejects known server requests with malformed required params", async () => {
let transport!: FakeTransport;
const client = createTestClient({
handlers: {
onNotification: () => undefined,
onServerRequest: () => undefined,
onLog: () => undefined,
onExit: () => undefined,
},
transportFactory: (handlers) => {
transport = new FakeTransport(handlers);
return transport;
},
});
const connecting = client.connect();
transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
await connecting;
transport.emitLine({ id: 12, method: "currentTime/read", params: { threadId: 42 } });
expect(latestSent(transport)).toEqual({ id: 12, error: { code: -32602, message: "Invalid params." } });
});
it("sends typed client requests", async () => {
2026-06-06 12:50:30 +00:00
const { client, transport } = await connectedClient();
const request = client.request("thread/inject_items", {
threadId: "thread-1",
items: [
{
type: "message",
role: "user",
content: [{ type: "input_text", text: "Ship this" }],
},
],
});
2026-06-06 12:50:30 +00:00
await expectRequest(
transport,
request,
{
method: "thread/inject_items",
params: {
threadId: "thread-1",
items: [
{
type: "message",
role: "user",
content: [{ type: "input_text", text: "Ship this" }],
},
],
},
},
{},
);
});
2026-05-28 11:53:20 +00:00
it("exposes initialized state through a single connection lifecycle", async () => {
const { client, transport } = await connectedClient();
expect(client.isConnected()).toBe(true);
expect(client.initializeResponse).toMatchObject({ codexHome: "/tmp/codex" });
transport.emitExit(0);
expect(client.isConnected()).toBe(false);
expect(() => client.initializeResponse).toThrow("Codex app-server has not initialized.");
});
it("clears initialized state and rejects pending requests on disconnect", async () => {
const { client } = await connectedClient();
const listing = listModels(client);
2026-05-28 11:53:20 +00:00
client.disconnect();
expect(client.isConnected()).toBe(false);
expect(() => client.initializeResponse).toThrow("Codex app-server has not initialized.");
await expect(listing).rejects.toThrow("Codex app-server disconnected.");
});
it("does not notify external exit handlers for intentional disconnect exits", async () => {
let transport!: FakeTransport;
const onExit = vi.fn();
const client = createTestClient({
handlers: {
onNotification: () => undefined,
onServerRequest: () => undefined,
onLog: () => undefined,
onExit,
},
transportFactory: (handlers) => {
transport = new FakeTransport(handlers);
return transport;
},
});
const connecting = client.connect();
transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
await connecting;
client.disconnect();
transport.emitExit(0);
expect(onExit).not.toHaveBeenCalled();
});
it("fails active transports on transport error without waiting for exit", async () => {
let transport!: FakeTransport;
const onExit = vi.fn();
const client = createTestClient({
handlers: {
onNotification: () => undefined,
onServerRequest: () => undefined,
onLog: () => undefined,
onExit,
},
transportFactory: (handlers) => {
transport = new FakeTransport(handlers);
return transport;
},
});
const connecting = client.connect();
transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
await connecting;
const listing = listModels(client);
transport.emitError(new Error("transport failed"));
expect(client.isConnected()).toBe(false);
expect(transport.isRunning()).toBe(false);
await expect(listing).rejects.toThrow("transport failed");
expect(onExit).toHaveBeenCalledOnce();
expect(onExit).toHaveBeenCalledWith(null, null);
transport.emitExit(1);
expect(onExit).toHaveBeenCalledOnce();
});
it("cleans up the active transport when initialize fails", async () => {
const transports: FakeTransport[] = [];
const onExit = vi.fn();
const client = createTestClient({
handlers: {
onNotification: () => undefined,
onServerRequest: () => undefined,
onLog: () => undefined,
onExit,
},
transportFactory: (handlers) => {
const transport = new FakeTransport(handlers);
transports.push(transport);
return transport;
},
});
const connecting = client.connect();
const firstTransport = expectTransport(transports[0]);
const initialize = latestSent(firstTransport);
if (!("id" in initialize) || typeof initialize.id !== "number") throw new Error("Expected initialize request.");
const rejection = expect(connecting).rejects.toThrow("initialize failed");
firstTransport.emitLine({ id: initialize.id, error: { code: -32000, message: "initialize failed" } });
await rejection;
expect(client.isConnected()).toBe(false);
expect(firstTransport.isRunning()).toBe(false);
firstTransport.emitExit(1);
expect(onExit).not.toHaveBeenCalled();
const reconnecting = client.connect();
const secondTransport = expectTransport(transports[1]);
const secondInitialize = latestSent(secondTransport);
if (!("id" in secondInitialize) || typeof secondInitialize.id !== "number") throw new Error("Expected initialize request.");
secondTransport.emitLine({ id: secondInitialize.id, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
await expect(reconnecting).resolves.toMatchObject({ codexHome: "/tmp/codex" });
});
it("cleans up the active transport when initialize times out", async () => {
vi.useFakeTimers();
vi.stubGlobal("window", {
clearTimeout,
setTimeout,
});
let transport!: FakeTransport;
const onExit = vi.fn();
const client = createTestClient({
handlers: {
onNotification: () => undefined,
onServerRequest: () => undefined,
onLog: () => undefined,
onExit,
},
transportFactory: (handlers) => {
transport = new FakeTransport(handlers);
return transport;
},
});
const connecting = client.connect();
const rejection = expect(connecting).rejects.toThrow("Codex app-server request timed out: initialize");
await vi.advanceTimersByTimeAsync(500);
await rejection;
expect(client.isConnected()).toBe(false);
expect(transport.isRunning()).toBe(false);
transport.emitExit(1);
expect(onExit).not.toHaveBeenCalled();
});
it("rejects connect without notifying external exit handlers when transport exits during initialize", async () => {
let transport!: FakeTransport;
const onExit = vi.fn();
const client = createTestClient({
handlers: {
onNotification: () => undefined,
onServerRequest: () => undefined,
onLog: () => undefined,
onExit,
},
transportFactory: (handlers) => {
transport = new FakeTransport(handlers);
return transport;
},
});
const connecting = client.connect();
const rejection = expect(connecting).rejects.toThrow("Codex app-server exited: 1");
transport.emitExit(1);
await rejection;
expect(client.isConnected()).toBe(false);
expect(onExit).not.toHaveBeenCalled();
});
it("ignores stale transport events after reconnecting", async () => {
const transports: FakeTransport[] = [];
const onExit = vi.fn();
const onNotification = vi.fn();
const onServerRequest = vi.fn();
const onLog = vi.fn();
const client = createTestClient({
handlers: {
onNotification,
onServerRequest,
onLog,
onExit,
},
transportFactory: (handlers) => {
const transport = new FakeTransport(handlers);
transports.push(transport);
return transport;
},
});
const firstConnect = client.connect();
transports[0]?.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
await firstConnect;
client.disconnect();
const secondConnect = client.connect();
const secondInitialize = latestSent(expectTransport(transports[1]));
if (!("id" in secondInitialize) || typeof secondInitialize.id !== "number") throw new Error("Expected initialize request.");
transports[1]?.emitLine({ id: secondInitialize.id, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
await secondConnect;
transports[0]?.emitLine({ method: "warning", params: { message: "stale" } });
transports[0]?.emitLine({ id: 9, method: "currentTime/read", params: { threadId: "stale" } });
transports[0]?.emitLog("stale log");
transports[0]?.emitError(new Error("stale failure"));
transports[0]?.emitExit(1);
expect(client.isConnected()).toBe(true);
expect(onNotification).not.toHaveBeenCalled();
expect(onServerRequest).not.toHaveBeenCalled();
expect(onLog).not.toHaveBeenCalled();
expect(onExit).not.toHaveBeenCalled();
});
it("ignores synchronous transport callbacks before the transport becomes active", async () => {
let transport!: FakeTransport;
const onExit = vi.fn();
const client = createTestClient({
handlers: {
onNotification: () => undefined,
onServerRequest: () => undefined,
onLog: () => undefined,
onExit,
},
transportFactory: (handlers) => {
handlers.onLine(JSON.stringify({ method: "warning", params: { message: "early" } }));
handlers.onError(new Error("early failure"));
handlers.onExit(1, null);
transport = new FakeTransport(handlers);
return transport;
},
});
const connecting = client.connect();
transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
await connecting;
expect(client.isConnected()).toBe(true);
expect(onExit).not.toHaveBeenCalled();
});
it("suppresses late responses after per-request timeouts", async () => {
vi.useFakeTimers();
vi.stubGlobal("window", {
clearTimeout,
setTimeout,
});
const logs: string[] = [];
let transport!: FakeTransport;
const client = createTestClient({
handlers: {
onNotification: () => undefined,
onServerRequest: () => undefined,
onLog: (message) => logs.push(message),
onExit: () => undefined,
},
transportFactory: (handlers) => {
transport = new FakeTransport(handlers);
return transport;
},
});
const connecting = client.connect();
transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
await connecting;
const reading = readFile(client, "/tmp/slow.jsonl", { timeoutMs: 10 });
const rejection = expect(reading).rejects.toThrow("Codex app-server request timed out: fs/readFile");
const sent = latestSent(transport);
if (!("id" in sent) || typeof sent.id !== "number") throw new Error("Expected an app-server request id.");
await vi.advanceTimersByTimeAsync(10);
await rejection;
transport.emitLine({ id: sent.id, result: { dataBase64: btoa("late") } });
expect(logs).toEqual([]);
});
it("does not leave a delayed timeout after a synchronous transport send failure", async () => {
vi.useFakeTimers();
vi.stubGlobal("window", {
clearTimeout,
setTimeout,
});
const { client, transport } = await connectedClient();
transport.sendError = new Error("write failed");
expect(() => listModels(client)).toThrow("write failed");
await vi.advanceTimersByTimeAsync(500);
});
it("bounds timed-out response suppression when responses never arrive", async () => {
vi.useFakeTimers();
vi.stubGlobal("window", {
clearTimeout,
setTimeout,
});
const logs: string[] = [];
let transport!: FakeTransport;
const client = createTestClient({
handlers: {
onNotification: () => undefined,
onServerRequest: () => undefined,
onLog: (message) => logs.push(message),
onExit: () => undefined,
},
transportFactory: (handlers) => {
transport = new FakeTransport(handlers);
return transport;
},
});
const connecting = client.connect();
transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
await connecting;
const timedOutRequests: { id: number; rejection: Promise<void> }[] = [];
for (let index = 0; index < 257; index += 1) {
const promise = readFile(client, `/tmp/slow-${String(index)}.jsonl`, { timeoutMs: 10 });
const rejection = expect(promise).rejects.toThrow("Codex app-server request timed out");
const sent = latestSent(transport);
if (!("id" in sent) || typeof sent.id !== "number") throw new Error("Expected an app-server request id.");
timedOutRequests.push({ id: sent.id, rejection });
}
await vi.advanceTimersByTimeAsync(10);
await Promise.all(timedOutRequests.map(({ rejection }) => rejection));
const firstTimedOutRequest = timedOutRequests[0];
const lastTimedOutRequest = timedOutRequests.at(-1);
if (!firstTimedOutRequest || !lastTimedOutRequest) throw new Error("Expected timed-out requests.");
transport.emitLine({ id: firstTimedOutRequest.id, result: { dataBase64: btoa("evicted") } });
expect(logs).toHaveLength(1);
expect(logs[0]).toContain("Orphan app-server response");
transport.emitLine({ id: lastTimedOutRequest.id, result: { dataBase64: btoa("suppressed") } });
expect(logs).toHaveLength(1);
});
2026-05-17 06:24:14 +00:00
it("preserves app-server RPC error codes", async () => {
const { client, transport } = await connectedClient();
const listing = listModels(client);
2026-05-17 06:24:14 +00:00
transport.emitLine({ id: 2, error: { code: -32601, message: "Method not found" } });
await expect(listing).rejects.toMatchObject({
name: "AppServerRpcError",
code: -32601,
method: "model/list",
message: "Method not found",
2026-06-14 20:57:32 +00:00
});
2026-05-17 06:24:14 +00:00
});
it("rejects malformed RPC error responses without constructing an RPC error", async () => {
const { client, transport } = await connectedClient();
const listing = listModels(client);
transport.emitLine({ id: 2, error: { code: "invalid", message: 42 } });
await expect(listing).rejects.toThrow("Codex app-server returned an invalid error response for model/list.");
});
2026-05-12 15:05:56 +00:00
});