import { expect, vi } from "vitest"; import type { AppServerClient } from "../../src/app-server/connection/client"; import type { AppServerClientAccessOptions } from "../../src/app-server/connection/client-access"; import type { CatalogHookMetadata, CatalogModel } from "../../src/app-server/protocol/catalog"; import type { ThreadRecord } from "../../src/app-server/protocol/thread"; import { StaleAppServerResourceContextError } from "../../src/app-server/query/resource-store"; import type { ModelMetadata, ReasoningEffort } from "../../src/domain/catalog/metadata"; import type { Thread } from "../../src/domain/threads/model"; import type { ThreadCatalogEvent } from "../../src/features/threads/catalog/thread-catalog"; import { SwappableSettingsDynamicData } from "../../src/plugin-runtime"; import { createSettingsAppServerDynamicData } from "../../src/settings/app-server-dynamic-data"; import type { SettingsDynamicDataAccess } from "../../src/settings/dynamic-data"; import type { CodexPanelSettingTabHost } from "../../src/settings/host"; import { type CodexPanelSettings, DEFAULT_SETTINGS } from "../../src/settings/model"; type ShortLivedClientOperation = ( codexPath: string, cwd: string, operation: (client: AppServerClient) => Promise, options?: AppServerClientAccessOptions, ) => Promise; type ShortLivedClientMock = ReturnType>; let shortLivedClientMock: ShortLivedClientMock | null = null; export function setSettingsShortLivedClientMock(mock: unknown): void { shortLivedClientMock = mock as ShortLivedClientMock; } function currentShortLivedClientMock(): ShortLivedClientMock { if (!shortLivedClientMock) throw new Error("Expected settings short-lived client mock"); return shortLivedClientMock; } export function panelThread(overrides: Partial = {}): Thread { return { id: "019e0182-cb70-7a72-ab48-8bc9d0b0d781", preview: "Preview", createdAt: 1, updatedAt: 1, name: null, archived: false, provenance: { kind: "interactive" }, ...overrides, }; } export function appServerThread(overrides: Partial = {}): ThreadRecord { return { id: "019e0182-cb70-7a72-ab48-8bc9d0b0d781", sessionId: "019e0182-cb70-7a72-ab48-8bc9d0b0d781", forkedFromId: null, parentThreadId: null, preview: "Preview", ephemeral: false, modelProvider: "openai", createdAt: 1, updatedAt: 1, status: { type: "idle" }, path: null, cwd: "/tmp", cliVersion: "codex-cli 0.0.0", source: "unknown", threadSource: null, agentNickname: null, agentRole: null, gitInfo: null, name: null, turns: [], ...overrides, }; } export function model(modelId: string, isDefault = false, hidden = false, efforts: ReasoningEffort[] = ["medium"]): CatalogModel { return { id: `${modelId}-id`, model: modelId, upgrade: null, upgradeInfo: null, availabilityNux: null, displayName: `Display ${modelId}`, description: "", isDefault, hidden, supportedReasoningEfforts: efforts.map((reasoningEffort) => ({ reasoningEffort, description: reasoningEffort })), defaultReasoningEffort: "medium", inputModalities: ["text"], supportsPersonality: false, serviceTiers: [], defaultServiceTier: null, } satisfies CatalogModel; } export function hook(overrides: Partial = {}): CatalogHookMetadata { return { key: "hook-key", eventName: "postToolUse", handlerType: "command", matcher: "apply_patch", command: "node hook.js", timeoutSec: 10n, statusMessage: null, sourcePath: "/vault/.codex/hooks.json", source: "project", pluginId: null, displayOrder: 0n, enabled: true, isManaged: false, currentHash: "hash", trustStatus: "trusted", ...overrides, }; } export function settingsClient( options: { models?: CatalogModel[]; hooks?: CatalogHookMetadata[]; hooksError?: Error; threads?: ThreadRecord[] } = {}, ): SettingsRequestClient { return settingsRequestClient({ "model/list": vi.fn().mockResolvedValue({ data: options.models ?? [model("gpt-5.4")] }), "hooks/list": vi.fn().mockImplementation(() => { if (options.hooksError) return Promise.reject(options.hooksError); return Promise.resolve({ data: [ { cwd: "/vault", hooks: options.hooks ?? [], warnings: [], errors: [], }, ], }); }), "thread/list": vi.fn().mockResolvedValue({ data: options.threads ?? [appServerThread({ preview: "Archived" })] }), "config/batchWrite": vi.fn().mockResolvedValue({}), "thread/unarchive": vi.fn().mockResolvedValue({ thread: appServerThread({ preview: "Restored" }) }), "thread/delete": vi.fn().mockResolvedValue({}), }); } export type SettingsRequestClient = AppServerClient & { request: ReturnType unknown>>; requestHandlers: Record unknown>>>; }; export function useShortLivedClients(...clients: SettingsRequestClient[]): void { const mock = currentShortLivedClientMock(); const runWithClient = (client: SettingsRequestClient, operation: (client: AppServerClient) => Promise) => operation(client); if (clients.length === 1) { const [client] = clients; if (!client) throw new Error("Expected a short-lived client."); mock.mockImplementation((_codexPath: string, _cwd: string, operation: (client: AppServerClient) => Promise) => runWithClient(client, operation), ); return; } for (const client of clients) { mock.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: AppServerClient) => Promise) => runWithClient(client, operation), ); } } export function settingsRequestClient( handlers: Record unknown>>>, ): SettingsRequestClient { return { requestHandlers: handlers, request: vi.fn((method: string, params: unknown) => { const handler = handlers[method]; if (!handler) throw new Error(`Unexpected app-server request: ${method}`); return handler(params); }), } as unknown as SettingsRequestClient; } export function requestMethods(client: SettingsRequestClient): string[] { return client.request.mock.calls.map(([method]) => method); } export function expectRequestTimes(client: SettingsRequestClient, method: string, times: number): void { expect(requestMethods(client).filter((calledMethod) => calledMethod === method)).toHaveLength(times); } export interface SettingsTabHostOptions { saveSettings?: (settings: CodexPanelSettings) => Promise; sendShortcut?: "enter" | "mod-enter"; modelsSnapshot?: ModelMetadata[]; fetchModels?: () => Promise; refreshModels?: () => Promise; observeModels?: SettingsDynamicDataAccess["observeModelsResult"]; refreshOpenViews?: () => void; archivedThreads?: Thread[]; archivedSnapshot?: Thread[] | null; refreshArchived?: () => Promise; observeArchived?: SettingsDynamicDataAccess["observeArchivedThreadsResult"]; applyThreadCatalogEvent?: (event: ThreadCatalogEvent) => void; dynamicData?: SettingsDynamicDataAccess; settings?: Partial<{ threadNamingModel: string | null; threadNamingEffort: string | null; rewriteSelectionModel: string | null; rewriteSelectionEffort: string | null; }>; } export function settingsTabHost(options: SettingsTabHostOptions = {}): CodexPanelSettingTabHost { const defaultArchivedThreads = [panelThread({ id: "thread-archived", preview: "Archived thread", archived: true })]; const settings = { ...DEFAULT_SETTINGS, threadNamingModel: options.settings?.threadNamingModel ?? null, threadNamingEffort: options.settings?.threadNamingEffort ?? null, rewriteSelectionModel: options.settings?.rewriteSelectionModel ?? null, rewriteSelectionEffort: options.settings?.rewriteSelectionEffort ?? null, sendShortcut: options.sendShortcut ?? "enter", }; const appServerQueries = { contextKey: () => settings.codexPath, modelsSnapshot: vi.fn(() => options.modelsSnapshot ?? []), fetchModels: options.fetchModels ?? vi.fn().mockResolvedValue(options.modelsSnapshot ?? []), refreshModels: options.refreshModels ?? vi.fn().mockResolvedValue(options.modelsSnapshot ?? []), observeModelsResult: options.observeModels ?? vi.fn(() => () => undefined), }; const threadCatalog = { archivedSnapshot: vi.fn(() => options.archivedSnapshot ?? null), refreshArchived: options.refreshArchived ?? vi.fn().mockResolvedValue(options.archivedThreads ?? defaultArchivedThreads), observeArchived: options.observeArchived ?? vi.fn(() => () => undefined), apply: options.applyThreadCatalogEvent ?? vi.fn(), }; const clientAccess = { withClient: async (operation: (client: AppServerClient) => Promise, clientOptions?: AppServerClientAccessOptions): Promise => { const contextKey = appServerQueries.contextKey(); const result = (await currentShortLivedClientMock()( settings.codexPath, "/vault", async (client) => { if (appServerQueries.contextKey() !== contextKey) throw new StaleAppServerResourceContextError(); return operation(client); }, clientOptions, )) as T; if (appServerQueries.contextKey() !== contextKey) throw new StaleAppServerResourceContextError(); return result; }, }; const swappableDynamicData = options.dynamicData ? null : new SwappableSettingsDynamicData(); const dynamicData: SettingsDynamicDataAccess = options.dynamicData ?? (swappableDynamicData as SwappableSettingsDynamicData); const createDynamicData = () => createSettingsAppServerDynamicData({ vaultPath: "/vault", clientAccess, appServerQueries, threadCatalog, }); swappableDynamicData?.replace(createDynamicData()); return { settings, dynamicData, publishSettings: async (nextSettings) => { const previousSettings = { ...settings }; await (options.saveSettings ?? vi.fn().mockResolvedValue(undefined))(nextSettings); const appServerContextReplaced = previousSettings.codexPath !== nextSettings.codexPath; Object.assign(settings, nextSettings); if (appServerContextReplaced) swappableDynamicData?.replace(createDynamicData()); if (appServerContextReplaced || previousSettings.showToolbar !== nextSettings.showToolbar) options.refreshOpenViews?.(); return { appServerContextReplaced }; }, }; } export async function flushPromises(): Promise { await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve)); }