diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index 10bd12a7cf..ce8c2cb2eb 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -6,6 +6,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { checkCodexProviderStatus, + checkCodexProviderStatusForInput, hasCustomModelProvider, parseAuthStatusFromOutput, readCodexConfigModelProvider, @@ -42,6 +43,26 @@ function mockSpawnerLayer( ); } +function mockSpawnerLayerWithCommand( + handler: (command: { + command: string; + args: ReadonlyArray; + env?: Record; + }) => { stdout: string; stderr: string; code: number }, +) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const cmd = command as unknown as { + command: string; + args: ReadonlyArray; + env?: Record; + }; + return Effect.succeed(mockHandle(handler(cmd))); + }), + ); +} + function failingSpawnerLayer(description: string) { return Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -157,6 +178,39 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ), ); + it.effect("uses overridden binary path and custom CODEX_HOME config for validation", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tmpDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-test-codex-home-" }); + yield* fileSystem.writeFileString( + path.join(tmpDir, "config.toml"), + ['model_provider = "portkey"'].join("\n"), + ); + const status = yield* checkCodexProviderStatusForInput({ + binaryPath: "/custom/bin/codex", + homePath: tmpDir, + }); + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Using a custom Codex model provider; OpenAI login check skipped.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayerWithCommand((command) => { + assert.strictEqual(command.command, "/custom/bin/codex"); + const joined = command.args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns unauthenticated when auth probe reports login required", () => Effect.gen(function* () { yield* withTempCodexHome(); diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 1fed0597a2..881b23fad9 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -1,8 +1,9 @@ /** - * ProviderHealthLive - Startup-time provider health checks. + * ProviderHealthLive - In-memory provider health checks. * - * Performs one-time provider readiness probes when the server starts and - * keeps the resulting snapshot in memory for `server.getConfig`. + * Performs an initial provider readiness probe at server startup and keeps + * the resulting snapshot in memory for `server.getConfig` plus on-demand + * revalidation. * * Uses effect's ChildProcessSpawner to run CLI probes natively. * @@ -13,8 +14,9 @@ import type { ServerProviderAuthStatus, ServerProviderStatus, ServerProviderStatusState, + ServerValidateCodexCliInput, } from "@t3tools/contracts"; -import { Array, Effect, Fiber, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; +import { Effect, FileSystem, Layer, Option, Path, Ref, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { @@ -35,12 +37,27 @@ export interface CommandResult { readonly code: number; } +interface CodexCliCheckInput { + readonly binaryPath?: string | undefined; + readonly homePath?: string | undefined; +} + function nonEmptyTrimmed(value: string | undefined): string | undefined { if (!value) return undefined; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } +function normalizeCodexCheckInput(input?: CodexCliCheckInput): { + readonly binaryPath: string; + readonly homePath: string; +} { + return { + binaryPath: nonEmptyTrimmed(input?.binaryPath) ?? "codex", + homePath: nonEmptyTrimmed(input?.homePath) ?? "", + }; +} + function isCommandMissingCause(error: unknown): boolean { if (!(error instanceof Error)) return false; const lower = error.message.toLowerCase(); @@ -189,39 +206,44 @@ const OPENAI_AUTH_PROVIDERS = new Set(["openai"]); * Returns `undefined` when the file does not exist or does not set * `model_provider`. */ -export const readCodexConfigModelProvider = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const codexHome = process.env.CODEX_HOME || path.join(OS.homedir(), ".codex"); - const configPath = path.join(codexHome, "config.toml"); - - const content = yield* fileSystem - .readFileString(configPath) - .pipe(Effect.orElseSucceed(() => undefined)); - if (content === undefined) { - return undefined; - } +const readCodexConfigModelProviderForInput = (input?: CodexCliCheckInput) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const normalizedInput = normalizeCodexCheckInput(input); + const codexHome = + normalizedInput.homePath || process.env.CODEX_HOME || path.join(OS.homedir(), ".codex"); + const configPath = path.join(codexHome, "config.toml"); + + const content = yield* fileSystem + .readFileString(configPath) + .pipe(Effect.orElseSucceed(() => undefined)); + if (content === undefined) { + return undefined; + } - // We need to find `model_provider = "..."` at the top level of the - // TOML file (i.e. before any `[section]` header). Lines inside - // `[profiles.*]`, `[model_providers.*]`, etc. are ignored. - let inTopLevel = true; - for (const line of content.split("\n")) { - const trimmed = line.trim(); - // Skip comments and empty lines. - if (!trimmed || trimmed.startsWith("#")) continue; - // Detect section headers — once we leave the top level, stop. - if (trimmed.startsWith("[")) { - inTopLevel = false; - continue; + // We need to find `model_provider = "..."` at the top level of the + // TOML file (i.e. before any `[section]` header). Lines inside + // `[profiles.*]`, `[model_providers.*]`, etc. are ignored. + let inTopLevel = true; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + // Skip comments and empty lines. + if (!trimmed || trimmed.startsWith("#")) continue; + // Detect section headers — once we leave the top level, stop. + if (trimmed.startsWith("[")) { + inTopLevel = false; + continue; + } + if (!inTopLevel) continue; + + const match = trimmed.match(/^model_provider\s*=\s*["']([^"']+)["']/); + if (match) return match[1]; } - if (!inTopLevel) continue; + return undefined; + }); - const match = trimmed.match(/^model_provider\s*=\s*["']([^"']+)["']/); - if (match) return match[1]; - } - return undefined; -}); +export const readCodexConfigModelProvider = readCodexConfigModelProviderForInput(); /** * Returns `true` when the Codex CLI is configured with a custom @@ -229,10 +251,13 @@ export const readCodexConfigModelProvider = Effect.gen(function* () { * required because authentication is handled through provider-specific * environment variables. */ -export const hasCustomModelProvider = Effect.map( - readCodexConfigModelProvider, - (provider) => provider !== undefined && !OPENAI_AUTH_PROVIDERS.has(provider), -); +const hasCustomModelProviderForInput = (input?: CodexCliCheckInput) => + Effect.map( + readCodexConfigModelProviderForInput(input), + (provider) => provider !== undefined && !OPENAI_AUTH_PROVIDERS.has(provider), + ); + +export const hasCustomModelProvider = hasCustomModelProviderForInput(); // ── Effect-native command execution ───────────────────────────────── @@ -243,11 +268,20 @@ const collectStreamAsString = (stream: Stream.Stream): Effect. (acc, chunk) => acc + new TextDecoder().decode(chunk), ); -const runCodexCommand = (args: ReadonlyArray) => +const runCodexCommand = (input: CodexCliCheckInput | undefined, args: ReadonlyArray) => Effect.gen(function* () { + const normalizedInput = normalizeCodexCheckInput(input); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make("codex", [...args], { + const command = ChildProcess.make(normalizedInput.binaryPath, [...args], { shell: process.platform === "win32", + ...(normalizedInput.homePath + ? { + env: { + ...process.env, + CODEX_HOME: normalizedInput.homePath, + }, + } + : {}), }); const child = yield* spawner.spawn(command); @@ -266,142 +300,178 @@ const runCodexCommand = (args: ReadonlyArray) => // ── Health check ──────────────────────────────────────────────────── -export const checkCodexProviderStatus: Effect.Effect< +export const checkCodexProviderStatusForInput = ( + input: ServerValidateCodexCliInput = {}, +): Effect.Effect< ServerProviderStatus, never, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path -> = Effect.gen(function* () { - const checkedAt = new Date().toISOString(); +> => + Effect.gen(function* () { + const checkedAt = new Date().toISOString(); + const normalizedInput = normalizeCodexCheckInput(input); - // Probe 1: `codex --version` — is the CLI reachable? - const versionProbe = yield* runCodexCommand(["--version"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); + // Probe 1: `codex --version` — is the CLI reachable? + const versionProbe = yield* runCodexCommand(normalizedInput, ["--version"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); - if (Result.isFailure(versionProbe)) { - const error = versionProbe.failure; - return { - provider: CODEX_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: isCommandMissingCause(error) - ? "Codex CLI (`codex`) is not installed or not on PATH." - : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`, - }; - } + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + return { + provider: CODEX_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: isCommandMissingCause(error) + ? normalizedInput.binaryPath === "codex" + ? "Codex CLI (`codex`) is not installed or not on PATH." + : `Codex CLI (${normalizedInput.binaryPath}) is not installed or not executable.` + : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }; + } - if (Option.isNone(versionProbe.success)) { - return { - provider: CODEX_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: "Codex CLI is installed but failed to run. Timed out while running command.", - }; - } + if (Option.isNone(versionProbe.success)) { + return { + provider: CODEX_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: "Codex CLI is installed but failed to run. Timed out while running command.", + }; + } - const version = versionProbe.success.value; - if (version.code !== 0) { - const detail = detailFromResult(version); - return { - provider: CODEX_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: detail - ? `Codex CLI is installed but failed to run. ${detail}` - : "Codex CLI is installed but failed to run.", - }; - } + const version = versionProbe.success.value; + if (version.code !== 0) { + const detail = detailFromResult(version); + return { + provider: CODEX_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: detail + ? `Codex CLI is installed but failed to run. ${detail}` + : "Codex CLI is installed but failed to run.", + }; + } - const parsedVersion = parseCodexCliVersion(`${version.stdout}\n${version.stderr}`); - if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) { - return { - provider: CODEX_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: formatCodexCliUpgradeMessage(parsedVersion), - }; - } + const parsedVersion = parseCodexCliVersion(`${version.stdout}\n${version.stderr}`); + if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) { + return { + provider: CODEX_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: formatCodexCliUpgradeMessage(parsedVersion), + }; + } - // Probe 2: `codex login status` — is the user authenticated? - // - // Custom model providers (e.g. Portkey, Azure OpenAI proxy) handle - // authentication through their own environment variables, so `codex - // login status` will report "not logged in" even when the CLI works - // fine. Skip the auth probe entirely for non-OpenAI providers. - if (yield* hasCustomModelProvider) { - return { - provider: CODEX_PROVIDER, - status: "ready" as const, - available: true, - authStatus: "unknown" as const, - checkedAt, - message: "Using a custom Codex model provider; OpenAI login check skipped.", - } satisfies ServerProviderStatus; - } + // Probe 2: `codex login status` — is the user authenticated? + // + // Custom model providers (e.g. Portkey, Azure OpenAI proxy) handle + // authentication through their own environment variables, so `codex + // login status` will report "not logged in" even when the CLI works + // fine. Skip the auth probe entirely for non-OpenAI providers. + if (yield* hasCustomModelProviderForInput(normalizedInput)) { + return { + provider: CODEX_PROVIDER, + status: "ready" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + message: "Using a custom Codex model provider; OpenAI login check skipped.", + } satisfies ServerProviderStatus; + } - const authProbe = yield* runCodexCommand(["login", "status"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); + const authProbe = yield* runCodexCommand(normalizedInput, ["login", "status"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); - if (Result.isFailure(authProbe)) { - const error = authProbe.failure; - return { - provider: CODEX_PROVIDER, - status: "warning" as const, - available: true, - authStatus: "unknown" as const, - checkedAt, - message: - error instanceof Error - ? `Could not verify Codex authentication status: ${error.message}.` - : "Could not verify Codex authentication status.", - }; - } + if (Result.isFailure(authProbe)) { + const error = authProbe.failure; + return { + provider: CODEX_PROVIDER, + status: "warning" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + message: + error instanceof Error + ? `Could not verify Codex authentication status: ${error.message}.` + : "Could not verify Codex authentication status.", + }; + } - if (Option.isNone(authProbe.success)) { + if (Option.isNone(authProbe.success)) { + return { + provider: CODEX_PROVIDER, + status: "warning" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + message: "Could not verify Codex authentication status. Timed out while running command.", + }; + } + + const parsed = parseAuthStatusFromOutput(authProbe.success.value); return { provider: CODEX_PROVIDER, - status: "warning" as const, + status: parsed.status, available: true, - authStatus: "unknown" as const, + authStatus: parsed.authStatus, checkedAt, - message: "Could not verify Codex authentication status. Timed out while running command.", - }; + ...(parsed.message ? { message: parsed.message } : {}), + } satisfies ServerProviderStatus; + }); + +export const checkCodexProviderStatus = checkCodexProviderStatusForInput(); + +function upsertProviderStatus( + statuses: ReadonlyArray, + nextStatus: ServerProviderStatus, +): ReadonlyArray { + const existingIndex = statuses.findIndex((status) => status.provider === nextStatus.provider); + if (existingIndex < 0) { + return [...statuses, nextStatus]; } - const parsed = parseAuthStatusFromOutput(authProbe.success.value); - return { - provider: CODEX_PROVIDER, - status: parsed.status, - available: true, - authStatus: parsed.authStatus, - checkedAt, - ...(parsed.message ? { message: parsed.message } : {}), - } satisfies ServerProviderStatus; -}); + return statuses.map((status, index) => (index === existingIndex ? nextStatus : status)); +} // ── Layer ─────────────────────────────────────────────────────────── export const ProviderHealthLive = Layer.effect( ProviderHealth, Effect.gen(function* () { - const codexStatusFiber = yield* checkCodexProviderStatus.pipe( - Effect.map(Array.of), - Effect.forkScoped, + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runHealthCheck = (input: ServerValidateCodexCliInput) => + checkCodexProviderStatusForInput(input).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + const statusesRef = yield* Ref.make( + yield* runHealthCheck({}).pipe( + Effect.map((status): ReadonlyArray => [status]), + ), ); return { - getStatuses: Fiber.join(codexStatusFiber), + getStatuses: Ref.get(statusesRef), + revalidateCodexStatus: (input) => + Effect.gen(function* () { + const status = yield* runHealthCheck(input); + yield* Ref.update(statusesRef, (statuses) => upsertProviderStatus(statuses, status)); + return status; + }), } satisfies ProviderHealthShape; }), ); diff --git a/apps/server/src/provider/Services/ProviderHealth.ts b/apps/server/src/provider/Services/ProviderHealth.ts index 318d7e18d0..f0fe7624b2 100644 --- a/apps/server/src/provider/Services/ProviderHealth.ts +++ b/apps/server/src/provider/Services/ProviderHealth.ts @@ -6,15 +6,21 @@ * * @module ProviderHealth */ -import type { ServerProviderStatus } from "@t3tools/contracts"; +import type { ServerProviderStatus, ServerValidateCodexCliInput } from "@t3tools/contracts"; import { ServiceMap } from "effect"; import type { Effect } from "effect"; export interface ProviderHealthShape { /** - * Read provider health statuses computed at server startup. + * Read provider health statuses cached in memory for transport layers. */ - readonly getStatuses: Effect.Effect>; + readonly getStatuses: Effect.Effect, never, never>; + /** + * Re-run the Codex CLI health check for the effective config and cache it. + */ + readonly revalidateCodexStatus: ( + input: ServerValidateCodexCliInput, + ) => Effect.Effect; } export class ProviderHealth extends ServiceMap.Service()( diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index f12792a318..9dfb7d82e9 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -76,6 +76,7 @@ const defaultProviderStatuses: ReadonlyArray = [ const defaultProviderHealthService: ProviderHealthShape = { getStatuses: Effect.succeed(defaultProviderStatuses), + revalidateCodexStatus: () => Effect.succeed(defaultProviderStatuses[0]!), }; class MockTerminalManager implements TerminalManagerShape { @@ -835,6 +836,52 @@ describe("WebSocket Server", () => { expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); }); + it("revalidates Codex status and pushes server.configUpdated", async () => { + const revalidatedStatus: ServerProviderStatus = { + provider: "codex", + status: "error", + available: false, + authStatus: "unknown", + checkedAt: "2026-03-18T00:00:00.000Z", + message: "Codex CLI (/custom/codex) is not installed or not executable.", + }; + let currentStatuses = defaultProviderStatuses; + + server = await createTestServer({ + providerHealth: { + getStatuses: Effect.sync(() => currentStatuses), + revalidateCodexStatus: () => + Effect.sync(() => { + currentStatuses = [revalidatedStatus]; + return revalidatedStatus; + }), + }, + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.serverValidateCodexCli, { + binaryPath: "/custom/codex", + homePath: "/tmp/custom-codex-home", + }); + + expect(response.error).toBeUndefined(); + expect(response.result).toEqual(revalidatedStatus); + + const push = await waitForPush( + ws, + WS_CHANNELS.serverConfigUpdated, + (message) => message.data.providers[0]?.checkedAt === revalidatedStatus.checkedAt, + ); + expect(push.data).toEqual({ + issues: [], + providers: [revalidatedStatus], + }); + }); + it("bootstraps default keybindings file when missing", async () => { const stateDir = makeTempDir("t3code-state-bootstrap-keybindings-"); const keybindingsPath = path.join(stateDir, "keybindings.json"); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 2e6ac51b7f..946be9c1e2 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -268,8 +268,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ), ); - const providerStatuses = yield* providerHealth.getStatuses; - const clients = yield* Ref.make(new Set()); const logger = createLogger("ws"); const readiness = yield* makeServerReadiness; @@ -612,10 +610,14 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ).pipe(Effect.forkIn(subscriptionsScope)); yield* Stream.runForEach(keybindingsManager.streamChanges, (event) => - pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { - issues: event.issues, - providers: providerStatuses, - }), + providerHealth.getStatuses.pipe( + Effect.flatMap((providers) => + pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { + issues: event.issues, + providers, + }), + ), + ), ).pipe(Effect.forkIn(subscriptionsScope)); yield* Scope.provide(orchestrationReactor.start, subscriptionsScope); @@ -873,7 +875,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< keybindingsConfigPath, keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, - providers: providerStatuses, + providers: yield* providerHealth.getStatuses, availableEditors, }; @@ -883,6 +885,18 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { keybindings: keybindingsConfig, issues: [] }; } + case WS_METHODS.serverValidateCodexCli: { + const body = stripRequestTag(request.body); + const status = yield* providerHealth.revalidateCodexStatus(body); + const keybindingsConfig = yield* keybindingsManager.loadConfigState; + const providers = yield* providerHealth.getStatuses; + yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { + issues: keybindingsConfig.issues, + providers, + }); + return status; + } + default: { const _exhaustiveCheck: never = request.body; return yield* new RouteRequestError({ diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 77cdb0ea19..7e68baa42a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -616,6 +616,17 @@ export default function ChatView({ threadId }: ChatViewProps) { }, }; }, [settings.codexBinaryPath, settings.codexHomePath]); + const refreshCodexProviderStatus = useCallback(async () => { + const api = readNativeApi(); + if (!api || selectedProvider !== "codex") { + return; + } + + await api.server.validateCodexCli({ + binaryPath: settings.codexBinaryPath.trim() || undefined, + homePath: settings.codexHomePath.trim() || undefined, + }); + }, [selectedProvider, settings.codexBinaryPath, settings.codexHomePath]); const selectedModelForPicker = selectedModel; const modelOptionsByProvider = useMemo( () => getCustomModelOptionsByProvider(settings), @@ -2601,7 +2612,9 @@ export default function ChatView({ threadId }: ChatViewProps) { createdAt: messageCreatedAt, }); turnStartSucceeded = true; + await refreshCodexProviderStatus().catch(() => undefined); })().catch(async (err: unknown) => { + await refreshCodexProviderStatus().catch(() => undefined); if (createdServerThreadForLocalDraft && !turnStartSucceeded) { await api.orchestration .dispatchCommand({ @@ -2880,6 +2893,7 @@ export default function ChatView({ threadId }: ChatViewProps) { interactionMode: nextInteractionMode, createdAt: messageCreatedAt, }); + await refreshCodexProviderStatus().catch(() => undefined); // Optimistically open the plan sidebar when implementing (not refining). // "default" mode here means the agent is executing the plan, which produces // step-tracking activities that the sidebar will display. @@ -2889,6 +2903,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } sendInFlightRef.current = false; } catch (err) { + await refreshCodexProviderStatus().catch(() => undefined); setOptimisticUserMessages((existing) => existing.filter((message) => message.id !== messageIdForSend), ); @@ -2913,6 +2928,7 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedModel, selectedModelOptionsForDispatch, providerOptionsForDispatch, + refreshCodexProviderStatus, selectedProvider, setComposerDraftInteractionMode, setThreadError, @@ -2990,6 +3006,9 @@ export default function ChatView({ threadId }: ChatViewProps) { createdAt, }); }) + .then(async () => { + await refreshCodexProviderStatus().catch(() => undefined); + }) .then(() => api.orchestration.getSnapshot()) .then((snapshot) => { syncServerReadModel(snapshot); @@ -3001,6 +3020,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); }) .catch(async (err) => { + await refreshCodexProviderStatus().catch(() => undefined); await api.orchestration .dispatchCommand({ type: "thread.delete", @@ -3036,6 +3056,7 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedModel, selectedModelOptionsForDispatch, providerOptionsForDispatch, + refreshCodexProviderStatus, selectedProvider, settings.enableAssistantStreaming, syncServerReadModel, diff --git a/apps/web/src/routes/-_chat.settings.browser.tsx b/apps/web/src/routes/-_chat.settings.browser.tsx new file mode 100644 index 0000000000..39370a04ca --- /dev/null +++ b/apps/web/src/routes/-_chat.settings.browser.tsx @@ -0,0 +1,241 @@ +import "../index.css"; + +import { + ORCHESTRATION_WS_METHODS, + type OrchestrationReadModel, + type ServerConfig, + WS_CHANNELS, + WS_METHODS, +} from "@t3tools/contracts"; +import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; +import { http, HttpResponse, ws } from "msw"; +import { setupWorker } from "msw/browser"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { useComposerDraftStore } from "../composerDraftStore"; +import { getRouter } from "../router"; +import { useStore } from "../store"; + +const NOW_ISO = "2026-03-18T00:00:00.000Z"; + +interface TestFixture { + snapshot: OrchestrationReadModel; + serverConfig: ServerConfig; +} + +let fixture: TestFixture; +let wsClient: { send: (data: string) => void } | null = null; +let pushSequence = 1; +const wsRequests: Array> = []; + +const wsLink = ws.link(/ws(s)?:\/\/.*/); + +function buildFixture(): TestFixture { + return { + snapshot: { + snapshotSequence: 1, + projects: [], + threads: [], + updatedAt: NOW_ISO, + }, + serverConfig: { + cwd: "/repo/project", + keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", + keybindings: [], + issues: [], + providers: [ + { + provider: "codex", + status: "error", + available: false, + authStatus: "unknown", + checkedAt: NOW_ISO, + message: "Codex CLI (`codex`) is not installed or not on PATH.", + }, + ], + availableEditors: [], + }, + }; +} + +function sendServerConfigUpdated() { + if (!wsClient) { + throw new Error("WebSocket client not connected"); + } + + wsClient.send( + JSON.stringify({ + type: "push", + sequence: pushSequence++, + channel: WS_CHANNELS.serverConfigUpdated, + data: { + issues: fixture.serverConfig.issues, + providers: fixture.serverConfig.providers, + }, + }), + ); +} + +const worker = setupWorker( + wsLink.addEventListener("connection", ({ client }) => { + wsClient = client; + pushSequence = 1; + client.send( + JSON.stringify({ + type: "push", + sequence: pushSequence++, + channel: WS_CHANNELS.serverWelcome, + data: { + cwd: fixture.serverConfig.cwd, + projectName: "Project", + }, + }), + ); + client.addEventListener("message", (event) => { + if (typeof event.data !== "string") { + return; + } + + let request: { id: string; body: Record & { _tag?: unknown } }; + try { + request = JSON.parse(event.data); + } catch { + return; + } + + const method = request.body._tag; + if (typeof method !== "string") { + return; + } + + wsRequests.push(request.body); + + let result: unknown = {}; + if (method === ORCHESTRATION_WS_METHODS.getSnapshot) { + result = fixture.snapshot; + } else if (method === WS_METHODS.serverGetConfig) { + result = fixture.serverConfig; + } else if (method === WS_METHODS.serverValidateCodexCli) { + fixture.serverConfig = { + ...fixture.serverConfig, + providers: [ + { + provider: "codex", + status: "ready", + available: true, + authStatus: "authenticated", + checkedAt: "2026-03-18T00:00:01.000Z", + }, + ], + }; + result = fixture.serverConfig.providers[0]; + queueMicrotask(() => { + sendServerConfigUpdated(); + }); + } + + client.send( + JSON.stringify({ + id: request.id, + result, + }), + ); + }); + }), + http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })), + http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), +); + +async function waitForElement( + query: () => T | null, + errorMessage: string, +): Promise { + let element: T | null = null; + await vi.waitFor( + () => { + element = query(); + expect(element, errorMessage).toBeTruthy(); + }, + { timeout: 8_000, interval: 16 }, + ); + return element!; +} + +function setInputValue(input: HTMLInputElement, value: string) { + const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value"); + descriptor?.set?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +describe("settings Codex validation", () => { + beforeAll(async () => { + await worker.start({ + onUnhandledRequest: "bypass", + quiet: true, + serviceWorker: { + url: "/mockServiceWorker.js", + }, + }); + }); + + afterAll(async () => { + await worker.stop(); + }); + + beforeEach(() => { + fixture = buildFixture(); + wsClient = null; + pushSequence = 1; + wsRequests.length = 0; + localStorage.clear(); + document.body.innerHTML = ""; + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + useStore.setState({ + projects: [], + threads: [], + threadsHydrated: false, + }); + }); + + it("validates the Codex binary path on blur", async () => { + const host = document.createElement("div"); + document.body.append(host); + + const router = getRouter( + createMemoryHistory({ + initialEntries: ["/settings"], + }), + ); + + const screen = await render(, { container: host }); + + try { + const binaryInput = await waitForElement( + () => document.getElementById("codex-binary-path") as HTMLInputElement | null, + "Unable to find the Codex binary path input.", + ); + + binaryInput.focus(); + setInputValue(binaryInput, "/custom/bin/codex"); + binaryInput.blur(); + + await vi.waitFor( + () => { + expect(wsRequests).toContainEqual({ + _tag: WS_METHODS.serverValidateCodexCli, + binaryPath: "/custom/bin/codex", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await screen.unmount(); + host.remove(); + } + }); +}); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index e79592c99b..f72836a6f3 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -92,12 +92,19 @@ function patchCustomModels(provider: ProviderKind, models: string[]) { } } +function toOptionalTrimmedValue(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + function SettingsRouteView() { const { theme, setTheme, resolvedTheme } = useTheme(); const { settings, defaults, updateSettings } = useAppSettings(); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); const [openKeybindingsError, setOpenKeybindingsError] = useState(null); + const [isValidatingCodexConfig, setIsValidatingCodexConfig] = useState(false); + const [codexValidationError, setCodexValidationError] = useState(null); const [customModelInputByProvider, setCustomModelInputByProvider] = useState< Record >({ @@ -146,6 +153,27 @@ function SettingsRouteView() { }); }, [availableEditors, keybindingsConfigPath]); + const validateCodexConfig = useCallback( + async (overrides?: { binaryPath?: string; homePath?: string }) => { + const api = ensureNativeApi(); + setCodexValidationError(null); + setIsValidatingCodexConfig(true); + try { + await api.server.validateCodexCli({ + binaryPath: toOptionalTrimmedValue(overrides?.binaryPath ?? codexBinaryPath), + homePath: toOptionalTrimmedValue(overrides?.homePath ?? codexHomePath), + }); + } catch (error) { + setCodexValidationError( + error instanceof Error ? error.message : "Unable to refresh Codex provider status.", + ); + } finally { + setIsValidatingCodexConfig(false); + } + }, + [codexBinaryPath, codexHomePath], + ); + const addCustomModel = useCallback( (provider: ProviderKind) => { const customModelInput = customModelInputByProvider[provider]; @@ -334,6 +362,9 @@ function SettingsRouteView() { id="codex-binary-path" value={codexBinaryPath} onChange={(event) => updateSettings({ codexBinaryPath: event.target.value })} + onBlur={(event) => + void validateCodexConfig({ binaryPath: event.currentTarget.value }) + } placeholder="codex" spellCheck={false} /> @@ -348,6 +379,9 @@ function SettingsRouteView() { id="codex-home-path" value={codexHomePath} onChange={(event) => updateSettings({ codexHomePath: event.target.value })} + onBlur={(event) => + void validateCodexConfig({ homePath: event.currentTarget.value }) + } placeholder="/Users/you/.codex" spellCheck={false} /> @@ -367,16 +401,27 @@ function SettingsRouteView() { size="xs" variant="outline" className="self-start" - onClick={() => - updateSettings({ + onClick={() => { + const nextSettings = { codexBinaryPath: defaults.codexBinaryPath, codexHomePath: defaults.codexHomePath, - }) - } + }; + updateSettings(nextSettings); + void validateCodexConfig({ + binaryPath: nextSettings.codexBinaryPath, + homePath: nextSettings.codexHomePath, + }); + }} > Reset codex overrides + {isValidatingCodexConfig ? ( +

Checking Codex CLI status...

+ ) : null} + {codexValidationError ? ( +

{codexValidationError}

+ ) : null} diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index ddfffbde69..9490a2409f 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -160,6 +160,7 @@ export function createWsNativeApi(): NativeApi { server: { getConfig: () => transport.request(WS_METHODS.serverGetConfig), upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), + validateCodexCli: (input) => transport.request(WS_METHODS.serverValidateCodexCli, input), }, orchestration: { getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot), diff --git a/apps/web/vitest.browser.config.ts b/apps/web/vitest.browser.config.ts index c67fdfbe99..dae394ab15 100644 --- a/apps/web/vitest.browser.config.ts +++ b/apps/web/vitest.browser.config.ts @@ -18,6 +18,7 @@ export default mergeConfig( include: [ "src/components/ChatView.browser.tsx", "src/components/KeybindingsToast.browser.tsx", + "src/routes/-_chat.settings.browser.tsx", ], browser: { enabled: true, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb176..5c81962c4f 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -24,7 +24,11 @@ import type { ProjectWriteFileInput, ProjectWriteFileResult, } from "./project"; -import type { ServerConfig } from "./server"; +import type { + ServerConfig, + ServerValidateCodexCliInput, + ServerValidateCodexCliResult, +} from "./server"; import type { TerminalClearInput, TerminalCloseInput, @@ -159,6 +163,7 @@ export interface NativeApi { server: { getConfig: () => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; + validateCodexCli: (input: ServerValidateCodexCliInput) => Promise; }; orchestration: { getSnapshot: () => Promise; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 96ea90c1f5..f217bfc710 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -64,6 +64,15 @@ export const ServerUpsertKeybindingResult = Schema.Struct({ }); export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.Type; +export const ServerValidateCodexCliInput = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyString), + homePath: Schema.optional(TrimmedNonEmptyString), +}); +export type ServerValidateCodexCliInput = typeof ServerValidateCodexCliInput.Type; + +export const ServerValidateCodexCliResult = ServerProviderStatus; +export type ServerValidateCodexCliResult = typeof ServerValidateCodexCliResult.Type; + export const ServerConfigUpdatedPayload = Schema.Struct({ issues: ServerConfigIssues, providers: ServerProviderStatuses, diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index ebb76138b8..c02c219392 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -36,7 +36,7 @@ import { import { KeybindingRule } from "./keybindings"; import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; import { OpenInEditorInput } from "./editor"; -import { ServerConfigUpdatedPayload } from "./server"; +import { ServerConfigUpdatedPayload, ServerValidateCodexCliInput } from "./server"; // ── WebSocket RPC Method Names ─────────────────────────────────────── @@ -75,6 +75,7 @@ export const WS_METHODS = { // Server meta serverGetConfig: "server.getConfig", serverUpsertKeybinding: "server.upsertKeybinding", + serverValidateCodexCli: "server.validateCodexCli", } as const; // ── Push Event Channels ────────────────────────────────────────────── @@ -139,6 +140,7 @@ const WebSocketRequestBody = Schema.Union([ // Server meta tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), + tagRequestBody(WS_METHODS.serverValidateCodexCli, ServerValidateCodexCliInput), ]); export const WebSocketRequest = Schema.Struct({