Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions src/adapters/codex-app-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ vi.mock("node:child_process", async () => {

import { buildCodexChildEnv, CodexAppServerAdapter } from "./codex-app-server.js";

const DEFAULT_CODEX_FEATURE_FLAGS = {
"features.enable_request_compression": true,
"features.collaboration_modes": true,
"features.personality": true,
"features.request_rule": true,
"features.fast_mode": true,
} as const;

function createFakeChild(options?: { deferWriteCallback?: boolean }): {
child: ChildProcessWithoutNullStreams;
stdinWrite: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -63,6 +71,33 @@ describe("buildCodexChildEnv", () => {
});

describe("CodexAppServerAdapter", () => {
it("sends initialized after initialize succeeds", async () => {
const { child, stdinWrite } = createFakeChild();
spawnMock.mockReturnValue(child);

const adapter = new CodexAppServerAdapter("/tmp/codex");
const startPromise = adapter.start();

expect(stdinWrite).toHaveBeenCalledTimes(1);
const initializePayload = JSON.parse(stdinWrite.mock.calls[0]?.[0] ?? "{}") as {
id?: string;
method?: string;
};

expect(initializePayload.method).toBe("initialize");
(child.stdout as PassThrough).write(
`${JSON.stringify({ jsonrpc: "2.0", id: initializePayload.id, result: {} })}\n`,
);

await expect(startPromise).resolves.toBeUndefined();

expect(stdinWrite).toHaveBeenCalledTimes(2);
expect(JSON.parse(stdinWrite.mock.calls[1]?.[0] ?? "{}")).toEqual({
jsonrpc: "2.0",
method: "initialized",
});
});

it("rejects start when the codex child emits an error", async () => {
const { child } = createFakeChild({ deferWriteCallback: true });
spawnMock.mockReturnValue(child);
Expand Down Expand Up @@ -99,8 +134,11 @@ describe("CodexAppServerAdapter", () => {
threadId: "thread-1",
cwd: "/tmp/workspace",
model: "gpt-5.4",
serviceTier: null,
approvalPolicy: "never",
sandbox: "danger-full-access",
persistExtendedHistory: true,
personality: null,
});
});

Expand All @@ -119,6 +157,90 @@ describe("CodexAppServerAdapter", () => {
}),
).rejects.toBe(error);
});

it("persists extended history when starting threads", async () => {
const adapter = new CodexAppServerAdapter("/tmp/codex");
const request = vi.fn().mockResolvedValue({ thread: { id: "thread-9" } });

(adapter as unknown as { request: typeof request }).request = request;

await expect(
adapter.startThread({
cwd: "/tmp/workspace",
model: "gpt-5.4",
}),
).resolves.toBe("thread-9");

expect(request).toHaveBeenCalledWith("thread/start", {
cwd: "/tmp/workspace",
model: "gpt-5.4",
experimentalRawEvents: false,
serviceTier: null,
approvalPolicy: "never",
sandbox: "danger-full-access",
persistExtendedHistory: true,
personality: null,
config: DEFAULT_CODEX_FEATURE_FLAGS,
});
});

it("omits nullish resume params instead of sending JSON nulls", async () => {
const adapter = new CodexAppServerAdapter("/tmp/codex");
const request = vi.fn().mockResolvedValue({});

(adapter as unknown as { request: typeof request }).request = request;

await expect(
adapter.resumeThread({
threadId: "thread-1",
cwd: null,
model: null,
}),
).resolves.toBeUndefined();

expect(request).toHaveBeenCalledWith("thread/resume", {
threadId: "thread-1",
serviceTier: null,
approvalPolicy: "never",
sandbox: "danger-full-access",
persistExtendedHistory: true,
personality: null,
});
});

it("omits nullish turn params instead of sending JSON nulls", async () => {
const adapter = new CodexAppServerAdapter("/tmp/codex");
const request = vi.fn().mockResolvedValue({ turn: { id: "turn-1" } });

(adapter as unknown as { request: typeof request }).request = request;

await expect(
adapter.startTurn({
threadId: "thread-1",
input: "ping",
cwd: null,
model: null,
effort: null,
}),
).resolves.toEqual({ turnId: "turn-1" });

expect(request).toHaveBeenCalledWith("turn/start", {
threadId: "thread-1",
input: [{ type: "text", text: "ping", text_elements: [] }],
serviceTier: null,
summary: "none",
sandboxPolicy: { type: "dangerFullAccess" },
personality: null,
collaborationMode: {
mode: "default",
settings: {
model: null,
reasoning_effort: null,
developer_instructions: null,
},
},
});
});
});

