Keep structured turn runtime resolution in-process

This commit is contained in:
murashit 2026-06-09 20:50:34 +09:00
parent 3f9a54af38
commit 7e5136423a
10 changed files with 76 additions and 76 deletions

View file

@ -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<PanelModelOption[]> {
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();
}
}

View file

@ -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<TurnStartResponse>;
}
export interface StructuredEphemeralTurnRuntimeClient {
listModels(includeHidden?: boolean): Promise<ModelListResponse>;
}
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<StructuredTurnRuntimeOverride>) | 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,

View file

@ -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<SelectionRewriteOutput> {
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<SelectionRewriteRuntimeOverride> {
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;
}

View file

@ -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<readonly
const cached = host.cachedThreadList();
if (cached) return cached;
return withShortLivedFallbackAppServerClient(
{
codexPath: host.settings.codexPath,
cwd: host.vaultPath,
unhandledServerRequestMessage: "Codex thread picker does not handle server requests.",
},
return withShortLivedAppServerClient(
host.settings.codexPath,
host.vaultPath,
async (client) =>
host.refreshThreadList(async () => {
const response = await client.listThreads(host.vaultPath);
return panelThreadsFromAppServerThreads(response.data);
}),
{
unhandledServerRequestMessage: "Codex thread picker does not handle server requests.",
},
);
}

View file

@ -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<T>(operation: (client: AppServerClient) => Promise<T>): Promise<T> {
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 {

View file

@ -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<T>(
options: ShortLivedFallbackAppServerClientOptions,
operation: (client: AppServerClient) => Promise<T>,
): Promise<T> {
return withShortLivedAppServerClient(options.codexPath, options.cwd, operation, {
unhandledServerRequestMessage: options.unhandledServerRequestMessage,
});
}

View file

@ -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<string | null> {
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<ThreadNamingRuntimeOverride> {
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;
}

View file

@ -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<typeof runStructuredEphemeralTurn>[0] {
@ -127,7 +152,9 @@ function fakeStructuredTurnClientFactory(configure?: (client: FakeStructuredTurn
class FakeStructuredTurnClient implements StructuredEphemeralTurnClient {
connectImpl: (() => Promise<InitializeResponse>) | null = null;
startEphemeralThreadImpl: (() => Promise<ThreadStartResponse>) | null = null;
startStructuredTurnImpl: (() => Promise<TurnStartResponse>) | null = null;
readonly listModels = vi.fn(async (): Promise<ModelListResponse> => ({ data: [], nextCursor: null }));
readonly rejectServerRequest = vi.fn();
readonly structuredTurnStarted: Promise<void>;
private resolveStructuredTurnStarted!: () => void;
@ -147,7 +174,7 @@ class FakeStructuredTurnClient implements StructuredEphemeralTurnClient {
}
async startEphemeralThread(): Promise<ThreadStartResponse> {
return threadStartResponse("thread");
return this.startEphemeralThreadImpl ? this.startEphemeralThreadImpl() : threadStartResponse("thread");
}
async startStructuredTurn(

View file

@ -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<ModelListResponse> {
return { data: [], nextCursor: null };
}
rejectServerRequest(_requestId: RequestId, _code: number, _message: string): void {
return undefined;
}

View file

@ -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<ModelListResponse> {
return { data: [], nextCursor: null };
}
rejectServerRequest(_requestId: RequestId, _code: number, _message: string): void {
return undefined;
}