diff --git a/src/app-server/model-options.ts b/src/app-server/model-options.ts deleted file mode 100644 index 69ea9978..00000000 --- a/src/app-server/model-options.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { AppServerClient } from "./client"; -import { panelModelOptionsFromAppServerModels } from "./catalog-model"; -import type { PanelModelOption } from "../domain/catalog/metadata"; - -export async function loadPanelModelOptions(codexPath: string, cwd: string, includeHidden = false): Promise { - let client!: AppServerClient; - client = new AppServerClient(codexPath, cwd, { - onNotification: () => undefined, - onServerRequest: (request) => { - client.rejectServerRequest(request.id, -32601, "Model option loading does not handle server requests."); - }, - onLog: () => undefined, - onExit: () => undefined, - }); - - try { - await client.connect(); - const response = await client.listModels(includeHidden); - return panelModelOptionsFromAppServerModels(response.data); - } finally { - client.disconnect(); - } -} diff --git a/src/app-server/structured-ephemeral-turn.ts b/src/app-server/structured-ephemeral-turn.ts index 9ff9365f..69d8ad54 100644 --- a/src/app-server/structured-ephemeral-turn.ts +++ b/src/app-server/structured-ephemeral-turn.ts @@ -9,6 +9,7 @@ import type { RequestId } from "../generated/app-server/RequestId"; import type { ReasoningEffort } from "../generated/app-server/ReasoningEffort"; import type { ServerNotification } from "../generated/app-server/ServerNotification"; import type { JsonValue } from "../generated/app-server/serde_json/JsonValue"; +import type { ModelListResponse } from "../generated/app-server/v2/ModelListResponse"; import type { ThreadItem } from "../generated/app-server/v2/ThreadItem"; import type { ThreadStartResponse } from "../generated/app-server/v2/ThreadStartResponse"; import type { Turn } from "../generated/app-server/v2/Turn"; @@ -38,11 +39,17 @@ export interface StructuredEphemeralTurnClient { ): Promise; } +export interface StructuredEphemeralTurnRuntimeClient { + listModels(includeHidden?: boolean): Promise; +} + +type StructuredEphemeralTurnRuntimeCapableClient = StructuredEphemeralTurnClient & StructuredEphemeralTurnRuntimeClient; + export type StructuredEphemeralTurnClientFactory = ( codexPath: string, cwd: string, handlers: AppServerClientHandlers, -) => StructuredEphemeralTurnClient; +) => StructuredEphemeralTurnRuntimeCapableClient; export interface RunStructuredEphemeralTurnOptions { codexPath: string; @@ -57,6 +64,7 @@ export interface RunStructuredEphemeralTurnOptions { timedOutMessage: string; abortMessage?: string; runtime?: StructuredTurnRuntimeOverride | undefined; + resolveRuntime?: ((client: StructuredEphemeralTurnRuntimeClient) => Promise) | undefined; signal?: AbortSignal | undefined; onProgress?: (event: StructuredTurnProgressEvent) => void; clientFactory?: StructuredEphemeralTurnClientFactory | undefined; @@ -85,7 +93,7 @@ export async function runStructuredEphemeralTurn(options: RunStructuredEphemeral }; }); - let client!: StructuredEphemeralTurnClient; + let client!: StructuredEphemeralTurnRuntimeCapableClient; const clientFactory = options.clientFactory ?? ((codexPath, cwd, handlers) => new AppServerClient(codexPath, cwd, handlers)); client = clientFactory(options.codexPath, options.cwd, { onNotification: (notification) => { @@ -104,7 +112,9 @@ export async function runStructuredEphemeralTurn(options: RunStructuredEphemeral try { await abortable(client.connect(), options.signal, options.abortMessage); - const runtime = options.runtime ?? {}; + const runtime = options.resolveRuntime + ? await abortable(options.resolveRuntime(client), options.signal, options.abortMessage) + : (options.runtime ?? {}); const threadResponse = await abortable( client.startEphemeralThread(options.cwd, options.serviceName, options.developerInstructions), options.signal, diff --git a/src/features/selection-rewrite/runner.ts b/src/features/selection-rewrite/runner.ts index 09c3dcdb..3adc6b7c 100644 --- a/src/features/selection-rewrite/runner.ts +++ b/src/features/selection-rewrite/runner.ts @@ -2,10 +2,11 @@ import { runStructuredEphemeralTurn, type StructuredEphemeralTurnClient, type StructuredEphemeralTurnClientFactory, + type StructuredEphemeralTurnRuntimeClient, type StructuredTurnOutputSchema, } from "../../app-server/structured-ephemeral-turn"; import type { ReasoningEffort } from "../../domain/catalog/metadata"; -import { loadPanelModelOptions } from "../../app-server/model-options"; +import { panelModelOptionsFromAppServerModels } from "../../app-server/catalog-model"; import type { PanelModelOption } from "../../domain/catalog/metadata"; import { runtimeOverride, validatedRuntimeOverrideForModelOptions } from "../../domain/catalog/runtime-overrides"; import type { SelectionRewriteRuntimeSettings } from "./model"; @@ -44,9 +45,6 @@ export type SelectionRewriteClientFactory = StructuredEphemeralTurnClientFactory export async function runSelectionRewrite(options: RunSelectionRewriteOptions): Promise { let preview = ""; const runtimeSettings = options.runtimeSettings; - const runtime = runtimeSettings - ? await selectionRewriteRuntimeOverrideForCodex(options.codexPath, options.cwd, runtimeSettings) - : undefined; const turn = await runStructuredEphemeralTurn({ codexPath: options.codexPath, cwd: options.cwd, @@ -60,7 +58,7 @@ export async function runSelectionRewrite(options: RunSelectionRewriteOptions): timedOutMessage: "Timed out while rewriting the selection.", abortMessage: "Selection rewrite cancelled.", signal: options.signal, - runtime, + resolveRuntime: runtimeSettings ? (client) => selectionRewriteRuntimeOverrideForClient(client, runtimeSettings) : undefined, clientFactory: options.clientFactory, onProgress: (event) => { if (event.type === "reasoning-activity") { @@ -96,15 +94,15 @@ export function validatedSelectionRewriteRuntimeOverride( ); } -async function selectionRewriteRuntimeOverrideForCodex( - codexPath: string, - cwd: string, +async function selectionRewriteRuntimeOverrideForClient( + client: StructuredEphemeralTurnRuntimeClient, settings: SelectionRewriteRuntimeSettings, ): Promise { const runtime = selectionRewriteRuntimeOverride(settings); if (!runtime.model || !runtime.effort) return runtime; try { - return validatedSelectionRewriteRuntimeOverride(settings, await loadPanelModelOptions(codexPath, cwd, false)); + const response = await client.listModels(false); + return validatedSelectionRewriteRuntimeOverride(settings, panelModelOptionsFromAppServerModels(response.data)); } catch { return runtime; } diff --git a/src/features/thread-picker/modal.ts b/src/features/thread-picker/modal.ts index ef9d638d..a6df6a50 100644 --- a/src/features/thread-picker/modal.ts +++ b/src/features/thread-picker/modal.ts @@ -1,10 +1,10 @@ import { Notice, Platform, SuggestModal, type App } from "obsidian"; +import { withShortLivedAppServerClient } from "../../app-server/short-lived-client"; import { panelThreadsFromAppServerThreads } from "../../app-server/thread-model"; import { getThreadTitle } from "../../domain/threads/model"; import type { PanelThread } from "../../domain/threads/model"; import type { CodexPanelSettings } from "../../settings/model"; -import { withShortLivedFallbackAppServerClient } from "../../workspace/short-lived-app-server-client"; import { shortThreadId } from "../../utils"; export interface ThreadPickerHost { @@ -82,17 +82,17 @@ async function loadThreadPickerThreads(host: ThreadPickerHost): Promise host.refreshThreadList(async () => { const response = await client.listThreads(host.vaultPath); return panelThreadsFromAppServerThreads(response.data); }), + { + unhandledServerRequestMessage: "Codex thread picker does not handle server requests.", + }, ); } diff --git a/src/settings/tab.ts b/src/settings/tab.ts index af3c7055..43f3c0be 100644 --- a/src/settings/tab.ts +++ b/src/settings/tab.ts @@ -1,6 +1,7 @@ import { type App, Notice, type Plugin, PluginSettingTab, Setting, setIcon } from "obsidian"; import type { AppServerClient } from "../app-server/client"; +import { withShortLivedAppServerClient } from "../app-server/short-lived-client"; import { DEFAULT_CODEX_PATH } from "../constants"; import type { ReasoningEffort } from "../domain/catalog/metadata"; import type { PanelHookItem, PanelModelOption } from "../domain/catalog/metadata"; @@ -15,7 +16,6 @@ import { transitionSettingsDataRefreshLifecycle, type SettingsDataRefreshLifecycleState, } from "./data"; -import { withShortLivedFallbackAppServerClient } from "../workspace/short-lived-app-server-client"; import { loadHookData, loadSettingsData, restoreArchivedPanelThread, setPanelHookEnabled, trustPanelHook } from "./app-server-data"; import { renderArchivedThreadSection, renderHookSection } from "./dynamic-sections"; import type { CodexPanelSettings } from "./model"; @@ -498,14 +498,9 @@ export class CodexPanelSettingTab extends PluginSettingTab { } private async withSettingsConnection(operation: (client: AppServerClient) => Promise): Promise { - return withShortLivedFallbackAppServerClient( - { - codexPath: this.plugin.settings.codexPath, - cwd: this.plugin.vaultPath, - unhandledServerRequestMessage: "Codex Panel settings does not handle server requests.", - }, - operation, - ); + return withShortLivedAppServerClient(this.plugin.settings.codexPath, this.plugin.vaultPath, operation, { + unhandledServerRequestMessage: "Codex Panel settings does not handle server requests.", + }); } private nextSettingsDynamicOperationId(): number { diff --git a/src/workspace/short-lived-app-server-client.ts b/src/workspace/short-lived-app-server-client.ts deleted file mode 100644 index 8998e13a..00000000 --- a/src/workspace/short-lived-app-server-client.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { AppServerClient } from "../app-server/client"; -import { withShortLivedAppServerClient } from "../app-server/short-lived-client"; - -export interface ShortLivedFallbackAppServerClientOptions { - codexPath: string; - cwd: string; - unhandledServerRequestMessage: string; -} - -export async function withShortLivedFallbackAppServerClient( - options: ShortLivedFallbackAppServerClientOptions, - operation: (client: AppServerClient) => Promise, -): Promise { - return withShortLivedAppServerClient(options.codexPath, options.cwd, operation, { - unhandledServerRequestMessage: options.unhandledServerRequestMessage, - }); -} diff --git a/src/workspace/thread-title-generator.ts b/src/workspace/thread-title-generator.ts index d54935db..ae1abef8 100644 --- a/src/workspace/thread-title-generator.ts +++ b/src/workspace/thread-title-generator.ts @@ -2,9 +2,10 @@ import { runStructuredEphemeralTurn, type StructuredEphemeralTurnClient, type StructuredEphemeralTurnClientFactory, + type StructuredEphemeralTurnRuntimeClient, type StructuredTurnOutputSchema, } from "../app-server/structured-ephemeral-turn"; -import { loadPanelModelOptions } from "../app-server/model-options"; +import { panelModelOptionsFromAppServerModels } from "../app-server/catalog-model"; import type { PanelModelOption, ReasoningEffort } from "../domain/catalog/metadata"; import { runtimeOverride, validatedRuntimeOverrideForModelOptions } from "../domain/catalog/runtime-overrides"; import { namingPrompt, titleFromNamingTurn, type ThreadNamingContext } from "../domain/threads/naming"; @@ -48,7 +49,6 @@ export async function generateThreadTitleWithCodex( runtimeSettings: ThreadNamingRuntimeSettings, clientFactory?: ThreadNamingClientFactory, ): Promise { - const runtime = await threadNamingRuntimeOverrideForCodex(codexPath, cwd, runtimeSettings); const turn = await runStructuredEphemeralTurn({ codexPath, cwd, @@ -60,7 +60,7 @@ export async function generateThreadTitleWithCodex( unhandledServerRequestMessage: "Thread title generation does not handle server requests.", exitedMessage: "Codex title generation app-server exited.", timedOutMessage: "Timed out while generating a Codex thread title.", - runtime, + resolveRuntime: (client) => threadNamingRuntimeOverrideForClient(client, runtimeSettings), clientFactory, }); return titleFromNamingTurn(turn); @@ -82,15 +82,15 @@ export function validatedThreadNamingRuntimeOverride( return validatedRuntimeOverrideForModelOptions({ model: settings.threadNamingModel, effort: settings.threadNamingEffort }, models); } -async function threadNamingRuntimeOverrideForCodex( - codexPath: string, - cwd: string, +async function threadNamingRuntimeOverrideForClient( + client: StructuredEphemeralTurnRuntimeClient, settings: ThreadNamingRuntimeSettings, ): Promise { const runtime = threadNamingRuntimeOverride(settings); if (!runtime.model || !runtime.effort) return runtime; try { - return validatedThreadNamingRuntimeOverride(settings, await loadPanelModelOptions(codexPath, cwd, false)); + const response = await client.listModels(false); + return validatedThreadNamingRuntimeOverride(settings, panelModelOptionsFromAppServerModels(response.data)); } catch { return runtime; } diff --git a/tests/app-server/structured-ephemeral-turn.test.ts b/tests/app-server/structured-ephemeral-turn.test.ts index d9d8bd37..a3278183 100644 --- a/tests/app-server/structured-ephemeral-turn.test.ts +++ b/tests/app-server/structured-ephemeral-turn.test.ts @@ -14,6 +14,7 @@ import type { RequestId } from "../../src/generated/app-server/RequestId"; import type { ServerNotification } from "../../src/generated/app-server/ServerNotification"; import type { ServerRequest } from "../../src/generated/app-server/ServerRequest"; import type { ReasoningEffort } from "../../src/generated/app-server/ReasoningEffort"; +import type { ModelListResponse } from "../../src/generated/app-server/v2/ModelListResponse"; import type { Thread } from "../../src/generated/app-server/v2/Thread"; import type { ThreadItem } from "../../src/generated/app-server/v2/ThreadItem"; import type { ThreadStartResponse } from "../../src/generated/app-server/v2/ThreadStartResponse"; @@ -92,6 +93,30 @@ describe("runStructuredEphemeralTurn", () => { "Structured test does not handle server requests.", ); }); + + it("resolves runtime on the same client before starting the ephemeral thread", async () => { + const callOrder: string[] = []; + const { clientFactory, client } = fakeStructuredTurnClientFactory((fake) => { + fake.startEphemeralThreadImpl = async () => { + callOrder.push("start-thread"); + return threadStartResponse("thread"); + }; + fake.startStructuredTurnImpl = async () => ({ turn: turn([agentMessage("answer", '{"ok":true}')]) }); + }); + + await runStructuredEphemeralTurn({ + ...runOptions(clientFactory), + resolveRuntime: async (runtimeClient) => { + callOrder.push("resolve-runtime"); + expect(runtimeClient).toBe(expectPresent(client.current)); + await runtimeClient.listModels(false); + return { model: "gpt-5.1", effort: "low" }; + }, + }); + + expect(callOrder).toEqual(["resolve-runtime", "start-thread"]); + expect(expectPresent(client.current).listModels).toHaveBeenCalledWith(false); + }); }); function runOptions(clientFactory: StructuredEphemeralTurnClientFactory): Parameters[0] { @@ -127,7 +152,9 @@ function fakeStructuredTurnClientFactory(configure?: (client: FakeStructuredTurn class FakeStructuredTurnClient implements StructuredEphemeralTurnClient { connectImpl: (() => Promise) | null = null; + startEphemeralThreadImpl: (() => Promise) | null = null; startStructuredTurnImpl: (() => Promise) | null = null; + readonly listModels = vi.fn(async (): Promise => ({ data: [], nextCursor: null })); readonly rejectServerRequest = vi.fn(); readonly structuredTurnStarted: Promise; private resolveStructuredTurnStarted!: () => void; @@ -147,7 +174,7 @@ class FakeStructuredTurnClient implements StructuredEphemeralTurnClient { } async startEphemeralThread(): Promise { - return threadStartResponse("thread"); + return this.startEphemeralThreadImpl ? this.startEphemeralThreadImpl() : threadStartResponse("thread"); } async startStructuredTurn( diff --git a/tests/features/chat/threads/thread-naming.test.ts b/tests/features/chat/threads/thread-naming.test.ts index 737d374f..bb8e55b7 100644 --- a/tests/features/chat/threads/thread-naming.test.ts +++ b/tests/features/chat/threads/thread-naming.test.ts @@ -23,6 +23,7 @@ import type { JsonValue } from "../../../../src/generated/app-server/serde_json/ import type { RequestId } from "../../../../src/generated/app-server/RequestId"; import type { ReasoningEffort } from "../../../../src/generated/app-server/ReasoningEffort"; import type { Model } from "../../../../src/generated/app-server/v2/Model"; +import type { ModelListResponse } from "../../../../src/generated/app-server/v2/ModelListResponse"; import type { ServerNotification } from "../../../../src/generated/app-server/ServerNotification"; import type { Thread } from "../../../../src/generated/app-server/v2/Thread"; import type { ThreadItem } from "../../../../src/generated/app-server/v2/ThreadItem"; @@ -329,6 +330,10 @@ class FakeThreadNamingClient implements ThreadNamingClient { return undefined; } + async listModels(): Promise { + return { data: [], nextCursor: null }; + } + rejectServerRequest(_requestId: RequestId, _code: number, _message: string): void { return undefined; } diff --git a/tests/features/selection-rewrite/selection-rewrite.test.ts b/tests/features/selection-rewrite/selection-rewrite.test.ts index 65d2a1d7..5919b725 100644 --- a/tests/features/selection-rewrite/selection-rewrite.test.ts +++ b/tests/features/selection-rewrite/selection-rewrite.test.ts @@ -28,6 +28,7 @@ import { import type { AppServerClientHandlers } from "../../../src/app-server/client"; import type { PanelModelOption } from "../../../src/domain/catalog/metadata"; import type { InitializeResponse } from "../../../src/generated/app-server/InitializeResponse"; +import type { ModelListResponse } from "../../../src/generated/app-server/v2/ModelListResponse"; import type { ServerNotification } from "../../../src/generated/app-server/ServerNotification"; import type { JsonValue } from "../../../src/generated/app-server/serde_json/JsonValue"; import type { RequestId } from "../../../src/generated/app-server/RequestId"; @@ -664,6 +665,10 @@ class FakeSelectionRewriteClient implements SelectionRewriteClient { this.disconnected = true; } + async listModels(): Promise { + return { data: [], nextCursor: null }; + } + rejectServerRequest(_requestId: RequestId, _code: number, _message: string): void { return undefined; }