import { beforeEach, describe, expect, it, vi } from "vitest"; import { AppServerClient } from "../src/app-server/client"; import type { AppServerTransport, AppServerTransportHandlers } from "../src/app-server/transport"; import type { RpcOutboundMessage } from "../src/app-server/types"; import type { InitializeResponse } from "../src/generated/app-server/InitializeResponse"; import type { ServerRequest } from "../src/generated/app-server/ServerRequest"; class FakeTransport implements AppServerTransport { readonly sent: RpcOutboundMessage[] = []; running = false; constructor(private readonly handlers: AppServerTransportHandlers) {} start(): void { this.running = true; } send(message: RpcOutboundMessage): void { this.sent.push(message); } stop(): void { this.running = false; } isRunning(): boolean { return this.running; } emitLine(message: unknown): void { this.handlers.onLine(JSON.stringify(message)); } } async function connectedClient(): Promise<{ client: AppServerClient; transport: FakeTransport }> { let transport!: FakeTransport; const client = new AppServerClient( "/bin/codex", "/vault", { onNotification: () => undefined, onServerRequest: () => undefined, onLog: () => undefined, onExit: () => undefined, }, 500, (handlers) => { transport = new FakeTransport(handlers); return transport; }, ); const connecting = client.connect(); transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial }); await connecting; return { client, transport }; } describe("AppServerClient", () => { beforeEach(() => { vi.stubGlobal("window", { clearTimeout, setTimeout, }); }); it("routes responses, notifications, and server requests", async () => { let transport: FakeTransport; const getTransport = () => transport; const notifications: string[] = []; const serverRequests: ServerRequest[] = []; const client = new AppServerClient( "/bin/codex", "/vault", { onNotification: (notification) => notifications.push(notification.method), onServerRequest: (request) => serverRequests.push(request), onLog: () => undefined, onExit: () => undefined, }, 500, (handlers) => { transport = new FakeTransport(handlers); return transport; }, ); const connecting = client.connect(); expect(getTransport().sent[0]).toMatchObject({ id: 1, method: "initialize" }); getTransport().emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial }); 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"); }); it("sends typed turn steering requests", async () => { let transport: FakeTransport; const getTransport = () => transport; const client = new AppServerClient( "/bin/codex", "/vault", { onNotification: () => undefined, onServerRequest: () => undefined, onLog: () => undefined, onExit: () => undefined, }, 500, (handlers) => { transport = new FakeTransport(handlers); return transport; }, ); const connecting = client.connect(); getTransport().emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial }); await connecting; const steering = client.steerTurn("thread-1", "turn-1", "Please adjust course."); expect(getTransport().sent[2]).toMatchObject({ id: 2, method: "turn/steer", params: { threadId: "thread-1", expectedTurnId: "turn-1", input: [{ type: "text", text: "Please adjust course.", text_elements: [] }], }, }); getTransport().emitLine({ id: 2, result: { turnId: "turn-1" } }); await expect(steering).resolves.toEqual({ turnId: "turn-1" }); }); it("sends golden thread and turn request payloads", async () => { const { client, transport } = await connectedClient(); const startingThread = client.startThread("/vault", "fast"); expect(transport.sent[2]).toMatchObject({ id: 2, method: "thread/start", params: { cwd: "/vault", serviceName: "codex-panel", serviceTier: "fast" }, }); transport.emitLine({ id: 2, result: { thread: { id: "thread-1", title: null }, serviceTier: "fast" } }); await startingThread; const startingTurn = client.startTurn("thread-1", "/vault", "hello", null); expect(transport.sent[3]).toMatchObject({ id: 3, method: "turn/start", params: { threadId: "thread-1", cwd: "/vault", serviceTier: null, input: [{ type: "text", text: "hello", text_elements: [] }], }, }); transport.emitLine({ id: 3, result: { turn: { id: "turn-1" } } }); await startingTurn; }); it("sends structured user input for turns and steering", async () => { const { client, transport } = await connectedClient(); const input = [ { type: "text" as const, text: "Read [[Alpha]].", text_elements: [] }, { type: "mention" as const, name: "Alpha", path: "thoughts/Alpha.md" }, ]; const startingTurn = client.startTurn("thread-1", "/vault", input, null); expect(transport.sent[2]).toMatchObject({ method: "turn/start", params: { threadId: "thread-1", cwd: "/vault", input, }, }); transport.emitLine({ id: 2, result: { turn: { id: "turn-1" } } }); await startingTurn; const steering = client.steerTurn("thread-1", "turn-1", input); expect(transport.sent[3]).toMatchObject({ method: "turn/steer", params: { threadId: "thread-1", expectedTurnId: "turn-1", input, }, }); transport.emitLine({ id: 3, result: { turnId: "turn-1" } }); await steering; }); it("sends explicit null service tier when fast mode is disabled", async () => { const { client, transport } = await connectedClient(); const startingThread = client.startThread("/vault", null); expect(transport.sent[2]).toMatchObject({ id: 2, method: "thread/start", params: { cwd: "/vault", serviceTier: null }, }); transport.emitLine({ id: 2, result: { thread: { id: "thread-1", title: null }, serviceTier: null } }); await startingThread; }); it("sends collaboration mode only for Plan turns", async () => { const { client, transport } = await connectedClient(); const defaultTurn = client.startTurn("thread-1", "/vault", "default", null, null); expect(transport.sent[2]).toMatchObject({ method: "turn/start", params: { threadId: "thread-1", cwd: "/vault", input: [{ type: "text", text: "default", text_elements: [] }], }, }); expect((transport.sent[2] as { params?: Record }).params?.collaborationMode).toBeUndefined(); transport.emitLine({ id: 2, result: { turn: { id: "turn-default" } } }); await defaultTurn; const planTurn = client.startTurn("thread-1", "/vault", "plan", null, { mode: "plan", settings: { model: "gpt-5.5", reasoning_effort: "medium", developer_instructions: null, }, }); expect(transport.sent[3]).toMatchObject({ method: "turn/start", params: { threadId: "thread-1", cwd: "/vault", input: [{ type: "text", text: "plan", text_elements: [] }], collaborationMode: { mode: "plan", settings: { model: "gpt-5.5", reasoning_effort: "medium", developer_instructions: null, }, }, }, }); transport.emitLine({ id: 3, result: { turn: { id: "turn-plan" } } }); await planTurn; }); it("sends model and effort turn overrides when provided", async () => { const { client, transport } = await connectedClient(); const startingTurn = client.startTurn("thread-1", "/vault", "override", null, null, "gpt-5.5", "high"); expect(transport.sent[2]).toMatchObject({ method: "turn/start", params: { threadId: "thread-1", cwd: "/vault", model: "gpt-5.5", effort: "high", input: [{ type: "text", text: "override", text_elements: [] }], }, }); transport.emitLine({ id: 2, result: { turn: { id: "turn-override" } } }); await startingTurn; const resetTurn = client.startTurn("thread-1", "/vault", "reset", null, null, null, null); expect(transport.sent[3]).toMatchObject({ method: "turn/start", params: { threadId: "thread-1", cwd: "/vault", model: null, effort: null, input: [{ type: "text", text: "reset", text_elements: [] }], }, }); transport.emitLine({ id: 3, result: { turn: { id: "turn-reset" } } }); await resetTurn; }); it("sends model list request payloads", async () => { const { client, transport } = await connectedClient(); const listing = client.listModels(); expect(transport.sent[2]).toMatchObject({ id: 2, method: "model/list", params: { includeHidden: false, limit: 100 }, }); transport.emitLine({ id: 2, result: { data: [], nextCursor: null } }); await listing; }); it("sends golden list and history request payloads", async () => { const { client, transport } = await connectedClient(); const listing = client.listThreads("/vault", true); expect(transport.sent[2]).toMatchObject({ id: 2, method: "thread/list", params: { cwd: "/vault", limit: 20, archived: true, sortKey: "updated_at", sortDirection: "desc" }, }); transport.emitLine({ id: 2, result: { data: [], nextCursor: null } }); await listing; const resuming = client.resumeThread("thread-1", "/vault"); expect(transport.sent[3]).toMatchObject({ id: 3, method: "thread/resume", params: { threadId: "thread-1", cwd: "/vault", excludeTurns: true, persistExtendedHistory: false }, }); transport.emitLine({ id: 3, result: { thread: { id: "thread-1", title: null }, cwd: "/vault" } }); await resuming; const forking = client.forkThread("thread-1", "/vault"); expect(transport.sent[4]).toMatchObject({ id: 4, method: "thread/fork", params: { threadId: "thread-1", cwd: "/vault", excludeTurns: true, persistExtendedHistory: false }, }); transport.emitLine({ id: 4, result: { thread: { id: "thread-2", title: null }, cwd: "/vault" } }); await forking; const skills = client.listSkills("/vault"); expect(transport.sent[5]).toMatchObject({ id: 5, method: "skills/list", params: { cwds: ["/vault"], forceReload: false }, }); expect((transport.sent[5] as { params?: Record }).params?.perCwdExtraUserRoots).toBeUndefined(); transport.emitLine({ id: 5, result: { data: [] } }); await skills; const turns = client.threadTurnsList("thread-1", "cursor-1", 10); expect(transport.sent[6]).toMatchObject({ id: 6, method: "thread/turns/list", params: { threadId: "thread-1", cursor: "cursor-1", limit: 10, sortDirection: "desc", itemsView: "full" }, }); transport.emitLine({ id: 6, result: { data: [], nextCursor: null } }); await turns; const firstTurn = client.threadTurnsList("thread-1", null, 1, "asc"); expect(transport.sent[7]).toMatchObject({ id: 7, method: "thread/turns/list", params: { threadId: "thread-1", cursor: null, limit: 1, sortDirection: "asc", itemsView: "full" }, }); transport.emitLine({ id: 7, result: { data: [], nextCursor: null } }); await firstTurn; }); it("sends thread naming request payloads", async () => { const { client, transport } = await connectedClient(); const namingThread = client.startEphemeralThread("/vault", "naming", "Return a title."); expect(transport.sent[2]).toMatchObject({ id: 2, method: "thread/start", params: { cwd: "/vault", serviceName: "naming", developerInstructions: "Return a title.", ephemeral: true, sandbox: "read-only", approvalPolicy: "never", environments: [], }, }); transport.emitLine({ id: 2, result: { thread: { id: "naming-thread" } } }); await namingThread; const structuredTurn = client.startStructuredTurn( "naming-thread", "/vault", "title please", { type: "object", properties: { title: { type: "string" } }, required: ["title"], }, "gpt-5.4-mini", "minimal", ); expect(transport.sent[3]).toMatchObject({ id: 3, method: "turn/start", params: { threadId: "naming-thread", cwd: "/vault", model: "gpt-5.4-mini", effort: "minimal", input: [{ type: "text", text: "title please", text_elements: [] }], outputSchema: { type: "object", properties: { title: { type: "string" } }, required: ["title"], }, }, }); transport.emitLine({ id: 3, result: { turn: { id: "naming-turn" } } }); await structuredTurn; const setName = client.setThreadName("thread-1", "Codex Panel自動命名"); expect(transport.sent[4]).toMatchObject({ id: 4, method: "thread/name/set", params: { threadId: "thread-1", name: "Codex Panel自動命名" }, }); transport.emitLine({ id: 4, result: {} }); await setName; }); });