From a7ec0179fb42b12ab7e1fd4c2cabd508c8f8de67 Mon Sep 17 00:00:00 2001 From: "(CJ) Chukwudi Nwobodo" <142016413+chuks-qua@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:55:49 +0000 Subject: [PATCH] Fix codex binary path setting having no effect (#493) * refactor(contracts): export ProviderStartOptions schema * feat(contracts): add providerOptions to ThreadTurnStartCommand * feat(web): send codex binary/home path settings to server * feat(server): thread providerOptions through to session startup * fix(contracts): define ProviderStartOptions inline to avoid circular dep The import from provider.ts caused a ReferenceError at runtime because of module initialization order in the bundled output. * fix(server): preserve providerOptions across runtime mode changes and session recovery Runtime mode toggles and server restarts silently dropped the custom codex binary/home path, falling back to the default binary. Cache providerOptions per thread in ProviderCommandReactor and persist it in the session binding's runtimePayload so recovery can restore it. Closes #486 --- apps/server/scripts/cli.ts | 2 ++ .../Layers/ProviderCommandReactor.ts | 16 +++++++++- apps/server/src/orchestration/decider.ts | 1 + .../src/provider/Layers/ProviderService.ts | 26 ++++++++++++++-- apps/web/src/components/ChatView.tsx | 30 ++++++++++++++++--- packages/contracts/src/orchestration.ts | 10 +++++++ packages/contracts/src/provider.ts | 3 +- 7 files changed, 79 insertions(+), 9 deletions(-) diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index ccf1f5a91afe3e1790ae4f292710ecb804a28fa9..e5b5c0d00ae0b82d178517643c16e7c8d10f2068 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -132,6 +132,7 @@ const buildCmd = Command.make( cwd: serverDir, stdout: config.verbose ? "inherit" : "ignore", stderr: "inherit", + shell: process.platform === "win32", })`bun tsdown`, ); @@ -225,6 +226,7 @@ const publishCmd = Command.make( cwd: serverDir, stdout: config.verbose ? "inherit" : "ignore", stderr: "inherit", + shell: process.platform === "win32", }), ); }), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index d34791bc20ce4120e0932d92eb1ed8562123db2d..2a72d5902750d4ccd9a2a1d65218826d5e764d7c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -6,6 +6,7 @@ import { type ProviderModelOptions, type ProviderKind, type ProviderServiceTier, + type ProviderStartOptions, type OrchestrationSession, ThreadId, type ProviderSession, @@ -144,6 +145,8 @@ const make = Effect.gen(function* () { ), ); + const threadProviderOptions = new Map(); + const appendProviderFailureActivity = (input: { readonly threadId: ThreadId; readonly kind: @@ -203,6 +206,7 @@ const make = Effect.gen(function* () { readonly model?: string; readonly modelOptions?: ProviderModelOptions; readonly serviceTier?: ProviderServiceTier | null; + readonly providerOptions?: ProviderStartOptions; }, ) { const readModel = yield* orchestrationEngine.getReadModel(); @@ -239,6 +243,7 @@ const make = Effect.gen(function* () { ...(desiredModel ? { model: desiredModel } : {}), ...(options?.serviceTier !== undefined ? { serviceTier: options.serviceTier } : {}), ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}), + ...(options?.providerOptions !== undefined ? { providerOptions: options.providerOptions } : {}), ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, }); @@ -325,6 +330,7 @@ const make = Effect.gen(function* () { readonly model?: string; readonly serviceTier?: ProviderServiceTier | null; readonly modelOptions?: ProviderModelOptions; + readonly providerOptions?: ProviderStartOptions; readonly interactionMode?: "default" | "plan"; readonly createdAt: string; }) { @@ -332,11 +338,15 @@ const make = Effect.gen(function* () { if (!thread) { return; } + if (input.providerOptions !== undefined) { + threadProviderOptions.set(input.threadId, input.providerOptions); + } yield* ensureSessionForThread(input.threadId, input.createdAt, { ...(input.provider !== undefined ? { provider: input.provider } : {}), ...(input.model !== undefined ? { model: input.model } : {}), ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), + ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), }); const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; @@ -472,6 +482,7 @@ const make = Effect.gen(function* () { ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), ...(event.payload.serviceTier !== undefined ? { serviceTier: event.payload.serviceTier } : {}), ...(event.payload.modelOptions !== undefined ? { modelOptions: event.payload.modelOptions } : {}), + ...(event.payload.providerOptions !== undefined ? { providerOptions: event.payload.providerOptions } : {}), interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, }); @@ -627,7 +638,10 @@ const make = Effect.gen(function* () { if (!thread?.session || thread.session.status === "stopped") { return; } - yield* ensureSessionForThread(event.payload.threadId, event.occurredAt); + const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId); + yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, { + ...(cachedProviderOptions !== undefined ? { providerOptions: cachedProviderOptions } : {}), + }); return; } case "thread.turn-start-requested": diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index f036416429268327ffe1d768b77245b69ab9b23a..1c1ca4dd7957b3f05b2fe35b64d222eca757a531 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -303,6 +303,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.model !== undefined ? { model: command.model } : {}), ...(command.serviceTier !== undefined ? { serviceTier: command.serviceTier } : {}), ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}), + ...(command.providerOptions !== undefined ? { providerOptions: command.providerOptions } : {}), assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, runtimeMode: readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ?? diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 398a26fb7b9f0182e9825878ca7c5b233e80a538..05a1de1495224340c2e4ce7a2d509e9c80859960 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -86,15 +86,30 @@ function toRuntimeStatus(session: ProviderSession): "starting" | "running" | "st } } -function toRuntimePayloadFromSession(session: ProviderSession): Record { +function toRuntimePayloadFromSession( + session: ProviderSession, + extra?: { readonly providerOptions?: unknown }, +): Record { return { cwd: session.cwd ?? null, model: session.model ?? null, activeTurnId: session.activeTurnId ?? null, lastError: session.lastError ?? null, + ...(extra?.providerOptions !== undefined ? { providerOptions: extra.providerOptions } : {}), }; } +function readPersistedProviderOptions( + runtimePayload: ProviderRuntimeBinding["runtimePayload"], +): Record | undefined { + if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { + return undefined; + } + const raw = "providerOptions" in runtimePayload ? runtimePayload.providerOptions : undefined; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; + return raw as Record; +} + function readPersistedCwd( runtimePayload: ProviderRuntimeBinding["runtimePayload"], ): string | undefined { @@ -137,6 +152,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const upsertSessionBinding = ( session: ProviderSession, threadId: ThreadId, + extra?: { readonly providerOptions?: unknown }, ) => directory.upsert({ threadId, @@ -144,7 +160,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => runtimeMode: session.runtimeMode, status: toRuntimeStatus(session), ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}), - runtimePayload: toRuntimePayloadFromSession(session), + runtimePayload: toRuntimePayloadFromSession(session, extra), }); const providers = yield* registry.listProviders(); @@ -197,11 +213,13 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => } const persistedCwd = readPersistedCwd(input.binding.runtimePayload); + const persistedProviderOptions = readPersistedProviderOptions(input.binding.runtimePayload); const resumed = yield* adapter.startSession({ threadId: input.binding.threadId, provider: input.binding.provider, ...(persistedCwd ? { cwd: persistedCwd } : {}), + ...(persistedProviderOptions ? { providerOptions: persistedProviderOptions } : {}), ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), runtimeMode: input.binding.runtimeMode ?? "full-access", }); @@ -273,7 +291,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => ); } - yield* upsertSessionBinding(session, threadId); + yield* upsertSessionBinding(session, threadId, { + ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), + }); yield* analytics.record("provider.session.started", { provider: session.provider, runtimeMode: input.runtimeMode, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c1693f419979a9eb1c6b58df7472dc59cd7e2d29..70e1c8fa02421d0d48d2746f994fb2cca466fc17 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -830,6 +830,17 @@ export default function ChatView({ threadId }: ChatViewProps) { }; return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; }, [selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsReasoningEffort]); + const providerOptionsForDispatch = useMemo(() => { + if (!settings.codexBinaryPath && !settings.codexHomePath) { + return undefined; + } + return { + codex: { + ...(settings.codexBinaryPath ? { binaryPath: settings.codexBinaryPath } : {}), + ...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}), + }, + }; + }, [settings.codexBinaryPath, settings.codexHomePath]); const selectedModelForPicker = selectedModel; const modelOptionsByProvider = useMemo( () => getCustomModelOptionsByProvider(settings), @@ -2626,6 +2637,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), + ...(providerOptionsForDispatch + ? { providerOptions: providerOptionsForDispatch } + : {}), provider: selectedProvider, assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, @@ -2903,6 +2917,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), + ...(providerOptionsForDispatch + ? { providerOptions: providerOptionsForDispatch } + : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: nextInteractionMode, @@ -2933,6 +2950,7 @@ export default function ChatView({ threadId }: ChatViewProps) { runtimeMode, selectedModel, selectedModelOptionsForDispatch, + providerOptionsForDispatch, selectedProvider, setComposerDraftInteractionMode, setThreadError, @@ -2987,8 +3005,8 @@ export default function ChatView({ threadId }: ChatViewProps) { worktreePath: activeThread.worktreePath, createdAt, }) - .then(() => - api.orchestration.dispatchCommand({ + .then(() => { + return api.orchestration.dispatchCommand({ type: "thread.turn.start", commandId: newCommandId(), threadId: nextThreadId, @@ -3003,12 +3021,15 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), + ...(providerOptionsForDispatch + ? { providerOptions: providerOptionsForDispatch } + : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: "default", createdAt, - }), - ) + }); + }) .then(() => api.orchestration.getSnapshot()) .then((snapshot) => { syncServerReadModel(snapshot); @@ -3052,6 +3073,7 @@ export default function ChatView({ threadId }: ChatViewProps) { runtimeMode, selectedModel, selectedModelOptionsForDispatch, + providerOptionsForDispatch, selectedProvider, settings.enableAssistantStreaming, syncServerReadModel, diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index aa7bd827de7e4fe03b1b5a193a3843a82e23e677..e90ddd4b597b225fff5980c352427e088336295c 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -45,6 +45,13 @@ export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; export const ProviderServiceTier = Schema.Literals(["fast", "flex"]); export type ProviderServiceTier = typeof ProviderServiceTier.Type; export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; +const CodexProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyString), + homePath: Schema.optional(TrimmedNonEmptyString), +}); +const ProviderStartOptions = Schema.Struct({ + codex: Schema.optional(CodexProviderStartOptions), +}); export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); export type RuntimeMode = typeof RuntimeMode.Type; export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; @@ -366,6 +373,7 @@ export const ThreadTurnStartCommand = Schema.Struct({ model: Schema.optional(TrimmedNonEmptyString), serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), modelOptions: Schema.optional(ProviderModelOptions), + providerOptions: Schema.optional(ProviderStartOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( @@ -388,6 +396,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({ model: Schema.optional(TrimmedNonEmptyString), serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), modelOptions: Schema.optional(ProviderModelOptions), + providerOptions: Schema.optional(ProviderStartOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, @@ -668,6 +677,7 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ model: Schema.optional(TrimmedNonEmptyString), serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), modelOptions: Schema.optional(ProviderModelOptions), + providerOptions: Schema.optional(ProviderStartOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 9ca7068ad349c4b0acf2569b653071a179158020..5e551c55798bf5bdeb432b986bfbb44179341f0d 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -53,9 +53,10 @@ const CodexProviderStartOptions = Schema.Struct({ homePath: Schema.optional(TrimmedNonEmptyStringSchema), }); -const ProviderStartOptions = Schema.Struct({ +export const ProviderStartOptions = Schema.Struct({ codex: Schema.optional(CodexProviderStartOptions), }); +export type ProviderStartOptions = typeof ProviderStartOptions.Type; export const ProviderSessionStartInput = Schema.Struct({ threadId: ThreadId, -- 2.54.0