diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index 80323c7441..ecde09290e 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -6,16 +6,18 @@ import path from "node:path"; import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; import { - buildCodexInitializeParams, CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, CodexAppServerManager, classifyCodexStderrLine, isRecoverableThreadResumeError, normalizeCodexModelSlug, +} from "./codexAppServerManager"; +import { + buildCodexInitializeParams, readCodexAccountSnapshot, resolveCodexModelForAccount, -} from "./codexAppServerManager"; +} from "./provider/codexAccount"; const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); @@ -250,7 +252,6 @@ describe("readCodexAccountSnapshot", () => { expect( readCodexAccountSnapshot({ type: "chatgpt", - email: "plus@example.com", planType: "plus", }), ).toEqual({ @@ -264,7 +265,6 @@ describe("readCodexAccountSnapshot", () => { expect( readCodexAccountSnapshot({ type: "chatgpt", - email: "pro@example.com", planType: "pro", }), ).toEqual({ @@ -285,6 +285,32 @@ describe("readCodexAccountSnapshot", () => { sparkEnabled: true, }); }); + + it("accepts snake_case plan fields from account/read payloads", () => { + expect( + readCodexAccountSnapshot({ + type: "chatgpt", + plan_type: "team", + }), + ).toEqual({ + type: "chatgpt", + planType: "team", + sparkEnabled: true, + }); + }); + + it("falls back to an unknown plan type for unexpected chatgpt plan labels", () => { + expect( + readCodexAccountSnapshot({ + type: "chatgpt", + planType: "mystery-tier", + }), + ).toEqual({ + type: "chatgpt", + planType: "unknown", + sparkEnabled: true, + }); + }); }); describe("resolveCodexModelForAccount", () => { diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 0ac37db3e8..f138fc1a62 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -27,6 +27,12 @@ import { isCodexCliVersionSupported, parseCodexCliVersion, } from "./provider/codexCliVersion"; +import { + buildCodexInitializeParams, + readCodexAccountSnapshot, + resolveCodexModelForAccount, + type CodexAccountSnapshot, +} from "./provider/codexAccount"; type PendingRequestKey = string; @@ -97,23 +103,6 @@ interface JsonRpcNotification { params?: unknown; } -type CodexPlanType = - | "free" - | "go" - | "plus" - | "pro" - | "team" - | "business" - | "enterprise" - | "edu" - | "unknown"; - -interface CodexAccountSnapshot { - readonly type: "apiKey" | "chatgpt" | "unknown"; - readonly planType: CodexPlanType | null; - readonly sparkEnabled: boolean; -} - export interface CodexAppServerSendTurnInput { readonly threadId: ThreadId; readonly input?: string; @@ -162,50 +151,6 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ "unknown thread", "does not exist", ]; -const CODEX_DEFAULT_MODEL = "gpt-5.3-codex"; -const CODEX_SPARK_MODEL = "gpt-5.3-codex-spark"; -const CODEX_SPARK_DISABLED_PLAN_TYPES = new Set(["free", "go", "plus"]); - -function asObject(value: unknown): Record | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - return value as Record; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -export function readCodexAccountSnapshot(response: unknown): CodexAccountSnapshot { - const record = asObject(response); - const account = asObject(record?.account) ?? record; - const accountType = asString(account?.type); - - if (accountType === "apiKey") { - return { - type: "apiKey", - planType: null, - sparkEnabled: true, - }; - } - - if (accountType === "chatgpt") { - const planType = (account?.planType as CodexPlanType | null) ?? "unknown"; - return { - type: "chatgpt", - planType, - sparkEnabled: !CODEX_SPARK_DISABLED_PLAN_TYPES.has(planType), - }; - } - - return { - type: "unknown", - planType: null, - sparkEnabled: true, - }; -} - export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `# Plan Mode (Conversational) You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions. @@ -358,17 +303,6 @@ function mapCodexRuntimeMode(runtimeMode: RuntimeMode): { }; } -export function resolveCodexModelForAccount( - model: string | undefined, - account: CodexAccountSnapshot, -): string | undefined { - if (model !== CODEX_SPARK_MODEL || account.sparkEnabled) { - return model; - } - - return CODEX_DEFAULT_MODEL; -} - /** * On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe` * wrapper, leaving the actual command running. Use `taskkill /T` to kill the @@ -402,19 +336,6 @@ export function normalizeCodexModelSlug( return normalized; } -export function buildCodexInitializeParams() { - return { - clientInfo: { - name: "t3code_desktop", - title: "T3 Code Desktop", - version: "0.1.0", - }, - capabilities: { - experimentalApi: true, - }, - } as const; -} - function buildCodexCollaborationMode(input: { readonly interactionMode?: "default" | "plan"; readonly model?: string; @@ -587,21 +508,33 @@ export class CodexAppServerManager extends EventEmitter {}); +} else { + const SqliteClient = await import("./NodeSqliteClient.ts"); + const layer = it.layer(SqliteClient.layerMemory()); -layer("NodeSqliteClient", (it) => { - it.effect("runs prepared queries and returns positional values", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; + layer("NodeSqliteClient", (it) => { + it.effect("runs prepared queries and returns positional values", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; - yield* sql`CREATE TABLE entries(id INTEGER PRIMARY KEY, name TEXT NOT NULL)`; - yield* sql`INSERT INTO entries(name) VALUES (${"alpha"}), (${"beta"})`; + yield* sql`CREATE TABLE entries(id INTEGER PRIMARY KEY, name TEXT NOT NULL)`; + yield* sql`INSERT INTO entries(name) VALUES (${"alpha"}), (${"beta"})`; - const rows = yield* sql<{ readonly id: number; readonly name: string }>` + const rows = yield* sql<{ readonly id: number; readonly name: string }>` SELECT id, name FROM entries ORDER BY id `; - assert.equal(rows.length, 2); - assert.equal(rows[0]?.name, "alpha"); - assert.equal(rows[1]?.name, "beta"); + assert.equal(rows.length, 2); + assert.equal(rows[0]?.name, "alpha"); + assert.equal(rows[1]?.name, "beta"); - const values = yield* sql`SELECT id, name FROM entries ORDER BY id`.values; - assert.equal(values.length, 2); - assert.equal(values[0]?.[1], "alpha"); - assert.equal(values[1]?.[1], "beta"); - }), - ); -}); + const values = yield* sql`SELECT id, name FROM entries ORDER BY id`.values; + assert.equal(values.length, 2); + assert.equal(values[0]?.[1], "alpha"); + assert.equal(values[1]?.[1], "beta"); + }), + ); + }); +} diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index e24f07bcfa..7455627ff4 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -1,10 +1,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, it, assert } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path, Sink, Stream } from "effect"; +import { Deferred, Effect, FileSystem, Layer, Path, Sink, Stream } from "effect"; import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { vi } from "vitest"; import { + ProviderHealthLive, checkClaudeProviderStatus, checkCodexProviderStatus, hasCustomModelProvider, @@ -12,6 +14,7 @@ import { parseClaudeAuthStatusFromOutput, readCodexConfigModelProvider, } from "./ProviderHealth"; +import { extractCodexAccountPlan } from "../codexAccount"; // ── Test helpers ──────────────────────────────────────────────────── @@ -32,6 +35,24 @@ function mockHandle(result: { stdout: string; stderr: string; code: number }) { }); } +function blockingHandle( + result: { stdout: string; stderr: string; code: number }, + release: Deferred.Deferred, +) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Deferred.await(release).pipe(Effect.as(ChildProcessSpawner.ExitCode(result.code))), + isRunning: Effect.succeed(true), + kill: () => Effect.void, + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(result.stdout)), + stderr: Stream.make(encoder.encode(result.stderr)), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + function mockSpawnerLayer( handler: (args: ReadonlyArray) => { stdout: string; stderr: string; code: number }, ) { @@ -102,6 +123,35 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { // path being tested. describe("checkCodexProviderStatus", () => { + it.effect("does not block layer startup on the initial provider checks", () => + Effect.scoped( + Effect.gen(function* () { + const release = yield* Deferred.make(); + const spawned = yield* Deferred.make(); + + const blockingSpawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Deferred.succeed(spawned, undefined).pipe( + Effect.ignore, + Effect.as(blockingHandle({ stdout: "", stderr: "", code: 0 }, release)), + ), + ), + ); + + const buildFiber = yield* Layer.build( + ProviderHealthLive.pipe(Layer.provide(blockingSpawnerLayer)), + ).pipe(Effect.forkScoped); + + yield* Deferred.await(spawned); + yield* Effect.yieldNow; + + const layerExit = yield* Effect.sync(() => buildFiber.pollUnsafe()); + assert.notStrictEqual(layerExit, undefined); + }), + ), + ); + it.effect("returns ready when codex is installed and authenticated", () => Effect.gen(function* () { // Point CODEX_HOME at an empty tmp dir (no config.toml) so the @@ -112,12 +162,66 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { assert.strictEqual(status.status, "ready"); assert.strictEqual(status.available, true); assert.strictEqual(status.authStatus, "authenticated"); + assert.strictEqual(status.plan, "pro"); }).pipe( Effect.provide( mockSpawnerLayer((args) => { const joined = args.join(" "); if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; + if (joined === "login status") { + return { + stdout: '{"authenticated":true,"account":{"planType":"pro"}}\n', + stderr: "", + code: 0, + }; + } + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("reads the codex plan via app-server when login status omits it", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + yield* Effect.sync(() => { + vi.resetModules(); + vi.doMock("../codexAccount", async () => { + const actual = + await vi.importActual("../codexAccount"); + return { + ...actual, + readCodexAccountPlanViaAppServer: () => Effect.succeed("team"), + }; + }); + }); + + const { checkCodexProviderStatus: checkCodexProviderStatusWithPlanMock } = + yield* Effect.promise(() => import("./ProviderHealth")); + const status = yield* checkCodexProviderStatusWithPlanMock; + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "authenticated"); + assert.strictEqual(status.plan, "team"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + vi.doUnmock("../codexAccount"); + vi.resetModules(); + }), + ), + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") { + return { + stdout: '{"authenticated":true}\n', + stderr: "", + code: 0, + }; + } throw new Error(`Unexpected args: ${joined}`); }), ), @@ -332,6 +436,28 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { assert.strictEqual(parsed.authStatus, "unauthenticated"); }); + it("extracts planType from JSON output", () => { + const parsed = parseAuthStatusFromOutput({ + stdout: '{"authenticated":true,"account":{"planType":"team"}}\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "ready"); + assert.strictEqual(parsed.authStatus, "authenticated"); + assert.strictEqual(parsed.plan, "team"); + }); + + it("extracts snake_case plan fields from JSON output", () => { + const parsed = parseAuthStatusFromOutput({ + stdout: '{"authenticated":true,"account":{"plan_type":"pro"}}\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "ready"); + assert.strictEqual(parsed.authStatus, "authenticated"); + assert.strictEqual(parsed.plan, "pro"); + }); + it("JSON without auth marker is warning", () => { const parsed = parseAuthStatusFromOutput({ stdout: '[{"ok":true}]\n', @@ -343,34 +469,64 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { }); }); + describe("extractCodexAccountPlan", () => { + it("extracts planType from account/read responses", () => { + assert.strictEqual( + extractCodexAccountPlan({ + result: { + account: { + type: "chatgpt", + planType: "pro", + }, + }, + }), + "pro", + ); + }); + + it("extracts snake_case plan fields from account/read responses", () => { + assert.strictEqual( + extractCodexAccountPlan({ + result: { + account: { + type: "chatgpt", + plan_type: "team", + }, + }, + }), + "team", + ); + }); + }); + // ── readCodexConfigModelProvider tests ───────────────────────────── describe("readCodexConfigModelProvider", () => { it.effect("returns undefined when config file does not exist", () => Effect.gen(function* () { yield* withTempCodexHome(); - assert.strictEqual(yield* readCodexConfigModelProvider, undefined); + assert.strictEqual(yield* readCodexConfigModelProvider(), undefined); }), ); it.effect("returns undefined when config has no model_provider key", () => Effect.gen(function* () { yield* withTempCodexHome('model = "gpt-5-codex"\n'); - assert.strictEqual(yield* readCodexConfigModelProvider, undefined); + assert.strictEqual(yield* readCodexConfigModelProvider(), undefined); }), ); it.effect("returns the provider when model_provider is set at top level", () => Effect.gen(function* () { yield* withTempCodexHome('model = "gpt-5-codex"\nmodel_provider = "portkey"\n'); - assert.strictEqual(yield* readCodexConfigModelProvider, "portkey"); + assert.strictEqual(yield* readCodexConfigModelProvider(), "portkey"); }), ); it.effect("returns openai when model_provider is openai", () => Effect.gen(function* () { yield* withTempCodexHome('model_provider = "openai"\n'); - assert.strictEqual(yield* readCodexConfigModelProvider, "openai"); + assert.strictEqual(yield* readCodexConfigModelProvider(), "openai"); }), ); @@ -386,7 +542,7 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { "", ].join("\n"), ); - assert.strictEqual(yield* readCodexConfigModelProvider, undefined); + assert.strictEqual(yield* readCodexConfigModelProvider(), undefined); }), ); @@ -402,14 +558,14 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { 'model = "gpt-5-pro"', ].join("\n"), ); - assert.strictEqual(yield* readCodexConfigModelProvider, "azure"); + assert.strictEqual(yield* readCodexConfigModelProvider(), "azure"); }), ); it.effect("handles single-quoted values in TOML", () => Effect.gen(function* () { yield* withTempCodexHome("model_provider = 'mistral'\n"); - assert.strictEqual(yield* readCodexConfigModelProvider, "mistral"); + assert.strictEqual(yield* readCodexConfigModelProvider(), "mistral"); }), ); }); @@ -609,12 +765,14 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { it("JSON with loggedIn=true is authenticated", () => { const parsed = parseClaudeAuthStatusFromOutput({ - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stdout: + '{"loggedIn":true,"authMethod":"claude.ai","email":"claude@example.com","subscriptionType":"pro"}\n', stderr: "", code: 0, }); assert.strictEqual(parsed.status, "ready"); assert.strictEqual(parsed.authStatus, "authenticated"); + assert.strictEqual(parsed.plan, "pro"); }); it("JSON with loggedIn=false is unauthenticated", () => { diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index cbb97a807e..6890a80c49 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -10,23 +10,46 @@ */ import * as OS from "node:os"; import type { + ProviderStartOptions, ServerProviderAuthStatus, ServerProviderStatus, ServerProviderStatusState, } from "@t3tools/contracts"; -import { Array, Effect, Fiber, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; +import { Effect, Fiber, FileSystem, Layer, Option, Path, Ref, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import type { ProviderKind } from "@t3tools/contracts"; + import { formatCodexCliUpgradeMessage, isCodexCliVersionSupported, parseCodexCliVersion, } from "../codexCliVersion"; -import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth"; +import { nonEmptyTrimmed, readAuthProbeDetails } from "../authProbe"; +import { readCodexAccountPlanViaAppServer } from "../codexAccount"; +import { + ProviderHealth, + type ProviderAuthActionResult, + type ProviderHealthShape, +} from "../Services/ProviderHealth"; const DEFAULT_TIMEOUT_MS = 4_000; const CODEX_PROVIDER = "codex" as const; const CLAUDE_AGENT_PROVIDER = "claudeAgent" as const; +const CODEX_CLI_PROBE_MESSAGES = { + missing: "Codex CLI (`codex`) is not installed or not on PATH.", + versionExecutionFailed: "Failed to execute Codex CLI health check", + installedButFailed: "Codex CLI is installed but failed to run.", + authUnknownPrefix: "Could not verify Codex authentication status", +} as const satisfies CliProbeMessages; +const CLAUDE_CLI_PROBE_MESSAGES = { + missing: "Claude Agent CLI (`claude`) is not installed or not on PATH.", + versionExecutionFailed: "Failed to execute Claude Agent CLI health check", + installedButFailed: "Claude Agent CLI is installed but failed to run.", + authUnknownPrefix: "Could not verify Claude authentication status", +} as const satisfies CliProbeMessages; + +const CLI_VERSION_PATTERN = /\bv?(\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?)\b/; // ── Pure helpers ──────────────────────────────────────────────────── @@ -36,10 +59,9 @@ export interface CommandResult { readonly code: number; } -function nonEmptyTrimmed(value: string | undefined): string | undefined { - if (!value) return undefined; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; +interface ResolvedCommandConfig { + readonly binaryPath: string; + readonly env: NodeJS.ProcessEnv; } function isCommandMissingCause(error: unknown): boolean { @@ -62,32 +84,22 @@ function detailFromResult( return undefined; } -function extractAuthBoolean(value: unknown): boolean | undefined { - if (Array.isArray(value)) { - for (const entry of value) { - const nested = extractAuthBoolean(entry); - if (nested !== undefined) return nested; - } - return undefined; - } - - if (!value || typeof value !== "object") return undefined; - - const record = value as Record; - for (const key of ["authenticated", "isAuthenticated", "loggedIn", "isLoggedIn"] as const) { - if (typeof record[key] === "boolean") return record[key]; - } - for (const key of ["auth", "status", "session", "account"] as const) { - const nested = extractAuthBoolean(record[key]); - if (nested !== undefined) return nested; - } - return undefined; +interface AuthStatusParserMessages { + readonly unsupportedCommand: string; + readonly unauthenticated: string; + readonly missingAuthMarker: string; + readonly unknownStatusPrefix: string; + readonly unauthenticatedHints: ReadonlyArray; } -export function parseAuthStatusFromOutput(result: CommandResult): { +function parseCliAuthStatusFromOutput( + result: CommandResult, + messages: AuthStatusParserMessages, +): { readonly status: ServerProviderStatusState; readonly authStatus: ServerProviderAuthStatus; readonly message?: string; + readonly plan?: string; } { const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); @@ -99,59 +111,47 @@ export function parseAuthStatusFromOutput(result: CommandResult): { return { status: "warning", authStatus: "unknown", - message: "Codex CLI authentication status command is unavailable in this Codex version.", + message: messages.unsupportedCommand, }; } - if ( - lowerOutput.includes("not logged in") || - lowerOutput.includes("login required") || - lowerOutput.includes("authentication required") || - lowerOutput.includes("run `codex login`") || - lowerOutput.includes("run codex login") - ) { + if (messages.unauthenticatedHints.some((hint) => lowerOutput.includes(hint))) { return { status: "error", authStatus: "unauthenticated", - message: "Codex CLI is not authenticated. Run `codex login` and try again.", + message: messages.unauthenticated, }; } - const parsedAuth = (() => { - const trimmed = result.stdout.trim(); - if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { - return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; - } - try { - return { - attemptedJsonParse: true as const, - auth: extractAuthBoolean(JSON.parse(trimmed)), - }; - } catch { - return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; - } - })(); + const parsedAuth = readAuthProbeDetails(result); if (parsedAuth.auth === true) { - return { status: "ready", authStatus: "authenticated" }; + return { + status: "ready", + authStatus: "authenticated", + ...(parsedAuth.plan ? { plan: parsedAuth.plan } : {}), + }; } if (parsedAuth.auth === false) { return { status: "error", authStatus: "unauthenticated", - message: "Codex CLI is not authenticated. Run `codex login` and try again.", + message: messages.unauthenticated, }; } if (parsedAuth.attemptedJsonParse) { return { status: "warning", authStatus: "unknown", - message: - "Could not verify Codex authentication status from JSON output (missing auth marker).", + message: messages.missingAuthMarker, }; } if (result.code === 0) { - return { status: "ready", authStatus: "authenticated" }; + return { + status: "ready", + authStatus: "authenticated", + ...(parsedAuth.plan ? { plan: parsedAuth.plan } : {}), + }; } const detail = detailFromResult(result); @@ -159,11 +159,131 @@ export function parseAuthStatusFromOutput(result: CommandResult): { status: "warning", authStatus: "unknown", message: detail - ? `Could not verify Codex authentication status. ${detail}` - : "Could not verify Codex authentication status.", + ? `${messages.unknownStatusPrefix}. ${detail}` + : `${messages.unknownStatusPrefix}.`, + }; +} + +interface CliProbeMessages { + readonly missing: string; + readonly versionExecutionFailed: string; + readonly installedButFailed: string; + readonly authUnknownPrefix: string; +} + +function buildVersionProbeFailureStatus(input: { + readonly provider: ProviderKind; + readonly checkedAt: string; + readonly error: unknown; + readonly messages: CliProbeMessages; +}): ServerProviderStatus { + return { + provider: input.provider, + status: "error", + available: false, + authStatus: "unknown", + checkedAt: input.checkedAt, + message: isCommandMissingCause(input.error) + ? input.messages.missing + : `${input.messages.versionExecutionFailed}: ${input.error instanceof Error ? input.error.message : String(input.error)}.`, + }; +} + +function buildVersionProbeTimeoutStatus(input: { + readonly provider: ProviderKind; + readonly checkedAt: string; + readonly messages: CliProbeMessages; +}): ServerProviderStatus { + return { + provider: input.provider, + status: "error", + available: false, + authStatus: "unknown", + checkedAt: input.checkedAt, + message: `${input.messages.installedButFailed} Timed out while running command.`, + }; +} + +function buildVersionProbeExitStatus(input: { + readonly provider: ProviderKind; + readonly checkedAt: string; + readonly result: CommandResult; + readonly messages: CliProbeMessages; +}): ServerProviderStatus { + const detail = detailFromResult(input.result); + return { + provider: input.provider, + status: "error", + available: false, + authStatus: "unknown", + checkedAt: input.checkedAt, + message: detail + ? `${input.messages.installedButFailed} ${detail}` + : input.messages.installedButFailed, + }; +} + +function buildAuthProbeFailureStatus(input: { + readonly provider: ProviderKind; + readonly checkedAt: string; + readonly error: unknown; + readonly messages: CliProbeMessages; + readonly version?: string; +}): ServerProviderStatus { + return { + provider: input.provider, + status: "warning", + available: true, + authStatus: "unknown", + checkedAt: input.checkedAt, + message: + input.error instanceof Error + ? `${input.messages.authUnknownPrefix}: ${input.error.message}.` + : `${input.messages.authUnknownPrefix}.`, + ...(input.version ? { version: input.version } : {}), }; } +function buildAuthProbeTimeoutStatus(input: { + readonly provider: ProviderKind; + readonly checkedAt: string; + readonly messages: CliProbeMessages; + readonly version?: string; +}): ServerProviderStatus { + return { + provider: input.provider, + status: "warning", + available: true, + authStatus: "unknown", + checkedAt: input.checkedAt, + message: `${input.messages.authUnknownPrefix}. Timed out while running command.`, + ...(input.version ? { version: input.version } : {}), + }; +} + +export function parseAuthStatusFromOutput(result: CommandResult): { + readonly status: ServerProviderStatusState; + readonly authStatus: ServerProviderAuthStatus; + readonly message?: string; + readonly plan?: string; +} { + return parseCliAuthStatusFromOutput(result, { + unsupportedCommand: + "Codex CLI authentication status command is unavailable in this Codex version.", + unauthenticated: "Codex CLI is not authenticated. Run `codex login` and try again.", + missingAuthMarker: + "Could not verify Codex authentication status from JSON output (missing auth marker).", + unknownStatusPrefix: "Could not verify Codex authentication status", + unauthenticatedHints: [ + "not logged in", + "login required", + "authentication required", + "run `codex login`", + "run codex login", + ], + }); +} + // ── Codex CLI config detection ────────────────────────────────────── /** @@ -185,39 +305,41 @@ 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; - } - - // 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; +export const readCodexConfigModelProvider = (options?: { readonly codexHomePath?: string }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const codexHome = + options?.codexHomePath || 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; } - if (!inTopLevel) continue; - const match = trimmed.match(/^model_provider\s*=\s*["']([^"']+)["']/); - if (match) return match[1]; - } - 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; + } + if (!inTopLevel) continue; + + const match = trimmed.match(/^model_provider\s*=\s*["']([^"']+)["']/); + if (match) return match[1]; + } + return undefined; + }); /** * Returns `true` when the Codex CLI is configured with a custom @@ -225,10 +347,16 @@ 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 hasCustomModelProviderForOptions = (providerOptions?: ProviderStartOptions) => + Effect.map( + readCodexConfigModelProvider( + providerOptions?.codex?.homePath + ? { codexHomePath: providerOptions.codex.homePath } + : undefined, + ), + (provider) => provider !== undefined && !OPENAI_AUTH_PROVIDERS.has(provider), + ); +export const hasCustomModelProvider = hasCustomModelProviderForOptions(); // ── Effect-native command execution ───────────────────────────────── @@ -239,31 +367,27 @@ const collectStreamAsString = (stream: Stream.Stream): Effect. (acc, chunk) => acc + new TextDecoder().decode(chunk), ); -const runCodexCommand = (args: ReadonlyArray) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make("codex", [...args], { - shell: process.platform === "win32", - }); - - const child = yield* spawner.spawn(command); - - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectStreamAsString(child.stdout), - collectStreamAsString(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ); +function resolveCodexCommandConfig(providerOptions?: ProviderStartOptions): ResolvedCommandConfig { + const binaryPath = providerOptions?.codex?.binaryPath ?? "codex"; + const homePath = providerOptions?.codex?.homePath; + return { + binaryPath, + env: homePath ? { ...process.env, CODEX_HOME: homePath } : process.env, + }; +} - return { stdout, stderr, code: exitCode } satisfies CommandResult; - }).pipe(Effect.scoped); +function resolveClaudeCommandConfig(providerOptions?: ProviderStartOptions): ResolvedCommandConfig { + return { + binaryPath: providerOptions?.claudeAgent?.binaryPath ?? "claude", + env: process.env, + }; +} -const runClaudeCommand = (args: ReadonlyArray) => +const runCommand = (config: ResolvedCommandConfig, args: ReadonlyArray) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make("claude", [...args], { + const command = ChildProcess.make(config.binaryPath, [...args], { + env: config.env, shell: process.platform === "win32", }); @@ -281,131 +405,131 @@ const runClaudeCommand = (args: ReadonlyArray) => return { stdout, stderr, code: exitCode } satisfies CommandResult; }).pipe(Effect.scoped); +const runCodexCommand = (args: ReadonlyArray, providerOptions?: ProviderStartOptions) => + runCommand(resolveCodexCommandConfig(providerOptions), args); + +const runClaudeCommand = (args: ReadonlyArray, providerOptions?: ProviderStartOptions) => + runCommand(resolveClaudeCommandConfig(providerOptions), args); + // ── Health check ──────────────────────────────────────────────────── -export const checkCodexProviderStatus: Effect.Effect< +export const makeCheckCodexProviderStatus = ( + providerOptions?: ProviderStartOptions, +): 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(); - // 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(["--version"], providerOptions).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)) { + return buildVersionProbeFailureStatus({ + provider: CODEX_PROVIDER, + checkedAt, + error: versionProbe.failure, + messages: CODEX_CLI_PROBE_MESSAGES, + }); + } - 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 buildVersionProbeTimeoutStatus({ + provider: CODEX_PROVIDER, + checkedAt, + messages: CODEX_CLI_PROBE_MESSAGES, + }); + } - 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) { + return buildVersionProbeExitStatus({ + provider: CODEX_PROVIDER, + checkedAt, + result: version, + messages: CODEX_CLI_PROBE_MESSAGES, + }); + } - 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), + ...(parsedVersion ? { version: 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* hasCustomModelProviderForOptions(providerOptions)) { + 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.", + ...(parsedVersion ? { version: parsedVersion } : {}), + } satisfies ServerProviderStatus; + } - const authProbe = yield* runCodexCommand(["login", "status"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); + const authProbe = yield* runCodexCommand(["login", "status"], providerOptions).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)) { + return buildAuthProbeFailureStatus({ + provider: CODEX_PROVIDER, + checkedAt, + error: authProbe.failure, + messages: CODEX_CLI_PROBE_MESSAGES, + ...(parsedVersion ? { version: parsedVersion } : {}), + }); + } + + if (Option.isNone(authProbe.success)) { + return buildAuthProbeTimeoutStatus({ + provider: CODEX_PROVIDER, + checkedAt, + messages: CODEX_CLI_PROBE_MESSAGES, + ...(parsedVersion ? { version: parsedVersion } : {}), + }); + } - if (Option.isNone(authProbe.success)) { + const parsed = parseAuthStatusFromOutput(authProbe.success.value); + const codexPlan = + parsed.plan ?? + (parsed.authStatus === "authenticated" + ? yield* readCodexAccountPlanViaAppServer(providerOptions?.codex) + : undefined); 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 } : {}), + ...(codexPlan ? { plan: codexPlan } : {}), + ...(parsedVersion ? { version: parsedVersion } : {}), + } satisfies ServerProviderStatus; + }); - 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; -}); +export const checkCodexProviderStatus = makeCheckCodexProviderStatus(); // ── Claude Agent health check ─────────────────────────────────────── @@ -413,191 +537,290 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): { readonly status: ServerProviderStatusState; readonly authStatus: ServerProviderAuthStatus; readonly message?: string; + readonly plan?: string; } { - const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); + return parseCliAuthStatusFromOutput(result, { + unsupportedCommand: + "Claude Agent authentication status command is unavailable in this version of Claude.", + unauthenticated: "Claude is not authenticated. Run `claude auth login` and try again.", + missingAuthMarker: + "Could not verify Claude authentication status from JSON output (missing auth marker).", + unknownStatusPrefix: "Could not verify Claude authentication status", + unauthenticatedHints: [ + "not logged in", + "login required", + "authentication required", + "run `claude login`", + "run claude login", + ], + }); +} - if ( - lowerOutput.includes("unknown command") || - lowerOutput.includes("unrecognized command") || - lowerOutput.includes("unexpected argument") - ) { - return { - status: "warning", - authStatus: "unknown", - message: - "Claude Agent authentication status command is unavailable in this version of Claude.", - }; - } +export const makeCheckClaudeProviderStatus = ( + providerOptions?: ProviderStartOptions, +): Effect.Effect => + Effect.gen(function* () { + const checkedAt = new Date().toISOString(); - if ( - lowerOutput.includes("not logged in") || - lowerOutput.includes("login required") || - lowerOutput.includes("authentication required") || - lowerOutput.includes("run `claude login`") || - lowerOutput.includes("run claude login") - ) { - return { - status: "error", - authStatus: "unauthenticated", - message: "Claude is not authenticated. Run `claude auth login` and try again.", - }; - } + // Probe 1: `claude --version` — is the CLI reachable? + const versionProbe = yield* runClaudeCommand(["--version"], providerOptions).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); - // `claude auth status` returns JSON with a `loggedIn` boolean. - const parsedAuth = (() => { - const trimmed = result.stdout.trim(); - if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { - return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; + if (Result.isFailure(versionProbe)) { + return buildVersionProbeFailureStatus({ + provider: CLAUDE_AGENT_PROVIDER, + checkedAt, + error: versionProbe.failure, + messages: CLAUDE_CLI_PROBE_MESSAGES, + }); } - try { - return { - attemptedJsonParse: true as const, - auth: extractAuthBoolean(JSON.parse(trimmed)), - }; - } catch { - return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; + + if (Option.isNone(versionProbe.success)) { + return buildVersionProbeTimeoutStatus({ + provider: CLAUDE_AGENT_PROVIDER, + checkedAt, + messages: CLAUDE_CLI_PROBE_MESSAGES, + }); } - })(); - if (parsedAuth.auth === true) { - return { status: "ready", authStatus: "authenticated" }; - } - if (parsedAuth.auth === false) { - return { - status: "error", - authStatus: "unauthenticated", - message: "Claude is not authenticated. Run `claude auth login` and try again.", - }; - } - if (parsedAuth.attemptedJsonParse) { - return { - status: "warning", - authStatus: "unknown", - message: - "Could not verify Claude authentication status from JSON output (missing auth marker).", - }; - } - if (result.code === 0) { - return { status: "ready", authStatus: "authenticated" }; - } + const version = versionProbe.success.value; + if (version.code !== 0) { + return buildVersionProbeExitStatus({ + provider: CLAUDE_AGENT_PROVIDER, + checkedAt, + result: version, + messages: CLAUDE_CLI_PROBE_MESSAGES, + }); + } - const detail = detailFromResult(result); - return { - status: "warning", - authStatus: "unknown", - message: detail - ? `Could not verify Claude authentication status. ${detail}` - : "Could not verify Claude authentication status.", - }; -} + const claudeVersionMatch = CLI_VERSION_PATTERN.exec(`${version.stdout}\n${version.stderr}`); + const claudeVersion = claudeVersionMatch?.[1] ?? undefined; -export const checkClaudeProviderStatus: Effect.Effect< - ServerProviderStatus, - never, - ChildProcessSpawner.ChildProcessSpawner -> = Effect.gen(function* () { - const checkedAt = new Date().toISOString(); - - // Probe 1: `claude --version` — is the CLI reachable? - const versionProbe = yield* runClaudeCommand(["--version"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); + // Probe 2: `claude auth status` — is the user authenticated? + const authProbe = yield* runClaudeCommand(["auth", "status"], providerOptions).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); - if (Result.isFailure(versionProbe)) { - const error = versionProbe.failure; - return { - provider: CLAUDE_AGENT_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: isCommandMissingCause(error) - ? "Claude Agent CLI (`claude`) is not installed or not on PATH." - : `Failed to execute Claude Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, - }; - } + if (Result.isFailure(authProbe)) { + return buildAuthProbeFailureStatus({ + provider: CLAUDE_AGENT_PROVIDER, + checkedAt, + error: authProbe.failure, + messages: CLAUDE_CLI_PROBE_MESSAGES, + ...(claudeVersion ? { version: claudeVersion } : {}), + }); + } - if (Option.isNone(versionProbe.success)) { - return { - provider: CLAUDE_AGENT_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: "Claude Agent CLI is installed but failed to run. Timed out while running command.", - }; - } + if (Option.isNone(authProbe.success)) { + return buildAuthProbeTimeoutStatus({ + provider: CLAUDE_AGENT_PROVIDER, + checkedAt, + messages: CLAUDE_CLI_PROBE_MESSAGES, + ...(claudeVersion ? { version: claudeVersion } : {}), + }); + } - const version = versionProbe.success.value; - if (version.code !== 0) { - const detail = detailFromResult(version); + const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value); return { provider: CLAUDE_AGENT_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, + status: parsed.status, + available: true, + authStatus: parsed.authStatus, checkedAt, - message: detail - ? `Claude Agent CLI is installed but failed to run. ${detail}` - : "Claude Agent CLI is installed but failed to run.", - }; - } + ...(parsed.message ? { message: parsed.message } : {}), + ...(parsed.plan ? { plan: parsed.plan } : {}), + ...(claudeVersion ? { version: claudeVersion } : {}), + } satisfies ServerProviderStatus; + }); - // Probe 2: `claude auth status` — is the user authenticated? - const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); +export const checkClaudeProviderStatus = makeCheckClaudeProviderStatus(); - if (Result.isFailure(authProbe)) { - const error = authProbe.failure; - return { - provider: CLAUDE_AGENT_PROVIDER, - status: "warning" as const, - available: true, - authStatus: "unknown" as const, - checkedAt, - message: - error instanceof Error - ? `Could not verify Claude authentication status: ${error.message}.` - : "Could not verify Claude authentication status.", - }; +// ── Auth action helpers ────────────────────────────────────────────── + +const LOGIN_TIMEOUT_MS = 120_000; +const LOGOUT_TIMEOUT_MS = 10_000; + +function loginArgs(provider: ProviderKind): { + run: ( + args: ReadonlyArray, + providerOptions?: ProviderStartOptions, + ) => ReturnType; + args: ReadonlyArray; +} { + switch (provider) { + case "codex": + return { run: runCodexCommand, args: ["login"] }; + case "claudeAgent": + return { run: runClaudeCommand, args: ["auth", "login"] }; } +} - if (Option.isNone(authProbe.success)) { - return { - provider: CLAUDE_AGENT_PROVIDER, - status: "warning" as const, - available: true, - authStatus: "unknown" as const, - checkedAt, - message: "Could not verify Claude authentication status. Timed out while running command.", - }; +function logoutArgs(provider: ProviderKind): { + run: ( + args: ReadonlyArray, + providerOptions?: ProviderStartOptions, + ) => ReturnType; + args: ReadonlyArray; +} { + switch (provider) { + case "codex": + return { run: runCodexCommand, args: ["logout"] }; + case "claudeAgent": + return { run: runClaudeCommand, args: ["auth", "logout"] }; } +} - const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value); - return { - provider: CLAUDE_AGENT_PROVIDER, - status: parsed.status, - available: true, - authStatus: parsed.authStatus, - checkedAt, - ...(parsed.message ? { message: parsed.message } : {}), - } satisfies ServerProviderStatus; -}); +function providerCheck( + provider: ProviderKind, + providerOptions?: ProviderStartOptions, +): Effect.Effect< + ServerProviderStatus, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> { + switch (provider) { + case CODEX_PROVIDER: + return makeCheckCodexProviderStatus(providerOptions); + case CLAUDE_AGENT_PROVIDER: + return makeCheckClaudeProviderStatus(providerOptions); + } +} // ── Layer ─────────────────────────────────────────────────────────── export const ProviderHealthLive = Layer.effect( ProviderHealth, Effect.gen(function* () { - const statusesFiber = yield* Effect.all([checkCodexProviderStatus, checkClaudeProviderStatus], { - concurrency: "unbounded", - }).pipe(Effect.forkScoped); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const configuredProviderOptionsRef = yield* Ref.make( + undefined, + ); + const runProviderChecks = (providerOptions?: ProviderStartOptions) => + Effect.all( + [ + makeCheckCodexProviderStatus(providerOptions), + makeCheckClaudeProviderStatus(providerOptions), + ], + { + concurrency: "unbounded", + }, + ).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const getEffectiveProviderOptions = (providerOptions?: ProviderStartOptions) => + providerOptions !== undefined + ? Ref.set(configuredProviderOptionsRef, providerOptions).pipe(Effect.as(providerOptions)) + : Ref.get(configuredProviderOptionsRef); + const initialStatusesFiber = yield* Effect.forkScoped(runProviderChecks()); + const statusesRef = yield* Ref.make | undefined>(undefined); + const getStatuses = Effect.gen(function* () { + const cachedStatuses = yield* Ref.get(statusesRef); + if (cachedStatuses !== undefined) { + return cachedStatuses; + } + + const initialStatuses: ReadonlyArray = + yield* Fiber.join(initialStatusesFiber); + return yield* Ref.modify(statusesRef, (currentStatuses) => { + if (currentStatuses !== undefined) { + return [currentStatuses, currentStatuses] as const; + } + return [initialStatuses, initialStatuses] as const; + }); + }); + + const refreshAndStore = (providerOptions?: ProviderStartOptions) => + Effect.gen(function* () { + const effectiveProviderOptions = yield* getEffectiveProviderOptions(providerOptions); + const statuses = yield* runProviderChecks(effectiveProviderOptions); + yield* Ref.set(statusesRef, statuses); + return statuses; + }); + const refreshStatusAndStore = ( + provider: ProviderKind, + providerOptions?: ProviderStartOptions, + ) => + Effect.gen(function* () { + const effectiveProviderOptions = yield* getEffectiveProviderOptions(providerOptions); + const baseStatuses = yield* getStatuses; + const nextStatus = yield* providerCheck(provider, effectiveProviderOptions).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + return yield* Ref.modify(statusesRef, (currentStatuses) => { + const providers = (currentStatuses ?? baseStatuses).map((status) => + status.provider === provider ? nextStatus : status, + ); + return [providers, providers] as const; + }); + }); + + const runAuthAction = ( + provider: ProviderKind, + getConfig: (p: ProviderKind) => ReturnType, + timeoutMs: number, + providerOptions?: ProviderStartOptions, + ): Effect.Effect => + Effect.gen(function* () { + const effectiveProviderOptions = yield* getEffectiveProviderOptions(providerOptions); + const { run, args } = getConfig(provider); + const result = yield* run(args, effectiveProviderOptions).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.timeoutOption(timeoutMs), + Effect.result, + ); + + if (Result.isFailure(result)) { + const error = result.failure; + const providers = yield* refreshAndStore(effectiveProviderOptions); + return { + success: false, + message: error instanceof Error ? error.message : "Command failed.", + providers, + }; + } + + if (Option.isNone(result.success)) { + const providers = yield* refreshAndStore(effectiveProviderOptions); + return { + success: false, + message: "Command timed out.", + providers, + }; + } + + const cmd = result.success.value; + const providers = yield* refreshAndStore(effectiveProviderOptions); + return { + success: cmd.code === 0, + ...(cmd.code !== 0 + ? { + message: + nonEmptyTrimmed(cmd.stderr) ?? + nonEmptyTrimmed(cmd.stdout) ?? + `Command exited with code ${cmd.code}.`, + } + : {}), + providers, + }; + }); return { - getStatuses: Fiber.join(statusesFiber), + getStatuses, + refreshStatuses: refreshAndStore, + refreshStatus: refreshStatusAndStore, + login: (provider: ProviderKind, providerOptions?: ProviderStartOptions) => + runAuthAction(provider, loginArgs, LOGIN_TIMEOUT_MS, providerOptions), + logout: (provider: ProviderKind, providerOptions?: ProviderStartOptions) => + runAuthAction(provider, logoutArgs, LOGOUT_TIMEOUT_MS, providerOptions), } satisfies ProviderHealthShape; }), ); diff --git a/apps/server/src/provider/Services/ProviderHealth.ts b/apps/server/src/provider/Services/ProviderHealth.ts index ec3b2d318d..1929b320e1 100644 --- a/apps/server/src/provider/Services/ProviderHealth.ts +++ b/apps/server/src/provider/Services/ProviderHealth.ts @@ -6,15 +6,52 @@ * * @module ProviderHealth */ -import type { ServerProviderStatus } from "@t3tools/contracts"; +import type { ProviderKind, ProviderStartOptions, ServerProviderStatus } from "@t3tools/contracts"; import { ServiceMap } from "effect"; import type { Effect } from "effect"; +export interface ProviderAuthActionResult { + readonly success: boolean; + readonly message?: string; + readonly providers: ReadonlyArray; +} + export interface ProviderHealthShape { /** * Read the latest provider health statuses. */ readonly getStatuses: Effect.Effect>; + + /** + * Re-run provider health probes and update the cached snapshot. + */ + readonly refreshStatuses: ( + providerOptions?: ProviderStartOptions, + ) => Effect.Effect>; + + /** + * Re-run provider health probes for a single provider and update the cached snapshot. + */ + readonly refreshStatus: ( + provider: ProviderKind, + providerOptions?: ProviderStartOptions, + ) => Effect.Effect>; + + /** + * Run the login command for a provider and refresh statuses. + */ + readonly login: ( + provider: ProviderKind, + providerOptions?: ProviderStartOptions, + ) => Effect.Effect; + + /** + * Run the logout command for a provider and refresh statuses. + */ + readonly logout: ( + provider: ProviderKind, + providerOptions?: ProviderStartOptions, + ) => Effect.Effect; } export class ProviderHealth extends ServiceMap.Service()( diff --git a/apps/server/src/provider/authProbe.ts b/apps/server/src/provider/authProbe.ts new file mode 100644 index 0000000000..5b0e045f99 --- /dev/null +++ b/apps/server/src/provider/authProbe.ts @@ -0,0 +1,98 @@ +import { Exit, Schema } from "effect"; + +export function nonEmptyTrimmed(value: string | undefined): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export function extractAuthBoolean(value: unknown): boolean | undefined { + if (Array.isArray(value)) { + for (const entry of value) { + const nested = extractAuthBoolean(entry); + if (nested !== undefined) return nested; + } + return undefined; + } + + if (!value || typeof value !== "object") return undefined; + + const record = value as Record; + for (const key of [ + "authenticated", + "isAuthenticated", + "loggedIn", + "isLoggedIn", + "is_authenticated", + "logged_in", + ] as const) { + if (typeof record[key] === "boolean") return record[key]; + } + for (const key of ["auth", "status", "session", "account"] as const) { + const nested = extractAuthBoolean(record[key]); + if (nested !== undefined) return nested; + } + return undefined; +} + +export function extractPlanLabel(value: unknown): string | undefined { + if (Array.isArray(value)) { + for (const entry of value) { + const nested = extractPlanLabel(entry); + if (nested !== undefined) return nested; + } + return undefined; + } + + if (!value || typeof value !== "object") return undefined; + + const record = value as Record; + for (const key of [ + "planType", + "plan_type", + "subscriptionType", + "subscription_type", + "chatgptPlanType", + "chatgpt_plan_type", + "plan", + "subscription", + ] as const) { + const candidate = nonEmptyTrimmed(typeof record[key] === "string" ? record[key] : undefined); + if (candidate !== undefined) return candidate; + } + for (const key of ["auth", "status", "session", "account", "user"] as const) { + const nested = extractPlanLabel(record[key]); + if (nested !== undefined) return nested; + } + return undefined; +} + +export interface CommandJsonOutput { + readonly stdout: string; +} + +const decodeUnknownJsonString = Schema.decodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); + +export function parseJsonOutput(result: CommandJsonOutput): unknown { + const trimmed = result.stdout.trim(); + if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) return undefined; + const decoded = decodeUnknownJsonString(trimmed); + if (Exit.isFailure(decoded)) { + return undefined; + } + return decoded.value; +} + +export function readAuthProbeDetails(result: CommandJsonOutput): { + readonly attemptedJsonParse: boolean; + readonly auth: boolean | undefined; + readonly plan?: string; +} { + const parsedJson = parseJsonOutput(result); + const plan = extractPlanLabel(parsedJson); + return { + attemptedJsonParse: parsedJson !== undefined, + auth: extractAuthBoolean(parsedJson), + ...(plan ? { plan } : {}), + }; +} diff --git a/apps/server/src/provider/codexAccount.test.ts b/apps/server/src/provider/codexAccount.test.ts new file mode 100644 index 0000000000..3ede7df2af --- /dev/null +++ b/apps/server/src/provider/codexAccount.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { Effect, Sink, Stream } from "effect"; +import * as PlatformError from "effect/PlatformError"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { readCodexAccountPlanViaAppServer } from "./codexAccount"; + +const encoder = new TextEncoder(); + +function mockHandle(input: { + readonly stdout?: ReadonlyArray; + readonly stdin?: ChildProcessSpawner.ChildProcessHandle["stdin"]; + readonly stderr?: ChildProcessSpawner.ChildProcessHandle["stderr"]; +}) { + const stdoutChunks = input.stdout?.map((line) => encoder.encode(line)) ?? []; + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: input.stdin ?? Sink.drain, + stdout: stdoutChunks.length > 0 ? Stream.fromIterable(stdoutChunks) : Stream.empty, + stderr: input.stderr ?? Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +describe("readCodexAccountPlanViaAppServer", () => { + it("returns the plan from the app-server account/read response", async () => { + const effect = readCodexAccountPlanViaAppServer().pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + mockHandle({ + stdout: [ + '{"id":1,"result":{}}\n', + '{"id":2,"result":{"account":{"type":"chatgpt","planType":"team"}}}\n', + ], + }), + ), + ), + ), + ); + + await expect(Effect.runPromise(effect)).resolves.toBe("team"); + }); + + it("returns undefined when the codex binary fails to spawn", async () => { + const effect = readCodexAccountPlanViaAppServer().pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn codex ENOENT", + }), + ), + ), + ), + ); + + await expect(Effect.runPromise(effect)).resolves.toBeUndefined(); + }); + + it("returns undefined when the app-server stdin pipe closes during request writes", async () => { + const effect = readCodexAccountPlanViaAppServer().pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + mockHandle({ + stdin: Sink.failSync(() => + PlatformError.systemError({ + _tag: "BadResource", + module: "ChildProcess", + method: "stdin.write", + description: "write EPIPE", + }), + ), + }), + ), + ), + ), + ); + + await expect(Effect.runPromise(effect)).resolves.toBeUndefined(); + }); + + it("reads stdout even when stdin never completes", async () => { + const effect = readCodexAccountPlanViaAppServer().pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + mockHandle({ + stdin: Sink.never as ChildProcessSpawner.ChildProcessHandle["stdin"], + stdout: [ + '{"id":1,"result":{}}\n', + '{"id":2,"result":{"account":{"type":"chatgpt","planType":"team"}}}\n', + ], + }), + ), + ), + ), + ); + + await expect(Effect.runPromise(effect)).resolves.toBe("team"); + }); + + it("returns undefined when initialize responds with a string error", async () => { + const effect = readCodexAccountPlanViaAppServer().pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + mockHandle({ + stdout: ['{"id":1,"error":"unauthorized"}\n'], + }), + ), + ), + ), + ); + + await expect(Effect.runPromise(effect)).resolves.toBeUndefined(); + }); + + it("returns undefined when account/read responds with a string error", async () => { + const effect = readCodexAccountPlanViaAppServer().pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + mockHandle({ + stdout: ['{"id":1,"result":{}}\n', '{"id":2,"error":"unauthorized"}\n'], + }), + ), + ), + ), + ); + + await expect(Effect.runPromise(effect)).resolves.toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/codexAccount.ts b/apps/server/src/provider/codexAccount.ts new file mode 100644 index 0000000000..8c5fcdbc46 --- /dev/null +++ b/apps/server/src/provider/codexAccount.ts @@ -0,0 +1,264 @@ +import { Effect, Exit, Option, Ref, Schema, Stream } from "effect"; +import type * as PlatformError from "effect/PlatformError"; +import type * as Scope from "effect/Scope"; +import type { ProviderStartOptions } from "@t3tools/contracts"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { extractPlanLabel } from "./authProbe"; + +export type CodexPlanType = + | "free" + | "go" + | "plus" + | "pro" + | "team" + | "business" + | "enterprise" + | "edu" + | "unknown"; + +export interface CodexAccountSnapshot { + readonly type: "apiKey" | "chatgpt" | "unknown"; + readonly planType: CodexPlanType | null; + readonly sparkEnabled: boolean; +} + +const CODEX_SPARK_DISABLED_PLAN_TYPES = new Set(["free", "go", "plus"]); +const APP_SERVER_PROBE_TIMEOUT_MS = 4_000; +const APP_SERVER_INITIALIZE_REQUEST_ID = 1; +const APP_SERVER_ACCOUNT_READ_REQUEST_ID = 2; +const APP_SERVER_REQUEST_ENCODER = new TextEncoder(); +const AppServerInitializeRequestSchema = Schema.Struct({ + id: Schema.Literal(APP_SERVER_INITIALIZE_REQUEST_ID), + method: Schema.Literal("initialize"), + params: Schema.Struct({ + clientInfo: Schema.Struct({ + name: Schema.String, + title: Schema.String, + version: Schema.String, + }), + capabilities: Schema.Struct({ + experimentalApi: Schema.Boolean, + }), + }), +}); + +const AppServerInitializedNotificationSchema = Schema.Struct({ + method: Schema.Literal("initialized"), +}); + +const AppServerAccountReadRequestSchema = Schema.Struct({ + id: Schema.Literal(APP_SERVER_ACCOUNT_READ_REQUEST_ID), + method: Schema.Literal("account/read"), + params: Schema.Struct({}), +}); + +const AppServerResponseSchema = Schema.Struct({ + id: Schema.Union([Schema.Number, Schema.String]), + result: Schema.optional(Schema.Unknown), + error: Schema.optional(Schema.Unknown), +}); + +const encodeAppServerInitializeRequest = Schema.encodeSync( + Schema.fromJsonString(AppServerInitializeRequestSchema), +); +const encodeAppServerInitializedNotification = Schema.encodeSync( + Schema.fromJsonString(AppServerInitializedNotificationSchema), +); +const encodeAppServerAccountReadRequest = Schema.encodeSync( + Schema.fromJsonString(AppServerAccountReadRequestSchema), +); +const decodeAppServerResponse = Schema.decodeUnknownExit( + Schema.fromJsonString(AppServerResponseSchema), +); + +function asObject(value: unknown): Record | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + return value as Record; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function hasAppServerError(value: unknown): boolean { + return value != null; +} + +function isCodexPlanType(value: string): value is CodexPlanType { + switch (value) { + case "free": + case "go": + case "plus": + case "pro": + case "team": + case "business": + case "enterprise": + case "edu": + case "unknown": + return true; + default: + return false; + } +} + +function readCodexPlanType(value: Record): CodexPlanType | null { + const planType = asString(value.planType) ?? asString(value.plan_type); + if (!planType) { + return null; + } + return isCodexPlanType(planType) ? planType : "unknown"; +} + +export function readCodexAccountSnapshot(response: unknown): CodexAccountSnapshot { + const record = asObject(response); + const account = asObject(record?.account) ?? record; + if (!account) { + return { + type: "unknown", + planType: null, + sparkEnabled: true, + }; + } + const accountType = asString(account?.type); + + if (accountType === "apiKey") { + return { + type: "apiKey", + planType: null, + sparkEnabled: true, + }; + } + + if (accountType === "chatgpt") { + const planType = readCodexPlanType(account) ?? "unknown"; + return { + type: "chatgpt", + planType, + sparkEnabled: !CODEX_SPARK_DISABLED_PLAN_TYPES.has(planType), + }; + } + + return { + type: "unknown", + planType: null, + sparkEnabled: true, + }; +} + +export function resolveCodexModelForAccount( + model: string | undefined, + account: CodexAccountSnapshot, +): string | undefined { + if (model !== "gpt-5.3-codex-spark" || account.sparkEnabled) { + return model; + } + + return "gpt-5.3-codex"; +} + +export function buildCodexInitializeParams() { + return { + clientInfo: { + name: "t3code_desktop", + title: "T3 Code Desktop", + version: "0.1.0", + }, + capabilities: { + experimentalApi: true, + }, + } as const; +} + +export function extractCodexAccountPlan(response: unknown): string | undefined { + if (!response || typeof response !== "object") return undefined; + const record = response as Record; + return extractPlanLabel(record.result ?? response); +} + +export function readCodexAccountPlanViaAppServer( + codexOptions?: ProviderStartOptions["codex"], +): Effect.Effect { + const messages = [ + APP_SERVER_REQUEST_ENCODER.encode( + `${encodeAppServerInitializeRequest({ + id: APP_SERVER_INITIALIZE_REQUEST_ID, + method: "initialize", + params: buildCodexInitializeParams(), + })}\n`, + ), + APP_SERVER_REQUEST_ENCODER.encode( + `${encodeAppServerInitializedNotification({ method: "initialized" })}\n`, + ), + APP_SERVER_REQUEST_ENCODER.encode( + `${encodeAppServerAccountReadRequest({ + id: APP_SERVER_ACCOUNT_READ_REQUEST_ID, + method: "account/read", + params: {}, + })}\n`, + ), + ] as const; + + const program: Effect.Effect< + string | undefined, + Error | PlatformError.PlatformError, + ChildProcessSpawner.ChildProcessSpawner | Scope.Scope + > = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make(codexOptions?.binaryPath ?? "codex", ["app-server"], { + env: codexOptions?.homePath + ? { ...process.env, CODEX_HOME: codexOptions.homePath } + : process.env, + shell: process.platform === "win32", + }), + ); + const planRef = yield* Ref.make(undefined); + + const readPlan = Stream.decodeText(child.stdout).pipe( + Stream.splitLines, + Stream.runForEachWhile((line) => + Effect.gen(function* () { + const decoded = decodeAppServerResponse(line); + if (Exit.isFailure(decoded)) { + return true; + } + + const record = decoded.value; + if (record.id === APP_SERVER_INITIALIZE_REQUEST_ID) { + if (hasAppServerError(record.error)) { + return yield* Effect.fail(new Error("Codex app-server initialize failed.")); + } + return true; + } + if (record.id !== APP_SERVER_ACCOUNT_READ_REQUEST_ID) { + return true; + } + + yield* Ref.set( + planRef, + hasAppServerError(record.error) ? undefined : extractCodexAccountPlan(record), + ); + return false; + }), + ), + Effect.flatMap(() => Ref.get(planRef)), + ); + + return yield* Effect.gen(function* () { + yield* Effect.forkScoped(Stream.runDrain(child.stderr).pipe(Effect.ignore)); + yield* Effect.forkScoped( + Stream.run(Stream.fromIterable(messages), child.stdin).pipe(Effect.ignore), + ); + return yield* readPlan; + }).pipe(Effect.ensuring(child.kill().pipe(Effect.ignore))); + }); + + return program.pipe( + Effect.scoped, + Effect.timeoutOption(APP_SERVER_PROBE_TIMEOUT_MS), + Effect.map((result) => (Option.isSome(result) ? result.value : undefined)), + Effect.orElseSucceed(() => undefined), + ); +} diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 7dc4a59e7c..294f5cf130 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -76,6 +76,10 @@ const defaultProviderStatuses: ReadonlyArray = [ const defaultProviderHealthService: ProviderHealthShape = { getStatuses: Effect.succeed(defaultProviderStatuses), + refreshStatuses: () => Effect.succeed(defaultProviderStatuses), + refreshStatus: () => Effect.succeed(defaultProviderStatuses), + login: () => Effect.succeed({ success: true, providers: defaultProviderStatuses }), + logout: () => Effect.succeed({ success: true, providers: defaultProviderStatuses }), }; class MockTerminalManager implements TerminalManagerShape { @@ -1123,6 +1127,88 @@ describe("WebSocket Server", () => { ); }); + it("refreshes provider statuses on demand", async () => { + const refreshedProviders: ReadonlyArray = [ + { + provider: "codex", + status: "warning", + available: true, + authStatus: "unknown", + checkedAt: "2026-01-02T00:00:00.000Z", + message: "Could not verify Codex authentication status.", + }, + ]; + + server = await createTestServer({ + cwd: "/my/workspace", + providerHealth: { + getStatuses: Effect.succeed(defaultProviderStatuses), + refreshStatuses: () => Effect.succeed(refreshedProviders), + refreshStatus: () => Effect.succeed(refreshedProviders), + login: () => Effect.succeed({ success: true, providers: refreshedProviders }), + logout: () => Effect.succeed({ success: true, providers: refreshedProviders }), + }, + }); + 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.serverRefreshProviderStatuses); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + providers: refreshedProviders, + }); + }); + + it("refreshes a single provider status on demand", async () => { + const refreshedProviders: ReadonlyArray = [ + { + provider: "codex", + status: "warning", + available: true, + authStatus: "unknown", + checkedAt: "2026-01-02T00:00:00.000Z", + message: "Could not verify Codex authentication status.", + }, + { + provider: "claudeAgent", + status: "ready", + available: true, + authStatus: "authenticated", + checkedAt: "2026-01-01T00:00:00.000Z", + }, + ]; + + server = await createTestServer({ + cwd: "/my/workspace", + providerHealth: { + getStatuses: Effect.succeed(defaultProviderStatuses), + refreshStatuses: () => Effect.succeed(defaultProviderStatuses), + refreshStatus: (provider) => { + expect(provider).toBe("codex"); + return Effect.succeed(refreshedProviders); + }, + login: () => Effect.succeed({ success: true, providers: refreshedProviders }), + logout: () => Effect.succeed({ success: true, providers: refreshedProviders }), + }, + }); + 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.serverRefreshProviderStatus, { + provider: "codex", + }); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + providers: refreshedProviders, + }); + }); + it("returns error for unknown methods", async () => { server = await createTestServer({ cwd: "/test" }); const addr = server.address(); @@ -1835,7 +1921,7 @@ describe("WebSocket Server", () => { connections.push(ws); const response = await sendRequest(ws, WS_METHODS.gitRunStackedAction, { - actionId: "client-action-1", + actionId: "action-1", cwd: "/test", action: "commit_push", modelSelection: { @@ -1845,96 +1931,15 @@ describe("WebSocket Server", () => { }); expect(response.result).toBeUndefined(); expect(response.error?.message).toContain("detached HEAD"); - expect(runStackedAction).toHaveBeenCalledWith( - { - actionId: "client-action-1", - cwd: "/test", - action: "commit_push", - modelSelection: { - provider: "codex", - model: "gpt-5.4-mini", - }, - }, - expect.objectContaining({ - actionId: "client-action-1", - progressReporter: expect.any(Object), - }), - ); - }); - - it("publishes git action progress only to the initiating websocket", async () => { - const runStackedAction = vi.fn( - (_input, options) => - options?.progressReporter - ?.publish({ - actionId: options.actionId ?? "action-1", - cwd: "/test", - action: "commit", - kind: "phase_started", - phase: "commit", - label: "Committing...", - }) - .pipe( - Effect.flatMap(() => - Effect.succeed({ - action: "commit" as const, - branch: { status: "skipped_not_requested" as const }, - commit: { - status: "created" as const, - commitSha: "abc1234", - subject: "Test commit", - }, - push: { status: "skipped_not_requested" as const }, - pr: { status: "skipped_not_requested" as const }, - }), - ), - ) ?? Effect.void, - ); - const gitManager: GitManagerShape = { - status: vi.fn(() => Effect.void as any), - resolvePullRequest: vi.fn(() => Effect.void as any), - preparePullRequestThread: vi.fn(() => Effect.void as any), - runStackedAction, - }; - - server = await createTestServer({ cwd: "/test", gitManager }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [initiatingWs] = await connectAndAwaitWelcome(port); - const [otherWs] = await connectAndAwaitWelcome(port); - connections.push(initiatingWs, otherWs); - - const responsePromise = sendRequest(initiatingWs, WS_METHODS.gitRunStackedAction, { - actionId: "client-action-2", + expect(runStackedAction).toHaveBeenCalledWith({ + actionId: "action-1", cwd: "/test", - action: "commit", + action: "commit_push", modelSelection: { provider: "codex", model: "gpt-5.4-mini", }, }); - const progressPush = await waitForPush(initiatingWs, WS_CHANNELS.gitActionProgress); - - expect(progressPush.data).toEqual({ - actionId: "client-action-2", - cwd: "/test", - action: "commit", - kind: "phase_started", - phase: "commit", - label: "Committing...", - }); - - await expect( - waitForPush(otherWs, WS_CHANNELS.gitActionProgress, undefined, 10, 100), - ).rejects.toThrow("Timed out waiting for WebSocket message after 100ms"); - await expect(responsePromise).resolves.toEqual( - expect.objectContaining({ - result: expect.objectContaining({ - action: "commit", - }), - }), - ); }); it("rejects websocket connections without a valid auth token", async () => { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index bcb3850e7a..4fe312d857 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,12 @@ 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, - }), + Effect.flatMap(providerHealth.getStatuses, (providers) => + pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { + issues: event.issues, + providers, + }), + ), ).pipe(Effect.forkIn(subscriptionsScope)); yield* Scope.provide(orchestrationReactor.start, subscriptionsScope); @@ -710,7 +710,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< Effect.all([closeAllClients, closeWebSocketServer.pipe(Effect.ignoreCause({ log: true }))]), ); - const routeRequest = Effect.fnUntraced(function* (ws: WebSocket, request: WebSocketRequest) { + const routeRequest = Effect.fnUntraced(function* (request: WebSocketRequest) { switch (request.body._tag) { case ORCHESTRATION_WS_METHODS.getSnapshot: return yield* projectionReadModelQuery.getSnapshot(); @@ -799,13 +799,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< case WS_METHODS.gitRunStackedAction: { const body = stripRequestTag(request.body); - return yield* gitManager.runStackedAction(body, { - actionId: body.actionId, - progressReporter: { - publish: (event) => - pushBus.publishClient(ws, WS_CHANNELS.gitActionProgress, event).pipe(Effect.asVoid), - }, - }); + return yield* gitManager.runStackedAction(body); } case WS_METHODS.gitResolvePullRequest: { @@ -880,15 +874,38 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< case WS_METHODS.serverGetConfig: const keybindingsConfig = yield* keybindingsManager.loadConfigState; + const providers = yield* providerHealth.getStatuses; return { cwd, keybindingsConfigPath, keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, - providers: providerStatuses, + providers, availableEditors, }; + case WS_METHODS.serverRefreshProviderStatuses: { + const body = stripRequestTag(request.body); + const providers = yield* providerHealth.refreshStatuses(body.providerOptions); + return { providers }; + } + + case WS_METHODS.serverRefreshProviderStatus: { + const body = stripRequestTag(request.body); + const providers = yield* providerHealth.refreshStatus(body.provider, body.providerOptions); + return { providers }; + } + + case WS_METHODS.serverProviderLogin: { + const body = stripRequestTag(request.body); + return yield* providerHealth.login(body.provider, body.providerOptions); + } + + case WS_METHODS.serverProviderLogout: { + const body = stripRequestTag(request.body); + return yield* providerHealth.logout(body.provider, body.providerOptions); + } + case WS_METHODS.serverUpsertKeybinding: { const body = stripRequestTag(request.body); const keybindingsConfig = yield* keybindingsManager.upsertKeybindingRule(body); @@ -927,7 +944,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); } - const result = yield* Effect.exit(routeRequest(ws, request.success)); + const result = yield* Effect.exit(routeRequest(request.success)); if (Exit.isFailure(result)) { return yield* sendWsResponse({ id: request.success.id, diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index fea74edd72..bc6d3a144e 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -7,6 +7,8 @@ import { DEFAULT_SIDEBAR_THREAD_SORT_ORDER, DEFAULT_TIMESTAMP_FORMAT, getProviderStartOptions, + isProviderEnabled, + patchProviderEnabled, } from "./appSettings"; import { getAppModelOptions, @@ -242,6 +244,55 @@ describe("provider-indexed custom model settings", () => { }); }); +describe("provider enablement", () => { + it("defaults providers to enabled when decoding older persisted settings", () => { + const decode = Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema)); + + expect( + decode( + JSON.stringify({ + codexBinaryPath: "/usr/local/bin/codex", + }), + ).enabledProviders, + ).toEqual({ + codex: true, + claudeAgent: true, + }); + }); + + it("reads enabled providers", () => { + const settings = { + enabledProviders: { + codex: true, + claudeAgent: false, + }, + } as const; + + expect(isProviderEnabled(settings, "codex")).toBe(true); + expect(isProviderEnabled(settings, "claudeAgent")).toBe(false); + }); + + it("patches provider enabled state without resetting other providers", () => { + expect( + patchProviderEnabled( + { + enabledProviders: { + codex: true, + claudeAgent: true, + }, + }, + "claudeAgent", + false, + ), + ).toEqual({ + enabledProviders: { + codex: true, + claudeAgent: false, + }, + }); + }); +}); + describe("AppSettingsSchema", () => { it("fills decoding defaults for persisted settings that predate newer keys", () => { const decode = Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema)); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index e2aac52a84..12f56dccc8 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -3,6 +3,7 @@ import { Option, Schema } from "effect"; import { DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, ModelSelection, + type ProviderKind, type ProviderStartOptions, } from "@t3tools/contracts"; import { useLocalStorage } from "./hooks/useLocalStorage"; @@ -20,6 +21,9 @@ export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "upda export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]); export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; +type EnabledProvidersSettings = { + [provider in ProviderKind]: boolean; +}; const withDefaults = < @@ -36,6 +40,10 @@ const withDefaults = export const AppSettingsSchema = Schema.Struct({ claudeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), + enabledProviders: Schema.Struct({ + codex: Schema.Boolean, + claudeAgent: Schema.Boolean, + }).pipe(withDefaults(() => ({ codex: true, claudeAgent: true }))), codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)), @@ -62,6 +70,26 @@ export type AppSettings = typeof AppSettingsSchema.Type; const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); +export function isProviderEnabled( + settings: Pick, + provider: ProviderKind, +): boolean { + return settings.enabledProviders[provider] ?? true; +} + +export function patchProviderEnabled( + settings: Pick, + provider: ProviderKind, + enabled: boolean, +): { enabledProviders: EnabledProvidersSettings } { + return { + enabledProviders: { + ...settings.enabledProviders, + [provider]: enabled, + }, + }; +} + function normalizeAppSettings(settings: AppSettings): AppSettings { return { ...settings, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ace74a5cc8..abf554a0d8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -120,7 +120,7 @@ import { import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -import { getProviderStartOptions, useAppSettings } from "../appSettings"; +import { getProviderStartOptions, isProviderEnabled, useAppSettings } from "../appSettings"; import { getCustomModelOptionsByProvider, getCustomModelsByProvider, @@ -182,6 +182,7 @@ import { SendPhase, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { getProviderUsabilityIssue, isProviderUsable } from "~/lib/providerUsability"; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -610,6 +611,8 @@ export default function ChatView({ threadId }: ChatViewProps) { : null; const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? threadProvider ?? "codex"; + const enabledProviders = settings.enabledProviders; + const isSelectedProviderEnabled = isProviderEnabled(settings, selectedProvider); const customModelsByProvider = useMemo(() => getCustomModelsByProvider(settings), [settings]); const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ threadId, @@ -652,8 +655,10 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); const searchableModelOptions = useMemo( () => - AVAILABLE_PROVIDER_OPTIONS.filter( - (option) => lockedProvider === null || option.value === lockedProvider, + AVAILABLE_PROVIDER_OPTIONS.filter((option) => + lockedProvider === null + ? enabledProviders[option.value] !== false + : option.value === lockedProvider, ).flatMap((option) => modelOptionsByProvider[option.value].map(({ slug, name }) => ({ provider: option.value, @@ -665,7 +670,7 @@ export default function ChatView({ threadId }: ChatViewProps) { searchProvider: option.label.toLowerCase(), })), ), - [lockedProvider, modelOptionsByProvider], + [enabledProviders, lockedProvider, modelOptionsByProvider], ); const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; @@ -1124,6 +1129,15 @@ export default function ChatView({ threadId }: ChatViewProps) { () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, [selectedProvider, providerStatuses], ); + const isSelectedProviderUsable = isProviderUsable( + activeProviderStatus, + isSelectedProviderEnabled, + ); + const selectedProviderIssue = getProviderUsabilityIssue( + selectedProvider, + activeProviderStatus, + isSelectedProviderEnabled, + ); const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const threadTerminalRuntimeEnv = useMemo(() => { @@ -1207,11 +1221,31 @@ export default function ChatView({ threadId }: ChatViewProps) { const focusComposer = useCallback(() => { composerEditorRef.current?.focusAtEnd(); }, []); + const openSettings = useCallback(() => { + void navigate({ to: "/settings" }); + }, [navigate]); const scheduleComposerFocus = useCallback(() => { window.requestAnimationFrame(() => { focusComposer(); }); }, [focusComposer]); + const guardProviderEnabled = useCallback( + (provider: ProviderKind, targetThreadId: ThreadId | null): boolean => { + const status = providerStatuses.find((s) => s.provider === provider); + const issue = getProviderUsabilityIssue( + provider, + status ?? null, + isProviderEnabled(settings, provider), + ); + if (issue !== null) { + setThreadError(targetThreadId, issue); + scheduleComposerFocus(); + return false; + } + return true; + }, + [providerStatuses, scheduleComposerFocus, setThreadError, settings], + ); const addTerminalContextToDraft = useCallback( (selection: TerminalContextSelection) => { if (!activeThread) { @@ -2368,6 +2402,7 @@ export default function ChatView({ threadId }: ChatViewProps) { e?.preventDefault(); const api = readNativeApi(); if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; + if (!guardProviderEnabled(selectedProvider, activeThread.id)) return; if (activePendingProgress) { onAdvanceActivePendingUserInput(); return; @@ -2871,6 +2906,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ) { return; } + if (!guardProviderEnabled(selectedProvider, activeThread.id)) { + return; + } const trimmed = text.trim(); if (!trimmed) { @@ -2966,6 +3004,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activeProposedPlan, beginSendPhase, forceStickToBottom, + guardProviderEnabled, isConnecting, isSendBusy, isServerThread, @@ -2997,6 +3036,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ) { return; } + if (!guardProviderEnabled(selectedProvider, activeThread.id)) { + return; + } const createdAt = new Date().toISOString(); const nextThreadId = newThreadId(); @@ -3088,6 +3130,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activeProposedPlan, activeThread, beginSendPhase, + guardProviderEnabled, isConnecting, isSendBusy, isServerThread, @@ -3110,6 +3153,9 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus(); return; } + if (!guardProviderEnabled(provider, activeThread.id)) { + return; + } const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model); const nextModelSelection: ModelSelection = { provider, @@ -3121,6 +3167,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [ activeThread, + guardProviderEnabled, lockedProvider, scheduleComposerFocus, setComposerDraftModelSelection, @@ -3757,9 +3804,32 @@ export default function ChatView({ threadId }: ChatViewProps) { ? "Ask for follow-up changes or attach images" : "Ask anything, @tag files/folders, or use / to show available commands" } - disabled={isConnecting || isComposerApprovalState} + disabled={ + isConnecting || isComposerApprovalState || !isSelectedProviderUsable + } /> + {selectedProviderIssue ? ( +
+
+

+ {selectedProviderIssue} +

+

+ Fix the issue in settings or switch to another provider. +

+
+ +
+ ) : null} {/* Bottom toolbar */} {activePendingApproval ? ( @@ -3790,12 +3860,15 @@ export default function ChatView({ threadId }: ChatViewProps) { : "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible", )} > - {/* Provider/model picker */} + {/* Provider/model picker — keep interactive even when the + active provider is disabled so the user can switch providers */} {isConnecting || isSendBusy ? "Sending..." : "Refine"} @@ -3991,7 +4065,7 @@ export default function ChatView({ threadId }: ChatViewProps) { type="submit" size="sm" className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8" - disabled={isSendBusy || isConnecting} + disabled={isSendBusy || isConnecting || !isSelectedProviderUsable} > {isConnecting || isSendBusy ? "Sending..." : "Implement"} @@ -4003,7 +4077,9 @@ export default function ChatView({ threadId }: ChatViewProps) { variant="default" className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8" aria-label="Implementation actions" - disabled={isSendBusy || isConnecting} + disabled={ + isSendBusy || isConnecting || !isSelectedProviderUsable + } /> } > @@ -4011,7 +4087,9 @@ export default function ChatView({ threadId }: ChatViewProps) { void onImplementPlanInNewThread()} > Implement in a new thread @@ -4025,7 +4103,10 @@ export default function ChatView({ threadId }: ChatViewProps) { type="submit" className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8" disabled={ - isSendBusy || isConnecting || !composerSendState.hasSendableContent + isSendBusy || + isConnecting || + !composerSendState.hasSendableContent || + !isSelectedProviderUsable } aria-label={ isConnecting diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 79fb1ff11e..9b7a9f92dc 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1,9 +1,4 @@ -import type { - GitActionProgressEvent, - GitStackedAction, - GitStatusResult, - ThreadId, -} from "@t3tools/contracts"; +import type { GitStackedAction, GitStatusResult, ThreadId } from "@t3tools/contracts"; import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; @@ -297,74 +292,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }) : null; - useEffect(() => { - const api = readNativeApi(); - if (!api) { - return; - } - - const applyProgressEvent = (event: GitActionProgressEvent) => { - const progress = activeGitActionProgressRef.current; - if (!progress) { - return; - } - if (gitCwd && event.cwd !== gitCwd) { - return; - } - if (progress.actionId !== event.actionId) { - return; - } - - const now = Date.now(); - switch (event.kind) { - case "action_started": - progress.phaseStartedAtMs = now; - progress.hookStartedAtMs = null; - progress.hookName = null; - progress.lastOutputLine = null; - break; - case "phase_started": - progress.title = event.label; - progress.currentPhaseLabel = event.label; - progress.phaseStartedAtMs = now; - progress.hookStartedAtMs = null; - progress.hookName = null; - progress.lastOutputLine = null; - break; - case "hook_started": - progress.title = `Running ${event.hookName}...`; - progress.hookName = event.hookName; - progress.hookStartedAtMs = now; - progress.lastOutputLine = null; - break; - case "hook_output": - progress.lastOutputLine = event.text; - break; - case "hook_finished": - progress.title = progress.currentPhaseLabel ?? "Committing..."; - progress.hookName = null; - progress.hookStartedAtMs = null; - progress.lastOutputLine = null; - break; - case "action_finished": - // Don't clear timestamps here — the HTTP response handler (line 496) - // sets activeGitActionProgressRef to null and shows the success toast. - // Clearing timestamps early causes the "Running for Xs" description - // to disappear before the success state renders, leaving a bare - // "Pushing..." toast in the gap between the WS event and HTTP response. - return; - case "action_failed": - // Same reasoning as action_finished — let the HTTP error handler - // manage the final toast state to avoid a flash of bare title. - return; - } - - updateActiveProgressToast(); - }; - - return api.git.onActionProgress(applyProgressEvent); - }, [gitCwd, updateActiveProgressToast]); - useEffect(() => { const interval = window.setInterval(() => { if (!activeGitActionProgressRef.current) { diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index fad008eba7..884aa649d7 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -1,4 +1,4 @@ -import { type ModelSlug, type ProviderKind } from "@t3tools/contracts"; +import { type ModelSlug, type ProviderKind, type ServerProviderStatus } from "@t3tools/contracts"; import { page } from "vitest/browser"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -17,10 +17,16 @@ const MODEL_OPTIONS_BY_PROVIDER = { ], } as const satisfies Record>; +const ENABLED_PROVIDERS = { + codex: true, + claudeAgent: true, +} as const satisfies Record; + async function mountPicker(props: { provider: ProviderKind; model: ModelSlug; lockedProvider: ProviderKind | null; + providerStatuses?: ReadonlyArray; triggerVariant?: "ghost" | "outline"; }) { const host = document.createElement("div"); @@ -31,6 +37,8 @@ async function mountPicker(props: { provider={props.provider} model={props.model} lockedProvider={props.lockedProvider} + enabledProviders={ENABLED_PROVIDERS} + {...(props.providerStatuses ? { providerStatuses: props.providerStatuses } : {})} modelOptionsByProvider={MODEL_OPTIONS_BY_PROVIDER} triggerVariant={props.triggerVariant} onProviderModelChange={onProviderModelChange} @@ -159,6 +167,43 @@ describe("ProviderModelPicker", () => { } }); + it("shows unusable providers as disabled when provider statuses are supplied", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: null, + providerStatuses: [ + { + provider: "claudeAgent", + status: "ready", + available: true, + authStatus: "authenticated", + checkedAt: "2026-03-25T12:00:00.000Z", + }, + { + provider: "codex", + status: "error", + available: false, + authStatus: "unknown", + checkedAt: "2026-03-25T12:00:00.000Z", + }, + ], + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Codex"); + expect(text).toContain("Not found"); + expect(text).not.toContain("GPT-5 Codex"); + }); + } finally { + await mounted.cleanup(); + } + }); + it("accepts outline trigger styling", async () => { const mounted = await mountPicker({ provider: "codex", diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index ccf756fec6..a9d8037339 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,4 +1,4 @@ -import { type ModelSlug, type ProviderKind } from "@t3tools/contracts"; +import { type ModelSlug, type ProviderKind, type ServerProviderStatus } from "@t3tools/contracts"; import { resolveSelectableModel } from "@t3tools/shared/model"; import { memo, useState } from "react"; import type { VariantProps } from "class-variance-authority"; @@ -19,6 +19,7 @@ import { MenuTrigger, } from "../ui/menu"; import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { getProviderIssueLabel, isProviderUsable } from "~/lib/providerUsability"; import { cn } from "~/lib/utils"; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { @@ -53,6 +54,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { provider: ProviderKind; model: ModelSlug; lockedProvider: ProviderKind | null; + enabledProviders: Record; + providerStatuses?: ReadonlyArray; modelOptionsByProvider: Record>; activeProviderIconClassName?: string; compact?: boolean; @@ -63,12 +66,24 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { }) { const [isMenuOpen, setIsMenuOpen] = useState(false); const activeProvider = props.lockedProvider ?? props.provider; + const activeProviderStatus = props.providerStatuses?.find((s) => s.provider === activeProvider); + const lockedProviderStatus = + props.lockedProvider === null + ? undefined + : props.providerStatuses?.find((status) => status.provider === props.lockedProvider); + const isActiveProviderUsable = isProviderUsable( + activeProviderStatus, + props.enabledProviders[activeProvider] !== false, + ); + const showActiveProviderChevron = isActiveProviderUsable && !props.disabled; const selectedProviderOptions = props.modelOptionsByProvider[activeProvider]; const selectedModelLabel = selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[activeProvider]; const handleModelChange = (provider: ProviderKind, value: string) => { if (props.disabled) return; + const status = props.providerStatuses?.find((s) => s.provider === provider); + if (!isProviderUsable(status, props.enabledProviders[provider] !== false)) return; if (!value) return; const resolvedModel = resolveSelectableModel( provider, @@ -80,11 +95,13 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { setIsMenuOpen(false); }; + const canOpenMenu = !props.disabled; + return ( { - if (props.disabled) { + if (!canOpenMenu) { setIsMenuOpen(false); return; } @@ -99,9 +116,10 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { className={cn( "min-w-0 justify-start overflow-hidden whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 [&_svg]:mx-0", props.compact ? "max-w-42 shrink-0" : "max-w-48 shrink sm:max-w-56 sm:px-3", + !isActiveProviderUsable && "text-destructive/70 hover:text-destructive", props.triggerClassName, )} - disabled={props.disabled} + disabled={!canOpenMenu} /> } > @@ -115,12 +133,16 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { aria-hidden="true" className={cn( "size-4 shrink-0", - providerIconClassName(activeProvider, "text-muted-foreground/70"), - props.activeProviderIconClassName, + !isActiveProviderUsable + ? "text-destructive/50" + : providerIconClassName(activeProvider, "text-muted-foreground/70"), + isActiveProviderUsable && props.activeProviderIconClassName, )} /> {selectedModelLabel} -