From 6070a579417ed70dc09f8e3fcaba30e719fc2cfa Mon Sep 17 00:00:00 2001 From: hokupod Date: Wed, 12 Aug 2026 18:54:25 +0900 Subject: [PATCH] feat: support ephemeral ACP sessions --- README.md | 16 +++++ src/CodexAcpClient.ts | 17 +++++ .../CodexACPAgent/CodexAcpClient.test.ts | 69 +++++++++++++++++++ 3 files changed, 102 insertions(+) diff --git a/README.md b/README.md index 5246590a..7243bda0 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index d42199cb..5714ef5c 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -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(); @@ -1069,6 +1070,22 @@ function readMetaAdditionalRoots(meta?: Record | null): string[ .filter(value => value.length > 0)); } +function readEphemeralSession(meta?: Record | 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)["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 | null): string[] { const rawDirectories = additionalDirectories ?? readMetaAdditionalRoots(meta); if (!rawDirectories) { diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index d1df6999..1ec9520c 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -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, @@ -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();