describe("CodexAppServerAdapter.interruptTurn", () => {
Expand Down
106 changes: 85 additions & 21 deletions src/adapters/codex-app-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,36 @@ interface InterruptTurnOptions {
turnId: string;
}

const DEFAULT_CODEX_FEATURE_FLAGS = {
"features.enable_request_compression": true,
"features.collaboration_modes": true,
"features.personality": true,
"features.request_rule": true,
"features.fast_mode": true,
} as const;

function withOptionalFields(base: Record<string, unknown>, optional: Record<string, unknown>): Record<string, unknown> {
return {
...base,
...Object.fromEntries(Object.entries(optional).filter(([, value]) => value !== null && value !== undefined)),
};
}

function buildTextInput(text: string): Array<{ type: "text"; text: string; text_elements: [] }> {
return [{ type: "text", text, text_elements: [] }];
}

function buildCollaborationMode(model?: string | null, effort?: string | null): Record<string, unknown> {
return {
mode: "default",
settings: {
model: model ?? null,
reasoning_effort: effort ?? null,
developer_instructions: null,
},
};
}

const BRIDGE_ONLY_ENV_KEYS = [
"BRIDGE_DB_PATH",
"BRIDGE_ENV_PATH",
Expand Down Expand Up @@ -141,6 +171,7 @@ export class CodexAppServerAdapter extends EventEmitter {
experimentalApi: true,
},
});
await this.writeJsonRpcMessage({ jsonrpc: "2.0", method: "initialized" });
} catch (error) {
if (this.process === child) {
child.kill("SIGTERM");
Expand All @@ -158,12 +189,24 @@ export class CodexAppServerAdapter extends EventEmitter {
}

async startThread(options: StartThreadOptions): Promise<string> {
const result = (await this.request("thread/start", {
cwd: options.cwd,
model: options.model,
approvalPolicy: "never",
sandbox: "danger-full-access",
})) as { thread?: { id?: string } };
const result = (await this.request(
"thread/start",
withOptionalFields(
{
experimentalRawEvents: false,
persistExtendedHistory: true,
serviceTier: null,
sandbox: "danger-full-access",
approvalPolicy: "never",
personality: null,
config: DEFAULT_CODEX_FEATURE_FLAGS,
},
{
cwd: options.cwd,
model: options.model,
},
),
)) as { thread?: { id?: string } };
const threadId = result?.thread?.id;
if (!threadId) {
throw new Error(`thread/start did not return thread.id: ${JSON.stringify(result)}`);
Expand All @@ -173,13 +216,23 @@ export class CodexAppServerAdapter extends EventEmitter {

async resumeThread(options: ResumeThreadOptions): Promise<void> {
try {
await this.request("thread/resume", {
threadId: options.threadId,
cwd: options.cwd ?? null,
model: options.model ?? null,
approvalPolicy: "never",
sandbox: "danger-full-access",
});
await this.request(
"thread/resume",
withOptionalFields(
{
threadId: options.threadId,
persistExtendedHistory: true,
serviceTier: null,
sandbox: "danger-full-access",
approvalPolicy: "never",
personality: null,
},
{
cwd: options.cwd,
model: options.model,
},
),
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (options.allowMissingRollout && message.includes("no rollout found for thread id")) {
Expand Down Expand Up @@ -227,13 +280,24 @@ export class CodexAppServerAdapter extends EventEmitter {
}

async startTurn(options: StartTurnOptions): Promise<{ turnId: string }> {
const response = (await this.request("turn/start", {
threadId: options.threadId,
cwd: options.cwd ?? null,
model: options.model ?? null,
effort: options.effort ?? null,
input: [{ type: "text", text: options.input }],
})) as { turn?: { id?: string } };
const response = (await this.request(
"turn/start",
withOptionalFields(
{
threadId: options.threadId,
input: buildTextInput(options.input),
serviceTier: null,
summary: "none",
sandboxPolicy: { type: "dangerFullAccess" },
personality: null,
collaborationMode: buildCollaborationMode(options.model, options.effort),
},
{
model: options.model,
effort: options.effort,
},
),
)) as { turn?: { id?: string } };

const turnId = response?.turn?.id;
if (!turnId) {
Expand All @@ -246,7 +310,7 @@ export class CodexAppServerAdapter extends EventEmitter {
await this.request("turn/steer", {
threadId: options.threadId,
expectedTurnId: options.expectedTurnId,
input: [{ type: "text", text: options.input }],
input: buildTextInput(options.input),
});
}

Expand Down
Loading
Loading