mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
List a custom Codex provider's sessions in Import session (#5446)
A provider that extends codex with its own OPENAI_BASE_URL starts its threads under its own Codex model provider id. Codex's thread/list returns only the model provider its config selects unless the request names others, and the import scan named none, so those sessions were missing from the import list. After switching the default provider in the Codex config, stock Codex listed them instead and imported them onto itself. Ask Codex for the custom provider's own threads when listing its importable sessions. Stock Codex keeps its current listing. Closes #5381 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -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<typeof CodexAppServerAgentClient>[1];
|
||||
customProvider?: { id: string; label: string; extends: string };
|
||||
},
|
||||
): CodexAppServerAgentClient {
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger(), options?.runtimeSettings, {
|
||||
customProvider: options?.customProvider,
|
||||
});
|
||||
const internals = castInternals<{
|
||||
goalsEnabledPromise: Promise<boolean> | null;
|
||||
autoReviewEnabledPromise: Promise<boolean> | 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<Record<string, unknown>>) {
|
||||
return (input: unknown) => {
|
||||
const { modelProviders } = (input ?? {}) as { modelProviders?: string[] };
|
||||
const included = (thread: Record<string, unknown>) => {
|
||||
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 = [
|
||||
{
|
||||
|
||||
@@ -259,7 +259,7 @@ interface CodexAppServerAgentDeps {
|
||||
label: string;
|
||||
extends: string;
|
||||
};
|
||||
customCodexConfig?: Record<string, unknown> | null;
|
||||
customCodexConfig?: CodexCustomProviderConfig | null;
|
||||
_createCodexClient?: (
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
logger: Logger,
|
||||
@@ -919,7 +919,7 @@ const CodexModelListResponseSchema = z.object({
|
||||
async function readCodexThreadWindow(
|
||||
client: Pick<CodexAppServerClientLike, "request">,
|
||||
logger: Logger,
|
||||
window: { limit: number; cwd?: string },
|
||||
window: { limit: number; cwd?: string; modelProviders?: string[] },
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
// 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<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function buildCodexCustomProviderConfig(
|
||||
runtimeSettings: ProviderRuntimeSettings | undefined,
|
||||
customProvider: CodexAppServerAgentDeps["customProvider"],
|
||||
): Record<string, unknown> | 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<boolean> {
|
||||
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) => {
|
||||
|
||||
+139
@@ -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<Record<string, unknown>>): 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<void> {
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
}
|
||||
|
||||
describe("Codex custom provider import list", () => {
|
||||
const cleanups: Array<() => void | Promise<void>> = [];
|
||||
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user