diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index ab471a011a..00dc26652c 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -171,8 +171,16 @@ function createSession( return session; } -function createProviderWithFakeAppServer(appServer: FakeCodexAppServer): CodexAppServerAgentClient { - const provider = new CodexAppServerAgentClient(createTestLogger()); +function createProviderWithFakeAppServer( + appServer: FakeCodexAppServer, + options?: { + runtimeSettings?: ConstructorParameters[1]; + customProvider?: { id: string; label: string; extends: string }; + }, +): CodexAppServerAgentClient { + const provider = new CodexAppServerAgentClient(createTestLogger(), options?.runtimeSettings, { + customProvider: options?.customProvider, + }); const internals = castInternals<{ goalsEnabledPromise: Promise | null; autoReviewEnabledPromise: Promise | null; @@ -6212,6 +6220,55 @@ describe("Codex importable sessions", () => { appServer.assertNoErrors(); }); + // Codex filters thread/list by model provider: with no `modelProviders` it + // returns only threads of the provider its own config selects, an empty + // list returns every provider, and a list returns only those providers. + function providerFilteringThreadListHandler(threads: Array>) { + return (input: unknown) => { + const { modelProviders } = (input ?? {}) as { modelProviders?: string[] }; + const included = (thread: Record) => { + if (modelProviders === undefined) return thread.modelProvider === "openai"; + if (modelProviders.length === 0) return true; + return modelProviders.includes(String(thread.modelProvider)); + }; + return { data: threads.filter(included), nextCursor: null }; + }; + } + + const threadsByModelProvider = [ + { id: "stock-thread", cwd: "/workspace/project-a", modelProvider: "openai", updatedAt: 2 }, + { id: "custom-thread", cwd: "/workspace/project-a", modelProvider: "my-codex", updatedAt: 1 }, + ]; + + test("a custom Codex provider lists the threads it created", async () => { + const appServer = createFakeCodexAppServer({ + "thread/list": providerFilteringThreadListHandler(threadsByModelProvider), + }); + const provider = createProviderWithFakeAppServer(appServer, { + runtimeSettings: { + env: { OPENAI_BASE_URL: "https://llm.example.test/v1", OPENAI_API_KEY: "test-key" }, + }, + customProvider: { id: "my-codex", label: "My Codex", extends: "codex" }, + }); + + const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" }); + + expect(sessions.map((session) => session.providerHandleId)).toEqual(["custom-thread"]); + appServer.assertNoErrors(); + }); + + test("stock Codex keeps listing only its own threads", async () => { + const appServer = createFakeCodexAppServer({ + "thread/list": providerFilteringThreadListHandler(threadsByModelProvider), + }); + const provider = createProviderWithFakeAppServer(appServer); + + const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" }); + + expect(sessions.map((session) => session.providerHandleId)).toEqual(["stock-thread"]); + appServer.assertNoErrors(); + }); + test("listImportableSessions uses thread list metadata without hydrating thread history", async () => { const allThreads = [ { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 84f31c1bda..b7fc721f24 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -259,7 +259,7 @@ interface CodexAppServerAgentDeps { label: string; extends: string; }; - customCodexConfig?: Record | null; + customCodexConfig?: CodexCustomProviderConfig | null; _createCodexClient?: ( child: ChildProcessWithoutNullStreams, logger: Logger, @@ -919,7 +919,7 @@ const CodexModelListResponseSchema = z.object({ async function readCodexThreadWindow( client: Pick, logger: Logger, - window: { limit: number; cwd?: string }, + window: { limit: number; cwd?: string; modelProviders?: string[] }, ): Promise>> { // A thread updated while the scan is paging moves under `updated_at` order // and can come back on a later page, so identify each one and keep it once. @@ -935,6 +935,7 @@ async function readCodexThreadWindow( // Older Codex builds ignore the unknown key and keep that order. sortKey: "updated_at", ...(window.cwd ? { cwd: window.cwd } : {}), + ...(window.modelProviders ? { modelProviders: window.modelProviders } : {}), ...(cursor ? { cursor } : {}), }), ); @@ -3278,10 +3279,15 @@ function normalizeOpenAICompatibleBaseUrl(value: string): string | null { return `${withoutTrailingSlashes}/v1`; } +interface CodexCustomProviderConfig { + model_provider: string; + model_providers: Record>; +} + function buildCodexCustomProviderConfig( runtimeSettings: ProviderRuntimeSettings | undefined, customProvider: CodexAppServerAgentDeps["customProvider"], -): Record | null { +): CodexCustomProviderConfig | null { if (customProvider?.extends !== CODEX_PROVIDER) { return null; } @@ -7021,13 +7027,14 @@ export class CodexAppServerAgentClient implements AgentClient { private sessionDeps(): CodexAppServerAgentDeps { return { ...this.deps, - customCodexConfig: buildCodexCustomProviderConfig( - this.runtimeSettings, - this.deps.customProvider, - ), + customCodexConfig: this.customProviderConfig(), }; } + private customProviderConfig(): CodexCustomProviderConfig | null { + return buildCodexCustomProviderConfig(this.runtimeSettings, this.deps.customProvider); + } + private resolveGoalsEnabled(): Promise { if (!this.goalsEnabledPromise) { this.goalsEnabledPromise = (async () => { @@ -7191,9 +7198,14 @@ export class CodexAppServerAgentClient implements AgentClient { // filtering since most threads will be from other cwds, then keep the // local realpath-aware filter for symlink-equivalent workspace paths. const listLimit = options?.cwd ? Math.max(scanLimit, 50) : scanLimit; + // Codex records each thread under the model provider that ran it and, + // unless told otherwise, lists only the provider its own config selects. + // A custom provider runs under its own id, so ask for exactly that one. + const customProviderConfig = this.customProviderConfig(); const allThreads = await readCodexThreadWindow(client, this.logger, { limit: listLimit, cwd: options?.cwd, + ...(customProviderConfig ? { modelProviders: [customProviderConfig.model_provider] } : {}), }); const threads = filterCodexThreadsByCwd(allThreads, options?.cwd); return threads.slice(0, limit).map((thread) => { diff --git a/packages/server/src/server/agent/providers/codex-custom-provider-import.local.e2e.test.ts b/packages/server/src/server/agent/providers/codex-custom-provider-import.local.e2e.test.ts new file mode 100644 index 0000000000..ebd8de5ab5 --- /dev/null +++ b/packages/server/src/server/agent/providers/codex-custom-provider-import.local.e2e.test.ts @@ -0,0 +1,139 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { once } from "node:events"; +import { createServer, type Server } from "node:http"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { CodexAppServerAgentClient } from "./codex-app-server-agent.js"; + +// Runs the real `codex` binary against a local Responses endpoint standing in +// for a custom provider's OPENAI_BASE_URL, so no login is needed. + +function sse(events: Array>): string { + return events + .map((event) => `event: ${String(event.type)}\ndata: ${JSON.stringify(event)}\n\n`) + .join(""); +} + +function finalAnswer(): string { + return sse([ + { type: "response.created", response: { id: "resp-1" } }, + { + type: "response.output_item.done", + item: { + type: "message", + role: "assistant", + id: "msg-1", + content: [{ type: "output_text", text: "done" }], + }, + }, + { + type: "response.completed", + response: { + id: "resp-1", + usage: { + input_tokens: 0, + input_tokens_details: null, + output_tokens: 0, + output_tokens_details: null, + total_tokens: 0, + }, + }, + }, + ]); +} + +async function startResponsesEndpoint(): Promise<{ + baseUrl: string; + server: Server; + requestCount: () => number; +}> { + let requests = 0; + const server = createServer((req, res) => { + req.resume(); + req.on("end", () => { + if (req.method !== "POST" || req.url !== "/v1/responses") { + res.statusCode = 404; + res.end(); + return; + } + requests += 1; + res.setHeader("content-type", "text/event-stream"); + res.end(finalAnswer()); + }); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("no server address"); + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + server, + requestCount: () => requests, + }; +} + +async function closeServer(server: Server): Promise { + server.close(); + await once(server, "close"); +} + +describe("Codex custom provider import list", () => { + const cleanups: Array<() => void | Promise> = []; + + afterEach(async () => { + for (const cleanup of cleanups.splice(0).toReversed()) await cleanup(); + }); + + test("lists a custom provider's session under that provider and imports it there", async () => { + const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-home-")); + const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-cwd-")); + cleanups.push(() => rmSync(codexHome, { recursive: true, force: true })); + cleanups.push(() => rmSync(cwd, { recursive: true, force: true })); + const { baseUrl, server, requestCount } = await startResponsesEndpoint(); + cleanups.push(() => closeServer(server)); + + const logger = createTestLogger(); + const customCodex = new CodexAppServerAgentClient( + logger, + { env: { CODEX_HOME: codexHome, OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: "test-key" } }, + { customProvider: { id: "my-codex", label: "My Codex", extends: "codex" } }, + ); + const stockCodex = new CodexAppServerAgentClient(logger, { env: { CODEX_HOME: codexHome } }); + + const session = await customCodex.createSession({ + provider: "codex", + cwd, + model: "mock-model", + }); + try { + await session.run("hello from the custom provider"); + } finally { + await session.close(); + } + + const customSessions = await customCodex.listImportableSessions({ cwd }); + const stockSessions = await stockCodex.listImportableSessions({ cwd }); + + expect(customSessions.map((entry) => entry.firstPromptPreview)).toEqual([ + "hello from the custom provider", + ]); + expect(stockSessions).toEqual([]); + + const config = { provider: "codex", cwd, model: "mock-model" }; + const imported = await customCodex.importSession( + { providerHandleId: customSessions[0].providerHandleId, cwd }, + { config, storedConfig: config }, + ); + const requestsBeforeImportedTurn = requestCount(); + try { + await imported.session.run("hello again after import"); + } finally { + await imported.session.close(); + } + expect(requestCount()).toBeGreaterThan(requestsBeforeImportedTurn); + }, 120_000); +});