Skip to content
Open
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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,22 @@ The npm package includes a compatible `@openai/codex` dependency. Set `CODEX_PAT
CODEX_PATH=/path/to/codex npx -y @agentclientprotocol/codex-acp
```

## Ephemeral sessions

Clients can request an in-memory Codex thread for one-off work by adding provider-specific metadata to `session/new`:

```json
{
"_meta": {
"codex": {
"ephemeral": true
}
}
}
```

Without this metadata, or when `ephemeral` is `false`, sessions remain persisted as before. Ephemeral sessions are not intended to be listed or resumed.

## Authentication

The adapter advertises ACP auth methods during initialization. Clients can authenticate with:
Expand Down
17 changes: 17 additions & 0 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,7 @@ export class CodexAcpClient {
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers),
modelProvider: this.getModelProvider(),
cwd: request.cwd,
...(readEphemeralSession(request._meta) ? {ephemeral: true} : {}),
});

const codexModels = await this.fetchAvailableModels();
Expand Down Expand Up @@ -1069,6 +1070,22 @@ function readMetaAdditionalRoots(meta?: Record<string, unknown> | null): string[
.filter(value => value.length > 0));
}

function readEphemeralSession(meta?: Record<string, unknown> | null): boolean {
const codexMeta = meta?.["codex"];
if (codexMeta === null || typeof codexMeta !== "object" || Array.isArray(codexMeta)) {
return false;
}
if (!Object.prototype.hasOwnProperty.call(codexMeta, "ephemeral")) {
return false;
}

const ephemeral = (codexMeta as Record<string, unknown>)["ephemeral"];
if (typeof ephemeral !== "boolean") {
throw RequestError.invalidParams(undefined, "_meta.codex.ephemeral must be a boolean");
}
return ephemeral;
}

function readAdditionalDirectories(cwd: string, additionalDirectories?: string[], meta?: Record<string, unknown> | null): string[] {
const rawDirectories = additionalDirectories ?? readMetaAdditionalRoots(meta);
if (!rawDirectories) {
Expand Down
69 changes: 69 additions & 0 deletions src/__tests__/CodexACPAgent/CodexAcpClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {CODEX_API_KEY_ENV_VAR, OPENAI_API_KEY_ENV_VAR, type CodexAuthRequest} from "../../CodexAuthMethod";
import {RequestError} from "@agentclientprotocol/sdk";
import type * as acp from "@agentclientprotocol/sdk";
import {
createCodexMockTestFixture,
Expand Down Expand Up @@ -383,6 +384,74 @@ describe('ACP server test', { timeout: 40_000 }, () => {
expect(logoutSpy).toHaveBeenCalledWith({});
});

it.each([
{
name: 'forwards ephemeral Codex session metadata to thread start',
meta: {codex: {ephemeral: true}},
expectedEphemeral: true,
},
{
name: 'keeps sessions persisted when Codex session metadata is absent',
meta: undefined,
expectedEphemeral: undefined,
},
{
name: 'keeps sessions persisted when ephemeral Codex session metadata is false',
meta: {codex: {ephemeral: false}},
expectedEphemeral: undefined,
},
{
name: 'ignores unrelated Codex session metadata',
meta: {codex: {custom: true}},
expectedEphemeral: undefined,
},
])('$name', async ({meta, expectedEphemeral}) => {
const mockFixture = createCodexMockTestFixture();
const codexAcpClient = mockFixture.getCodexAcpClient();
const codexAppServerClient = mockFixture.getCodexAppServerClient();

const threadStartSpy = vi.spyOn(codexAppServerClient, "threadStart").mockResolvedValue({
thread: {id: "thread-id"} as any,
model: "gpt-5",
reasoningEffort: "medium",
serviceTier: null,
} as any);
vi.spyOn(codexAppServerClient, "listModels").mockResolvedValue({
data: [createTestModel({id: "gpt-5"})],
nextCursor: null,
});

await codexAcpClient.newSession({
cwd: "",
mcpServers: [],
...(meta ? {_meta: meta} : {}),
});

const threadStartRequest = threadStartSpy.mock.calls[0]![0];
if (expectedEphemeral) {
expect(threadStartRequest.ephemeral).toBe(true);
} else {
expect(threadStartRequest).not.toHaveProperty("ephemeral");
}
});

it('rejects non-boolean ephemeral Codex session metadata', async () => {
const mockFixture = createCodexMockTestFixture();
const codexAcpClient = mockFixture.getCodexAcpClient();
const threadStartSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "threadStart");

const error = await codexAcpClient.newSession({
cwd: "",
mcpServers: [],
_meta: {codex: {ephemeral: "true"}},
} as unknown as acp.NewSessionRequest).catch((caught: unknown) => caught);

expect(error).toBeInstanceOf(RequestError);
expect(error).toMatchObject({code: -32602});
expect((error as Error).message).toContain("_meta.codex.ephemeral must be a boolean");
expect(threadStartSpy).not.toHaveBeenCalled();
});

it('prefetches session additional skill roots before thread start', async () => {
const mockFixture = createCodexMockTestFixture();
const codexAcpClient = mockFixture.getCodexAcpClient();
Expand Down