refactor(server): exercise codex features through fake app-server (#887)
This commit is contained in:
+72
-115
@@ -1,7 +1,11 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.js";
|
import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.js";
|
||||||
import { __codexAppServerInternals } from "./codex-app-server-agent.js";
|
import { __codexAppServerInternals } from "./codex-app-server-agent.js";
|
||||||
|
import {
|
||||||
|
createFakeCodexAppServer,
|
||||||
|
type FakeCodexAppServer,
|
||||||
|
} from "./codex/test-utils/fake-app-server.js";
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
|
|
||||||
const CODEX_PROVIDER = "codex";
|
const CODEX_PROVIDER = "codex";
|
||||||
@@ -27,39 +31,7 @@ const TEST_COLLABORATION_MODES: CollaborationModeRecord[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
interface CodexRequestFn {
|
type CodexFeaturesTestSession = AgentSession;
|
||||||
(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CodexClientLike {
|
|
||||||
request: CodexRequestFn;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CodexSessionTestAccess {
|
|
||||||
client: CodexClientLike | null;
|
|
||||||
connected: boolean;
|
|
||||||
currentThreadId: string | null;
|
|
||||||
serviceTier: "fast" | null;
|
|
||||||
planModeEnabled: boolean;
|
|
||||||
cachedRuntimeInfo: unknown;
|
|
||||||
ensureThreadLoaded: () => Promise<void>;
|
|
||||||
ensureThread: () => Promise<void>;
|
|
||||||
buildUserInput: (...args: unknown[]) => Promise<unknown>;
|
|
||||||
resolveSlashCommandInvocation: (...args: unknown[]) => Promise<unknown>;
|
|
||||||
collaborationModes: CollaborationModeRecord[];
|
|
||||||
refreshResolvedCollaborationMode(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type CodexFeaturesTestSession = AgentSession & {
|
|
||||||
connected: boolean;
|
|
||||||
currentThreadId: string | null;
|
|
||||||
collaborationModes: CollaborationModeRecord[];
|
|
||||||
refreshResolvedCollaborationMode(): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function asInternals(session: CodexFeaturesTestSession): CodexSessionTestAccess {
|
|
||||||
return session as unknown as CodexSessionTestAccess;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
|
function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
|
||||||
return {
|
return {
|
||||||
@@ -71,28 +43,36 @@ function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSession
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSession(
|
function createSessionHarness(configOverrides: Partial<AgentSessionConfig> = {}): {
|
||||||
configOverrides: Partial<AgentSessionConfig> = {},
|
session: CodexFeaturesTestSession;
|
||||||
): CodexFeaturesTestSession {
|
appServer: FakeCodexAppServer;
|
||||||
|
} {
|
||||||
const config = createConfig(configOverrides);
|
const config = createConfig(configOverrides);
|
||||||
|
const appServer = createFakeCodexAppServer({
|
||||||
|
"collaborationMode/list": () => ({ data: TEST_COLLABORATION_MODES }),
|
||||||
|
});
|
||||||
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
|
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
|
||||||
{ ...config, provider: CODEX_PROVIDER },
|
{ ...config, provider: CODEX_PROVIDER },
|
||||||
null,
|
null,
|
||||||
createTestLogger(),
|
createTestLogger(),
|
||||||
() => {
|
async () => appServer.child,
|
||||||
throw new Error("Test session cannot spawn Codex app-server");
|
) as CodexFeaturesTestSession;
|
||||||
},
|
return { session, appServer };
|
||||||
) as unknown as CodexFeaturesTestSession;
|
}
|
||||||
session.connected = true;
|
|
||||||
session.currentThreadId = "test-thread";
|
async function createConnectedSession(configOverrides: Partial<AgentSessionConfig> = {}): Promise<{
|
||||||
session.collaborationModes = TEST_COLLABORATION_MODES;
|
session: CodexFeaturesTestSession;
|
||||||
session.refreshResolvedCollaborationMode();
|
appServer: FakeCodexAppServer;
|
||||||
return session;
|
}> {
|
||||||
|
const harness = createSessionHarness(configOverrides);
|
||||||
|
await harness.session.connect();
|
||||||
|
harness.appServer.assertNoErrors();
|
||||||
|
return harness;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Codex app-server provider features", () => {
|
describe("Codex app-server provider features", () => {
|
||||||
test("features returns fast and plan toggles when supported", async () => {
|
test("features returns fast and plan toggles when supported", async () => {
|
||||||
const session = createSession();
|
const { session } = await createConnectedSession();
|
||||||
|
|
||||||
expect(session.features).toEqual([
|
expect(session.features).toEqual([
|
||||||
{
|
{
|
||||||
@@ -140,8 +120,8 @@ describe("Codex app-server provider features", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("features returns only plan toggle when model does not support fast mode", () => {
|
test("features returns only plan toggle when model does not support fast mode", async () => {
|
||||||
const session = createSession({ model: "gpt-3.5-turbo" });
|
const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" });
|
||||||
|
|
||||||
expect(session.features).toEqual([
|
expect(session.features).toEqual([
|
||||||
{
|
{
|
||||||
@@ -157,49 +137,56 @@ describe("Codex app-server provider features", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("setFeature('fast_mode', true) sets serviceTier to fast", async () => {
|
test("setFeature('fast_mode', true) sets serviceTier to fast", async () => {
|
||||||
const session = createSession();
|
const { session, appServer } = await createConnectedSession();
|
||||||
|
|
||||||
await session.setFeature?.("fast_mode", true);
|
await session.setFeature?.("fast_mode", true);
|
||||||
|
await session.startTurn("hello");
|
||||||
|
|
||||||
expect(asInternals(session).serviceTier).toBe("fast");
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||||
|
serviceTier: "fast",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("setFeature('fast_mode', false) clears serviceTier to null", async () => {
|
test("setFeature('fast_mode', false) clears serviceTier to null", async () => {
|
||||||
const session = createSession({
|
const { session, appServer } = await createConnectedSession({
|
||||||
featureValues: { fast_mode: true },
|
featureValues: { fast_mode: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
await session.setFeature?.("fast_mode", false);
|
await session.setFeature?.("fast_mode", false);
|
||||||
|
await session.startTurn("hello");
|
||||||
|
|
||||||
expect(asInternals(session).serviceTier).toBeNull();
|
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
|
||||||
|
serviceTier: expect.anything(),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("setFeature invalidates cachedRuntimeInfo", async () => {
|
test("setFeature invalidates runtime info", async () => {
|
||||||
const session = createSession();
|
const { session } = await createConnectedSession();
|
||||||
|
|
||||||
await session.getRuntimeInfo();
|
await expect(session.getRuntimeInfo()).resolves.not.toMatchObject({
|
||||||
expect(asInternals(session).cachedRuntimeInfo).not.toBeNull();
|
extra: { collaborationMode: "Plan" },
|
||||||
|
});
|
||||||
|
|
||||||
await session.setFeature?.("fast_mode", true);
|
await session.setFeature?.("plan_mode", true);
|
||||||
|
|
||||||
expect(asInternals(session).cachedRuntimeInfo).toBeNull();
|
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
|
||||||
|
extra: { collaborationMode: "Plan" },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("setFeature throws for unknown feature ids", async () => {
|
test("setFeature throws for unknown feature ids", async () => {
|
||||||
const session = createSession();
|
const { session } = createSessionHarness();
|
||||||
|
|
||||||
await expect(session.setFeature?.("unknown_feature", true)).rejects.toThrow(
|
await expect(session.setFeature?.("unknown_feature", true)).rejects.toThrow(
|
||||||
"Unknown Codex feature: unknown_feature",
|
"Unknown Codex feature: unknown_feature",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("constructor restores feature flags from config.featureValues", () => {
|
test("constructor restores feature flags from config.featureValues", async () => {
|
||||||
const session = createSession({
|
const { session, appServer } = await createConnectedSession({
|
||||||
featureValues: { fast_mode: true, plan_mode: true },
|
featureValues: { fast_mode: true, plan_mode: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(asInternals(session).serviceTier).toBe("fast");
|
|
||||||
expect(asInternals(session).planModeEnabled).toBe(true);
|
|
||||||
expect(session.features).toEqual([
|
expect(session.features).toEqual([
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
@@ -220,41 +207,29 @@ describe("Codex app-server provider features", () => {
|
|||||||
value: true,
|
value: true,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
await session.startTurn("hello");
|
||||||
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||||
|
serviceTier: "fast",
|
||||||
|
collaborationMode: expect.objectContaining({
|
||||||
|
mode: "plan",
|
||||||
|
}),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("startTurn includes serviceTier when fast mode is enabled", async () => {
|
test("startTurn includes serviceTier when fast mode is enabled", async () => {
|
||||||
const session = createSession();
|
const { session, appServer } = await createConnectedSession();
|
||||||
const request = vi.fn().mockResolvedValue(undefined);
|
|
||||||
asInternals(session).client = { request };
|
|
||||||
asInternals(session).connected = true;
|
|
||||||
asInternals(session).currentThreadId = "thread-123";
|
|
||||||
asInternals(session).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
|
|
||||||
asInternals(session).ensureThread = vi.fn().mockResolvedValue(undefined);
|
|
||||||
asInternals(session).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
|
|
||||||
asInternals(session).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
|
|
||||||
|
|
||||||
await session.setFeature?.("fast_mode", true);
|
await session.setFeature?.("fast_mode", true);
|
||||||
await session.startTurn("hello");
|
await session.startTurn("hello");
|
||||||
|
|
||||||
expect(request).toHaveBeenCalledWith(
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||||
"turn/start",
|
serviceTier: "fast",
|
||||||
expect.objectContaining({
|
});
|
||||||
serviceTier: "fast",
|
|
||||||
}),
|
|
||||||
expect.any(Number),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("setModel clears fast mode when switching to an unsupported model", async () => {
|
test("setModel clears fast mode when switching to an unsupported model", async () => {
|
||||||
const session = createSession();
|
const { session, appServer } = await createConnectedSession();
|
||||||
const request = vi.fn().mockResolvedValue(undefined);
|
|
||||||
asInternals(session).client = { request };
|
|
||||||
asInternals(session).connected = true;
|
|
||||||
asInternals(session).currentThreadId = "thread-123";
|
|
||||||
asInternals(session).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
|
|
||||||
asInternals(session).ensureThread = vi.fn().mockResolvedValue(undefined);
|
|
||||||
asInternals(session).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
|
|
||||||
asInternals(session).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
|
|
||||||
|
|
||||||
await session.setFeature?.("fast_mode", true);
|
await session.setFeature?.("fast_mode", true);
|
||||||
await session.setModel("gpt-3.5-turbo");
|
await session.setModel("gpt-3.5-turbo");
|
||||||
@@ -270,41 +245,23 @@ describe("Codex app-server provider features", () => {
|
|||||||
value: false,
|
value: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(asInternals(session).serviceTier).toBeNull();
|
|
||||||
|
|
||||||
await session.startTurn("hello");
|
await session.startTurn("hello");
|
||||||
|
|
||||||
expect(request).toHaveBeenCalledWith(
|
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
|
||||||
"turn/start",
|
serviceTier: expect.anything(),
|
||||||
expect.not.objectContaining({
|
});
|
||||||
serviceTier: expect.anything(),
|
|
||||||
}),
|
|
||||||
expect.any(Number),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("startTurn switches collaboration mode when plan mode is enabled", async () => {
|
test("startTurn switches collaboration mode when plan mode is enabled", async () => {
|
||||||
const session = createSession();
|
const { session, appServer } = await createConnectedSession();
|
||||||
const request = vi.fn().mockResolvedValue(undefined);
|
|
||||||
asInternals(session).client = { request };
|
|
||||||
asInternals(session).connected = true;
|
|
||||||
asInternals(session).currentThreadId = "thread-123";
|
|
||||||
asInternals(session).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
|
|
||||||
asInternals(session).ensureThread = vi.fn().mockResolvedValue(undefined);
|
|
||||||
asInternals(session).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
|
|
||||||
asInternals(session).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
|
|
||||||
|
|
||||||
await session.setFeature?.("plan_mode", true);
|
await session.setFeature?.("plan_mode", true);
|
||||||
await session.startTurn("hello");
|
await session.startTurn("hello");
|
||||||
|
|
||||||
expect(request).toHaveBeenCalledWith(
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||||
"turn/start",
|
collaborationMode: expect.objectContaining({
|
||||||
expect.objectContaining({
|
mode: "plan",
|
||||||
collaborationMode: expect.objectContaining({
|
|
||||||
mode: "plan",
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
expect.any(Number),
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { PassThrough } from "node:stream";
|
|||||||
import type { AgentSession, AgentStreamEvent } from "../../../agent-sdk-types.js";
|
import type { AgentSession, AgentStreamEvent } from "../../../agent-sdk-types.js";
|
||||||
|
|
||||||
type JsonObject = Record<string, unknown>;
|
type JsonObject = Record<string, unknown>;
|
||||||
|
type FakeCodexAppServerHandler = (params: unknown) => unknown;
|
||||||
type CodexAppServerChildProcess = ChildProcessWithoutNullStreams & {
|
type CodexAppServerChildProcess = ChildProcessWithoutNullStreams & {
|
||||||
stdin: PassThrough;
|
stdin: PassThrough;
|
||||||
stdout: PassThrough;
|
stdout: PassThrough;
|
||||||
@@ -14,6 +15,7 @@ type CodexAppServerChildProcess = ChildProcessWithoutNullStreams & {
|
|||||||
export interface FakeCodexAppServer {
|
export interface FakeCodexAppServer {
|
||||||
readonly child: CodexAppServerChildProcess;
|
readonly child: CodexAppServerChildProcess;
|
||||||
assertNoErrors(): void;
|
assertNoErrors(): void;
|
||||||
|
waitForTurnStart(): Promise<JsonObject>;
|
||||||
requestCommandApproval(params: {
|
requestCommandApproval(params: {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
@@ -41,9 +43,30 @@ export function createCodexAppServerChildProcess(): CodexAppServerChildProcess {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createFakeCodexAppServer(
|
export function createFakeCodexAppServer(
|
||||||
handlers: Record<string, (params: unknown) => unknown>,
|
handlers: Record<string, FakeCodexAppServerHandler> = {},
|
||||||
): FakeCodexAppServer {
|
): FakeCodexAppServer {
|
||||||
const child = createCodexAppServerChildProcess();
|
const child = createCodexAppServerChildProcess();
|
||||||
|
const responseHandlers: Record<string, FakeCodexAppServerHandler> = {
|
||||||
|
initialize: () => ({}),
|
||||||
|
"collaborationMode/list": () => ({ data: [] }),
|
||||||
|
"config/read": () => ({ config: {} }),
|
||||||
|
getUserSavedConfig: () => ({ config: {} }),
|
||||||
|
"model/list": () => ({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "gpt-5.4",
|
||||||
|
isDefault: true,
|
||||||
|
defaultReasoningEffort: "medium",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
"skills/list": () => ({ data: [] }),
|
||||||
|
"thread/start": () => ({ thread: { id: "thread-1" } }),
|
||||||
|
"thread/loaded/list": () => ({ data: [] }),
|
||||||
|
"thread/resume": () => ({}),
|
||||||
|
"turn/start": () => ({}),
|
||||||
|
...handlers,
|
||||||
|
};
|
||||||
const messages: JsonObject[] = [];
|
const messages: JsonObject[] = [];
|
||||||
const errors: Error[] = [];
|
const errors: Error[] = [];
|
||||||
const approvalRequestIds = new Map<string, number>();
|
const approvalRequestIds = new Map<string, number>();
|
||||||
@@ -67,7 +90,7 @@ export function createFakeCodexAppServer(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handler = handlers[message.method];
|
const handler = responseHandlers[message.method];
|
||||||
if (!handler) {
|
if (!handler) {
|
||||||
errors.push(new Error(`Unexpected Codex app-server request: ${message.method}`));
|
errors.push(new Error(`Unexpected Codex app-server request: ${message.method}`));
|
||||||
return;
|
return;
|
||||||
@@ -143,6 +166,13 @@ export function createFakeCodexAppServer(
|
|||||||
throw errors[0];
|
throw errors[0];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async waitForTurnStart() {
|
||||||
|
const message = await waitForMessage(
|
||||||
|
(candidate) => candidate.method === "turn/start",
|
||||||
|
"turn start request",
|
||||||
|
);
|
||||||
|
return toJsonObject(message.params);
|
||||||
|
},
|
||||||
requestCommandApproval(params) {
|
requestCommandApproval(params) {
|
||||||
const requestId = nextServerRequestId;
|
const requestId = nextServerRequestId;
|
||||||
nextServerRequestId += 1;
|
nextServerRequestId += 1;
|
||||||
@@ -171,6 +201,13 @@ export function createFakeCodexAppServer(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toJsonObject(value: unknown): JsonObject {
|
||||||
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
return value as JsonObject;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
export function waitForNextPermission(
|
export function waitForNextPermission(
|
||||||
session: AgentSession,
|
session: AgentSession,
|
||||||
): Promise<Extract<AgentStreamEvent, { type: "permission_requested" }>> {
|
): Promise<Extract<AgentStreamEvent, { type: "permission_requested" }>> {
|
||||||
|
|||||||
Reference in New Issue
Block a user