murashit_codex-panel/tests/app-server-client.test.ts
2026-05-13 08:39:00 +09:00

424 lines
14 KiB
TypeScript

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<InitializeResponse> });
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<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");
});
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<InitializeResponse> });
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<string, unknown> }).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 skills = client.listSkills("/vault");
expect(transport.sent[4]).toMatchObject({
id: 4,
method: "skills/list",
params: { cwds: ["/vault"], forceReload: false },
});
expect((transport.sent[4] as { params?: Record<string, unknown> }).params?.perCwdExtraUserRoots).toBeUndefined();
transport.emitLine({ id: 4, result: { data: [] } });
await skills;
const turns = client.threadTurnsList("thread-1", "cursor-1", 10);
expect(transport.sent[5]).toMatchObject({
id: 5,
method: "thread/turns/list",
params: { threadId: "thread-1", cursor: "cursor-1", limit: 10, sortDirection: "desc", itemsView: "full" },
});
transport.emitLine({ id: 5, result: { data: [], nextCursor: null } });
await turns;
const firstTurn = client.threadTurnsList("thread-1", null, 1, "asc");
expect(transport.sent[6]).toMatchObject({
id: 6,
method: "thread/turns/list",
params: { threadId: "thread-1", cursor: null, limit: 1, sortDirection: "asc", itemsView: "full" },
});
transport.emitLine({ id: 6, 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;
});
});