diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index f540685b79..115d18d02b 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -38,6 +38,7 @@ import { ProjectionPendingApprovalRepository } from "../src/persistence/Services import { ProviderUnsupportedError } from "../src/provider/Errors.ts"; import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; +import { ServerSettingsService } from "../src/serverSettings.ts"; import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; import { makeCodexAdapterLive } from "../src/provider/Layers/CodexAdapter.ts"; import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts"; @@ -295,8 +296,10 @@ export const makeOrchestrationIntegrationHarness = ( providerLayer, RuntimeReceiptBusLive, ); + const serverSettingsLayer = ServerSettingsService.layerTest(); const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge(serverSettingsLayer), ); const gitCoreLayer = Layer.succeed(GitCore, { renameBranch: (input: Parameters[0]) => @@ -309,6 +312,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(gitCoreLayer), Layer.provideMerge(textGenerationLayer), + Layer.provideMerge(serverSettingsLayer), ); const checkpointReactorLayer = CheckpointReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), @@ -320,6 +324,7 @@ export const makeOrchestrationIntegrationHarness = ( ); const layer = orchestrationReactorLayer.pipe( Layer.provide(persistenceLayer), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 53b4f30a80..ef03a1ab5c 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -1,5 +1,6 @@ import type { ProviderRuntimeEvent } from "@t3tools/contracts"; import { ThreadId } from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, assert } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect"; @@ -12,6 +13,7 @@ import { ProviderService, type ProviderServiceShape, } from "../src/provider/Services/ProviderService.ts"; +import { ServerSettingsService } from "../src/serverSettings.ts"; import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; @@ -60,6 +62,7 @@ const makeIntegrationFixture = Effect.gen(function* () { const shared = Layer.mergeAll( directoryLayer, Layer.succeed(ProviderAdapterRegistry, registry), + ServerSettingsService.layerTest(DEFAULT_SERVER_SETTINGS), AnalyticsService.layerTest, ).pipe(Layer.provide(SqlitePersistenceMemory)); diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index 80323c7441..680d9d9608 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -342,6 +342,7 @@ describe("startSession", () => { manager.startSession({ threadId: asThreadId("thread-1"), provider: "codex", + binaryPath: "codex", runtimeMode: "full-access", }), ).rejects.toThrow("cwd missing"); @@ -390,6 +391,7 @@ describe("startSession", () => { manager.startSession({ threadId: asThreadId("thread-1"), provider: "codex", + binaryPath: "codex", runtimeMode: "full-access", }), ).rejects.toThrow( @@ -948,12 +950,8 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume" provider: "codex", cwd: workspaceDir, runtimeMode: "full-access", - providerOptions: { - codex: { - ...(process.env.CODEX_BINARY_PATH ? { binaryPath: process.env.CODEX_BINARY_PATH } : {}), - ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}), - }, - }, + binaryPath: process.env.CODEX_BINARY_PATH!, + ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}), }); const firstTurn = await manager.sendTurn({ @@ -983,12 +981,8 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume" cwd: workspaceDir, runtimeMode: "approval-required", resumeCursor: firstSession.resumeCursor, - providerOptions: { - codex: { - ...(process.env.CODEX_BINARY_PATH ? { binaryPath: process.env.CODEX_BINARY_PATH } : {}), - ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}), - }, - }, + binaryPath: process.env.CODEX_BINARY_PATH!, + ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}), }); expect(resumedSession.threadId).toBe(originalThreadId); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 0ac37db3e8..991a9783df 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -14,7 +14,6 @@ import { type ProviderApprovalDecision, type ProviderEvent, type ProviderSession, - type ProviderSessionStartInput, type ProviderTurnStartResult, RuntimeMode, ProviderInteractionMode, @@ -131,7 +130,8 @@ export interface CodexAppServerStartSessionInput { readonly model?: string; readonly serviceTier?: string; readonly resumeCursor?: unknown; - readonly providerOptions?: ProviderSessionStartInput["providerOptions"]; + readonly binaryPath: string; + readonly homePath?: string; readonly runtimeMode: RuntimeMode; } @@ -541,9 +541,8 @@ export class CodexAppServerManager extends EventEmitter normalized); } -function readCodexProviderOptions(input: CodexAppServerStartSessionInput): { - readonly binaryPath?: string; - readonly homePath?: string; -} { - const options = input.providerOptions?.codex; - if (!options) { - return {}; - } - return { - ...(options.binaryPath ? { binaryPath: options.binaryPath } : {}), - ...(options.homePath ? { homePath: options.homePath } : {}), - }; -} - function assertSupportedCodexCliVersion(input: { readonly binaryPath: string; readonly cwd: string; @@ -1658,7 +1643,11 @@ function readResumeCursorThreadId(resumeCursor: unknown): string | undefined { return typeof rawThreadId === "string" ? normalizeProviderThreadId(rawThreadId) : undefined; } -function readResumeThreadId(input: CodexAppServerStartSessionInput): string | undefined { +function readResumeThreadId(input: { + readonly resumeCursor?: unknown; + readonly threadId?: ThreadId; + readonly runtimeMode?: RuntimeMode; +}): string | undefined { return readResumeCursorThreadId(input.resumeCursor); } diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 8553ce9667..29f82bccf1 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -19,6 +19,7 @@ export interface ServerDerivedPaths { readonly stateDir: string; readonly dbPath: string; readonly keybindingsConfigPath: string; + readonly settingsPath: string; readonly worktreesDir: string; readonly attachmentsDir: string; readonly logsDir: string; @@ -60,6 +61,7 @@ export const deriveServerPaths = Effect.fn(function* ( stateDir, dbPath, keybindingsConfigPath: join(stateDir, "keybindings.json"), + settingsPath: join(stateDir, "settings.json"), worktreesDir: join(baseDir, "worktrees"), attachmentsDir, logsDir, diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts index 0a3829798e..29ae4796fe 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts @@ -6,8 +6,10 @@ import { expect } from "vitest"; import { ServerConfig } from "../../config.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const ClaudeTextGenerationTestLayer = ClaudeTextGenerationLive.pipe( + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3code-claude-text-generation-test-", diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 9f48a07c51..6ffedbf7b4 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -11,7 +11,6 @@ import { Effect, Layer, Option, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { ClaudeModelSelection } from "@t3tools/contracts"; -import { normalizeClaudeModelOptions } from "@t3tools/shared/model"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { TextGenerationError } from "../Errors.ts"; @@ -27,6 +26,8 @@ import { sanitizePrTitle, toJsonSchemaObject, } from "../Utils.ts"; +import { normalizeClaudeModelOptions } from "../../provider/Layers/ClaudeProvider.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const CLAUDE_TIMEOUT_MS = 180_000; @@ -40,6 +41,7 @@ const ClaudeOutputEnvelope = Schema.Struct({ const makeClaudeTextGeneration = Effect.gen(function* () { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverSettingsService = yield* Effect.service(ServerSettingsService); const readStreamAsString = ( operation: string, @@ -86,9 +88,14 @@ const makeClaudeTextGeneration = Effect.gen(function* () { ...(normalizedOptions?.fastMode ? { fastMode: true } : {}), }; + const claudeSettings = yield* Effect.map( + serverSettingsService.getSettings, + (settings) => settings.providers.claudeAgent, + ).pipe(Effect.catch(() => Effect.undefined)); + const runClaudeCommand = Effect.gen(function* () { const command = ChildProcess.make( - "claude", + claudeSettings?.binaryPath || "claude", [ "-p", "--output-format", diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index b53d7f15bd..1b07d87d90 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -7,6 +7,7 @@ import { ServerConfig } from "../../config.ts"; import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; import { TextGenerationError } from "../Errors.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const DEFAULT_TEST_MODEL_SELECTION = { provider: "codex" as const, @@ -14,6 +15,7 @@ const DEFAULT_TEST_MODEL_SELECTION = { }; const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe( + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3code-codex-text-generation-test-", @@ -22,7 +24,20 @@ const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe( Layer.provideMerge(NodeServices.layer), ); -function makeFakeCodexBinary(dir: string) { +function makeFakeCodexBinary( + dir: string, + input: { + output: string; + exitCode?: number; + stderr?: string; + requireImage?: boolean; + requireFastServiceTier?: boolean; + requireReasoningEffort?: string; + forbidReasoningEffort?: boolean; + stdinMustContain?: string; + stdinMustNotContain?: string; + }, +) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -35,12 +50,16 @@ function makeFakeCodexBinary(dir: string) { [ "#!/bin/sh", 'output_path=""', + 'seen_image="0"', + 'seen_fast_service_tier="0"', + 'seen_reasoning_effort=""', "while [ $# -gt 0 ]; do", ' if [ "$1" = "--image" ]; then', " shift", ' if [ -n "$1" ]; then', ' seen_image="1"', " fi", + " shift", " continue", " fi", ' if [ "$1" = "--config" ]; then', @@ -53,55 +72,80 @@ function makeFakeCodexBinary(dir: string) { ' seen_reasoning_effort="$1"', " ;;", " esac", + " shift", " continue", " fi", ' if [ "$1" = "--output-last-message" ]; then', " shift", ' output_path="$1"', + " shift", + " continue", " fi", " shift", "done", 'stdin_content="$(cat)"', - 'if [ "$T3_FAKE_CODEX_REQUIRE_IMAGE" = "1" ] && [ "$seen_image" != "1" ]; then', - ' printf "%s\\n" "missing --image input" >&2', - " exit 2", - "fi", - 'if [ "$T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER" = "1" ] && [ "$seen_fast_service_tier" != "1" ]; then', - ' printf "%s\\n" "missing fast service tier config" >&2', - " exit 5", - "fi", - 'if [ -n "$T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT" ] && [ "$seen_reasoning_effort" != "model_reasoning_effort=\\"$T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT\\"" ]; then', - ' printf "%s\\n" "unexpected reasoning effort config: $seen_reasoning_effort" >&2', - " exit 6", - "fi", - 'if [ "$T3_FAKE_CODEX_FORBID_REASONING_EFFORT" = "1" ] && [ -n "$seen_reasoning_effort" ]; then', - ' printf "%s\\n" "reasoning effort config should be omitted: $seen_reasoning_effort" >&2', - " exit 7", - "fi", - 'if [ -n "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" ]; then', - ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" >/dev/null || {', - ' printf "%s\\n" "stdin missing expected content" >&2', - " exit 3", - " }", - "fi", - 'if [ -n "$T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN" ]; then', - ' if printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN" >/dev/null; then', - ' printf "%s\\n" "stdin contained forbidden content" >&2', - " exit 4", - " fi", - "fi", - 'if [ -n "$T3_FAKE_CODEX_STDERR" ]; then', - ' printf "%s\\n" "$T3_FAKE_CODEX_STDERR" >&2', - "fi", + ...(input.requireImage + ? [ + 'if [ "$seen_image" != "1" ]; then', + ' printf "%s\\n" "missing --image input" >&2', + ` exit 2`, + "fi", + ] + : []), + ...(input.requireFastServiceTier + ? [ + 'if [ "$seen_fast_service_tier" != "1" ]; then', + ' printf "%s\\n" "missing fast service tier config" >&2', + ` exit 5`, + "fi", + ] + : []), + ...(input.requireReasoningEffort !== undefined + ? [ + `if [ "$seen_reasoning_effort" != "model_reasoning_effort=\\"${input.requireReasoningEffort}\\"" ]; then`, + ' printf "%s\\n" "unexpected reasoning effort config: $seen_reasoning_effort" >&2', + ` exit 6`, + "fi", + ] + : []), + ...(input.forbidReasoningEffort + ? [ + 'if [ -n "$seen_reasoning_effort" ]; then', + ' printf "%s\\n" "reasoning effort config should be omitted: $seen_reasoning_effort" >&2', + ` exit 7`, + "fi", + ] + : []), + ...(input.stdinMustContain !== undefined + ? [ + `if ! printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustContain)} >/dev/null; then`, + ' printf "%s\\n" "stdin missing expected content" >&2', + ` exit 3`, + "fi", + ] + : []), + ...(input.stdinMustNotContain !== undefined + ? [ + `if printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustNotContain)} >/dev/null; then`, + ' printf "%s\\n" "stdin contained forbidden content" >&2', + ` exit 4`, + "fi", + ] + : []), + ...(input.stderr !== undefined + ? [`printf "%s\\n" ${JSON.stringify(input.stderr)} >&2`] + : []), 'if [ -n "$output_path" ]; then', - ' node -e \'const fs=require("node:fs"); const value=process.argv[2] ?? ""; fs.writeFileSync(process.argv[1], Buffer.from(value, "base64"));\' "$output_path" "${T3_FAKE_CODEX_OUTPUT_B64:-e30=}"', + " cat > \"$output_path\" <<'__T3CODE_FAKE_CODEX_OUTPUT__'", + input.output, + "__T3CODE_FAKE_CODEX_OUTPUT__", "fi", - 'exit "${T3_FAKE_CODEX_EXIT_CODE:-0}"', + `exit ${input.exitCode ?? 0}`, "", ].join("\n"), ); yield* fs.chmod(codexPath, 0o755); - return binDir; + return codexPath; }); } @@ -123,146 +167,29 @@ function withFakeCodexEnv( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); - const binDir = yield* makeFakeCodexBinary(tempDir); - const previousPath = process.env.PATH; - const previousOutput = process.env.T3_FAKE_CODEX_OUTPUT_B64; - const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE; - const previousStderr = process.env.T3_FAKE_CODEX_STDERR; - const previousRequireImage = process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; - const previousRequireFastServiceTier = process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER; - const previousRequireReasoningEffort = process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT; - const previousForbidReasoningEffort = process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT; - const previousStdinMustContain = process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN; - const previousStdinMustNotContain = process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN; - - yield* Effect.sync(() => { - process.env.PATH = `${binDir}:${previousPath ?? ""}`; - process.env.T3_FAKE_CODEX_OUTPUT_B64 = Buffer.from(input.output, "utf8").toString("base64"); - - if (input.exitCode !== undefined) { - process.env.T3_FAKE_CODEX_EXIT_CODE = String(input.exitCode); - } else { - delete process.env.T3_FAKE_CODEX_EXIT_CODE; - } - - if (input.stderr !== undefined) { - process.env.T3_FAKE_CODEX_STDERR = input.stderr; - } else { - delete process.env.T3_FAKE_CODEX_STDERR; - } - - if (input.requireImage) { - process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = "1"; - } else { - delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; - } - - if (input.requireFastServiceTier) { - process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER = "1"; - } else { - delete process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER; - } - - if (input.requireReasoningEffort !== undefined) { - process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT = input.requireReasoningEffort; - } else { - delete process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT; - } - - if (input.forbidReasoningEffort) { - process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT = "1"; - } else { - delete process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT; - } - - if (input.stdinMustContain !== undefined) { - process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN = input.stdinMustContain; - } else { - delete process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN; - } - - if (input.stdinMustNotContain !== undefined) { - process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN = input.stdinMustNotContain; - } else { - delete process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN; - } + const codexPath = yield* makeFakeCodexBinary(tempDir, input); + const serverSettings = yield* ServerSettingsService; + const previousSettings = yield* serverSettings.getSettings; + yield* serverSettings.updateSettings({ + providers: { + codex: { + binaryPath: codexPath, + }, + }, }); - - return { - previousPath, - previousOutput, - previousExitCode, - previousStderr, - previousRequireImage, - previousRequireFastServiceTier, - previousRequireReasoningEffort, - previousForbidReasoningEffort, - previousStdinMustContain, - previousStdinMustNotContain, - }; + return { serverSettings, previousBinaryPath: previousSettings.providers.codex.binaryPath }; }), () => effect, - (previous) => - Effect.sync(() => { - process.env.PATH = previous.previousPath; - - if (previous.previousOutput === undefined) { - delete process.env.T3_FAKE_CODEX_OUTPUT_B64; - } else { - process.env.T3_FAKE_CODEX_OUTPUT_B64 = previous.previousOutput; - } - - if (previous.previousExitCode === undefined) { - delete process.env.T3_FAKE_CODEX_EXIT_CODE; - } else { - process.env.T3_FAKE_CODEX_EXIT_CODE = previous.previousExitCode; - } - - if (previous.previousStderr === undefined) { - delete process.env.T3_FAKE_CODEX_STDERR; - } else { - process.env.T3_FAKE_CODEX_STDERR = previous.previousStderr; - } - - if (previous.previousRequireImage === undefined) { - delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; - } else { - process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = previous.previousRequireImage; - } - - if (previous.previousRequireFastServiceTier === undefined) { - delete process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER; - } else { - process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER = - previous.previousRequireFastServiceTier; - } - - if (previous.previousRequireReasoningEffort === undefined) { - delete process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT; - } else { - process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT = - previous.previousRequireReasoningEffort; - } - - if (previous.previousForbidReasoningEffort === undefined) { - delete process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT; - } else { - process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT = - previous.previousForbidReasoningEffort; - } - - if (previous.previousStdinMustContain === undefined) { - delete process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN; - } else { - process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN = previous.previousStdinMustContain; - } - - if (previous.previousStdinMustNotContain === undefined) { - delete process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN; - } else { - process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN = previous.previousStdinMustNotContain; - } - }), + ({ serverSettings, previousBinaryPath }) => + serverSettings + .updateSettings({ + providers: { + codex: { + binaryPath: previousBinaryPath, + }, + }, + }) + .pipe(Effect.asVoid), ); } diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index afe972ab4a..8f332bf13e 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -4,7 +4,6 @@ import { Effect, FileSystem, Layer, Option, Path, Schema, Scope, Stream } from " import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { CodexModelSelection } from "@t3tools/contracts"; -import { normalizeCodexModelOptions } from "@t3tools/shared/model"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -26,6 +25,8 @@ import { sanitizePrTitle, toJsonSchemaObject, } from "../Utils.ts"; +import { normalizeCodexModelOptions } from "../../provider/Layers/CodexProvider.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; @@ -35,6 +36,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { const path = yield* Path.Path; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* Effect.service(ServerConfig); + const serverSettingsService = yield* Effect.service(ServerSettingsService); type MaterializedImageAttachments = { readonly imagePaths: ReadonlyArray; @@ -138,6 +140,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { ); const outputPath = yield* writeTempFile(operation, "codex-output", ""); + const codexSettings = yield* Effect.map( + serverSettingsService.getSettings, + (settings) => settings.providers.codex, + ).pipe(Effect.catch(() => Effect.undefined)); + const runCodexCommand = Effect.gen(function* () { const normalizedOptions = normalizeCodexModelOptions( modelSelection.model, @@ -146,7 +153,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { const reasoningEffort = modelSelection.options?.reasoningEffort ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; const command = ChildProcess.make( - "codex", + codexSettings?.binaryPath || "codex", [ "exec", "--ephemeral", @@ -165,6 +172,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { "-", ], { + env: { + ...process.env, + ...(codexSettings?.homePath ? { CODEX_HOME: codexSettings.homePath } : {}), + }, cwd, shell: process.platform === "win32", stdin: { diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 9302c562e8..57d6853c4a 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -20,11 +20,7 @@ import { GitCoreLive } from "./GitCore.ts"; import { GitCore } from "../Services/GitCore.ts"; import { makeGitManager } from "./GitManager.ts"; import { ServerConfig } from "../../config.ts"; - -const DEFAULT_TEST_MODEL_SELECTION = { - provider: "codex", - model: "gpt-5.4-mini", -} as const; +import { ServerSettingsService } from "../../serverSettings.ts"; interface FakeGhScenario { prListSequence?: string[]; @@ -466,7 +462,6 @@ function runStackedAction( { ...input, actionId: input.actionId ?? "test-action-id", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, }, options, ); @@ -493,6 +488,8 @@ function makeManager(input?: { prefix: "t3-git-manager-test-", }); + const serverSettingsLayer = ServerSettingsService.layerTest(); + const gitCoreLayer = GitCoreLive.pipe( Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ServerConfigLayer), @@ -502,6 +499,7 @@ function makeManager(input?: { Layer.succeed(GitHubCli, gitHubCli), Layer.succeed(TextGeneration, textGeneration), gitCoreLayer, + serverSettingsLayer, ).pipe(Layer.provideMerge(NodeServices.layer)); return makeGitManager.pipe( diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index e2ae56a90c..6fd86e1d58 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -19,6 +19,7 @@ import { import { GitCore } from "../Services/GitCore.ts"; import { GitHubCli } from "../Services/GitHubCli.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; @@ -358,6 +359,7 @@ export const makeGitManager = Effect.gen(function* () { const gitCore = yield* GitCore; const gitHubCli = yield* GitHubCli; const textGeneration = yield* TextGeneration; + const serverSettingsService = yield* ServerSettingsService; const createProgressEmitter = ( input: { cwd: string; action: "commit" | "commit_push" | "commit_push_pr" }, @@ -1173,6 +1175,13 @@ export const makeGitManager = Effect.gen(function* () { let commitMessageForStep = input.commitMessage; let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; + const modelSelection = yield* serverSettingsService.getSettings.pipe( + Effect.map((settings) => settings.textGenerationModelSelection), + Effect.mapError((cause) => + gitManagerError("runStackedAction", "Failed to get server settings.", cause), + ), + ); + if (input.featureBranch) { currentPhase = "branch"; yield* progress.emit({ @@ -1181,7 +1190,7 @@ export const makeGitManager = Effect.gen(function* () { label: "Preparing feature branch...", }); const result = yield* runFeatureBranchStep( - input.modelSelection, + modelSelection, input.cwd, initialStatus.branch, input.commitMessage, @@ -1198,7 +1207,7 @@ export const makeGitManager = Effect.gen(function* () { currentPhase = "commit"; const commit = yield* runCommitStep( - input.modelSelection, + modelSelection, input.cwd, input.action, currentBranch, @@ -1237,7 +1246,7 @@ export const makeGitManager = Effect.gen(function* () { Effect.flatMap(() => Effect.gen(function* () { currentPhase = "pr"; - return yield* runPrStep(input.modelSelection, input.cwd, currentBranch); + return yield* runPrStep(modelSelection, input.cwd, currentBranch); }), ), ) diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index bf58467825..58363d2138 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -23,6 +23,7 @@ import { Cache, Cause, Deferred, + Duration, Effect, Exit, FileSystem, @@ -42,6 +43,7 @@ import { } from "effect"; import * as Semaphore from "effect/Semaphore"; import { ServerConfig } from "./config"; +import { fromLenientJson } from "@t3tools/shared/schemaJson"; export class KeybindingsConfigError extends Schema.TaggedErrorClass()( "KeybindingsConfigParseError", @@ -408,7 +410,7 @@ function encodeWhenAst(node: KeybindingWhenNode): string { const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS); -const RawKeybindingsEntries = Schema.fromJsonString(Schema.Array(Schema.Unknown)); +const RawKeybindingsEntries = fromLenientJson(Schema.Array(Schema.Unknown)); const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); const PrettyJsonString = SchemaGetter.parseJson().compose( SchemaGetter.stringifyJson({ space: 2 }), @@ -672,6 +674,7 @@ const makeKeybindings = Effect.gen(function* () { Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })), Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)), + Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))), Effect.mapError( (cause) => new KeybindingsConfigError({ @@ -817,16 +820,25 @@ const makeKeybindings = Effect.gen(function* () { const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); - yield* Stream.runForEach(fs.watch(keybindingsConfigDir), (event) => { - const isTargetConfigEvent = - event.path === keybindingsConfigFile || - event.path === keybindingsConfigPath || - path.resolve(keybindingsConfigDir, event.path) === keybindingsConfigPathResolved; - if (!isTargetConfigEvent) { - return Effect.void; - } - return revalidateAndEmitSafely; - }).pipe(Effect.ignoreCause({ log: true }), Effect.forkIn(watcherScope), Effect.asVoid); + // Debounce watch events so the file is fully written before we read it. + // Editors emit multiple events per save (truncate, write, rename) and + // `fs.watch` can fire before the content has been flushed to disk. + const debouncedKeybindingsEvents = fs.watch(keybindingsConfigDir).pipe( + Stream.filter((event) => { + return ( + event.path === keybindingsConfigFile || + event.path === keybindingsConfigPath || + path.resolve(keybindingsConfigDir, event.path) === keybindingsConfigPathResolved + ); + }), + Stream.debounce(Duration.millis(100)), + ); + + yield* Stream.runForEach(debouncedKeybindingsEvents, () => revalidateAndEmitSafely).pipe( + Effect.ignoreCause({ log: true }), + Effect.forkIn(watcherScope), + Effect.asVoid, + ); }); const start = Effect.gen(function* () { diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 369a66c088..c644b4778e 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -17,6 +17,7 @@ import { Open, type OpenShape } from "./open"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; import { Server, type ServerShape } from "./wsServer"; +import { ServerSettingsService } from "./serverSettings"; const start = vi.fn(() => undefined); const stop = vi.fn(() => undefined); @@ -52,6 +53,7 @@ const testLayer = Layer.mergeAll( openBrowser: (_target: string) => Effect.void, openInEditor: () => Effect.void, } satisfies OpenShape), + ServerSettingsService.layerTest(), AnalyticsService.layerTest, FetchHttpClient.layer, NodeServices.layer, diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 5b21252884..019783c253 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -22,12 +22,13 @@ import { Open } from "./open"; import * as SqlitePersistence from "./persistence/Layers/Sqlite"; import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; -import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; +import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry"; import { Server } from "./wsServer"; import { ServerLoggerLive } from "./serverLogger"; import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; import { readBootstrapEnvelope } from "./bootstrap"; +import { ServerSettingsLive } from "./serverSettings"; export class StartupError extends Data.TaggedError("StartupError")<{ readonly message: string; @@ -293,10 +294,11 @@ const LayerLive = (input: CliInput) => Layer.empty.pipe( Layer.provideMerge(makeServerRuntimeServicesLayer()), Layer.provideMerge(makeServerProviderLayer()), - Layer.provideMerge(ProviderHealthLive), + Layer.provideMerge(ProviderRegistryLive), Layer.provideMerge(SqlitePersistence.layerConfig), Layer.provideMerge(ServerLoggerLive), Layer.provideMerge(AnalyticsServiceLayerLive), + Layer.provideMerge(ServerSettingsLive), Layer.provideMerge(ServerConfigLive(input)), ); @@ -331,12 +333,10 @@ export const recordStartupHeartbeat = Effect.gen(function* () { }); }); -const makeServerProgram = (input: CliInput) => +const makeServerRuntimeProgram = (input: CliInput) => Effect.gen(function* () { - const cliConfig = yield* CliConfig; const { start, stopSignal } = yield* Server; const openDeps = yield* Open; - yield* cliConfig.fixPath; const config = yield* ServerConfig; @@ -378,6 +378,13 @@ const makeServerProgram = (input: CliInput) => return yield* stopSignal; }).pipe(Effect.provide(LayerLive(input))); +const makeServerProgram = (input: CliInput) => + Effect.gen(function* () { + const cliConfig = yield* CliConfig; + yield* cliConfig.fixPath; + return yield* makeServerRuntimeProgram(input); + }); + /** * These flags mirrors the environment variables and the config shape. */ diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index b58c2522cb..834ab9be9e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -34,6 +34,7 @@ import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ServerSettingsService } from "../../serverSettings.ts"; const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); const asApprovalRequestId = (value: string): ApprovalRequestId => @@ -214,6 +215,7 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge( Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape), ), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 737ad665d7..7c522e5799 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1,12 +1,10 @@ import { type ChatAttachment, CommandId, - DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, EventId, type ModelSelection, type OrchestrationEvent, ProviderKind, - type ProviderStartOptions, type OrchestrationSession, ThreadId, type ProviderSession, @@ -26,6 +24,7 @@ import { ProviderCommandReactor, type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; type ProviderIntentEvent = Extract< OrchestrationEvent, @@ -138,6 +137,7 @@ const make = Effect.gen(function* () { const providerService = yield* ProviderService; const git = yield* GitCore; const textGeneration = yield* TextGeneration; + const serverSettingsService = yield* ServerSettingsService; const handledTurnStartKeys = yield* Cache.make({ capacity: HANDLED_TURN_START_KEY_MAX, timeToLive: HANDLED_TURN_START_KEY_TTL, @@ -151,7 +151,6 @@ const make = Effect.gen(function* () { ), ); - const threadProviderOptions = new Map(); const threadModelSelections = new Map(); const appendProviderFailureActivity = (input: { @@ -210,7 +209,6 @@ const make = Effect.gen(function* () { createdAt: string, options?: { readonly modelSelection?: ModelSelection; - readonly providerOptions?: ProviderStartOptions; }, ) { const readModel = yield* orchestrationEngine.getReadModel(); @@ -258,9 +256,6 @@ const make = Effect.gen(function* () { ...(preferredProvider ? { provider: preferredProvider } : {}), ...(effectiveCwd ? { cwd: effectiveCwd } : {}), modelSelection: desiredModelSelection, - ...(options?.providerOptions !== undefined - ? { providerOptions: options.providerOptions } - : {}), ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, }); @@ -354,7 +349,6 @@ const make = Effect.gen(function* () { readonly messageText: string; readonly attachments?: ReadonlyArray; readonly modelSelection?: ModelSelection; - readonly providerOptions?: ProviderStartOptions; readonly interactionMode?: "default" | "plan"; readonly createdAt: string; }) { @@ -362,13 +356,11 @@ const make = Effect.gen(function* () { if (!thread) { return; } - yield* ensureSessionForThread(input.threadId, input.createdAt, { - ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), - ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), - }); - if (input.providerOptions !== undefined) { - threadProviderOptions.set(input.threadId, input.providerOptions); - } + yield* ensureSessionForThread( + input.threadId, + input.createdAt, + input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}, + ); if (input.modelSelection !== undefined) { threadModelSelections.set(input.threadId, input.modelSelection); } @@ -432,48 +424,39 @@ const make = Effect.gen(function* () { const oldBranch = input.branch; const cwd = input.worktreePath; const attachments = input.attachments ?? []; - yield* textGeneration - .generateBranchName({ + yield* Effect.gen(function* () { + const { textGenerationModelSelection: modelSelection } = + yield* serverSettingsService.getSettings; + + const generated = yield* textGeneration.generateBranchName({ cwd, message: input.messageText, ...(attachments.length > 0 ? { attachments } : {}), - modelSelection: { - provider: "codex", - model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, - }, - }) - .pipe( - Effect.catch((error) => - Effect.logWarning( - "provider command reactor failed to generate worktree branch name; skipping rename", - { threadId: input.threadId, cwd, oldBranch, reason: error.message }, - ), - ), - Effect.flatMap((generated) => { - if (!generated) return Effect.void; - - const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); - if (targetBranch === oldBranch) return Effect.void; - - return Effect.flatMap( - git.renameBranch({ cwd, oldBranch, newBranch: targetBranch }), - (renamed) => - orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: serverCommandId("worktree-branch-rename"), - threadId: input.threadId, - branch: renamed.branch, - worktreePath: cwd, - }), - ); + modelSelection, + }); + if (!generated) return; + + const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); + if (targetBranch === oldBranch) return; + + const renamed = yield* git.renameBranch({ cwd, oldBranch, newBranch: targetBranch }); + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: serverCommandId("worktree-branch-rename"), + threadId: input.threadId, + branch: renamed.branch, + worktreePath: cwd, + }); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider command reactor failed to generate or rename worktree branch", { + threadId: input.threadId, + cwd, + oldBranch, + cause: Cause.pretty(cause), }), - Effect.catchCause((cause) => - Effect.logWarning( - "provider command reactor failed to generate or rename worktree branch", - { threadId: input.threadId, cwd, oldBranch, cause: Cause.pretty(cause) }, - ), - ), - ); + ), + ); }); const processTurnStartRequested = Effect.fnUntraced(function* ( @@ -518,9 +501,6 @@ const make = Effect.gen(function* () { ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), - ...(event.payload.providerOptions !== undefined - ? { providerOptions: event.payload.providerOptions } - : {}), interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, }).pipe( @@ -686,14 +666,12 @@ const make = Effect.gen(function* () { if (!thread?.session || thread.session.status === "stopped") { return; } - const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId); const cachedModelSelection = threadModelSelections.get(event.payload.threadId); - yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, { - ...(cachedProviderOptions !== undefined - ? { providerOptions: cachedProviderOptions } - : {}), - ...(cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {}), - }); + yield* ensureSessionForThread( + event.payload.threadId, + event.occurredAt, + cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {}, + ); return; } case "thread.turn-start-requested": diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index b29df5c8fe..8d205bbe2f 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -16,6 +16,7 @@ import { MessageId, ProjectId, ProviderItemId, + type ServerSettings, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -38,8 +39,13 @@ import { } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; +function makeTestServerSettingsLayer(overrides: Partial = {}) { + return ServerSettingsService.layerTest(overrides); +} + const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); @@ -155,7 +161,7 @@ describe("ProviderRuntimeIngestion", () => { } }); - async function createHarness() { + async function createHarness(options?: { serverSettings?: Partial }) { const workspaceRoot = makeTempDir("t3-provider-project-"); fs.mkdirSync(path.join(workspaceRoot, ".git")); const provider = createProviderServiceHarness(); @@ -169,6 +175,7 @@ describe("ProviderRuntimeIngestion", () => { Layer.provideMerge(orchestrationLayer), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), + Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(NodeServices.layer), ); @@ -1357,7 +1364,7 @@ describe("ProviderRuntimeIngestion", () => { }); it("streams assistant deltas when thread.turn.start requests streaming mode", async () => { - const harness = await createHarness(); + const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); const now = new Date().toISOString(); await Effect.runPromise( @@ -1371,7 +1378,6 @@ describe("ProviderRuntimeIngestion", () => { text: "stream please", attachments: [], }, - assistantDeliveryMode: "streaming", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 0b7af8bd4b..f9a662b84f 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -13,7 +13,7 @@ import { type OrchestrationThreadActivity, type ProviderRuntimeEvent, } from "@t3tools/contracts"; -import { Cache, Cause, Duration, Effect, Layer, Option, Ref, Stream } from "effect"; +import { Cache, Cause, Duration, Effect, Layer, Option, Stream } from "effect"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; @@ -26,12 +26,12 @@ import { ProviderRuntimeIngestionService, type ProviderRuntimeIngestionShape, } from "../Services/ProviderRuntimeIngestion.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId => CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`); -const DEFAULT_ASSISTANT_DELIVERY_MODE: AssistantDeliveryMode = "buffered"; const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000; const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120); const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; @@ -537,10 +537,7 @@ const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; const projectionTurnRepository = yield* ProjectionTurnRepository; - - const assistantDeliveryModeRef = yield* Ref.make( - DEFAULT_ASSISTANT_DELIVERY_MODE, - ); + const serverSettingsService = yield* ServerSettingsService; const turnMessageIdsByTurnKey = yield* Cache.make>({ capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY, @@ -1048,7 +1045,10 @@ const make = Effect.gen(function* () { yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); } - const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef); + const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( + serverSettingsService.getSettings, + (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"), + ); if (assistantDeliveryMode === "buffered") { const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); if (spillChunk.length > 0) { @@ -1256,11 +1256,7 @@ const make = Effect.gen(function* () { ).pipe(Effect.asVoid); }); - const processDomainEvent = (event: TurnStartRequestedDomainEvent) => - Ref.set( - assistantDeliveryModeRef, - event.payload.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, - ); + const processDomainEvent = (_event: TurnStartRequestedDomainEvent) => Effect.void; const processInput = (input: RuntimeIngestionInput) => input.source === "runtime" ? processRuntimeEvent(input.event) : processDomainEvent(input.event); diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 69a9117824..465865549b 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -188,7 +188,6 @@ describe("decider project scripts", () => { if (turnStartEvent?.type !== "thread.turn-start-requested") { return; } - expect(turnStartEvent.payload.assistantDeliveryMode).toBe("buffered"); expect(turnStartEvent.payload).toMatchObject({ threadId: ThreadId.makeUnsafe("thread-1"), messageId: asMessageId("message-user-1"), diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 761ab56a7d..c4cfd2314b 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -14,7 +14,6 @@ import { } from "./commandInvariants.ts"; const nowIso = () => new Date().toISOString(); -const DEFAULT_ASSISTANT_DELIVERY_MODE = "buffered" as const; const defaultMetadata: Omit = { eventId: crypto.randomUUID() as OrchestrationEvent["eventId"], @@ -330,10 +329,6 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), - ...(command.providerOptions !== undefined - ? { providerOptions: command.providerOptions } - : {}), - assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, runtimeMode: targetThread.runtimeMode, interactionMode: targetThread.interactionMode, ...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index d4ed6fba19..4e8238dbe6 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -21,6 +21,7 @@ import { Effect, Fiber, Layer, Random, Stream } from "effect"; import { attachmentRelativePath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterValidationError } from "../Errors.ts"; import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; import { makeClaudeAdapterLive, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; @@ -168,6 +169,7 @@ function makeHarness(config?: { config?.baseDir ?? "/tmp", ), ), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(NodeServices.layer), ), query, @@ -301,7 +303,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("keeps explicit claude permission mode over runtime-derived defaults", () => { + it.effect("uses bypass permissions for full-access claude sessions", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -309,16 +311,11 @@ describe("ClaudeAdapterLive", () => { threadId: THREAD_ID, provider: "claudeAgent", runtimeMode: "full-access", - providerOptions: { - claudeAgent: { - permissionMode: "plan", - }, - }, }); const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.permissionMode, "plan"); - assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); + assert.equal(createInput?.options.permissionMode, "bypassPermissions"); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, true); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -1195,6 +1192,7 @@ describe("ClaudeAdapterLive", () => { }, }).pipe( Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index af88fa634a..7ab8bc44ab 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -40,12 +40,7 @@ import { type UserInputQuestion, ClaudeCodeEffort, } from "@t3tools/contracts"; -import { - hasEffortLevel, - applyClaudePromptEffortPrefix, - getModelCapabilities, - trimOrNull, -} from "@t3tools/shared/model"; +import { hasEffortLevel, applyClaudePromptEffortPrefix, trimOrNull } from "@t3tools/shared/model"; import { Cause, DateTime, @@ -63,6 +58,8 @@ import { import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { getClaudeModelCapabilities } from "./ClaudeProvider.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -353,19 +350,6 @@ function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId { return RuntimeRequestId.makeUnsafe(value); } -function toPermissionMode(value: unknown): PermissionMode | undefined { - switch (value) { - case "default": - case "acceptEdits": - case "bypassPermissions": - case "plan": - case "dontAsk": - return value; - default: - return undefined; - } -} - function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undefined { if (!resumeCursor || typeof resumeCursor !== "object") { return undefined; @@ -525,7 +509,7 @@ function buildPromptText(input: ProviderSendTurnInput): string { const requestedEffort = trimOrNull(rawEffort); const claudeModel = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined; - const caps = getModelCapabilities("claudeAgent", claudeModel); + const caps = getClaudeModelCapabilities(claudeModel); const promptEffort = requestedEffort === "ultrathink" && caps.reasoningEffortLevels.length > 0 ? "ultrathink" @@ -943,6 +927,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { const sessions = new Map(); const runtimeEventQueue = yield* Queue.unbounded(); + const serverSettingsService = yield* ServerSettingsService; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); @@ -2727,11 +2712,23 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { }), ); - const providerOptions = input.providerOptions?.claudeAgent; + const claudeSettings = yield* serverSettingsService.getSettings.pipe( + Effect.map((settings) => settings.providers.claudeAgent), + Effect.mapError( + (error) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: error.message, + cause: error, + }), + ), + ); + const claudeBinaryPath = claudeSettings.binaryPath; const modelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; const requestedEffort = trimOrNull(modelSelection?.options?.effort ?? null); - const caps = getModelCapabilities("claudeAgent", modelSelection?.model); + const caps = getClaudeModelCapabilities(modelSelection?.model); const effort = requestedEffort && hasEffortLevel(caps, requestedEffort) ? requestedEffort : null; const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode; @@ -2741,8 +2738,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { : undefined; const effectiveEffort = getEffectiveClaudeCodeEffort(effort); const permissionMode = - toPermissionMode(providerOptions?.permissionMode) ?? - (input.runtimeMode === "full-access" ? "bypassPermissions" : undefined); + input.runtimeMode === "full-access" ? "bypassPermissions" : undefined; const settings = { ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), ...(fastMode ? { fastMode: true } : {}), @@ -2751,16 +2747,13 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), ...(modelSelection?.model ? { model: modelSelection.model } : {}), - pathToClaudeCodeExecutable: providerOptions?.binaryPath ?? "claude", + pathToClaudeCodeExecutable: claudeBinaryPath, settingSources: [...CLAUDE_SETTING_SOURCES], ...(effectiveEffort ? { effort: effectiveEffort } : {}), ...(permissionMode ? { permissionMode } : {}), ...(permissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } : {}), - ...(providerOptions?.maxThinkingTokens !== undefined - ? { maxThinkingTokens: providerOptions.maxThinkingTokens } - : {}), ...(Object.keys(settings).length > 0 ? { settings } : {}), ...(existingResumeSessionId ? { resume: existingResumeSessionId } : {}), ...(newSessionId ? { sessionId: newSessionId } : {}), @@ -2851,9 +2844,6 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { ...(input.cwd ? { cwd: input.cwd } : {}), ...(effectiveEffort ? { effort: effectiveEffort } : {}), ...(permissionMode ? { permissionMode } : {}), - ...(providerOptions?.maxThinkingTokens !== undefined - ? { maxThinkingTokens: providerOptions.maxThinkingTokens } - : {}), ...(fastMode ? { fastMode: true } : {}), }, }, diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts new file mode 100644 index 0000000000..e51f5096db --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -0,0 +1,397 @@ +import type { + ClaudeSettings, + ClaudeModelOptions, + ModelCapabilities, + ServerProvider, + ServerProviderModel, + ServerProviderAuthStatus, + ServerProviderState, +} from "@t3tools/contracts"; +import { Effect, Equal, Layer, Option, Result, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { getDefaultEffort, hasEffortLevel, trimOrNull } from "@t3tools/shared/model"; + +import { + buildServerProvider, + collectStreamAsString, + DEFAULT_TIMEOUT_MS, + detailFromResult, + extractAuthBoolean, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + type CommandResult, +} from "../providerSnapshot"; +import { makeManagedServerProvider } from "../makeManagedServerProvider"; +import { ClaudeProvider } from "../Services/ClaudeProvider"; +import { ServerSettingsError, ServerSettingsService } from "../../serverSettings"; + +const PROVIDER = "claudeAgent" as const; +const BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "claude-opus-4-6", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: ["ultrathink"], + } satisfies ModelCapabilities, + }, + { + slug: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: ["ultrathink"], + } satisfies ModelCapabilities, + }, + { + slug: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: true, + promptInjectedEffortLevels: [], + } satisfies ModelCapabilities, + }, +]; + +export function getClaudeModelCapabilities(model: string | null | undefined): ModelCapabilities { + const slug = model?.trim(); + return ( + BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + } + ); +} + +export function normalizeClaudeModelOptions( + model: string | null | undefined, + modelOptions: ClaudeModelOptions | null | undefined, +): ClaudeModelOptions | undefined { + const caps = getClaudeModelCapabilities(model); + const defaultReasoningEffort = getDefaultEffort(caps); + const resolvedEffort = trimOrNull(modelOptions?.effort); + const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? ""); + const effort = + resolvedEffort && + !isPromptInjected && + hasEffortLevel(caps, resolvedEffort) && + resolvedEffort !== defaultReasoningEffort + ? resolvedEffort + : undefined; + const thinking = + caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; + const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; + const nextOptions: ClaudeModelOptions = { + ...(thinking === false ? { thinking: false } : {}), + ...(effort ? { effort } : {}), + ...(fastMode ? { fastMode: true } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + +export function parseClaudeAuthStatusFromOutput(result: CommandResult): { + readonly status: Exclude; + readonly authStatus: ServerProviderAuthStatus; + readonly message?: string; +} { + const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); + + 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.", + }; + } + + 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.", + }; + } + + 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 }; + } + })(); + + 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 detail = detailFromResult(result); + return { + status: "warning", + authStatus: "unknown", + message: detail + ? `Could not verify Claude authentication status. ${detail}` + : "Could not verify Claude authentication status.", + }; +} + +const runClaudeCommand = (args: ReadonlyArray) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const claudeSettings = yield* Effect.service(ServerSettingsService).pipe( + Effect.flatMap((service) => service.getSettings), + Effect.map((settings) => settings.providers.claudeAgent), + ); + const command = ChildProcess.make(claudeSettings.binaryPath, [...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" }, + ); + + return { stdout, stderr, code: exitCode } satisfies CommandResult; + }).pipe(Effect.scoped); + +export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( + function* (): Effect.fn.Return< + ServerProvider, + ServerSettingsError, + ChildProcessSpawner.ChildProcessSpawner | ServerSettingsService + > { + const claudeSettings = yield* Effect.service(ServerSettingsService).pipe( + Effect.flatMap((service) => service.getSettings), + Effect.map((settings) => settings.providers.claudeAgent), + ); + const checkedAt = new Date().toISOString(); + const models = providerModelsFromSettings( + BUILT_IN_MODELS, + PROVIDER, + claudeSettings.customModels, + ); + + if (!claudeSettings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + authStatus: "unknown", + message: "Claude is disabled in T3 Code settings.", + }, + }); + } + + const versionProbe = yield* runClaudeCommand(["--version"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + return buildServerProvider({ + provider: PROVIDER, + enabled: claudeSettings.enabled, + checkedAt, + models, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + authStatus: "unknown", + 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 (Option.isNone(versionProbe.success)) { + return buildServerProvider({ + provider: PROVIDER, + enabled: claudeSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "error", + authStatus: "unknown", + message: + "Claude Agent CLI is installed but failed to run. Timed out while running command.", + }, + }); + } + + const version = versionProbe.success.value; + const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); + if (version.code !== 0) { + const detail = detailFromResult(version); + return buildServerProvider({ + provider: PROVIDER, + enabled: claudeSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "error", + authStatus: "unknown", + message: detail + ? `Claude Agent CLI is installed but failed to run. ${detail}` + : "Claude Agent CLI is installed but failed to run.", + }, + }); + } + + const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(authProbe)) { + const error = authProbe.failure; + return buildServerProvider({ + provider: PROVIDER, + enabled: claudeSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "warning", + authStatus: "unknown", + message: + error instanceof Error + ? `Could not verify Claude authentication status: ${error.message}.` + : "Could not verify Claude authentication status.", + }, + }); + } + + if (Option.isNone(authProbe.success)) { + return buildServerProvider({ + provider: PROVIDER, + enabled: claudeSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "warning", + authStatus: "unknown", + message: + "Could not verify Claude authentication status. Timed out while running command.", + }, + }); + } + + const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value); + return buildServerProvider({ + provider: PROVIDER, + enabled: claudeSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: parsed.status, + authStatus: parsed.authStatus, + ...(parsed.message ? { message: parsed.message } : {}), + }, + }); + }, +); + +export const ClaudeProviderLive = Layer.effect( + ClaudeProvider, + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const checkProvider = checkClaudeProviderStatus().pipe( + Effect.provideService(ServerSettingsService, serverSettings), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + return yield* makeManagedServerProvider({ + getSettings: serverSettings.getSettings.pipe( + Effect.map((settings) => settings.providers.claudeAgent), + Effect.orDie, + ), + streamSettings: serverSettings.streamChanges.pipe( + Stream.map((settings) => settings.providers.claudeAgent), + ), + haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), + checkProvider, + }); + }), +); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 3017235f1e..97ed621c7b 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -22,6 +22,7 @@ import { type CodexAppServerSendTurnInput, } from "../../codexAppServerManager.ts"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterValidationError } from "../Errors.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; @@ -151,6 +152,7 @@ const validationManager = new FakeCodexManager(); const validationLayer = it.layer( makeCodexAdapterLive({ manager: validationManager }).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ), @@ -201,6 +203,7 @@ validationLayer("CodexAdapterLive validation", (it) => { assert.deepStrictEqual(validationManager.startSessionImpl.mock.calls[0]?.[0], { provider: "codex", threadId: asThreadId("thread-1"), + binaryPath: "codex", model: "gpt-5.3-codex", serviceTier: "fast", runtimeMode: "full-access", @@ -216,6 +219,7 @@ sessionErrorManager.sendTurnImpl.mockImplementation(async () => { const sessionErrorLayer = it.layer( makeCodexAdapterLive({ manager: sessionErrorManager }).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ), @@ -284,6 +288,7 @@ const lifecycleManager = new FakeCodexManager(); const lifecycleLayer = it.layer( makeCodexAdapterLive({ manager: lifecycleManager }).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ), diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index ca9c52cf8e..9af5aac19d 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -38,6 +38,7 @@ import { } from "../../codexAppServerManager.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = "codex" as const; @@ -1342,25 +1343,39 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => } }), ); + const serverSettingsService = yield* ServerSettingsService; - const startSession: CodexAdapterShape["startSession"] = (input) => { + const startSession: CodexAdapterShape["startSession"] = Effect.fn(function* (input) { if (input.provider !== undefined && input.provider !== PROVIDER) { - return Effect.fail( - new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "startSession", - issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, - }), - ); + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); } + const codexSettings = yield* serverSettingsService.getSettings.pipe( + Effect.map((settings) => settings.providers.codex), + Effect.mapError( + (error) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: error.message, + cause: error, + }), + ), + ); + const binaryPath = codexSettings.binaryPath; + const homePath = codexSettings.homePath; const managerInput: CodexAppServerStartSessionInput = { threadId: input.threadId, provider: "codex", ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), - ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), runtimeMode: input.runtimeMode, + binaryPath, + ...(homePath ? { homePath } : {}), ...(input.modelSelection?.provider === "codex" ? { model: input.modelSelection.model } : {}), @@ -1369,7 +1384,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => : {}), }; - return Effect.tryPromise({ + return yield* Effect.tryPromise({ try: () => manager.startSession(managerInput), catch: (cause) => new ProviderAdapterProcessError({ @@ -1378,8 +1393,8 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => detail: toMessage(cause, "Failed to start Codex adapter session."), cause, }), - }).pipe(Effect.map((session) => session)); - }; + }); + }); const sendTurn: CodexAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts new file mode 100644 index 0000000000..913fbb58d5 --- /dev/null +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -0,0 +1,531 @@ +import * as OS from "node:os"; +import type { + ModelCapabilities, + CodexModelOptions, + CodexSettings, + ServerProvider, + ServerProviderModel, + ServerProviderAuthStatus, + ServerProviderState, +} from "@t3tools/contracts"; +import { Effect, Equal, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { getDefaultEffort, trimOrNull } from "@t3tools/shared/model"; + +import { + buildServerProvider, + collectStreamAsString, + DEFAULT_TIMEOUT_MS, + detailFromResult, + extractAuthBoolean, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + type CommandResult, +} from "../providerSnapshot"; +import { makeManagedServerProvider } from "../makeManagedServerProvider"; +import { + formatCodexCliUpgradeMessage, + isCodexCliVersionSupported, + parseCodexCliVersion, +} from "../codexCliVersion"; +import { CodexProvider } from "../Services/CodexProvider"; +import { ServerSettingsError, ServerSettingsService } from "../../serverSettings"; + +const PROVIDER = "codex" as const; +const OPENAI_AUTH_PROVIDERS = new Set(["openai"]); +const BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.4-mini", + name: "GPT-5.4 Mini", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.3-codex-spark", + name: "GPT-5.3 Codex Spark", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.2-codex", + name: "GPT-5.2 Codex", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.2", + name: "GPT-5.2", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, +]; + +export function getCodexModelCapabilities(model: string | null | undefined): ModelCapabilities { + const slug = model?.trim(); + return ( + BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + } + ); +} + +export function normalizeCodexModelOptions( + model: string | null | undefined, + modelOptions: CodexModelOptions | null | undefined, +): CodexModelOptions | undefined { + const caps = getCodexModelCapabilities(model); + const defaultReasoningEffort = getDefaultEffort(caps); + const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort; + const fastModeEnabled = modelOptions?.fastMode === true; + const nextOptions: CodexModelOptions = { + ...(reasoningEffort && reasoningEffort !== defaultReasoningEffort + ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] } + : {}), + ...(fastModeEnabled ? { fastMode: true } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + +export function parseAuthStatusFromOutput(result: CommandResult): { + readonly status: Exclude; + readonly authStatus: ServerProviderAuthStatus; + readonly message?: string; +} { + const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); + + if ( + lowerOutput.includes("unknown command") || + lowerOutput.includes("unrecognized command") || + lowerOutput.includes("unexpected argument") + ) { + return { + status: "warning", + authStatus: "unknown", + message: "Codex CLI authentication status command is unavailable in this Codex version.", + }; + } + + if ( + lowerOutput.includes("not logged in") || + lowerOutput.includes("login required") || + lowerOutput.includes("authentication required") || + lowerOutput.includes("run `codex login`") || + lowerOutput.includes("run codex login") + ) { + return { + status: "error", + authStatus: "unauthenticated", + message: "Codex CLI is not authenticated. Run `codex login` and try again.", + }; + } + + 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 }; + } + })(); + + if (parsedAuth.auth === true) { + return { status: "ready", authStatus: "authenticated" }; + } + if (parsedAuth.auth === false) { + return { + status: "error", + authStatus: "unauthenticated", + message: "Codex CLI is not authenticated. Run `codex login` and try again.", + }; + } + if (parsedAuth.attemptedJsonParse) { + return { + status: "warning", + authStatus: "unknown", + message: + "Could not verify Codex authentication status from JSON output (missing auth marker).", + }; + } + if (result.code === 0) { + return { status: "ready", authStatus: "authenticated" }; + } + + const detail = detailFromResult(result); + return { + status: "warning", + authStatus: "unknown", + message: detail + ? `Could not verify Codex authentication status. ${detail}` + : "Could not verify Codex authentication status.", + }; +} + +export const readCodexConfigModelProvider = Effect.fn("readCodexConfigModelProvider")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const settingsService = yield* ServerSettingsService; + const codexHome = yield* settingsService.getSettings.pipe( + Effect.map( + (settings) => + settings.providers.codex.homePath || + process.env.CODEX_HOME || + path.join(OS.homedir(), ".codex"), + ), + ); + const configPath = path.join(codexHome, "config.toml"); + + const content = yield* fileSystem + .readFileString(configPath) + .pipe(Effect.orElseSucceed(() => undefined)); + if (content === undefined) { + return undefined; + } + + let inTopLevel = true; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + if (trimmed.startsWith("[")) { + inTopLevel = false; + continue; + } + if (!inTopLevel) continue; + + const match = trimmed.match(/^model_provider\s*=\s*["']([^"']+)["']/); + if (match) return match[1]; + } + return undefined; +}); + +export const hasCustomModelProvider = readCodexConfigModelProvider().pipe( + Effect.map((provider) => provider !== undefined && !OPENAI_AUTH_PROVIDERS.has(provider)), + Effect.orElseSucceed(() => false), +); + +const runCodexCommand = (args: ReadonlyArray) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const settingsService = yield* ServerSettingsService; + const codexSettings = yield* settingsService.getSettings.pipe( + Effect.map((settings) => settings.providers.codex), + ); + const command = ChildProcess.make(codexSettings.binaryPath, [...args], { + shell: process.platform === "win32", + env: { + ...process.env, + ...(codexSettings.homePath ? { CODEX_HOME: codexSettings.homePath } : {}), + }, + }); + + 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" }, + ); + + return { stdout, stderr, code: exitCode } satisfies CommandResult; + }).pipe(Effect.scoped); + +export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")( + function* (): Effect.fn.Return< + ServerProvider, + ServerSettingsError, + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path + | ServerSettingsService + > { + const codexSettings = yield* Effect.service(ServerSettingsService).pipe( + Effect.flatMap((service) => service.getSettings), + Effect.map((settings) => settings.providers.codex), + ); + const checkedAt = new Date().toISOString(); + const models = providerModelsFromSettings( + BUILT_IN_MODELS, + PROVIDER, + codexSettings.customModels, + ); + + if (!codexSettings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + authStatus: "unknown", + message: "Codex is disabled in T3 Code settings.", + }, + }); + } + + const versionProbe = yield* runCodexCommand(["--version"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + return buildServerProvider({ + provider: PROVIDER, + enabled: codexSettings.enabled, + checkedAt, + models, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + authStatus: "unknown", + 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 (Option.isNone(versionProbe.success)) { + return buildServerProvider({ + provider: PROVIDER, + enabled: codexSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "error", + authStatus: "unknown", + message: "Codex CLI is installed but failed to run. Timed out while running command.", + }, + }); + } + + const version = versionProbe.success.value; + const parsedVersion = + parseCodexCliVersion(`${version.stdout}\n${version.stderr}`) ?? + parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); + if (version.code !== 0) { + const detail = detailFromResult(version); + return buildServerProvider({ + provider: PROVIDER, + enabled: codexSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "error", + authStatus: "unknown", + message: detail + ? `Codex CLI is installed but failed to run. ${detail}` + : "Codex CLI is installed but failed to run.", + }, + }); + } + + if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) { + return buildServerProvider({ + provider: PROVIDER, + enabled: codexSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "error", + authStatus: "unknown", + message: formatCodexCliUpgradeMessage(parsedVersion), + }, + }); + } + + if (yield* hasCustomModelProvider) { + return buildServerProvider({ + provider: PROVIDER, + enabled: codexSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "ready", + authStatus: "unknown", + message: "Using a custom Codex model provider; OpenAI login check skipped.", + }, + }); + } + + const authProbe = yield* runCodexCommand(["login", "status"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(authProbe)) { + const error = authProbe.failure; + return buildServerProvider({ + provider: PROVIDER, + enabled: codexSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "warning", + authStatus: "unknown", + message: + error instanceof Error + ? `Could not verify Codex authentication status: ${error.message}.` + : "Could not verify Codex authentication status.", + }, + }); + } + + if (Option.isNone(authProbe.success)) { + return buildServerProvider({ + provider: PROVIDER, + enabled: codexSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "warning", + authStatus: "unknown", + message: "Could not verify Codex authentication status. Timed out while running command.", + }, + }); + } + + const parsed = parseAuthStatusFromOutput(authProbe.success.value); + return buildServerProvider({ + provider: PROVIDER, + enabled: codexSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: parsed.status, + authStatus: parsed.authStatus, + ...(parsed.message ? { message: parsed.message } : {}), + }, + }); + }, +); + +export const CodexProviderLive = Layer.effect( + CodexProvider, + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const checkProvider = checkCodexProviderStatus().pipe( + Effect.provideService(ServerSettingsService, serverSettings), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + return yield* makeManagedServerProvider({ + getSettings: serverSettings.getSettings.pipe( + Effect.map((settings) => settings.providers.codex), + Effect.orDie, + ), + streamSettings: serverSettings.streamChanges.pipe( + Stream.map((settings) => settings.providers.codex), + ), + haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), + checkProvider, + }); + }), +); diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts deleted file mode 100644 index e24f07bcfa..0000000000 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ /dev/null @@ -1,640 +0,0 @@ -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 * as PlatformError from "effect/PlatformError"; -import { ChildProcessSpawner } from "effect/unstable/process"; - -import { - checkClaudeProviderStatus, - checkCodexProviderStatus, - hasCustomModelProvider, - parseAuthStatusFromOutput, - parseClaudeAuthStatusFromOutput, - readCodexConfigModelProvider, -} from "./ProviderHealth"; - -// ── Test helpers ──────────────────────────────────────────────────── - -const encoder = new TextEncoder(); - -function mockHandle(result: { stdout: string; stderr: string; code: number }) { - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code)), - isRunning: Effect.succeed(false), - 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 }, -) { - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => { - const cmd = command as unknown as { args: ReadonlyArray }; - return Effect.succeed(mockHandle(handler(cmd.args))); - }), - ); -} - -function failingSpawnerLayer(description: string) { - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description, - }), - ), - ), - ); -} - -/** - * Create a temporary CODEX_HOME scoped to the current Effect test. - * Cleanup is registered in the test scope rather than via Vitest hooks. - */ -function withTempCodexHome(configContent?: string) { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tmpDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-test-codex-" }); - - yield* Effect.acquireRelease( - Effect.sync(() => { - const originalCodexHome = process.env.CODEX_HOME; - process.env.CODEX_HOME = tmpDir; - return originalCodexHome; - }), - (originalCodexHome) => - Effect.sync(() => { - if (originalCodexHome !== undefined) { - process.env.CODEX_HOME = originalCodexHome; - } else { - delete process.env.CODEX_HOME; - } - }), - ); - - if (configContent !== undefined) { - yield* fileSystem.writeFileString(path.join(tmpDir, "config.toml"), configContent); - } - - return { tmpDir } as const; - }); -} - -it.layer(NodeServices.layer)("ProviderHealth", (it) => { - // ── checkCodexProviderStatus tests ──────────────────────────────── - // - // These tests control CODEX_HOME to ensure the custom-provider detection - // in hasCustomModelProvider() does not interfere with the auth-probe - // path being tested. - - describe("checkCodexProviderStatus", () => { - 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 - // default code path (OpenAI provider, auth probe runs) is exercised. - yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "authenticated"); - }).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 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns unavailable when codex is missing", () => - Effect.gen(function* () { - yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, false); - assert.strictEqual(status.authStatus, "unknown"); - assert.strictEqual(status.message, "Codex CLI (`codex`) is not installed or not on PATH."); - }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), - ); - - it.effect("returns unavailable when codex is below the minimum supported version", () => - Effect.gen(function* () { - yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, false); - assert.strictEqual(status.authStatus, "unknown"); - assert.strictEqual( - status.message, - "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 0.36.0\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns unauthenticated when auth probe reports login required", () => - Effect.gen(function* () { - yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "unauthenticated"); - assert.strictEqual( - status.message, - "Codex CLI is not authenticated. Run `codex login` and try again.", - ); - }).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: "", stderr: "Not logged in. Run codex login.", code: 1 }; - } - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns unauthenticated when login status output includes 'not logged in'", () => - Effect.gen(function* () { - yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "unauthenticated"); - assert.strictEqual( - status.message, - "Codex CLI is not authenticated. Run `codex login` and try again.", - ); - }).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: "Not logged in\n", stderr: "", code: 1 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns warning when login status command is unsupported", () => - Effect.gen(function* () { - yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "warning"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "unknown"); - assert.strictEqual( - status.message, - "Codex CLI authentication status command is unavailable in this Codex version.", - ); - }).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: "", stderr: "error: unknown command 'login'", code: 2 }; - } - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - }); - - // ── Custom model provider: checkCodexProviderStatus integration ─── - - describe("checkCodexProviderStatus with custom model provider", () => { - it.effect("skips auth probe and returns ready when a custom model provider is configured", () => - Effect.gen(function* () { - yield* withTempCodexHome( - [ - 'model_provider = "portkey"', - "", - "[model_providers.portkey]", - 'base_url = "https://api.portkey.ai/v1"', - 'env_key = "PORTKEY_API_KEY"', - ].join("\n"), - ); - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "unknown"); - assert.strictEqual( - status.message, - "Using a custom Codex model provider; OpenAI login check skipped.", - ); - }).pipe( - Effect.provide( - // The spawner only handles --version; if the test attempts - // "login status" the throw proves the auth probe was NOT skipped. - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - throw new Error(`Auth probe should have been skipped but got args: ${joined}`); - }), - ), - ), - ); - - it.effect("still reports error when codex CLI is missing even with custom provider", () => - Effect.gen(function* () { - yield* withTempCodexHome( - [ - 'model_provider = "portkey"', - "", - "[model_providers.portkey]", - 'base_url = "https://api.portkey.ai/v1"', - 'env_key = "PORTKEY_API_KEY"', - ].join("\n"), - ); - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, false); - }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), - ); - }); - - describe("checkCodexProviderStatus with openai model provider", () => { - it.effect("still runs auth probe when model_provider is openai", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "openai"\n'); - const status = yield* checkCodexProviderStatus; - // The auth probe runs and sees "not logged in" → error - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.authStatus, "unauthenticated"); - }).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: "Not logged in\n", stderr: "", code: 1 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - }); - - // ── parseAuthStatusFromOutput pure tests ────────────────────────── - - describe("parseAuthStatusFromOutput", () => { - it("exit code 0 with no auth markers is ready", () => { - const parsed = parseAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); - assert.strictEqual(parsed.status, "ready"); - assert.strictEqual(parsed.authStatus, "authenticated"); - }); - - it("JSON with authenticated=false is unauthenticated", () => { - const parsed = parseAuthStatusFromOutput({ - stdout: '[{"authenticated":false}]\n', - stderr: "", - code: 0, - }); - assert.strictEqual(parsed.status, "error"); - assert.strictEqual(parsed.authStatus, "unauthenticated"); - }); - - it("JSON without auth marker is warning", () => { - const parsed = parseAuthStatusFromOutput({ - stdout: '[{"ok":true}]\n', - stderr: "", - code: 0, - }); - assert.strictEqual(parsed.status, "warning"); - assert.strictEqual(parsed.authStatus, "unknown"); - }); - }); - - // ── readCodexConfigModelProvider tests ───────────────────────────── - - describe("readCodexConfigModelProvider", () => { - it.effect("returns undefined when config file does not exist", () => - Effect.gen(function* () { - yield* withTempCodexHome(); - 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); - }), - ); - - 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"); - }), - ); - - it.effect("returns openai when model_provider is openai", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "openai"\n'); - assert.strictEqual(yield* readCodexConfigModelProvider, "openai"); - }), - ); - - it.effect("ignores model_provider inside section headers", () => - Effect.gen(function* () { - yield* withTempCodexHome( - [ - 'model = "gpt-5-codex"', - "", - "[model_providers.portkey]", - 'base_url = "https://api.portkey.ai/v1"', - 'model_provider = "should-be-ignored"', - "", - ].join("\n"), - ); - assert.strictEqual(yield* readCodexConfigModelProvider, undefined); - }), - ); - - it.effect("handles comments and whitespace", () => - Effect.gen(function* () { - yield* withTempCodexHome( - [ - "# This is a comment", - "", - ' model_provider = "azure" ', - "", - "[profiles.deep-review]", - 'model = "gpt-5-pro"', - ].join("\n"), - ); - 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"); - }), - ); - }); - - // ── hasCustomModelProvider tests ─────────────────────────────────── - - describe("hasCustomModelProvider", () => { - it.effect("returns false when no config file exists", () => - Effect.gen(function* () { - yield* withTempCodexHome(); - assert.strictEqual(yield* hasCustomModelProvider, false); - }), - ); - - it.effect("returns false when model_provider is not set", () => - Effect.gen(function* () { - yield* withTempCodexHome('model = "gpt-5-codex"\n'); - assert.strictEqual(yield* hasCustomModelProvider, false); - }), - ); - - it.effect("returns false when model_provider is openai", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "openai"\n'); - assert.strictEqual(yield* hasCustomModelProvider, false); - }), - ); - - it.effect("returns true when model_provider is portkey", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "portkey"\n'); - assert.strictEqual(yield* hasCustomModelProvider, true); - }), - ); - - it.effect("returns true when model_provider is azure", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "azure"\n'); - assert.strictEqual(yield* hasCustomModelProvider, true); - }), - ); - - it.effect("returns true when model_provider is ollama", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "ollama"\n'); - assert.strictEqual(yield* hasCustomModelProvider, true); - }), - ); - - it.effect("returns true when model_provider is a custom proxy", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "my-company-proxy"\n'); - assert.strictEqual(yield* hasCustomModelProvider, true); - }), - ); - }); - - // ── checkClaudeProviderStatus tests ────────────────────────── - - describe("checkClaudeProviderStatus", () => { - it.effect("returns ready when claude is installed and authenticated", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus; - assert.strictEqual(status.provider, "claudeAgent"); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "authenticated"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns unavailable when claude is missing", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus; - assert.strictEqual(status.provider, "claudeAgent"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, false); - assert.strictEqual(status.authStatus, "unknown"); - assert.strictEqual( - status.message, - "Claude Agent CLI (`claude`) is not installed or not on PATH.", - ); - }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), - ); - - it.effect("returns error when version check fails with non-zero exit code", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus; - assert.strictEqual(status.provider, "claudeAgent"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, false); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") - return { stdout: "", stderr: "Something went wrong", code: 1 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns unauthenticated when auth status reports not logged in", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus; - assert.strictEqual(status.provider, "claudeAgent"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "unauthenticated"); - assert.strictEqual( - status.message, - "Claude is not authenticated. Run `claude auth login` and try again.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":false}\n', - stderr: "", - code: 1, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns unauthenticated when output includes 'not logged in'", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus; - assert.strictEqual(status.provider, "claudeAgent"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "unauthenticated"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") return { stdout: "Not logged in\n", stderr: "", code: 1 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns warning when auth status command is unsupported", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus; - assert.strictEqual(status.provider, "claudeAgent"); - assert.strictEqual(status.status, "warning"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "unknown"); - assert.strictEqual( - status.message, - "Claude Agent authentication status command is unavailable in this version of Claude.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { stdout: "", stderr: "error: unknown command 'auth'", code: 2 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - }); - - // ── parseClaudeAuthStatusFromOutput pure tests ──────────────────── - - describe("parseClaudeAuthStatusFromOutput", () => { - it("exit code 0 with no auth markers is ready", () => { - const parsed = parseClaudeAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); - assert.strictEqual(parsed.status, "ready"); - assert.strictEqual(parsed.authStatus, "authenticated"); - }); - - it("JSON with loggedIn=true is authenticated", () => { - const parsed = parseClaudeAuthStatusFromOutput({ - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }); - assert.strictEqual(parsed.status, "ready"); - assert.strictEqual(parsed.authStatus, "authenticated"); - }); - - it("JSON with loggedIn=false is unauthenticated", () => { - const parsed = parseClaudeAuthStatusFromOutput({ - stdout: '{"loggedIn":false}\n', - stderr: "", - code: 0, - }); - assert.strictEqual(parsed.status, "error"); - assert.strictEqual(parsed.authStatus, "unauthenticated"); - }); - - it("JSON without auth marker is warning", () => { - const parsed = parseClaudeAuthStatusFromOutput({ - stdout: '{"ok":true}\n', - stderr: "", - code: 0, - }); - assert.strictEqual(parsed.status, "warning"); - assert.strictEqual(parsed.authStatus, "unknown"); - }); - }); -}); diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts deleted file mode 100644 index cbb97a807e..0000000000 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ /dev/null @@ -1,603 +0,0 @@ -/** - * ProviderHealthLive - Startup-time provider health checks. - * - * Performs one-time provider readiness probes when the server starts and - * keeps the resulting snapshot in memory for `server.getConfig`. - * - * Uses effect's ChildProcessSpawner to run CLI probes natively. - * - * @module ProviderHealthLive - */ -import * as OS from "node:os"; -import type { - ServerProviderAuthStatus, - ServerProviderStatus, - ServerProviderStatusState, -} from "@t3tools/contracts"; -import { Array, Effect, Fiber, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; - -import { - formatCodexCliUpgradeMessage, - isCodexCliVersionSupported, - parseCodexCliVersion, -} from "../codexCliVersion"; -import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth"; - -const DEFAULT_TIMEOUT_MS = 4_000; -const CODEX_PROVIDER = "codex" as const; -const CLAUDE_AGENT_PROVIDER = "claudeAgent" as const; - -// ── Pure helpers ──────────────────────────────────────────────────── - -export interface CommandResult { - readonly stdout: string; - readonly stderr: string; - readonly code: number; -} - -function nonEmptyTrimmed(value: string | undefined): string | undefined { - if (!value) return undefined; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function isCommandMissingCause(error: unknown): boolean { - if (!(error instanceof Error)) return false; - const lower = error.message.toLowerCase(); - return lower.includes("enoent") || lower.includes("notfound"); -} - -function detailFromResult( - result: CommandResult & { readonly timedOut?: boolean }, -): string | undefined { - if (result.timedOut) return "Timed out while running command."; - const stderr = nonEmptyTrimmed(result.stderr); - if (stderr) return stderr; - const stdout = nonEmptyTrimmed(result.stdout); - if (stdout) return stdout; - if (result.code !== 0) { - return `Command exited with code ${result.code}.`; - } - 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; -} - -export function parseAuthStatusFromOutput(result: CommandResult): { - readonly status: ServerProviderStatusState; - readonly authStatus: ServerProviderAuthStatus; - readonly message?: string; -} { - const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); - - if ( - lowerOutput.includes("unknown command") || - lowerOutput.includes("unrecognized command") || - lowerOutput.includes("unexpected argument") - ) { - return { - status: "warning", - authStatus: "unknown", - message: "Codex CLI authentication status command is unavailable in this Codex version.", - }; - } - - if ( - lowerOutput.includes("not logged in") || - lowerOutput.includes("login required") || - lowerOutput.includes("authentication required") || - lowerOutput.includes("run `codex login`") || - lowerOutput.includes("run codex login") - ) { - return { - status: "error", - authStatus: "unauthenticated", - message: "Codex CLI is not authenticated. Run `codex login` and try again.", - }; - } - - 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 }; - } - })(); - - if (parsedAuth.auth === true) { - return { status: "ready", authStatus: "authenticated" }; - } - if (parsedAuth.auth === false) { - return { - status: "error", - authStatus: "unauthenticated", - message: "Codex CLI is not authenticated. Run `codex login` and try again.", - }; - } - if (parsedAuth.attemptedJsonParse) { - return { - status: "warning", - authStatus: "unknown", - message: - "Could not verify Codex authentication status from JSON output (missing auth marker).", - }; - } - if (result.code === 0) { - return { status: "ready", authStatus: "authenticated" }; - } - - const detail = detailFromResult(result); - return { - status: "warning", - authStatus: "unknown", - message: detail - ? `Could not verify Codex authentication status. ${detail}` - : "Could not verify Codex authentication status.", - }; -} - -// ── Codex CLI config detection ────────────────────────────────────── - -/** - * Providers that use OpenAI-native authentication via `codex login`. - * When the configured `model_provider` is one of these, the `codex login - * status` probe still runs. For any other provider value the auth probe - * is skipped because authentication is handled externally (e.g. via - * environment variables like `PORTKEY_API_KEY` or `AZURE_API_KEY`). - */ -const OPENAI_AUTH_PROVIDERS = new Set(["openai"]); - -/** - * Read the `model_provider` value from the Codex CLI config file. - * - * Looks for the file at `$CODEX_HOME/config.toml` (falls back to - * `~/.codex/config.toml`). Uses a simple line-by-line scan rather than - * a full TOML parser to avoid adding a dependency for a single key. - * - * 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; - } - 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 - * (non-OpenAI) model provider, meaning `codex login` auth is not - * required because authentication is handled through provider-specific - * environment variables. - */ -export const hasCustomModelProvider = Effect.map( - readCodexConfigModelProvider, - (provider) => provider !== undefined && !OPENAI_AUTH_PROVIDERS.has(provider), -); - -// ── Effect-native command execution ───────────────────────────────── - -const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => - Stream.runFold( - stream, - () => "", - (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" }, - ); - - return { stdout, stderr, code: exitCode } satisfies CommandResult; - }).pipe(Effect.scoped); - -const runClaudeCommand = (args: ReadonlyArray) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make("claude", [...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" }, - ); - - return { stdout, stderr, code: exitCode } satisfies CommandResult; - }).pipe(Effect.scoped); - -// ── Health check ──────────────────────────────────────────────────── - -export const checkCodexProviderStatus: Effect.Effect< - ServerProviderStatus, - never, - ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path -> = 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, - ); - - 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 (Option.isNone(versionProbe.success)) { - return { - provider: CODEX_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: "Codex CLI is installed but failed to run. Timed out while running command.", - }; - } - - const version = versionProbe.success.value; - if (version.code !== 0) { - const detail = detailFromResult(version); - return { - provider: CODEX_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: detail - ? `Codex CLI is installed but failed to run. ${detail}` - : "Codex CLI is installed but failed to run.", - }; - } - - const parsedVersion = parseCodexCliVersion(`${version.stdout}\n${version.stderr}`); - if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) { - return { - provider: CODEX_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: formatCodexCliUpgradeMessage(parsedVersion), - }; - } - - // Probe 2: `codex login status` — is the user authenticated? - // - // Custom model providers (e.g. Portkey, Azure OpenAI proxy) handle - // authentication through their own environment variables, so `codex - // login status` will report "not logged in" even when the CLI works - // fine. Skip the auth probe entirely for non-OpenAI providers. - if (yield* hasCustomModelProvider) { - return { - provider: CODEX_PROVIDER, - status: "ready" as const, - available: true, - authStatus: "unknown" as const, - checkedAt, - message: "Using a custom Codex model provider; OpenAI login check skipped.", - } satisfies ServerProviderStatus; - } - - const authProbe = yield* runCodexCommand(["login", "status"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); - - if (Result.isFailure(authProbe)) { - const error = authProbe.failure; - return { - provider: CODEX_PROVIDER, - status: "warning" as const, - available: true, - authStatus: "unknown" as const, - checkedAt, - message: - error instanceof Error - ? `Could not verify Codex authentication status: ${error.message}.` - : "Could not verify Codex authentication status.", - }; - } - - if (Option.isNone(authProbe.success)) { - return { - provider: CODEX_PROVIDER, - status: "warning" as const, - available: true, - authStatus: "unknown" as const, - checkedAt, - message: "Could not verify Codex authentication status. Timed out while running command.", - }; - } - - const parsed = parseAuthStatusFromOutput(authProbe.success.value); - return { - provider: CODEX_PROVIDER, - status: parsed.status, - available: true, - authStatus: parsed.authStatus, - checkedAt, - ...(parsed.message ? { message: parsed.message } : {}), - } satisfies ServerProviderStatus; -}); - -// ── Claude Agent health check ─────────────────────────────────────── - -export function parseClaudeAuthStatusFromOutput(result: CommandResult): { - readonly status: ServerProviderStatusState; - readonly authStatus: ServerProviderAuthStatus; - readonly message?: string; -} { - const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); - - 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.", - }; - } - - 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.", - }; - } - - // `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 }; - } - try { - return { - attemptedJsonParse: true as const, - auth: extractAuthBoolean(JSON.parse(trimmed)), - }; - } catch { - return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; - } - })(); - - 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 detail = detailFromResult(result); - return { - status: "warning", - authStatus: "unknown", - message: detail - ? `Could not verify Claude authentication status. ${detail}` - : "Could not verify Claude authentication status.", - }; -} - -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, - ); - - 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 (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.", - }; - } - - const version = versionProbe.success.value; - if (version.code !== 0) { - const detail = detailFromResult(version); - return { - provider: CLAUDE_AGENT_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: detail - ? `Claude Agent CLI is installed but failed to run. ${detail}` - : "Claude Agent CLI is installed but failed to run.", - }; - } - - // Probe 2: `claude auth status` — is the user authenticated? - const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); - - 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.", - }; - } - - 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.", - }; - } - - 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; -}); - -// ── Layer ─────────────────────────────────────────────────────────── - -export const ProviderHealthLive = Layer.effect( - ProviderHealth, - Effect.gen(function* () { - const statusesFiber = yield* Effect.all([checkCodexProviderStatus, checkClaudeProviderStatus], { - concurrency: "unbounded", - }).pipe(Effect.forkScoped); - - return { - getStatuses: Fiber.join(statusesFiber), - } satisfies ProviderHealthShape; - }), -); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts new file mode 100644 index 0000000000..bed25977d6 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -0,0 +1,879 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, it, assert } from "@effect/vitest"; +import { + Effect, + Exit, + FileSystem, + Layer, + Path, + PubSub, + Ref, + Schema, + Scope, + Sink, + Stream, +} from "effect"; +import { + DEFAULT_SERVER_SETTINGS, + ServerSettings, + type ServerProvider, + type ServerSettings as ContractServerSettings, +} from "@t3tools/contracts"; +import * as PlatformError from "effect/PlatformError"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { deepMerge } from "@t3tools/shared/Struct"; + +import { + checkCodexProviderStatus, + hasCustomModelProvider, + parseAuthStatusFromOutput, + readCodexConfigModelProvider, +} from "./CodexProvider"; +import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider"; +import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry"; +import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings"; +import { ProviderRegistry } from "../Services/ProviderRegistry"; + +// ── Test helpers ──────────────────────────────────────────────────── + +const encoder = new TextEncoder(); + +function mockHandle(result: { stdout: string; stderr: string; code: number }) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code)), + isRunning: Effect.succeed(false), + 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 }, +) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const cmd = command as unknown as { args: ReadonlyArray }; + return Effect.succeed(mockHandle(handler(cmd.args))); + }), + ); +} + +function mockCommandSpawnerLayer( + handler: ( + command: string, + args: ReadonlyArray, + ) => { stdout: string; stderr: string; code: number }, +) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const cmd = command as unknown as { command: string; args: ReadonlyArray }; + return Effect.succeed(mockHandle(handler(cmd.command, cmd.args))); + }), + ); +} + +function failingSpawnerLayer(description: string) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description, + }), + ), + ), + ); +} + +function makeMutableServerSettingsService( + initial: ContractServerSettings = DEFAULT_SERVER_SETTINGS, +) { + return Effect.gen(function* () { + const settingsRef = yield* Ref.make(initial); + const changes = yield* PubSub.unbounded(); + + return { + start: Effect.void, + ready: Effect.void, + getSettings: Ref.get(settingsRef), + updateSettings: (patch) => + Effect.gen(function* () { + const current = yield* Ref.get(settingsRef); + const next = Schema.decodeSync(ServerSettings)(deepMerge(current, patch)); + yield* Ref.set(settingsRef, next); + yield* PubSub.publish(changes, next); + return next; + }), + streamChanges: Stream.fromPubSub(changes), + } satisfies ServerSettingsShape; + }); +} + +/** + * Create a temporary CODEX_HOME scoped to the current Effect test. + * Cleanup is registered in the test scope rather than via Vitest hooks. + */ +function withTempCodexHome(configContent?: string) { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tmpDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-test-codex-" }); + + yield* Effect.acquireRelease( + Effect.sync(() => { + const originalCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = tmpDir; + return originalCodexHome; + }), + (originalCodexHome) => + Effect.sync(() => { + if (originalCodexHome !== undefined) { + process.env.CODEX_HOME = originalCodexHome; + } else { + delete process.env.CODEX_HOME; + } + }), + ); + + if (configContent !== undefined) { + yield* fileSystem.writeFileString(path.join(tmpDir, "config.toml"), configContent); + } + + return { tmpDir } as const; + }); +} + +it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( + "ProviderRegistry", + (it) => { + // ── checkCodexProviderStatus tests ──────────────────────────────── + // + // These tests control CODEX_HOME to ensure the custom-provider detection + // in hasCustomModelProvider() does not interfere with the auth-probe + // path being tested. + + describe("checkCodexProviderStatus", () => { + 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 + // default code path (OpenAI provider, auth probe runs) is exercised. + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus(); + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.authStatus, "authenticated"); + }).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 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("inherits PATH when launching the codex probe with a CODEX_HOME override", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-test-codex-bin-", + }); + const codexPath = path.join(binDir, "codex"); + yield* fileSystem.writeFileString( + codexPath, + [ + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then', + ' echo "codex-cli 1.0.0"', + " exit 0", + "fi", + 'if [ "$1" = "login" ] && [ "$2" = "status" ]; then', + ' echo "Logged in using ChatGPT"', + " exit 0", + "fi", + 'echo "unexpected args: $*" >&2', + "exit 1", + "", + ].join("\n"), + ); + yield* fileSystem.chmod(codexPath, 0o755); + const customCodexHome = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-test-codex-home-", + }); + const previousPath = process.env.PATH; + process.env.PATH = binDir; + + try { + const serverSettingsLayer = ServerSettingsService.layerTest({ + providers: { + codex: { + homePath: customCodexHome, + }, + }, + }); + + const status = yield* checkCodexProviderStatus().pipe( + Effect.provide(serverSettingsLayer), + ); + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.authStatus, "authenticated"); + } finally { + process.env.PATH = previousPath; + } + }), + ); + + it.effect("returns unavailable when codex is missing", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus(); + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Codex CLI (`codex`) is not installed or not on PATH.", + ); + }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), + ); + + it.effect("returns unavailable when codex is below the minimum supported version", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus(); + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 0.36.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unauthenticated when auth probe reports login required", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus(); + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.authStatus, "unauthenticated"); + assert.strictEqual( + status.message, + "Codex CLI is not authenticated. Run `codex login` and try again.", + ); + }).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: "", stderr: "Not logged in. Run codex login.", code: 1 }; + } + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unauthenticated when login status output includes 'not logged in'", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus(); + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.authStatus, "unauthenticated"); + assert.strictEqual( + status.message, + "Codex CLI is not authenticated. Run `codex login` and try again.", + ); + }).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: "Not logged in\n", stderr: "", code: 1 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns warning when login status command is unsupported", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus(); + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Codex CLI authentication status command is unavailable in this Codex version.", + ); + }).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: "", stderr: "error: unknown command 'login'", code: 2 }; + } + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + }); + + describe("ProviderRegistryLive", () => { + it("treats equal provider snapshots as unchanged", () => { + const providers = [ + { + provider: "codex", + status: "ready", + enabled: true, + installed: true, + authStatus: "authenticated", + checkedAt: "2026-03-25T00:00:00.000Z", + version: "1.0.0", + models: [], + }, + { + provider: "claudeAgent", + status: "warning", + enabled: true, + installed: true, + authStatus: "unknown", + checkedAt: "2026-03-25T00:00:00.000Z", + version: "1.0.0", + models: [], + }, + ] as const satisfies ReadonlyArray; + + assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); + }); + + it.effect("reruns codex health when codex provider settings change", () => + Effect.gen(function* () { + const serverSettings = yield* makeMutableServerSettingsService(); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + mockCommandSpawnerLayer((command, args) => { + const joined = args.join(" "); + if (joined === "--version") { + if (command === "codex") { + return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "spawn ENOENT", code: 1 }; + } + if (joined === "login status") { + return { stdout: "Logged in\n", stderr: "", code: 0 }; + } + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ); + const runtimeServices = yield* Layer.build( + Layer.mergeAll( + Layer.succeed(ServerSettingsService, serverSettings), + providerRegistryLayer, + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + + const initial = yield* registry.getProviders; + assert.strictEqual( + initial.find((status) => status.provider === "codex")?.status, + "ready", + ); + + yield* serverSettings.updateSettings({ + providers: { + codex: { + binaryPath: "/custom/codex", + }, + }, + }); + + for (let attempt = 0; attempt < 20; attempt += 1) { + const updated = yield* registry.getProviders; + if (updated.find((status) => status.provider === "codex")?.status === "error") { + return; + } + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); + } + + const updated = yield* registry.getProviders; + assert.strictEqual( + updated.find((status) => status.provider === "codex")?.status, + "error", + ); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect("skips codex probes entirely when the provider is disabled", () => + Effect.gen(function* () { + const serverSettingsLayer = ServerSettingsService.layerTest({ + providers: { + codex: { + enabled: false, + }, + }, + }); + + const status = yield* checkCodexProviderStatus().pipe( + Effect.provide( + Layer.mergeAll(serverSettingsLayer, failingSpawnerLayer("spawn codex ENOENT")), + ), + ); + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.enabled, false); + assert.strictEqual(status.status, "disabled"); + assert.strictEqual(status.installed, false); + assert.strictEqual(status.message, "Codex is disabled in T3 Code settings."); + }), + ); + }); + + // ── Custom model provider: checkCodexProviderStatus integration ─── + + describe("checkCodexProviderStatus with custom model provider", () => { + it.effect( + "skips auth probe and returns ready when a custom model provider is configured", + () => + Effect.gen(function* () { + yield* withTempCodexHome( + [ + 'model_provider = "portkey"', + "", + "[model_providers.portkey]", + 'base_url = "https://api.portkey.ai/v1"', + 'env_key = "PORTKEY_API_KEY"', + ].join("\n"), + ); + const status = yield* checkCodexProviderStatus(); + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Using a custom Codex model provider; OpenAI login check skipped.", + ); + }).pipe( + Effect.provide( + // The spawner only handles --version; if the test attempts + // "login status" the throw proves the auth probe was NOT skipped. + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Auth probe should have been skipped but got args: ${joined}`); + }), + ), + ), + ); + + it.effect("still reports error when codex CLI is missing even with custom provider", () => + Effect.gen(function* () { + yield* withTempCodexHome( + [ + 'model_provider = "portkey"', + "", + "[model_providers.portkey]", + 'base_url = "https://api.portkey.ai/v1"', + 'env_key = "PORTKEY_API_KEY"', + ].join("\n"), + ); + const status = yield* checkCodexProviderStatus(); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, false); + }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), + ); + }); + + describe("checkCodexProviderStatus with openai model provider", () => { + it.effect("still runs auth probe when model_provider is openai", () => + Effect.gen(function* () { + yield* withTempCodexHome('model_provider = "openai"\n'); + const status = yield* checkCodexProviderStatus(); + // The auth probe runs and sees "not logged in" → error + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.authStatus, "unauthenticated"); + }).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: "Not logged in\n", stderr: "", code: 1 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + }); + + // ── parseAuthStatusFromOutput pure tests ────────────────────────── + + describe("parseAuthStatusFromOutput", () => { + it("exit code 0 with no auth markers is ready", () => { + const parsed = parseAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); + assert.strictEqual(parsed.status, "ready"); + assert.strictEqual(parsed.authStatus, "authenticated"); + }); + + it("JSON with authenticated=false is unauthenticated", () => { + const parsed = parseAuthStatusFromOutput({ + stdout: '[{"authenticated":false}]\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "error"); + assert.strictEqual(parsed.authStatus, "unauthenticated"); + }); + + it("JSON without auth marker is warning", () => { + const parsed = parseAuthStatusFromOutput({ + stdout: '[{"ok":true}]\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "warning"); + assert.strictEqual(parsed.authStatus, "unknown"); + }); + }); + + // ── readCodexConfigModelProvider tests ───────────────────────────── + + describe("readCodexConfigModelProvider", () => { + it.effect("returns undefined when config file does not exist", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + 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); + }), + ); + + 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"); + }), + ); + + it.effect("returns openai when model_provider is openai", () => + Effect.gen(function* () { + yield* withTempCodexHome('model_provider = "openai"\n'); + assert.strictEqual(yield* readCodexConfigModelProvider(), "openai"); + }), + ); + + it.effect("ignores model_provider inside section headers", () => + Effect.gen(function* () { + yield* withTempCodexHome( + [ + 'model = "gpt-5-codex"', + "", + "[model_providers.portkey]", + 'base_url = "https://api.portkey.ai/v1"', + 'model_provider = "should-be-ignored"', + "", + ].join("\n"), + ); + assert.strictEqual(yield* readCodexConfigModelProvider(), undefined); + }), + ); + + it.effect("handles comments and whitespace", () => + Effect.gen(function* () { + yield* withTempCodexHome( + [ + "# This is a comment", + "", + ' model_provider = "azure" ', + "", + "[profiles.deep-review]", + 'model = "gpt-5-pro"', + ].join("\n"), + ); + 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"); + }), + ); + }); + + // ── hasCustomModelProvider tests ─────────────────────────────────── + + describe("hasCustomModelProvider", () => { + it.effect("returns false when no config file exists", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + assert.strictEqual(yield* hasCustomModelProvider, false); + }), + ); + + it.effect("returns false when model_provider is not set", () => + Effect.gen(function* () { + yield* withTempCodexHome('model = "gpt-5-codex"\n'); + assert.strictEqual(yield* hasCustomModelProvider, false); + }), + ); + + it.effect("returns false when model_provider is openai", () => + Effect.gen(function* () { + yield* withTempCodexHome('model_provider = "openai"\n'); + assert.strictEqual(yield* hasCustomModelProvider, false); + }), + ); + + it.effect("returns true when model_provider is portkey", () => + Effect.gen(function* () { + yield* withTempCodexHome('model_provider = "portkey"\n'); + assert.strictEqual(yield* hasCustomModelProvider, true); + }), + ); + + it.effect("returns true when model_provider is azure", () => + Effect.gen(function* () { + yield* withTempCodexHome('model_provider = "azure"\n'); + assert.strictEqual(yield* hasCustomModelProvider, true); + }), + ); + + it.effect("returns true when model_provider is ollama", () => + Effect.gen(function* () { + yield* withTempCodexHome('model_provider = "ollama"\n'); + assert.strictEqual(yield* hasCustomModelProvider, true); + }), + ); + + it.effect("returns true when model_provider is a custom proxy", () => + Effect.gen(function* () { + yield* withTempCodexHome('model_provider = "my-company-proxy"\n'); + assert.strictEqual(yield* hasCustomModelProvider, true); + }), + ); + }); + + // ── checkClaudeProviderStatus tests ────────────────────────── + + describe("checkClaudeProviderStatus", () => { + it.effect("returns ready when claude is installed and authenticated", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus(); + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.authStatus, "authenticated"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unavailable when claude is missing", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus(); + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Claude Agent CLI (`claude`) is not installed or not on PATH.", + ); + }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), + ); + + it.effect("returns error when version check fails with non-zero exit code", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus(); + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, true); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") + return { stdout: "", stderr: "Something went wrong", code: 1 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unauthenticated when auth status reports not logged in", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus(); + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.authStatus, "unauthenticated"); + assert.strictEqual( + status.message, + "Claude is not authenticated. Run `claude auth login` and try again.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":false}\n', + stderr: "", + code: 1, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unauthenticated when output includes 'not logged in'", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus(); + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.authStatus, "unauthenticated"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { stdout: "Not logged in\n", stderr: "", code: 1 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns warning when auth status command is unsupported", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus(); + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Claude Agent authentication status command is unavailable in this version of Claude.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { stdout: "", stderr: "error: unknown command 'auth'", code: 2 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + }); + + // ── parseClaudeAuthStatusFromOutput pure tests ──────────────────── + + describe("parseClaudeAuthStatusFromOutput", () => { + it("exit code 0 with no auth markers is ready", () => { + const parsed = parseClaudeAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); + assert.strictEqual(parsed.status, "ready"); + assert.strictEqual(parsed.authStatus, "authenticated"); + }); + + it("JSON with loggedIn=true is authenticated", () => { + const parsed = parseClaudeAuthStatusFromOutput({ + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "ready"); + assert.strictEqual(parsed.authStatus, "authenticated"); + }); + + it("JSON with loggedIn=false is unauthenticated", () => { + const parsed = parseClaudeAuthStatusFromOutput({ + stdout: '{"loggedIn":false}\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "error"); + assert.strictEqual(parsed.authStatus, "unauthenticated"); + }); + + it("JSON without auth marker is warning", () => { + const parsed = parseClaudeAuthStatusFromOutput({ + stdout: '{"ok":true}\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "warning"); + assert.strictEqual(parsed.authStatus, "unknown"); + }); + }); + }, +); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts new file mode 100644 index 0000000000..1e66ce8ff5 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -0,0 +1,93 @@ +/** + * ProviderRegistryLive - Aggregates provider-specific snapshot services. + * + * @module ProviderRegistryLive + */ +import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; +import { Effect, Equal, Layer, PubSub, Ref, Stream } from "effect"; + +import { ClaudeProviderLive } from "./ClaudeProvider"; +import { CodexProviderLive } from "./CodexProvider"; +import type { ClaudeProviderShape } from "../Services/ClaudeProvider"; +import { ClaudeProvider } from "../Services/ClaudeProvider"; +import type { CodexProviderShape } from "../Services/CodexProvider"; +import { CodexProvider } from "../Services/CodexProvider"; +import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry"; + +const loadProviders = ( + codexProvider: CodexProviderShape, + claudeProvider: ClaudeProviderShape, +): Effect.Effect => + Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot], { + concurrency: "unbounded", + }); + +export const haveProvidersChanged = ( + previousProviders: ReadonlyArray, + nextProviders: ReadonlyArray, +): boolean => !Equal.equals(previousProviders, nextProviders); + +export const ProviderRegistryLive = Layer.effect( + ProviderRegistry, + Effect.gen(function* () { + const codexProvider = yield* CodexProvider; + const claudeProvider = yield* ClaudeProvider; + const changesPubSub = yield* Effect.acquireRelease( + PubSub.unbounded>(), + PubSub.shutdown, + ); + const providersRef = yield* Ref.make>( + yield* loadProviders(codexProvider, claudeProvider), + ); + + const syncProviders = (options?: { readonly publish?: boolean }) => + Effect.gen(function* () { + const previousProviders = yield* Ref.get(providersRef); + const providers = yield* loadProviders(codexProvider, claudeProvider); + yield* Ref.set(providersRef, providers); + + if (options?.publish !== false && haveProvidersChanged(previousProviders, providers)) { + yield* PubSub.publish(changesPubSub, providers); + } + + return providers; + }); + + yield* Stream.runForEach(codexProvider.streamChanges, () => syncProviders()).pipe( + Effect.forkScoped, + ); + yield* Stream.runForEach(claudeProvider.streamChanges, () => syncProviders()).pipe( + Effect.forkScoped, + ); + + return { + getProviders: syncProviders({ publish: false }).pipe( + Effect.tapError(Effect.logError), + Effect.orElseSucceed(() => []), + ), + refresh: (provider?: ProviderKind) => + Effect.gen(function* () { + switch (provider) { + case "codex": + yield* codexProvider.refresh; + break; + case "claudeAgent": + yield* claudeProvider.refresh; + break; + default: + yield* Effect.all([codexProvider.refresh, claudeProvider.refresh], { + concurrency: "unbounded", + }); + break; + } + return yield* syncProviders(); + }).pipe( + Effect.tapError(Effect.logError), + Effect.orElseSucceed(() => []), + ), + get streamChanges() { + return Stream.fromPubSub(changesPubSub); + }, + } satisfies ProviderRegistryShape; + }), +).pipe(Layer.provideMerge(CodexProviderLive), Layer.provideMerge(ClaudeProviderLive)); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 7af85aafd2..651a611649 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -42,8 +42,11 @@ import { makeSqlitePersistenceLive, SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; +const defaultServerSettingsLayer = ServerSettingsService.layerTest(); + const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); @@ -251,6 +254,7 @@ function makeProviderServiceLayer() { makeProviderServiceLive().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), Layer.provideMerge(AnalyticsService.layerTest), ), directoryLayer, @@ -267,6 +271,55 @@ function makeProviderServiceLayer() { }; } +it.effect("ProviderServiceLive rejects new sessions for disabled providers", () => + Effect.gen(function* () { + const codex = makeFakeCodexAdapter(); + const claude = makeFakeCodexAdapter("claudeAgent"); + const registry: typeof ProviderAdapterRegistry.Service = { + getByProvider: (provider) => + provider === "codex" + ? Effect.succeed(codex.adapter) + : provider === "claudeAgent" + ? Effect.succeed(claude.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["codex", "claudeAgent"]), + }; + const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); + const serverSettingsLayer = ServerSettingsService.layerTest({ + providers: { + claudeAgent: { + enabled: false, + }, + }, + }); + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(providerAdapterLayer), + Layer.provide(directoryLayer), + Layer.provide(serverSettingsLayer), + Layer.provide(AnalyticsService.layerTest), + ); + + const failure = yield* Effect.flip( + Effect.gen(function* () { + const provider = yield* ProviderService; + return yield* provider.startSession(asThreadId("thread-disabled"), { + provider: "claudeAgent", + threadId: asThreadId("thread-disabled"), + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(providerLayer)), + ); + + assert.instanceOf(failure, ProviderValidationError); + assert.include(failure.issue, "Provider 'claudeAgent' is disabled in T3 Code settings."); + assert.equal(claude.startSession.mock.calls.length, 0); + }).pipe(Effect.provide(NodeServices.layer)), +); + const routing = makeProviderServiceLayer(); it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", () => Effect.gen(function* () { @@ -299,6 +352,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( const providerLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), ); @@ -358,6 +412,7 @@ it.effect( const firstProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), Layer.provide(firstDirectoryLayer), + Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), ); const updatedResumeCursor = { @@ -409,6 +464,7 @@ it.effect( const secondProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), Layer.provide(secondDirectoryLayer), + Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), ); @@ -769,6 +825,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const firstProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), Layer.provide(firstDirectoryLayer), + Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), ); @@ -801,6 +858,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const secondProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), Layer.provide(secondDirectoryLayer), + Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), ); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 364e30fd0e..0137152e83 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -33,6 +33,7 @@ import { } from "../Services/ProviderSessionDirectory.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; export interface ProviderServiceLiveOptions { readonly canonicalEventLogPath?: string; @@ -91,7 +92,6 @@ function toRuntimePayloadFromSession( session: ProviderSession, extra?: { readonly modelSelection?: unknown; - readonly providerOptions?: unknown; readonly lastRuntimeEvent?: string; readonly lastRuntimeEventAt?: string; }, @@ -102,7 +102,6 @@ function toRuntimePayloadFromSession( activeTurnId: session.activeTurnId ?? null, lastError: session.lastError ?? null, ...(extra?.modelSelection !== undefined ? { modelSelection: extra.modelSelection } : {}), - ...(extra?.providerOptions !== undefined ? { providerOptions: extra.providerOptions } : {}), ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}), ...(extra?.lastRuntimeEventAt !== undefined ? { lastRuntimeEventAt: extra.lastRuntimeEventAt } @@ -120,17 +119,6 @@ function readPersistedModelSelection( return Schema.is(ModelSelection)(raw) ? raw : undefined; } -function readPersistedProviderOptions( - runtimePayload: ProviderRuntimeBinding["runtimePayload"], -): Record | undefined { - if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { - return undefined; - } - const raw = "providerOptions" in runtimePayload ? runtimePayload.providerOptions : undefined; - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; - return raw as Record; -} - function readPersistedCwd( runtimePayload: ProviderRuntimeBinding["runtimePayload"], ): string | undefined { @@ -146,6 +134,7 @@ function readPersistedCwd( const makeProviderService = (options?: ProviderServiceLiveOptions) => Effect.gen(function* () { const analytics = yield* Effect.service(AnalyticsService); + const serverSettings = yield* ServerSettingsService; const canonicalEventLogger = options?.canonicalEventLogger ?? (options?.canonicalEventLogPath !== undefined @@ -173,7 +162,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => threadId: ThreadId, extra?: { readonly modelSelection?: unknown; - readonly providerOptions?: unknown; readonly lastRuntimeEvent?: string; readonly lastRuntimeEventAt?: string; }, @@ -239,14 +227,12 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const persistedCwd = readPersistedCwd(input.binding.runtimePayload); const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); - const persistedProviderOptions = readPersistedProviderOptions(input.binding.runtimePayload); const resumed = yield* adapter.startSession({ threadId: input.binding.threadId, provider: input.binding.provider, ...(persistedCwd ? { cwd: persistedCwd } : {}), ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), - ...(persistedProviderOptions ? { providerOptions: persistedProviderOptions } : {}), ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), runtimeMode: input.binding.runtimeMode ?? "full-access", }); @@ -308,6 +294,21 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => threadId, provider: parsed.provider ?? "codex", }; + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError((error) => + toValidationError( + "ProviderService.startSession", + `Failed to load provider settings: ${error.message}`, + error, + ), + ), + ); + if (!settings.providers[input.provider].enabled) { + return yield* toValidationError( + "ProviderService.startSession", + `Provider '${input.provider}' is disabled in T3 Code settings.`, + ); + } const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId)); const effectiveResumeCursor = input.resumeCursor ?? @@ -329,7 +330,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => yield* upsertSessionBinding(session, threadId, { modelSelection: input.modelSelection, - providerOptions: input.providerOptions, }); yield* analytics.record("provider.session.started", { provider: session.provider, diff --git a/apps/server/src/provider/Services/ClaudeProvider.ts b/apps/server/src/provider/Services/ClaudeProvider.ts new file mode 100644 index 0000000000..18ee8a4f6d --- /dev/null +++ b/apps/server/src/provider/Services/ClaudeProvider.ts @@ -0,0 +1,9 @@ +import { ServiceMap } from "effect"; + +import type { ServerProviderShape } from "./ServerProvider"; + +export interface ClaudeProviderShape extends ServerProviderShape {} + +export class ClaudeProvider extends ServiceMap.Service()( + "t3/provider/Services/ClaudeProvider", +) {} diff --git a/apps/server/src/provider/Services/CodexProvider.ts b/apps/server/src/provider/Services/CodexProvider.ts new file mode 100644 index 0000000000..2e9b57c89b --- /dev/null +++ b/apps/server/src/provider/Services/CodexProvider.ts @@ -0,0 +1,9 @@ +import { ServiceMap } from "effect"; + +import type { ServerProviderShape } from "./ServerProvider"; + +export interface CodexProviderShape extends ServerProviderShape {} + +export class CodexProvider extends ServiceMap.Service()( + "t3/provider/Services/CodexProvider", +) {} diff --git a/apps/server/src/provider/Services/ProviderHealth.ts b/apps/server/src/provider/Services/ProviderHealth.ts deleted file mode 100644 index ec3b2d318d..0000000000 --- a/apps/server/src/provider/Services/ProviderHealth.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * ProviderHealth - Provider readiness snapshot service. - * - * Owns provider health checks (install/auth reachability) and exposes the - * latest results to transport layers. - * - * @module ProviderHealth - */ -import type { ServerProviderStatus } from "@t3tools/contracts"; -import { ServiceMap } from "effect"; -import type { Effect } from "effect"; - -export interface ProviderHealthShape { - /** - * Read the latest provider health statuses. - */ - readonly getStatuses: Effect.Effect>; -} - -export class ProviderHealth extends ServiceMap.Service()( - "t3/provider/Services/ProviderHealth", -) {} diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts new file mode 100644 index 0000000000..80710691c1 --- /dev/null +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -0,0 +1,32 @@ +/** + * ProviderRegistry - Provider snapshot service. + * + * Owns provider install/auth/version/model snapshots and exposes the latest + * provider state to transport layers. + * + * @module ProviderRegistry + */ +import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect, Stream } from "effect"; + +export interface ProviderRegistryShape { + /** + * Read the latest provider snapshots. + */ + readonly getProviders: Effect.Effect>; + + /** + * Refresh all providers, or a single provider when specified. + */ + readonly refresh: (provider?: ProviderKind) => Effect.Effect>; + + /** + * Stream of provider snapshot updates. + */ + readonly streamChanges: Stream.Stream>; +} + +export class ProviderRegistry extends ServiceMap.Service()( + "t3/provider/Services/ProviderRegistry", +) {} diff --git a/apps/server/src/provider/Services/ServerProvider.ts b/apps/server/src/provider/Services/ServerProvider.ts new file mode 100644 index 0000000000..4df0bc8fc2 --- /dev/null +++ b/apps/server/src/provider/Services/ServerProvider.ts @@ -0,0 +1,8 @@ +import type { ServerProvider } from "@t3tools/contracts"; +import type { Effect, Stream } from "effect"; + +export interface ServerProviderShape { + readonly getSnapshot: Effect.Effect; + readonly refresh: Effect.Effect; + readonly streamChanges: Stream.Stream; +} diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts new file mode 100644 index 0000000000..e519e82af5 --- /dev/null +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -0,0 +1,72 @@ +import type { ServerProvider } from "@t3tools/contracts"; +import { Duration, Effect, PubSub, Ref, Scope, Stream } from "effect"; +import * as Semaphore from "effect/Semaphore"; + +import type { ServerProviderShape } from "./Services/ServerProvider"; +import { ServerSettingsError } from "../serverSettings"; + +export function makeManagedServerProvider(input: { + readonly getSettings: Effect.Effect; + readonly streamSettings: Stream.Stream; + readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; + readonly checkProvider: Effect.Effect; + readonly refreshInterval?: Duration.Input; +}): Effect.Effect { + return Effect.gen(function* () { + const refreshSemaphore = yield* Semaphore.make(1); + const changesPubSub = yield* Effect.acquireRelease( + PubSub.unbounded(), + PubSub.shutdown, + ); + const initialSettings = yield* input.getSettings; + const initialSnapshot = yield* input.checkProvider; + const snapshotRef = yield* Ref.make(initialSnapshot); + const settingsRef = yield* Ref.make(initialSettings); + + const applySnapshot = (nextSettings: Settings, options?: { readonly forceRefresh?: boolean }) => + refreshSemaphore.withPermits(1)( + Effect.gen(function* () { + const forceRefresh = options?.forceRefresh === true; + const previousSettings = yield* Ref.get(settingsRef); + if (!forceRefresh && !input.haveSettingsChanged(previousSettings, nextSettings)) { + yield* Ref.set(settingsRef, nextSettings); + return yield* Ref.get(snapshotRef); + } + + const nextSnapshot = yield* input.checkProvider; + yield* Ref.set(settingsRef, nextSettings); + yield* Ref.set(snapshotRef, nextSnapshot); + yield* PubSub.publish(changesPubSub, nextSnapshot); + return nextSnapshot; + }), + ); + + const refreshSnapshot = Effect.gen(function* () { + const nextSettings = yield* input.getSettings; + return yield* applySnapshot(nextSettings, { forceRefresh: true }); + }); + + yield* Stream.runForEach(input.streamSettings, (nextSettings) => + Effect.asVoid(applySnapshot(nextSettings)), + ).pipe(Effect.forkScoped); + + yield* Effect.forever( + Effect.sleep(input.refreshInterval ?? "60 seconds").pipe( + Effect.flatMap(() => refreshSnapshot), + Effect.ignoreCause({ log: true }), + ), + ).pipe(Effect.forkScoped); + + return { + getSnapshot: input.getSettings.pipe( + Effect.flatMap(applySnapshot), + Effect.tapError(Effect.logError), + Effect.orDie, + ), + refresh: refreshSnapshot.pipe(Effect.tapError(Effect.logError), Effect.orDie), + get streamChanges() { + return Stream.fromPubSub(changesPubSub); + }, + } satisfies ServerProviderShape; + }); +} diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts new file mode 100644 index 0000000000..19111b0485 --- /dev/null +++ b/apps/server/src/provider/providerSnapshot.ts @@ -0,0 +1,134 @@ +import type { + ServerProvider, + ServerProviderAuthStatus, + ServerProviderModel, + ServerProviderState, +} from "@t3tools/contracts"; +import { Effect, Stream } from "effect"; +import { normalizeModelSlug } from "@t3tools/shared/model"; + +export const DEFAULT_TIMEOUT_MS = 4_000; + +export interface CommandResult { + readonly stdout: string; + readonly stderr: string; + readonly code: number; +} + +export interface ProviderProbeResult { + readonly installed: boolean; + readonly version: string | null; + readonly status: Exclude; + readonly authStatus: ServerProviderAuthStatus; + readonly message?: string; +} + +export function nonEmptyTrimmed(value: string | undefined): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export function isCommandMissingCause(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const lower = error.message.toLowerCase(); + return lower.includes("enoent") || lower.includes("notfound"); +} + +export function detailFromResult( + result: CommandResult & { readonly timedOut?: boolean }, +): string | undefined { + if (result.timedOut) return "Timed out while running command."; + const stderr = nonEmptyTrimmed(result.stderr); + if (stderr) return stderr; + const stdout = nonEmptyTrimmed(result.stdout); + if (stdout) return stdout; + if (result.code !== 0) { + return `Command exited with code ${result.code}.`; + } + return undefined; +} + +export function extractAuthBoolean(value: unknown): boolean | undefined { + if (globalThis.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; +} + +export function parseGenericCliVersion(output: string): string | null { + const match = output.match(/\b(\d+\.\d+\.\d+)\b/); + return match?.[1] ?? null; +} + +export function providerModelsFromSettings( + builtInModels: ReadonlyArray, + provider: ServerProvider["provider"], + customModels: ReadonlyArray, +): ReadonlyArray { + const resolvedBuiltInModels = [...builtInModels]; + const seen = new Set(resolvedBuiltInModels.map((model) => model.slug)); + const customEntries: ServerProviderModel[] = []; + + for (const candidate of customModels) { + const normalized = normalizeModelSlug(candidate, provider); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + customEntries.push({ + slug: normalized, + name: normalized, + isCustom: true, + capabilities: null, + }); + } + + return [...resolvedBuiltInModels, ...customEntries]; +} + +export function buildServerProvider(input: { + provider: ServerProvider["provider"]; + enabled: boolean; + checkedAt: string; + models: ReadonlyArray; + probe: ProviderProbeResult; +}): ServerProvider { + return { + provider: input.provider, + enabled: input.enabled, + installed: input.probe.installed, + version: input.probe.version, + status: input.enabled ? input.probe.status : "disabled", + authStatus: input.probe.authStatus, + checkedAt: input.checkedAt, + ...(input.probe.message ? { message: input.probe.message } : {}), + models: input.models, + }; +} + +export const collectStreamAsString = ( + stream: Stream.Stream, +): Effect.Effect => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ); diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 68fa9e8708..a8c1a13f7f 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -24,6 +24,7 @@ import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; import { ProviderService } from "./provider/Services/ProviderService"; import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; +import { ServerSettingsService } from "./serverSettings"; import { TerminalManagerLive } from "./terminal/Layers/Manager"; import { KeybindingsLive } from "./keybindings"; @@ -54,7 +55,11 @@ const makeRuntimePtyAdapterLayer = () => export function makeServerProviderLayer(): Layer.Layer< ProviderService, ProviderUnsupportedError, - SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem | AnalyticsService + | SqlClient.SqlClient + | ServerConfig + | ServerSettingsService + | FileSystem.FileSystem + | AnalyticsService > { return Effect.gen(function* () { const { providerEventLogPath } = yield* ServerConfig; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts new file mode 100644 index 0000000000..f26fece246 --- /dev/null +++ b/apps/server/src/serverSettings.test.ts @@ -0,0 +1,182 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { DEFAULT_SERVER_SETTINGS, ServerSettingsPatch } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Schema } from "effect"; +import { ServerConfig } from "./config"; +import { ServerSettingsLive, ServerSettingsService } from "./serverSettings"; + +const makeServerSettingsLayer = () => + ServerSettingsLive.pipe( + Layer.provideMerge( + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-server-settings-test-", + }), + ), + ), + ); + +it.layer(NodeServices.layer)("server settings", (it) => { + it.effect("decodes nested settings patches", () => + Effect.sync(() => { + const decodePatch = Schema.decodeUnknownSync(ServerSettingsPatch); + + assert.deepEqual(decodePatch({ providers: { codex: { binaryPath: "/tmp/codex" } } }), { + providers: { codex: { binaryPath: "/tmp/codex" } }, + }); + + assert.deepEqual( + decodePatch({ + textGenerationModelSelection: { + options: { + fastMode: false, + }, + }, + }), + { + textGenerationModelSelection: { + options: { + fastMode: false, + }, + }, + }, + ); + }), + ); + + it.effect("deep merges nested settings updates without dropping siblings", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + + yield* serverSettings.updateSettings({ + providers: { + codex: { + binaryPath: "/usr/local/bin/codex", + homePath: "/Users/julius/.codex", + }, + claudeAgent: { + binaryPath: "/usr/local/bin/claude", + customModels: ["claude-custom"], + }, + }, + textGenerationModelSelection: { + provider: "codex", + model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + options: { + reasoningEffort: "high", + fastMode: true, + }, + }, + }); + + const next = yield* serverSettings.updateSettings({ + providers: { + codex: { + binaryPath: "/opt/homebrew/bin/codex", + }, + }, + textGenerationModelSelection: { + options: { + fastMode: false, + }, + }, + }); + + assert.deepEqual(next.providers.codex, { + enabled: true, + binaryPath: "/opt/homebrew/bin/codex", + homePath: "/Users/julius/.codex", + customModels: [], + }); + assert.deepEqual(next.providers.claudeAgent, { + enabled: true, + binaryPath: "/usr/local/bin/claude", + customModels: ["claude-custom"], + }); + assert.deepEqual(next.textGenerationModelSelection, { + provider: "codex", + model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + options: { + reasoningEffort: "high", + fastMode: false, + }, + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("trims provider path settings when updates are applied", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + + const next = yield* serverSettings.updateSettings({ + providers: { + codex: { + binaryPath: " /opt/homebrew/bin/codex ", + homePath: " ", + }, + claudeAgent: { + binaryPath: " /opt/homebrew/bin/claude ", + }, + }, + }); + + assert.deepEqual(next.providers.codex, { + enabled: true, + binaryPath: "/opt/homebrew/bin/codex", + homePath: "", + customModels: [], + }); + assert.deepEqual(next.providers.claudeAgent, { + enabled: true, + binaryPath: "/opt/homebrew/bin/claude", + customModels: [], + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("defaults blank binary paths to provider executables", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + + const next = yield* serverSettings.updateSettings({ + providers: { + codex: { + binaryPath: " ", + }, + claudeAgent: { + binaryPath: "", + }, + }, + }); + + assert.equal(next.providers.codex.binaryPath, "codex"); + assert.equal(next.providers.claudeAgent.binaryPath, "claude"); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("writes only non-default server settings to disk", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const serverConfig = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const next = yield* serverSettings.updateSettings({ + providers: { + codex: { + binaryPath: "/opt/homebrew/bin/codex", + }, + }, + }); + + assert.equal(next.providers.codex.binaryPath, "/opt/homebrew/bin/codex"); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.deepEqual(JSON.parse(raw), { + providers: { + codex: { + binaryPath: "/opt/homebrew/bin/codex", + }, + }, + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); +}); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts new file mode 100644 index 0000000000..f638e7fdfa --- /dev/null +++ b/apps/server/src/serverSettings.ts @@ -0,0 +1,339 @@ +/** + * ServerSettings - Server-authoritative settings service. + * + * Owns persistence, validation, and change notification of settings that affect + * server-side behavior (binary paths, streaming mode, env mode, custom models, + * text generation model selection). + * + * Follows the same pattern as `keybindings.ts`: JSON file + Cache + PubSub + + * Semaphore + FileSystem.watch for concurrency and external edit detection. + * + * @module ServerSettings + */ +import { + DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, + DEFAULT_SERVER_SETTINGS, + type ModelSelection, + type ProviderKind, + ServerSettings, + type ServerSettingsPatch, +} from "@t3tools/contracts"; +import { + Cache, + Deferred, + Duration, + Effect, + Exit, + FileSystem, + Layer, + Path, + PubSub, + Ref, + Schema, + SchemaIssue, + Scope, + ServiceMap, + Stream, +} from "effect"; +import * as Semaphore from "effect/Semaphore"; +import { ServerConfig } from "./config"; +import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; +import { fromLenientJson } from "@t3tools/shared/schemaJson"; + +export class ServerSettingsError extends Schema.TaggedErrorClass()( + "ServerSettingsError", + { + settingsPath: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Server settings error at ${this.settingsPath}: ${this.detail}`; + } +} + +export interface ServerSettingsShape { + /** Start the settings runtime and attach file watching. */ + readonly start: Effect.Effect; + + /** Await settings runtime readiness. */ + readonly ready: Effect.Effect; + + /** Read the current settings. */ + readonly getSettings: Effect.Effect; + + /** Patch settings and persist. Returns the new full settings object. */ + readonly updateSettings: ( + patch: ServerSettingsPatch, + ) => Effect.Effect; + + /** Stream of settings change events. */ + readonly streamChanges: Stream.Stream; +} + +export class ServerSettingsService extends ServiceMap.Service< + ServerSettingsService, + ServerSettingsShape +>()("t3/serverSettings/ServerSettingsService") { + static readonly layerTest = (overrides: DeepPartial = {}) => + Layer.effect( + ServerSettingsService, + Effect.gen(function* () { + const currentSettingsRef = yield* Ref.make( + deepMerge(DEFAULT_SERVER_SETTINGS, overrides), + ); + + return { + start: Effect.void, + ready: Effect.void, + getSettings: Ref.get(currentSettingsRef), + updateSettings: (patch) => + Ref.get(currentSettingsRef).pipe( + Effect.map((currentSettings) => deepMerge(currentSettings, patch)), + Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), + ), + streamChanges: Stream.empty, + } satisfies ServerSettingsShape; + }), + ); +} + +const ServerSettingsJson = fromLenientJson(ServerSettings); + +const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent"]; + +/** + * Ensure the `textGenerationModelSelection` points to an enabled provider. + * If the selected provider is disabled, fall back to the first enabled + * provider with its default model. This is applied at read-time so the + * persisted preference is preserved for when a provider is re-enabled. + */ +function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { + const selection = settings.textGenerationModelSelection; + if (settings.providers[selection.provider].enabled) { + return settings; + } + + const fallback = PROVIDER_ORDER.find((p) => settings.providers[p].enabled); + if (!fallback) { + // No providers enabled — return as-is; callers will report the error. + return settings; + } + + return { + ...settings, + textGenerationModelSelection: { + provider: fallback, + model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback], + } as ModelSelection, + }; +} + +function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined { + if (Array.isArray(current) || Array.isArray(defaults)) { + return JSON.stringify(current) === JSON.stringify(defaults) ? undefined : current; + } + + if ( + current !== null && + defaults !== null && + typeof current === "object" && + typeof defaults === "object" + ) { + const currentRecord = current as Record; + const defaultsRecord = defaults as Record; + const next: Record = {}; + + for (const key of Object.keys(currentRecord)) { + const stripped = stripDefaultServerSettings(currentRecord[key], defaultsRecord[key]); + if (stripped !== undefined) { + next[key] = stripped; + } + } + + return Object.keys(next).length > 0 ? next : undefined; + } + + return Object.is(current, defaults) ? undefined : current; +} + +const makeServerSettings = Effect.gen(function* () { + const { settingsPath } = yield* ServerConfig; + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const writeSemaphore = yield* Semaphore.make(1); + const cacheKey = "settings" as const; + const changesPubSub = yield* PubSub.unbounded(); + const startedRef = yield* Ref.make(false); + const startedDeferred = yield* Deferred.make(); + const watcherScope = yield* Scope.make("sequential"); + yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void)); + + const emitChange = (settings: ServerSettings) => + PubSub.publish(changesPubSub, settings).pipe(Effect.asVoid); + + const readConfigExists = fs.exists(settingsPath).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + detail: "failed to check settings file existence", + cause, + }), + ), + ); + + const readRawConfig = fs.readFileString(settingsPath).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + detail: "failed to read settings file", + cause, + }), + ), + ); + + const loadSettingsFromDisk = Effect.gen(function* () { + if (!(yield* readConfigExists)) { + return DEFAULT_SERVER_SETTINGS; + } + + const raw = yield* readRawConfig; + const decoded = Schema.decodeUnknownExit(ServerSettingsJson)(raw); + if (decoded._tag === "Failure") { + yield* Effect.logWarning("failed to parse settings.json, using defaults", { + path: settingsPath, + }); + return DEFAULT_SERVER_SETTINGS; + } + return decoded.value; + }); + + const settingsCache = yield* Cache.make({ + capacity: 1, + lookup: () => loadSettingsFromDisk, + }); + + const getSettingsFromCache = Cache.get(settingsCache, cacheKey); + + const writeSettingsAtomically = (settings: ServerSettings) => { + const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`; + const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}; + + return Effect.succeed(`${JSON.stringify(sparseSettings, null, 2)}\n`).pipe( + Effect.tap(() => fs.makeDirectory(pathService.dirname(settingsPath), { recursive: true })), + Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), + Effect.flatMap(() => fs.rename(tempPath, settingsPath)), + Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))), + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + detail: "failed to write settings file", + cause, + }), + ), + ); + }; + + const revalidateAndEmit = writeSemaphore.withPermits(1)( + Effect.gen(function* () { + yield* Cache.invalidate(settingsCache, cacheKey); + const settings = yield* getSettingsFromCache; + yield* emitChange(settings); + }), + ); + + const startWatcher = Effect.gen(function* () { + const settingsDir = pathService.dirname(settingsPath); + const settingsFile = pathService.basename(settingsPath); + const settingsPathResolved = pathService.resolve(settingsPath); + + yield* fs.makeDirectory(settingsDir, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + detail: "failed to prepare settings directory", + cause, + }), + ), + ); + + const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); + + // Debounce watch events so the file is fully written before we read it. + // Editors emit multiple events per save (truncate, write, rename) and + // `fs.watch` can fire before the content has been flushed to disk. + const debouncedSettingsEvents = fs.watch(settingsDir).pipe( + Stream.filter((event) => { + return ( + event.path === settingsFile || + event.path === settingsPath || + pathService.resolve(settingsDir, event.path) === settingsPathResolved + ); + }), + Stream.debounce(Duration.millis(100)), + ); + + yield* Stream.runForEach(debouncedSettingsEvents, () => revalidateAndEmitSafely).pipe( + Effect.ignoreCause({ log: true }), + Effect.forkIn(watcherScope), + Effect.asVoid, + ); + }); + + const start = Effect.gen(function* () { + const shouldStart = yield* Ref.modify(startedRef, (started) => [!started, true]); + if (!shouldStart) { + return yield* Deferred.await(startedDeferred); + } + + const startup = Effect.gen(function* () { + yield* startWatcher; + yield* Cache.invalidate(settingsCache, cacheKey); + yield* getSettingsFromCache; + }); + + const startupExit = yield* Effect.exit(startup); + if (startupExit._tag === "Failure") { + yield* Deferred.failCause(startedDeferred, startupExit.cause).pipe(Effect.orDie); + return yield* Effect.failCause(startupExit.cause); + } + + yield* Deferred.succeed(startedDeferred, undefined).pipe(Effect.orDie); + }); + + return { + start, + ready: Deferred.await(startedDeferred), + getSettings: getSettingsFromCache.pipe(Effect.map(resolveTextGenerationProvider)), + updateSettings: (patch) => + writeSemaphore.withPermits(1)( + Effect.gen(function* () { + const current = yield* getSettingsFromCache; + const next = yield* Schema.decodeEffect(ServerSettings)(deepMerge(current, patch)).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath: "", + detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, + cause, + }), + ), + ); + yield* writeSettingsAtomically(next); + yield* Cache.set(settingsCache, cacheKey, next); + yield* emitChange(next); + return resolveTextGenerationProvider(next); + }), + ), + get streamChanges() { + return Stream.fromPubSub(changesPubSub).pipe(Stream.map(resolveTextGenerationProvider)); + }, + } satisfies ServerSettingsShape; +}); + +export const ServerSettingsLive = Layer.effect(ServerSettingsService, makeServerSettings); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 7dc4a59e7c..826b9ad6fd 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -13,18 +13,20 @@ import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serve import { DEFAULT_TERMINAL_ID, + DEFAULT_SERVER_SETTINGS, EDITORS, EventId, ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, ProviderItemId, + type ServerSettings, ThreadId, TurnId, WS_CHANNELS, WS_METHODS, type WebSocketResponse, type ProviderRuntimeEvent, - type ServerProviderStatus, + type ServerProvider, type KeybindingsConfig, type ResolvedKeybindingsConfig, type WsPushChannel, @@ -45,7 +47,7 @@ import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/ import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; import { SqlClient, SqlError } from "effect/unstable/sql"; import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; -import { ProviderHealth, type ProviderHealthShape } from "./provider/Services/ProviderHealth"; +import { ProviderRegistry, type ProviderRegistryShape } from "./provider/Services/ProviderRegistry"; import { Open, type OpenShape } from "./open"; import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; import type { GitCoreShape } from "./git/Services/GitCore.ts"; @@ -53,6 +55,7 @@ import { GitCore } from "./git/Services/GitCore.ts"; import { GitCommandError, GitManagerError } from "./git/Errors.ts"; import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; +import { ServerSettingsService } from "./serverSettings.ts"; const asEventId = (value: string): EventId => EventId.makeUnsafe(value); const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); @@ -64,20 +67,27 @@ const defaultOpenService: OpenShape = { openInEditor: () => Effect.void, }; -const defaultProviderStatuses: ReadonlyArray = [ +const defaultProviderStatuses: ReadonlyArray = [ { provider: "codex", + enabled: true, + installed: true, + version: "0.116.0", status: "ready", - available: true, authStatus: "authenticated", checkedAt: "2026-01-01T00:00:00.000Z", + models: [], }, ]; -const defaultProviderHealthService: ProviderHealthShape = { - getStatuses: Effect.succeed(defaultProviderStatuses), +const defaultProviderRegistryService: ProviderRegistryShape = { + getProviders: Effect.succeed(defaultProviderStatuses), + refresh: () => Effect.succeed(defaultProviderStatuses), + streamChanges: Stream.empty, }; +const defaultServerSettings = DEFAULT_SERVER_SETTINGS; + class MockTerminalManager implements TerminalManagerShape { private readonly sessions = new Map(); private readonly listeners = new Set<(event: TerminalEvent) => void>(); @@ -487,11 +497,12 @@ describe("WebSocket Server", () => { baseDir?: string; staticDir?: string; providerLayer?: Layer.Layer; - providerHealth?: ProviderHealthShape; + providerRegistry?: ProviderRegistryShape; open?: OpenShape; gitManager?: GitManagerShape; gitCore?: Pick; terminalManager?: TerminalManagerShape; + serverSettings?: Partial; } = {}, ): Promise { if (serverScope) { @@ -504,9 +515,9 @@ describe("WebSocket Server", () => { const scope = await Effect.runPromise(Scope.make("sequential")); const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory; const providerLayer = options.providerLayer ?? makeServerProviderLayer(); - const providerHealthLayer = Layer.succeed( - ProviderHealth, - options.providerHealth ?? defaultProviderHealthService, + const providerRegistryLayer = Layer.succeed( + ProviderRegistry, + options.providerRegistry ?? defaultProviderRegistryService, ); const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService); const serverConfigLayer = Layer.succeed(ServerConfig, { @@ -543,8 +554,9 @@ describe("WebSocket Server", () => { ); const dependenciesLayer = Layer.empty.pipe( Layer.provideMerge(runtimeLayer), - Layer.provideMerge(providerHealthLayer), + Layer.provideMerge(providerRegistryLayer), Layer.provideMerge(openLayer), + Layer.provideMerge(ServerSettingsService.layerTest(options.serverSettings)), Layer.provideMerge(serverConfigLayer), Layer.provideMerge(AnalyticsService.layerTest), Layer.provideMerge(NodeServices.layer), @@ -858,6 +870,7 @@ describe("WebSocket Server", () => { issues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), + settings: defaultServerSettings, }); expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); }); @@ -883,6 +896,7 @@ describe("WebSocket Server", () => { issues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), + settings: defaultServerSettings, }); expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); @@ -919,6 +933,7 @@ describe("WebSocket Server", () => { ], providers: defaultProviderStatuses, availableEditors: expect.any(Array), + settings: defaultServerSettings, }); expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); expect(fs.readFileSync(keybindingsPath, "utf8")).toBe("{ not-json"); @@ -952,7 +967,7 @@ describe("WebSocket Server", () => { keybindingsConfigPath: string; keybindings: ResolvedKeybindingsConfig; issues: Array<{ kind: string; index?: number; message: string }>; - providers: ReadonlyArray; + providers: ReadonlyArray; availableEditors: unknown; }; expect(result.cwd).toBe("/my/workspace"); @@ -1000,7 +1015,6 @@ describe("WebSocket Server", () => { ); expect(malformedPush.data).toEqual({ issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }], - providers: defaultProviderStatuses, }); const successPush = await rewriteKeybindingsAndWaitForPush( @@ -1009,7 +1023,7 @@ describe("WebSocket Server", () => { "[]", (push) => Array.isArray(push.data.issues) && push.data.issues.length === 0, ); - expect(successPush.data).toEqual({ issues: [], providers: defaultProviderStatuses }); + expect(successPush.data).toEqual({ issues: [] }); }); it("routes shell.openInEditor through the injected open service", async () => { @@ -1069,6 +1083,7 @@ describe("WebSocket Server", () => { issues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), + settings: defaultServerSettings, }); expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); }); @@ -1117,6 +1132,7 @@ describe("WebSocket Server", () => { issues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), + settings: defaultServerSettings, }); expectAvailableEditors( (configResponse.result as { availableEditors: unknown }).availableEditors, @@ -1273,6 +1289,7 @@ describe("WebSocket Server", () => { server = await createTestServer({ cwd: "/test", providerLayer, + serverSettings: { enableAssistantStreaming: true }, }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; @@ -1323,7 +1340,6 @@ describe("WebSocket Server", () => { text: "hello", attachments: [], }, - assistantDeliveryMode: "streaming", runtimeMode: "approval-required", interactionMode: "default", createdAt, @@ -1850,10 +1866,6 @@ describe("WebSocket Server", () => { actionId: "client-action-1", cwd: "/test", action: "commit_push", - modelSelection: { - provider: "codex", - model: "gpt-5.4-mini", - }, }, expect.objectContaining({ actionId: "client-action-1", diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index bcb3850e7a..a4f6f987b6 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -49,12 +49,13 @@ import { createLogger } from "./logger"; import { GitManager } from "./git/Services/GitManager.ts"; import { TerminalManager } from "./terminal/Services/Manager.ts"; import { Keybindings } from "./keybindings"; +import { ServerSettingsService } from "./serverSettings"; import { searchWorkspaceEntries } from "./workspaceEntries"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; import { ProviderService } from "./provider/Services/ProviderService"; -import { ProviderHealth } from "./provider/Services/ProviderHealth"; +import { ProviderRegistry } from "./provider/Services/ProviderRegistry"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; import { clamp } from "effect/Number"; import { Open, resolveAvailableEditors } from "./open"; @@ -208,7 +209,7 @@ export type ServerCoreRuntimeServices = | CheckpointDiffQuery | OrchestrationReactor | ProviderService - | ProviderHealth; + | ProviderRegistry; export type ServerRuntimeServices = | ServerCoreRuntimeServices @@ -216,6 +217,7 @@ export type ServerRuntimeServices = | GitCore | TerminalManager | Keybindings + | ServerSettingsService | Open | AnalyticsService; @@ -253,7 +255,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const gitManager = yield* GitManager; const terminalManager = yield* TerminalManager; const keybindingsManager = yield* Keybindings; - const providerHealth = yield* ProviderHealth; + const serverSettingsManager = yield* ServerSettingsService; + const providerRegistry = yield* ProviderRegistry; const git = yield* GitCore; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -268,7 +271,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ), ); - const providerStatuses = yield* providerHealth.getStatuses; + const providersRef = yield* Ref.make(yield* providerRegistry.getProviders); const clients = yield* Ref.make(new Set()); const logger = createLogger("ws"); @@ -295,6 +298,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ), ); yield* readiness.markKeybindingsReady; + yield* serverSettingsManager.start.pipe( + Effect.mapError( + (cause) => new ServerLifecycleError({ operation: "serverSettingsRuntimeStart", cause }), + ), + ); const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { readonly command: ClientOrchestrationCommand; @@ -614,7 +622,22 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< yield* Stream.runForEach(keybindingsManager.streamChanges, (event) => pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { issues: event.issues, - providers: providerStatuses, + }), + ).pipe(Effect.forkIn(subscriptionsScope)); + + yield* Stream.runForEach(serverSettingsManager.streamChanges, (settings) => + pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { + issues: [], + settings, + }), + ).pipe(Effect.forkIn(subscriptionsScope)); + + yield* Stream.runForEach(providerRegistry.streamChanges, (providers) => + Effect.gen(function* () { + yield* Ref.set(providersRef, providers); + yield* pushBus.publishAll(WS_CHANNELS.serverProvidersUpdated, { + providers, + }); }), ).pipe(Effect.forkIn(subscriptionsScope)); @@ -878,16 +901,26 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return yield* terminalManager.close(body); } - case WS_METHODS.serverGetConfig: + case WS_METHODS.serverGetConfig: { const keybindingsConfig = yield* keybindingsManager.loadConfigState; + const settings = yield* serverSettingsManager.getSettings; + const providers = yield* Ref.get(providersRef); return { cwd, keybindingsConfigPath, keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, - providers: providerStatuses, + providers, availableEditors, + settings, }; + } + + case WS_METHODS.serverRefreshProviders: { + const providers = yield* providerRegistry.refresh(); + yield* Ref.set(providersRef, providers); + return { providers }; + } case WS_METHODS.serverUpsertKeybinding: { const body = stripRequestTag(request.body); @@ -895,6 +928,15 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { keybindings: keybindingsConfig, issues: [] }; } + case WS_METHODS.serverGetSettings: { + return yield* serverSettingsManager.getSettings; + } + + case WS_METHODS.serverUpdateSettings: { + const body = stripRequestTag(request.body); + return yield* serverSettingsManager.updateSettings(body.patch); + } + default: { const _exhaustiveCheck: never = request.body; return yield* new RouteRequestError({ diff --git a/apps/server/src/wsServer/pushBus.test.ts b/apps/server/src/wsServer/pushBus.test.ts index 172944607b..80e8be2185 100644 --- a/apps/server/src/wsServer/pushBus.test.ts +++ b/apps/server/src/wsServer/pushBus.test.ts @@ -53,7 +53,6 @@ describe("makeServerPushBus", () => { yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { issues: [{ kind: "keybindings.malformed-config", message: "queued-before-connect" }], - providers: [], }); const delivered = yield* pushBus.publishClient( @@ -70,7 +69,6 @@ describe("makeServerPushBus", () => { yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { issues: [], - providers: [], }); yield* Effect.promise(() => client.waitForSentCount(2)); @@ -95,7 +93,6 @@ describe("makeServerPushBus", () => { channel: WS_CHANNELS.serverConfigUpdated, data: { issues: [], - providers: [], }, }); }), diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts deleted file mode 100644 index fea74edd72..0000000000 --- a/apps/web/src/appSettings.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { Schema } from "effect"; -import { describe, expect, it } from "vitest"; - -import { - AppSettingsSchema, - DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - DEFAULT_SIDEBAR_THREAD_SORT_ORDER, - DEFAULT_TIMESTAMP_FORMAT, - getProviderStartOptions, -} from "./appSettings"; -import { - getAppModelOptions, - getCustomModelOptionsByProvider, - getCustomModelsByProvider, - getCustomModelsForProvider, - getDefaultCustomModelsForProvider, - MODEL_PROVIDER_SETTINGS, - normalizeCustomModelSlugs, - patchCustomModels, - resolveAppModelSelectionState, - resolveAppModelSelection, -} from "./modelSelection"; - -describe("normalizeCustomModelSlugs", () => { - it("normalizes aliases, removes built-ins, and deduplicates values", () => { - expect( - normalizeCustomModelSlugs([ - " custom/internal-model ", - "gpt-5.3-codex", - "5.3", - "custom/internal-model", - "", - null, - ]), - ).toEqual(["custom/internal-model"]); - }); - - it("normalizes provider-specific aliases for claude", () => { - expect(normalizeCustomModelSlugs(["sonnet"], "claudeAgent")).toEqual([]); - expect(normalizeCustomModelSlugs(["claude/custom-sonnet"], "claudeAgent")).toEqual([ - "claude/custom-sonnet", - ]); - }); -}); - -describe("getAppModelOptions", () => { - it("appends saved custom models after the built-in options", () => { - const options = getAppModelOptions("codex", ["custom/internal-model"]); - - expect(options.map((option) => option.slug)).toEqual([ - "gpt-5.4", - "gpt-5.4-mini", - "gpt-5.3-codex", - "gpt-5.3-codex-spark", - "gpt-5.2-codex", - "gpt-5.2", - "custom/internal-model", - ]); - }); - - it("keeps the currently selected custom model available even if it is no longer saved", () => { - const options = getAppModelOptions("codex", [], "custom/selected-model"); - - expect(options.at(-1)).toEqual({ - slug: "custom/selected-model", - name: "custom/selected-model", - isCustom: true, - }); - }); - it("keeps a saved custom provider model available as an exact slug option", () => { - const options = getAppModelOptions("claudeAgent", ["claude/custom-opus"], "claude/custom-opus"); - - expect(options.some((option) => option.slug === "claude/custom-opus" && option.isCustom)).toBe( - true, - ); - }); -}); - -describe("resolveAppModelSelection", () => { - it("preserves saved custom model slugs instead of falling back to the default", () => { - expect( - resolveAppModelSelection( - "codex", - { codex: ["galapagos-alpha"], claudeAgent: [] }, - "galapagos-alpha", - ), - ).toBe("galapagos-alpha"); - }); - - it("falls back to the provider default when no model is selected", () => { - expect(resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "")).toBe("gpt-5.4"); - }); - - it("resolves display names through the shared resolver", () => { - expect(resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "GPT-5.3 Codex")).toBe( - "gpt-5.3-codex", - ); - }); - - it("resolves aliases through the shared resolver", () => { - expect(resolveAppModelSelection("claudeAgent", { codex: [], claudeAgent: [] }, "sonnet")).toBe( - "claude-sonnet-4-6", - ); - }); - - it("resolves transient selected custom models included in app model options", () => { - expect( - resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "custom/selected-model"), - ).toBe("custom/selected-model"); - }); -}); - -describe("timestamp format defaults", () => { - it("defaults timestamp format to locale", () => { - expect(DEFAULT_TIMESTAMP_FORMAT).toBe("locale"); - }); -}); - -describe("sidebar sort defaults", () => { - it("defaults project sorting to updated_at", () => { - expect(DEFAULT_SIDEBAR_PROJECT_SORT_ORDER).toBe("updated_at"); - }); - - it("defaults thread sorting to updated_at", () => { - expect(DEFAULT_SIDEBAR_THREAD_SORT_ORDER).toBe("updated_at"); - }); -}); - -describe("provider-specific custom models", () => { - it("includes provider-specific custom slugs in non-codex model lists", () => { - const claudeOptions = getAppModelOptions("claudeAgent", ["claude/custom-opus"]); - - expect(claudeOptions.some((option) => option.slug === "claude/custom-opus")).toBe(true); - }); -}); - -describe("getProviderStartOptions", () => { - it("returns only populated provider overrides", () => { - expect( - getProviderStartOptions({ - claudeBinaryPath: "/usr/local/bin/claude", - codexBinaryPath: "", - codexHomePath: "/Users/you/.codex", - }), - ).toEqual({ - claudeAgent: { - binaryPath: "/usr/local/bin/claude", - }, - codex: { - homePath: "/Users/you/.codex", - }, - }); - }); - - it("returns undefined when no provider overrides are configured", () => { - expect( - getProviderStartOptions({ - claudeBinaryPath: "", - codexBinaryPath: "", - codexHomePath: "", - }), - ).toBeUndefined(); - }); -}); - -describe("provider-indexed custom model settings", () => { - const settings = { - customCodexModels: ["custom/codex-model"], - customClaudeModels: ["claude/custom-opus"], - } as const; - - it("exports one provider config per provider", () => { - expect(MODEL_PROVIDER_SETTINGS.map((config) => config.provider)).toEqual([ - "codex", - "claudeAgent", - ]); - }); - - it("reads custom models for each provider", () => { - expect(getCustomModelsForProvider(settings, "codex")).toEqual(["custom/codex-model"]); - expect(getCustomModelsForProvider(settings, "claudeAgent")).toEqual(["claude/custom-opus"]); - }); - - it("reads default custom models for each provider", () => { - const defaults = { - customCodexModels: ["default/codex-model"], - customClaudeModels: ["claude/default-opus"], - } as const; - - expect(getDefaultCustomModelsForProvider(defaults, "codex")).toEqual(["default/codex-model"]); - expect(getDefaultCustomModelsForProvider(defaults, "claudeAgent")).toEqual([ - "claude/default-opus", - ]); - }); - - it("patches custom models for codex", () => { - expect(patchCustomModels("codex", ["custom/codex-model"])).toEqual({ - customCodexModels: ["custom/codex-model"], - }); - }); - - it("patches custom models for claude", () => { - expect(patchCustomModels("claudeAgent", ["claude/custom-opus"])).toEqual({ - customClaudeModels: ["claude/custom-opus"], - }); - }); - - it("builds a complete provider-indexed custom model record", () => { - expect(getCustomModelsByProvider(settings)).toEqual({ - codex: ["custom/codex-model"], - claudeAgent: ["claude/custom-opus"], - }); - }); - - it("builds provider-indexed model options including custom models", () => { - const modelOptionsByProvider = getCustomModelOptionsByProvider(settings); - - expect( - modelOptionsByProvider.codex.some((option) => option.slug === "custom/codex-model"), - ).toBe(true); - expect( - modelOptionsByProvider.claudeAgent.some((option) => option.slug === "claude/custom-opus"), - ).toBe(true); - }); - - it("normalizes and deduplicates custom model options per provider", () => { - const modelOptionsByProvider = getCustomModelOptionsByProvider({ - customCodexModels: [" custom/codex-model ", "gpt-5.4", "custom/codex-model"], - customClaudeModels: [" sonnet ", "claude/custom-opus", "claude/custom-opus"], - }); - - expect( - modelOptionsByProvider.codex.filter((option) => option.slug === "custom/codex-model"), - ).toHaveLength(1); - expect(modelOptionsByProvider.codex.some((option) => option.slug === "gpt-5.4")).toBe(true); - expect( - modelOptionsByProvider.claudeAgent.filter((option) => option.slug === "claude/custom-opus"), - ).toHaveLength(1); - expect( - modelOptionsByProvider.claudeAgent.some((option) => option.slug === "claude-sonnet-4-6"), - ).toBe(true); - }); -}); - -describe("AppSettingsSchema", () => { - it("fills decoding defaults for persisted settings that predate newer keys", () => { - const decode = Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema)); - - expect( - decode( - JSON.stringify({ - codexBinaryPath: "/usr/local/bin/codex", - confirmThreadDelete: false, - }), - ), - ).toMatchObject({ - claudeBinaryPath: "", - codexBinaryPath: "/usr/local/bin/codex", - codexHomePath: "", - defaultThreadEnvMode: "local", - confirmThreadDelete: false, - enableAssistantStreaming: false, - sidebarProjectSortOrder: DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - sidebarThreadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, - timestampFormat: DEFAULT_TIMESTAMP_FORMAT, - customCodexModels: [], - customClaudeModels: [], - }); - }); -}); - -describe("resolveAppModelSelectionState", () => { - it("falls back to the default git-writing codex selection", () => { - expect( - resolveAppModelSelectionState({ - customCodexModels: [], - customClaudeModels: [], - textGenerationModelSelection: undefined, - }), - ).toEqual({ - provider: "codex", - model: "gpt-5.4-mini", - }); - }); - - it("preserves the selected provider and resolves saved custom models", () => { - expect( - resolveAppModelSelectionState({ - customCodexModels: [], - customClaudeModels: ["claude/custom-haiku"], - textGenerationModelSelection: { - provider: "claudeAgent", - model: "claude/custom-haiku", - }, - }), - ).toEqual({ - provider: "claudeAgent", - model: "claude/custom-haiku", - }); - }); - - it("normalizes provider options against the resolved model capabilities", () => { - expect( - resolveAppModelSelectionState({ - customCodexModels: [], - customClaudeModels: [], - textGenerationModelSelection: { - provider: "claudeAgent", - model: "claude-haiku-4-5", - options: { - effort: "max", - thinking: false, - fastMode: true, - }, - }, - }), - ).toEqual({ - provider: "claudeAgent", - model: "claude-haiku-4-5", - options: { - thinking: false, - }, - }); - }); -}); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts deleted file mode 100644 index e2aac52a84..0000000000 --- a/apps/web/src/appSettings.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { useCallback } from "react"; -import { Option, Schema } from "effect"; -import { - DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, - ModelSelection, - type ProviderStartOptions, -} from "@t3tools/contracts"; -import { useLocalStorage } from "./hooks/useLocalStorage"; -import { EnvMode } from "./components/BranchToolbar.logic"; -import { normalizeCustomModelSlugs } from "./modelSelection"; - -const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; - -export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); -export type TimestampFormat = typeof TimestampFormat.Type; -export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; -export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); -export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; -export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; -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"; - -const withDefaults = - < - S extends Schema.Top & Schema.WithoutConstructorDefault, - D extends S["~type.make.in"] & S["Encoded"], - >( - fallback: () => D, - ) => - (schema: S) => - schema.pipe( - Schema.withConstructorDefault(() => Option.some(fallback())), - Schema.withDecodingDefault(() => fallback()), - ); - -export const AppSettingsSchema = Schema.Struct({ - claudeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), - 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)), - confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)), - diffWordWrap: Schema.Boolean.pipe(withDefaults(() => false)), - enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)), - sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( - withDefaults(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER), - ), - sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( - withDefaults(() => DEFAULT_SIDEBAR_THREAD_SORT_ORDER), - ), - timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)), - customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), - customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), - textGenerationModelSelection: ModelSelection.pipe( - withDefaults(() => ({ - provider: "codex" as const, - model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, - })), - ), -}); -export type AppSettings = typeof AppSettingsSchema.Type; - -const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); - -function normalizeAppSettings(settings: AppSettings): AppSettings { - return { - ...settings, - customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), - customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"), - }; -} - -export function getProviderStartOptions( - settings: Pick, -): ProviderStartOptions | undefined { - const providerOptions: ProviderStartOptions = { - ...(settings.codexBinaryPath || settings.codexHomePath - ? { - codex: { - ...(settings.codexBinaryPath ? { binaryPath: settings.codexBinaryPath } : {}), - ...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}), - }, - } - : {}), - ...(settings.claudeBinaryPath - ? { - claudeAgent: { - binaryPath: settings.claudeBinaryPath, - }, - } - : {}), - }; - - return Object.keys(providerOptions).length > 0 ? providerOptions : undefined; -} - -export function useAppSettings() { - const [settings, setSettings] = useLocalStorage( - APP_SETTINGS_STORAGE_KEY, - DEFAULT_APP_SETTINGS, - AppSettingsSchema, - ); - - const updateSettings = useCallback( - (patch: Partial) => { - setSettings((prev) => normalizeAppSettings({ ...prev, ...patch })); - }, - [setSettings], - ); - - const resetSettings = useCallback(() => { - setSettings(DEFAULT_APP_SETTINGS); - }, [setSettings]); - - return { - settings, - updateSettings, - resetSettings, - defaults: DEFAULT_APP_SETTINGS, - } as const; -} diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 4e18092463..0995ae1ccc 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -12,6 +12,7 @@ import { WS_CHANNELS, WS_METHODS, OrchestrationSessionStatus, + DEFAULT_SERVER_SETTINGS, } from "@t3tools/contracts"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { HttpResponse, http, ws } from "msw"; @@ -30,6 +31,7 @@ import { isMacPlatform } from "../lib/utils"; import { getRouter } from "../router"; import { useStore } from "../store"; import { estimateTimelineMessageHeight } from "./timelineHeight"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; const THREAD_ID = "thread-browser-test" as ThreadId; const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; @@ -110,13 +112,20 @@ function createBaseServerConfig(): ServerConfig { providers: [ { provider: "codex", + enabled: true, + installed: true, + version: "0.116.0", status: "ready", - available: true, authStatus: "authenticated", checkedAt: NOW_ISO, + models: [], }, ], availableEditors: [], + settings: { + ...DEFAULT_SERVER_SETTINGS, + ...DEFAULT_CLIENT_SETTINGS, + }, }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ace74a5cc8..06d37eb60b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -13,7 +13,7 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ResolvedKeybindingsConfig, - type ServerProviderStatus, + type ServerProvider, type ThreadId, type TurnId, type EditorId, @@ -22,11 +22,7 @@ import { ProviderInteractionMode, RuntimeMode, } from "@t3tools/contracts"; -import { - applyClaudePromptEffortPrefix, - getModelCapabilities, - normalizeModelSlug, -} from "@t3tools/shared/model"; +import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; @@ -120,12 +116,13 @@ import { import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -import { getProviderStartOptions, useAppSettings } from "../appSettings"; import { - getCustomModelOptionsByProvider, - getCustomModelsByProvider, - resolveAppModelSelection, -} from "../modelSelection"; + getProviderModelCapabilities, + getProviderModels, + resolveSelectableProvider, +} from "../providerModels"; +import { useSettings } from "../hooks/useSettings"; +import { resolveAppModelSelection } from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; import { type ComposerImageAttachment, @@ -164,7 +161,7 @@ import { renderProviderTraitsMenuContent, renderProviderTraitsPicker, } from "./chat/composerProviderRegistry"; -import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; +import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { buildExpiredTerminalContextToastCopy, @@ -191,16 +188,17 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; -const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; +const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; function formatOutgoingPrompt(params: { provider: ProviderKind; model: string | null; + models: ReadonlyArray; effort: string | null; text: string; }): string { - const caps = getModelCapabilities(params.provider, params.model); + const caps = getProviderModelCapabilities(params.models, params.model, params.provider); if (params.effort && caps.promptInjectedEffortLevels.includes(params.effort)) { return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null); } @@ -249,7 +247,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); - const { settings } = useAppSettings(); + const settings = useSettings(); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); @@ -608,25 +606,32 @@ export default function ChatView({ threadId }: ChatViewProps) { const lockedProvider: ProviderKind | null = hasThreadStarted ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null) : null; - const selectedProvider: ProviderKind = - lockedProvider ?? selectedProviderByThreadId ?? threadProvider ?? "codex"; - const customModelsByProvider = useMemo(() => getCustomModelsByProvider(settings), [settings]); + const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDERS; + const unlockedSelectedProvider = resolveSelectableProvider( + providerStatuses, + selectedProviderByThreadId ?? threadProvider ?? "codex", + ); + const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ threadId, + providers: providerStatuses, selectedProvider, threadModelSelection: activeThread?.modelSelection, projectModelSelection: activeProject?.defaultModelSelection, - customModelsByProvider, + settings, }); + const selectedProviderModels = getProviderModels(providerStatuses, selectedProvider); const composerProviderState = useMemo( () => getComposerProviderState({ provider: selectedProvider, model: selectedModel, + models: selectedProviderModels, prompt, modelOptions: composerModelOptions, }), - [composerModelOptions, prompt, selectedModel, selectedProvider], + [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels], ); const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; @@ -638,35 +643,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }), [selectedModel, selectedModelOptionsForDispatch, selectedProvider], ); - const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]); const selectedModelForPicker = selectedModel; - const modelOptionsByProvider = useMemo( - () => getCustomModelOptionsByProvider(settings, selectedProvider, selectedModel), - [settings, selectedProvider, selectedModel], - ); - const selectedModelForPickerWithCustomFallback = useMemo(() => { - const currentOptions = modelOptionsByProvider[selectedProvider]; - return currentOptions.some((option) => option.slug === selectedModelForPicker) - ? selectedModelForPicker - : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); - }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); - const searchableModelOptions = useMemo( - () => - AVAILABLE_PROVIDER_OPTIONS.filter( - (option) => lockedProvider === null || option.value === lockedProvider, - ).flatMap((option) => - modelOptionsByProvider[option.value].map(({ slug, name }) => ({ - provider: option.value, - providerLabel: option.label, - slug, - name, - searchSlug: slug.toLowerCase(), - searchName: name.toLowerCase(), - searchProvider: option.label.toLowerCase(), - })), - ), - [lockedProvider, modelOptionsByProvider], - ); const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; const isPreparingWorktree = sendPhase === "preparing-worktree"; @@ -1029,7 +1006,39 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); - const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; + const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; + const modelOptionsByProvider = useMemo( + () => ({ + codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], + claudeAgent: + providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [], + }), + [providerStatuses], + ); + const selectedModelForPickerWithCustomFallback = useMemo(() => { + const currentOptions = modelOptionsByProvider[selectedProvider]; + return currentOptions.some((option) => option.slug === selectedModelForPicker) + ? selectedModelForPicker + : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); + }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); + const searchableModelOptions = useMemo( + () => + AVAILABLE_PROVIDER_OPTIONS.filter( + (option) => lockedProvider === null || option.value === lockedProvider, + ).flatMap((option) => + modelOptionsByProvider[option.value].map(({ slug, name }) => ({ + provider: option.value, + providerLabel: option.label, + slug, + name, + searchSlug: slug.toLowerCase(), + searchName: name.toLowerCase(), + searchProvider: option.label.toLowerCase(), + })), + ), + [lockedProvider, modelOptionsByProvider], + ); const workspaceEntriesQuery = useQuery( projectSearchEntriesQueryOptions({ cwd: gitCwd, @@ -1117,9 +1126,6 @@ export default function ChatView({ threadId }: ChatViewProps) { () => new Set(nonPersistedComposerImageIds), [nonPersistedComposerImageIds], ); - const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; - const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; - const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; const activeProviderStatus = useMemo( () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, [selectedProvider, providerStatuses], @@ -2460,6 +2466,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const outgoingMessageText = formatOutgoingPrompt({ provider: selectedProvider, model: selectedModel, + models: selectedProviderModels, effort: selectedPromptEffort, text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, }); @@ -2645,8 +2652,6 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: turnAttachments, }, modelSelection: selectedModelSelection, - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode, createdAt: messageCreatedAt, @@ -2883,6 +2888,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const outgoingMessageText = formatOutgoingPrompt({ provider: selectedProvider, model: selectedModel, + models: selectedProviderModels, effort: selectedPromptEffort, text: trimmed, }); @@ -2927,8 +2933,6 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: nextInteractionMode, ...(nextInteractionMode === "default" && activeProposedPlan @@ -2974,11 +2978,10 @@ export default function ChatView({ threadId }: ChatViewProps) { runtimeMode, selectedPromptEffort, selectedModelSelection, - providerOptionsForDispatch, selectedProvider, + selectedProviderModels, setComposerDraftInteractionMode, setThreadError, - settings.enableAssistantStreaming, selectedModel, ], ); @@ -3005,6 +3008,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const outgoingImplementationPrompt = formatOutgoingPrompt({ provider: selectedProvider, model: selectedModel, + models: selectedProviderModels, effort: selectedPromptEffort, text: implementationPrompt, }); @@ -3044,8 +3048,6 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: "default", createdAt, @@ -3096,9 +3098,8 @@ export default function ChatView({ threadId }: ChatViewProps) { runtimeMode, selectedPromptEffort, selectedModelSelection, - providerOptionsForDispatch, selectedProvider, - settings.enableAssistantStreaming, + selectedProviderModels, syncServerReadModel, selectedModel, ]); @@ -3110,9 +3111,15 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus(); return; } - const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model); + const resolvedProvider = resolveSelectableProvider(providerStatuses, provider); + const resolvedModel = resolveAppModelSelection( + resolvedProvider, + settings, + providerStatuses, + model, + ); const nextModelSelection: ModelSelection = { - provider, + provider: resolvedProvider, model: resolvedModel, }; setComposerDraftModelSelection(activeThread.id, nextModelSelection); @@ -3125,7 +3132,8 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus, setComposerDraftModelSelection, setStickyComposerModelSelection, - customModelsByProvider, + providerStatuses, + settings, ], ); const setPromptFromTraits = useCallback( @@ -3148,6 +3156,7 @@ export default function ChatView({ threadId }: ChatViewProps) { provider: selectedProvider, threadId, model: selectedModel, + models: selectedProviderModels, modelOptions: composerModelOptions?.[selectedProvider], prompt, onPromptChange: setPromptFromTraits, @@ -3156,6 +3165,7 @@ export default function ChatView({ threadId }: ChatViewProps) { provider: selectedProvider, threadId, model: selectedModel, + models: selectedProviderModels, modelOptions: composerModelOptions?.[selectedProvider], prompt, onPromptChange: setPromptFromTraits, @@ -3519,7 +3529,7 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Error banner */} - + setThreadError(activeThread.id, null)} @@ -3796,6 +3806,7 @@ export default function ChatView({ threadId }: ChatViewProps) { provider={selectedProvider} model={selectedModelForPickerWithCustomFallback} lockedProvider={lockedProvider} + providers={providerStatuses} modelOptionsByProvider={modelOptionsByProvider} {...(composerProviderState.modelPickerIconClassName ? { diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 96e0219872..fadb8cb69d 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -30,7 +30,7 @@ import { buildPatchCacheKey } from "../lib/diffRendering"; import { resolveDiffThemeName } from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useStore } from "../store"; -import { useAppSettings } from "../appSettings"; +import { useSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; @@ -166,7 +166,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const navigate = useNavigate(); const { resolvedTheme } = useTheme(); - const { settings } = useAppSettings(); + const settings = useSettings(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); const patchViewportRef = useRef(null); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 79fb1ff11e..be9670df9e 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -20,7 +20,6 @@ import { resolveQuickAction, summarizeGitResult, } from "./GitActionsControl.logic"; -import { useAppSettings } from "~/appSettings"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; import { @@ -205,7 +204,6 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { } export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { - const { settings } = useAppSettings(); const threadToastData = useMemo( () => (activeThreadId ? { threadId: activeThreadId } : undefined), [activeThreadId], @@ -261,7 +259,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient, - modelSelection: settings.textGenerationModelSelection, }), ); const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 7cb55e795c..e64a981eec 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -46,13 +46,25 @@ function createBaseServerConfig(): ServerConfig { providers: [ { provider: "codex", + enabled: true, + installed: true, + version: "0.116.0", status: "ready", - available: true, authStatus: "authenticated", checkedAt: NOW_ISO, + models: [], }, ], availableEditors: [], + settings: { + enableAssistantStreaming: false, + defaultThreadEnvMode: "local" as const, + textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" }, + providers: { + codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, + claudeAgent: { enabled: true, binaryPath: "", customModels: [] }, + }, + }, }; } diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index 47bee930cc..01341dc803 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -1,5 +1,5 @@ import { memo, useState, useCallback } from "react"; -import { type TimestampFormat } from "../appSettings"; +import { type TimestampFormat } from "@t3tools/contracts/settings"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { ScrollArea } from "./ui/scroll-area"; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index de5859af92..6ca29d27e9 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,4 +1,4 @@ -import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "../appSettings"; +import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { Thread } from "../types"; import { cn } from "../lib/utils"; import { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 923c30b2f9..6c531b1e7a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -41,8 +41,7 @@ import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { type SidebarProjectSortOrder, type SidebarThreadSortOrder, - useAppSettings, -} from "../appSettings"; +} from "@t3tools/contracts/settings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; @@ -102,6 +101,7 @@ import { sortThreadsForSidebar, } from "./Sidebar.logic"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -381,7 +381,8 @@ export default function Sidebar() { ); const navigate = useNavigate(); const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" }); - const { settings: appSettings, updateSettings } = useAppSettings(); + const appSettings = useSettings(); + const { updateSettings } = useUpdateSettings(); const { handleNewThread } = useHandleNewThread(); const routeThreadId = useParams({ strict: false, diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 01a5d32d64..8770e58138 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -43,6 +43,70 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str document.body.append(host); const onPromptChange = vi.fn(); const providerOptions = props?.modelSelection?.options; + const models = + provider === "claudeAgent" + ? [ + { + slug: "claude-opus-4-6", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: ["ultrathink"], + }, + }, + { + slug: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: true, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: ["ultrathink"], + }, + }, + ] + : [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + ]; const screen = await render( >; +function effort(value: string, isDefault = false) { + return { + value, + label: value, + ...(isDefault ? { isDefault: true } : {}), + }; +} + +const TEST_PROVIDERS: ReadonlyArray = [ + { + provider: "codex", + enabled: true, + installed: true, + version: "0.116.0", + status: "ready", + authStatus: "authenticated", + checkedAt: new Date().toISOString(), + models: [ + { + slug: "gpt-5-codex", + name: "GPT-5 Codex", + isCustom: false, + capabilities: { + reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + isCustom: false, + capabilities: { + reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + ], + }, + { + provider: "claudeAgent", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + authStatus: "authenticated", + checkedAt: new Date().toISOString(), + models: [ + { + slug: "claude-opus-4-6", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + effort("low"), + effort("medium", true), + effort("high"), + effort("max"), + ], + supportsFastMode: false, + supportsThinkingToggle: true, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + effort("low"), + effort("medium", true), + effort("high"), + effort("max"), + ], + supportsFastMode: false, + supportsThinkingToggle: true, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + isCustom: false, + capabilities: { + reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], + supportsFastMode: false, + supportsThinkingToggle: true, + promptInjectedEffortLevels: [], + }, + }, + ], + }, +]; async function mountPicker(props: { provider: ProviderKind; model: ModelSlug; lockedProvider: ProviderKind | null; + providers?: ReadonlyArray; triggerVariant?: "ghost" | "outline"; }) { const host = document.createElement("div"); document.body.append(host); const onProviderModelChange = vi.fn(); + const providers = props.providers ?? TEST_PROVIDERS; + const modelOptionsByProvider = getCustomModelOptionsByProvider( + DEFAULT_UNIFIED_SETTINGS, + providers, + props.provider, + props.model, + ); const screen = await render( , @@ -159,6 +256,40 @@ describe("ProviderModelPicker", () => { } }); + it("shows disabled providers as non-selectable entries", async () => { + const disabledProviders = TEST_PROVIDERS.slice(); + const claudeIndex = disabledProviders.findIndex( + (provider) => provider.provider === "claudeAgent", + ); + if (claudeIndex >= 0) { + const claudeProvider = disabledProviders[claudeIndex]!; + disabledProviders[claudeIndex] = { + ...claudeProvider, + enabled: false, + status: "disabled", + }; + } + const mounted = await mountPicker({ + provider: "codex", + model: "gpt-5-codex", + lockedProvider: null, + providers: disabledProviders, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Claude"); + expect(text).toContain("Disabled"); + expect(text).not.toContain("Claude Sonnet 4.6"); + }); + } 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..5a09defc72 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 ServerProvider } from "@t3tools/contracts"; import { resolveSelectableModel } from "@t3tools/shared/model"; import { memo, useState } from "react"; import type { VariantProps } from "class-variance-authority"; @@ -20,6 +20,7 @@ import { } from "../ui/menu"; import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons"; import { cn } from "~/lib/utils"; +import { getProviderSnapshot } from "../../providerModels"; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { value: ProviderKind; @@ -53,6 +54,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { provider: ProviderKind; model: ModelSlug; lockedProvider: ProviderKind | null; + providers?: ReadonlyArray; modelOptionsByProvider: Record>; activeProviderIconClassName?: string; compact?: boolean; @@ -145,6 +147,31 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { <> {AVAILABLE_PROVIDER_OPTIONS.map((option) => { const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; + const liveProvider = props.providers + ? getProviderSnapshot(props.providers, option.value) + : undefined; + if (liveProvider && liveProvider.status !== "ready") { + const unavailableLabel = !liveProvider.enabled + ? "Disabled" + : !liveProvider.installed + ? "Not installed" + : "Unavailable"; + return ( + + + ); + } return ( diff --git a/apps/web/src/components/chat/ProviderHealthBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx similarity index 77% rename from apps/web/src/components/chat/ProviderHealthBanner.tsx rename to apps/web/src/components/chat/ProviderStatusBanner.tsx index bfdefe58ec..e709e75da3 100644 --- a/apps/web/src/components/chat/ProviderHealthBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -1,14 +1,14 @@ -import { PROVIDER_DISPLAY_NAMES, type ServerProviderStatus } from "@t3tools/contracts"; +import { PROVIDER_DISPLAY_NAMES, type ServerProvider } from "@t3tools/contracts"; import { memo } from "react"; import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { CircleAlertIcon } from "lucide-react"; -export const ProviderHealthBanner = memo(function ProviderHealthBanner({ +export const ProviderStatusBanner = memo(function ProviderStatusBanner({ status, }: { - status: ServerProviderStatus | null; + status: ServerProvider | null; }) { - if (!status || status.status === "ready") { + if (!status || status.status === "ready" || status.status === "disabled") { return null; } diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index 61697c944a..bd8c61ee56 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -5,7 +5,9 @@ import { ClaudeModelOptions, CodexModelOptions, DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_SERVER_SETTINGS, ProjectId, + type ServerProvider, ThreadId, } from "@t3tools/contracts"; import { page } from "vitest/browser"; @@ -21,10 +23,93 @@ import { useComposerThreadDraft, useEffectiveComposerModelState, } from "../../composerDraftStore"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; // ── Claude TraitsPicker tests ───────────────────────────────────────── const CLAUDE_THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits"); +const TEST_PROVIDERS: ReadonlyArray = [ + { + provider: "codex", + enabled: true, + installed: true, + version: "0.1.0", + status: "ready", + authStatus: "authenticated", + checkedAt: "2026-01-01T00:00:00.000Z", + models: [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + ], + }, + { + provider: "claudeAgent", + enabled: true, + installed: true, + version: "0.1.0", + status: "ready", + authStatus: "authenticated", + checkedAt: "2026-01-01T00:00:00.000Z", + models: [ + { + slug: "claude-opus-4-6", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: ["ultrathink"], + }, + }, + { + slug: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: ["ultrathink"], + }, + }, + { + slug: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: true, + promptInjectedEffortLevels: [], + }, + }, + ], + }, +]; function ClaudeTraitsPickerHarness(props: { model: string; @@ -35,10 +120,14 @@ function ClaudeTraitsPickerHarness(props: { const setPrompt = useComposerDraftStore((store) => store.setPrompt); const { modelOptions, selectedModel } = useEffectiveComposerModelState({ threadId: CLAUDE_THREAD_ID, + providers: TEST_PROVIDERS, selectedProvider: "claudeAgent", threadModelSelection: props.fallbackModelSelection, projectModelSelection: null, - customModelsByProvider: { codex: [], claudeAgent: [] }, + settings: { + ...DEFAULT_SERVER_SETTINGS, + ...DEFAULT_CLIENT_SETTINGS, + }, }); const handlePromptChange = useCallback( (nextPrompt: string) => { @@ -50,6 +139,7 @@ function ClaudeTraitsPickerHarness(props: { return ( { await vi.waitFor(() => { const text = document.body.textContent ?? ""; - expect(text).toContain("Low"); - expect(text).toContain("Medium"); - expect(text).toContain("High"); expect(text).toContain("Extra High"); + expect(text).toContain("High"); + expect(text).not.toContain("Low"); + expect(text).not.toContain("Medium"); }); }); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index f48d525d02..5fd97b8cde 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -3,11 +3,11 @@ import { type CodexModelOptions, type ProviderKind, type ProviderModelOptions, + type ServerProviderModel, type ThreadId, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, - getModelCapabilities, isClaudeUltrathinkPrompt, trimOrNull, getDefaultEffort, @@ -27,6 +27,7 @@ import { MenuTrigger, } from "../ui/menu"; import { useComposerDraftStore } from "../../composerDraftStore"; +import { getProviderModelCapabilities } from "../../providerModels"; import { cn } from "~/lib/utils"; type ProviderOptions = ProviderModelOptions[ProviderKind]; @@ -65,12 +66,13 @@ function buildNextOptions( function getSelectedTraits( provider: ProviderKind, + models: ReadonlyArray, model: string | null | undefined, prompt: string, modelOptions: ProviderOptions | null | undefined, allowPromptInjectedEffort: boolean, ) { - const caps = getModelCapabilities(provider, model); + const caps = getProviderModelCapabilities(models, model, provider); const effortLevels = allowPromptInjectedEffort ? caps.reasoningEffortLevels : caps.reasoningEffortLevels.filter( @@ -120,6 +122,7 @@ function getSelectedTraits( export interface TraitsMenuContentProps { provider: ProviderKind; + models: ReadonlyArray; model: string | null | undefined; prompt: string; onPromptChange: (prompt: string) => void; @@ -131,6 +134,7 @@ export interface TraitsMenuContentProps { export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ provider, + models, model, prompt, onPromptChange, @@ -156,7 +160,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ thinkingEnabled, fastModeEnabled, ultrathinkPromptControlled, - } = getSelectedTraits(provider, model, prompt, modelOptions, allowPromptInjectedEffort); + } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort); const defaultEffort = getDefaultEffort(caps); const handleEffortChange = useCallback( @@ -260,6 +264,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ export const TraitsPicker = memo(function TraitsPicker({ provider, + models, model, prompt, onPromptChange, @@ -277,7 +282,7 @@ export const TraitsPicker = memo(function TraitsPicker({ thinkingEnabled, fastModeEnabled, ultrathinkPromptControlled, - } = getSelectedTraits(provider, model, prompt, modelOptions, allowPromptInjectedEffort); + } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort); const effortLabel = effort ? (effortLevels.find((l) => l.value === effort)?.label ?? effort) @@ -333,6 +338,7 @@ export const TraitsPicker = memo(function TraitsPicker({ = [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, +]; + +const CLAUDE_MODELS: ReadonlyArray = [ + { + slug: "claude-opus-4-6", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: ["ultrathink"], + }, + }, + { + slug: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: ["ultrathink"], + }, + }, + { + slug: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: true, + promptInjectedEffortLevels: [], + }, + }, +]; + describe("getComposerProviderState", () => { it("returns codex defaults when no codex draft options exist", () => { const state = getComposerProviderState({ provider: "codex", model: "gpt-5.4", + models: CODEX_MODELS, prompt: "", modelOptions: undefined, }); @@ -21,6 +88,7 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "codex", model: "gpt-5.4", + models: CODEX_MODELS, prompt: "", modelOptions: { codex: { @@ -44,6 +112,7 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "codex", model: "gpt-5.4", + models: CODEX_MODELS, prompt: "", modelOptions: { codex: { @@ -65,6 +134,7 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "codex", model: "gpt-5.4", + models: CODEX_MODELS, prompt: "", modelOptions: { codex: { @@ -85,6 +155,7 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "claudeAgent", model: "claude-sonnet-4-6", + models: CLAUDE_MODELS, prompt: "", modelOptions: undefined, }); @@ -100,6 +171,7 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "claudeAgent", model: "claude-sonnet-4-6", + models: CLAUDE_MODELS, prompt: "Ultrathink:\nInvestigate this failure", modelOptions: { claudeAgent: { @@ -124,6 +196,7 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "claudeAgent", model: "claude-haiku-4-5", + models: CLAUDE_MODELS, prompt: "", modelOptions: { claudeAgent: { @@ -146,6 +219,7 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "claudeAgent", model: "claude-opus-4-6", + models: CLAUDE_MODELS, prompt: "", modelOptions: { claudeAgent: { @@ -167,6 +241,7 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "claudeAgent", model: "claude-opus-4-6", + models: CLAUDE_MODELS, prompt: "", modelOptions: { claudeAgent: { diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 088a2a47be..2cebd8d4f4 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -2,23 +2,27 @@ import { type ModelSlug, type ProviderKind, type ProviderModelOptions, + type ServerProviderModel, type ThreadId, } from "@t3tools/contracts"; import { - getModelCapabilities, isClaudeUltrathinkPrompt, - normalizeClaudeModelOptions, - normalizeCodexModelOptions, trimOrNull, getDefaultEffort, hasEffortLevel, } from "@t3tools/shared/model"; import type { ReactNode } from "react"; +import { + getProviderModelCapabilities, + normalizeClaudeModelOptionsWithCapabilities, + normalizeCodexModelOptionsWithCapabilities, +} from "../../providerModels"; import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; export type ComposerProviderStateInput = { provider: ProviderKind; model: ModelSlug; + models: ReadonlyArray; prompt: string; modelOptions: ProviderModelOptions | null | undefined; }; @@ -37,6 +41,7 @@ type ProviderRegistryEntry = { renderTraitsMenuContent: (input: { threadId: ThreadId; model: ModelSlug; + models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; prompt: string; onPromptChange: (prompt: string) => void; @@ -44,6 +49,7 @@ type ProviderRegistryEntry = { renderTraitsPicker: (input: { threadId: ThreadId; model: ModelSlug; + models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; prompt: string; onPromptChange: (prompt: string) => void; @@ -53,8 +59,8 @@ type ProviderRegistryEntry = { function getProviderStateFromCapabilities( input: ComposerProviderStateInput, ): ComposerProviderState { - const { provider, model, prompt, modelOptions } = input; - const caps = getModelCapabilities(provider, model); + const { provider, model, models, prompt, modelOptions } = input; + const caps = getProviderModelCapabilities(models, model, provider); const providerOptions = modelOptions?.[provider]; // Resolve effort @@ -81,8 +87,8 @@ function getProviderStateFromCapabilities( // Normalize options for dispatch const normalizedOptions = provider === "codex" - ? normalizeCodexModelOptions(model, providerOptions) - : normalizeClaudeModelOptions(model, providerOptions); + ? normalizeCodexModelOptionsWithCapabilities(caps, providerOptions) + : normalizeClaudeModelOptionsWithCapabilities(caps, providerOptions); // Ultrathink styling (driven by capabilities data, not provider identity) const ultrathinkActive = @@ -103,9 +109,17 @@ function getProviderStateFromCapabilities( const composerProviderRegistry: Record = { codex: { getState: (input) => getProviderStateFromCapabilities(input), - renderTraitsMenuContent: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( + renderTraitsMenuContent: ({ + threadId, + model, + models, + modelOptions, + prompt, + onPromptChange, + }) => ( = { onPromptChange={onPromptChange} /> ), - renderTraitsPicker: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( + renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( = { }, claudeAgent: { getState: (input) => getProviderStateFromCapabilities(input), - renderTraitsMenuContent: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( + renderTraitsMenuContent: ({ + threadId, + model, + models, + modelOptions, + prompt, + onPromptChange, + }) => ( = { onPromptChange={onPromptChange} /> ), - renderTraitsPicker: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( + renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( ; modelOptions: ProviderModelOptions[ProviderKind] | undefined; prompt: string; onPromptChange: (prompt: string) => void; @@ -164,6 +189,7 @@ export function renderProviderTraitsMenuContent(input: { return composerProviderRegistry[input.provider].renderTraitsMenuContent({ threadId: input.threadId, model: input.model, + models: input.models, modelOptions: input.modelOptions, prompt: input.prompt, onPromptChange: input.onPromptChange, @@ -174,6 +200,7 @@ export function renderProviderTraitsPicker(input: { provider: ProviderKind; threadId: ThreadId; model: ModelSlug; + models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; prompt: string; onPromptChange: (prompt: string) => void; @@ -181,6 +208,7 @@ export function renderProviderTraitsPicker(input: { return composerProviderRegistry[input.provider].renderTraitsPicker({ threadId: input.threadId, model: input.model, + models: input.models, modelOptions: input.modelOptions, prompt: input.prompt, onPromptChange: input.onPromptChange, diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index a4acd810e7..3d54c526f1 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -9,16 +9,13 @@ import { ProviderKind, ProviderModelOptions, RuntimeMode, + type ServerProvider, ThreadId, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as Equal from "effect/Equal"; import { DeepMutable } from "effect/Types"; -import { - getDefaultModel, - normalizeModelSlug, - resolveModelSlugForProvider, -} from "@t3tools/shared/model"; +import { getDefaultModel, normalizeModelSlug } from "@t3tools/shared/model"; import { useMemo } from "react"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; import { resolveAppModelSelection } from "./modelSelection"; @@ -31,6 +28,8 @@ import { import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; +import { getDefaultServerModel } from "./providerModels"; +import { UnifiedSettings } from "@t3tools/contracts/settings"; export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; const COMPOSER_DRAFT_STORAGE_VERSION = 3; @@ -613,22 +612,23 @@ export function deriveEffectiveComposerModelState(input: { | Pick | null | undefined; + providers: ReadonlyArray; selectedProvider: ProviderKind; threadModelSelection: ModelSelection | null | undefined; projectModelSelection: ModelSelection | null | undefined; - customModelsByProvider: Record; + settings: UnifiedSettings; }): EffectiveComposerModelState { - const baseModel = resolveModelSlugForProvider( - input.selectedProvider, - input.threadModelSelection?.model ?? - input.projectModelSelection?.model ?? - getDefaultModel(input.selectedProvider), - ); + const baseModel = + normalizeModelSlug( + input.threadModelSelection?.model ?? input.projectModelSelection?.model, + input.selectedProvider, + ) ?? getDefaultServerModel(input.providers, input.selectedProvider); const activeSelection = input.draft?.modelSelectionByProvider?.[input.selectedProvider]; const selectedModel = activeSelection?.model ? resolveAppModelSelection( input.selectedProvider, - input.customModelsByProvider, + input.settings, + input.providers, activeSelection.model, ) : baseModel; @@ -2157,10 +2157,11 @@ export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftS export function useEffectiveComposerModelState(input: { threadId: ThreadId; + providers: ReadonlyArray; selectedProvider: ProviderKind; threadModelSelection: ModelSelection | null | undefined; projectModelSelection: ModelSelection | null | undefined; - customModelsByProvider: Record; + settings: UnifiedSettings; }): EffectiveComposerModelState { const draft = useComposerThreadDraft(input.threadId); @@ -2168,14 +2169,16 @@ export function useEffectiveComposerModelState(input: { () => deriveEffectiveComposerModelState({ draft, + providers: input.providers, selectedProvider: input.selectedProvider, threadModelSelection: input.threadModelSelection, projectModelSelection: input.projectModelSelection, - customModelsByProvider: input.customModelsByProvider, + settings: input.settings, }), [ draft, - input.customModelsByProvider, + input.providers, + input.settings, input.projectModelSelection, input.selectedProvider, input.threadModelSelection, diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts new file mode 100644 index 0000000000..addf550e38 --- /dev/null +++ b/apps/web/src/hooks/useSettings.ts @@ -0,0 +1,266 @@ +/** + * Unified settings hook. + * + * Abstracts the split between server-authoritative settings (persisted in + * `settings.json` on the server, fetched via `server.getConfig`) and + * client-only settings (persisted in localStorage). + * + * Consumers use `useSettings(selector)` to read, and `useUpdateSettings()` to + * write. The hook transparently routes reads/writes to the correct backing + * store. + */ +import { useCallback, useMemo } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ServerSettings, + ServerSettingsPatch, + ServerConfig, + ModelSelection, + ThreadEnvMode, +} from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; +import { + type ClientSettings, + ClientSettingsSchema, + DEFAULT_CLIENT_SETTINGS, + DEFAULT_UNIFIED_SETTINGS, + SidebarProjectSortOrder, + SidebarThreadSortOrder, + TimestampFormat, + UnifiedSettings, +} from "@t3tools/contracts/settings"; +import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; +import { ensureNativeApi } from "~/nativeApi"; +import { useLocalStorage } from "./useLocalStorage"; +import { normalizeCustomModelSlugs } from "~/modelSelection"; +import { Predicate, Schema, Struct } from "effect"; +import { DeepMutable } from "effect/Types"; +import { deepMerge } from "@t3tools/shared/Struct"; + +const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; +const OLD_SETTINGS_KEY = "t3code:app-settings:v1"; + +// ── Key sets for routing patches ───────────────────────────────────── + +const SERVER_SETTINGS_KEYS = new Set(Struct.keys(ServerSettings.fields)); + +function splitPatch(patch: Partial): { + serverPatch: ServerSettingsPatch; + clientPatch: Partial; +} { + const serverPatch: Record = {}; + const clientPatch: Record = {}; + for (const [key, value] of Object.entries(patch)) { + if (SERVER_SETTINGS_KEYS.has(key)) { + serverPatch[key] = value; + } else { + clientPatch[key] = value; + } + } + return { + serverPatch: serverPatch as ServerSettingsPatch, + clientPatch: clientPatch as Partial, + }; +} + +// ── Hooks ──────────────────────────────────────────────────────────── + +/** + * Read merged settings. Selector narrows the subscription so components + * only re-render when the slice they care about changes. + */ + +export function useSettings( + selector?: (s: UnifiedSettings) => T, +): T { + const { data: serverConfig } = useQuery(serverConfigQueryOptions()); + const [clientSettings] = useLocalStorage( + CLIENT_SETTINGS_STORAGE_KEY, + DEFAULT_CLIENT_SETTINGS, + ClientSettingsSchema, + ); + + const merged = useMemo( + () => ({ + ...(serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS), + ...clientSettings, + }), + [serverConfig?.settings, clientSettings], + ); + + return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]); +} + +/** + * Returns an updater that routes each key to the correct backing store. + * + * Server keys are optimistically patched in the React Query cache, then + * persisted via RPC. Client keys go straight to localStorage. + */ +export function useUpdateSettings() { + const queryClient = useQueryClient(); + const [, setClientSettings] = useLocalStorage( + CLIENT_SETTINGS_STORAGE_KEY, + DEFAULT_CLIENT_SETTINGS, + ClientSettingsSchema, + ); + + const updateSettings = useCallback( + (patch: Partial) => { + const { serverPatch, clientPatch } = splitPatch(patch); + + if (Object.keys(serverPatch).length > 0) { + // Optimistic update of the React Query cache + queryClient.setQueryData(serverQueryKeys.config(), (old) => { + if (!old) return old; + return { + ...old, + settings: deepMerge(old.settings, serverPatch), + }; + }); + // Fire-and-forget RPC — push will reconcile on success + void ensureNativeApi().server.updateSettings(serverPatch); + } + + if (Object.keys(clientPatch).length > 0) { + setClientSettings((prev) => ({ ...prev, ...clientPatch })); + } + }, + [queryClient, setClientSettings], + ); + + const resetSettings = useCallback(() => { + updateSettings(DEFAULT_UNIFIED_SETTINGS); + }, [updateSettings]); + + return { + updateSettings, + resetSettings, + }; +} + +// ── One-time migration from localStorage ───────────────────────────── + +export function buildLegacyServerSettingsMigrationPatch(legacySettings: Record) { + const patch: DeepMutable = {}; + + if (Predicate.isBoolean(legacySettings.enableAssistantStreaming)) { + patch.enableAssistantStreaming = legacySettings.enableAssistantStreaming; + } + + if (Schema.is(ThreadEnvMode)(legacySettings.defaultThreadEnvMode)) { + patch.defaultThreadEnvMode = legacySettings.defaultThreadEnvMode; + } + + if (Schema.is(ModelSelection)(legacySettings.textGenerationModelSelection)) { + patch.textGenerationModelSelection = legacySettings.textGenerationModelSelection; + } + + if (typeof legacySettings.codexBinaryPath === "string") { + patch.providers ??= {}; + patch.providers.codex ??= {}; + patch.providers.codex.binaryPath = legacySettings.codexBinaryPath; + } + + if (typeof legacySettings.codexHomePath === "string") { + patch.providers ??= {}; + patch.providers.codex ??= {}; + patch.providers.codex.homePath = legacySettings.codexHomePath; + } + + if (Array.isArray(legacySettings.customCodexModels)) { + patch.providers ??= {}; + patch.providers.codex ??= {}; + patch.providers.codex.customModels = normalizeCustomModelSlugs( + legacySettings.customCodexModels, + new Set(), + "codex", + ); + } + + if (Predicate.isString(legacySettings.claudeBinaryPath)) { + patch.providers ??= {}; + patch.providers.claudeAgent ??= {}; + patch.providers.claudeAgent.binaryPath = legacySettings.claudeBinaryPath; + } + + if (Array.isArray(legacySettings.customClaudeModels)) { + patch.providers ??= {}; + patch.providers.claudeAgent ??= {}; + patch.providers.claudeAgent.customModels = normalizeCustomModelSlugs( + legacySettings.customClaudeModels, + new Set(), + "claudeAgent", + ); + } + + return patch; +} + +export function buildLegacyClientSettingsMigrationPatch( + legacySettings: Record, +): Partial> { + const patch: Partial> = {}; + + if (Predicate.isBoolean(legacySettings.confirmThreadDelete)) { + patch.confirmThreadDelete = legacySettings.confirmThreadDelete; + } + + if (Predicate.isBoolean(legacySettings.diffWordWrap)) { + patch.diffWordWrap = legacySettings.diffWordWrap; + } + + if (Schema.is(SidebarProjectSortOrder)(legacySettings.sidebarProjectSortOrder)) { + patch.sidebarProjectSortOrder = legacySettings.sidebarProjectSortOrder; + } + + if (Schema.is(SidebarThreadSortOrder)(legacySettings.sidebarThreadSortOrder)) { + patch.sidebarThreadSortOrder = legacySettings.sidebarThreadSortOrder; + } + + if (Schema.is(TimestampFormat)(legacySettings.timestampFormat)) { + patch.timestampFormat = legacySettings.timestampFormat; + } + + return patch; +} + +/** + * Call once on app startup. + * If the legacy localStorage key exists, migrate its values to the new server + * and client storage formats, then remove the legacy key so this only runs once. + */ +export function migrateLocalSettingsToServer(): void { + if (typeof window === "undefined") return; + + const raw = localStorage.getItem(OLD_SETTINGS_KEY); + if (!raw) return; + + try { + const old = JSON.parse(raw); + if (!Predicate.isObject(old)) return; + + // Migrate server-relevant keys via RPC + const serverPatch = buildLegacyServerSettingsMigrationPatch(old); + if (Object.keys(serverPatch).length > 0) { + const api = ensureNativeApi(); + void api.server.updateSettings(serverPatch); + } + + // Migrate client-only keys to the new localStorage key + const clientPatch = buildLegacyClientSettingsMigrationPatch(old); + if (Object.keys(clientPatch).length > 0) { + const existing = localStorage.getItem(CLIENT_SETTINGS_STORAGE_KEY); + const current = existing ? (JSON.parse(existing) as Record) : {}; + localStorage.setItem( + CLIENT_SETTINGS_STORAGE_KEY, + JSON.stringify({ ...current, ...clientPatch }), + ); + } + } catch (error) { + console.error("[MIGRATION] Error migrating local settings:", error); + } finally { + // Remove the legacy key regardless to keep migration one-shot behavior. + localStorage.removeItem(OLD_SETTINGS_KEY); + } +} diff --git a/apps/web/src/lib/gitReactQuery.test.ts b/apps/web/src/lib/gitReactQuery.test.ts index b5d75f743c..25fcba7843 100644 --- a/apps/web/src/lib/gitReactQuery.test.ts +++ b/apps/web/src/lib/gitReactQuery.test.ts @@ -32,10 +32,6 @@ describe("git mutation options", () => { const options = gitRunStackedActionMutationOptions({ cwd: "/repo/a", queryClient, - modelSelection: { - provider: "codex", - model: "gpt-5.4", - }, }); expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction("/repo/a")); }); diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index 02d725d2b2..cfa2c72f74 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -1,4 +1,4 @@ -import { type GitStackedAction, type ModelSelection } from "@t3tools/contracts"; +import { type GitStackedAction } from "@t3tools/contracts"; import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; import { ensureNativeApi } from "../nativeApi"; @@ -112,7 +112,6 @@ export function gitCheckoutMutationOptions(input: { export function gitRunStackedActionMutationOptions(input: { cwd: string | null; queryClient: QueryClient; - modelSelection: ModelSelection; }) { return mutationOptions({ mutationKey: gitMutationKeys.runStackedAction(input.cwd), @@ -134,7 +133,6 @@ export function gitRunStackedActionMutationOptions(input: { return api.git.runStackedAction({ actionId, cwd: input.cwd, - modelSelection: input.modelSelection, action, ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), diff --git a/apps/web/src/lib/serverReactQuery.ts b/apps/web/src/lib/serverReactQuery.ts index 85853e2ee2..37029a3a3e 100644 --- a/apps/web/src/lib/serverReactQuery.ts +++ b/apps/web/src/lib/serverReactQuery.ts @@ -6,6 +6,13 @@ export const serverQueryKeys = { config: () => ["server", "config"] as const, }; +/** + * Server config query options. + * + * `staleTime` is kept short so that push-driven `invalidateQueries` calls in + * the EventRouter always trigger a refetch, and so the query re-fetches when + * the component re-mounts (e.g. navigating away from settings and back). + */ export function serverConfigQueryOptions() { return queryOptions({ queryKey: serverQueryKeys.config(), @@ -13,6 +20,5 @@ export function serverConfigQueryOptions() { const api = ensureNativeApi(); return api.server.getConfig(); }, - staleTime: Infinity, }); } diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 9534170b1f..98e2884adf 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -2,27 +2,22 @@ import { DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, type ModelSelection, type ProviderKind, + type ServerProvider, } from "@t3tools/contracts"; -import { - getDefaultModel, - getModelOptions, - normalizeModelSlug, - resolveSelectableModel, -} from "@t3tools/shared/model"; +import { normalizeModelSlug, resolveSelectableModel } from "@t3tools/shared/model"; import { getComposerProviderState } from "./components/chat/composerProviderRegistry"; +import { UnifiedSettings } from "@t3tools/contracts/settings"; +import { + getDefaultServerModel, + getProviderModels, + resolveSelectableProvider, +} from "./providerModels"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; -export type CustomModelSettings = { - customCodexModels: readonly string[]; - customClaudeModels: readonly string[]; -}; - export type ProviderCustomModelConfig = { provider: ProviderKind; - settingsKey: keyof CustomModelSettings; - defaultSettingsKey: keyof CustomModelSettings; title: string; description: string; placeholder: string; @@ -38,8 +33,6 @@ export interface AppModelOption { const PROVIDER_CUSTOM_MODEL_CONFIG: Record = { codex: { provider: "codex", - settingsKey: "customCodexModels", - defaultSettingsKey: "customCodexModels", title: "Codex", description: "Save additional Codex model slugs for the picker and `/model` command.", placeholder: "your-codex-model-slug", @@ -47,8 +40,6 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record> = { - codex: new Set(getModelOptions("codex").map((option) => option.slug)), - claudeAgent: new Set(getModelOptions("claudeAgent").map((option) => option.slug)), -}; - export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG); export function normalizeCustomModelSlugs( models: Iterable, + builtInModelSlugs: ReadonlySet, provider: ProviderKind = "codex", ): string[] { const normalizedModels: string[] = []; const seen = new Set(); - const builtInModelSlugs = BUILT_IN_MODEL_SLUGS_BY_PROVIDER[provider]; for (const candidate of models) { const normalized = normalizeModelSlug(candidate, provider); @@ -92,52 +78,29 @@ export function normalizeCustomModelSlugs( return normalizedModels; } -export function getCustomModelsForProvider( - settings: CustomModelSettings, - provider: ProviderKind, -): readonly string[] { - return settings[PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]; -} - -export function getDefaultCustomModelsForProvider( - defaults: CustomModelSettings, - provider: ProviderKind, -): readonly string[] { - return defaults[PROVIDER_CUSTOM_MODEL_CONFIG[provider].defaultSettingsKey]; -} - -export function patchCustomModels( - provider: ProviderKind, - models: string[], -): Partial { - return { - [PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]: models, - }; -} - -export function getCustomModelsByProvider( - settings: CustomModelSettings, -): Record { - return { - codex: getCustomModelsForProvider(settings, "codex"), - claudeAgent: getCustomModelsForProvider(settings, "claudeAgent"), - }; -} - export function getAppModelOptions( + settings: UnifiedSettings, + providers: ReadonlyArray, provider: ProviderKind, - customModels: readonly string[], selectedModel?: string | null, ): AppModelOption[] { - const options: AppModelOption[] = getModelOptions(provider).map(({ slug, name }) => ({ - slug, - name, - isCustom: false, - })); + const options: AppModelOption[] = getProviderModels(providers, provider).map( + ({ slug, name, isCustom }) => ({ + slug, + name, + isCustom, + }), + ); const seen = new Set(options.map((option) => option.slug)); const trimmedSelectedModel = selectedModel?.trim().toLowerCase(); - - for (const slug of normalizeCustomModelSlugs(customModels, provider)) { + const builtInModelSlugs = new Set( + getProviderModels(providers, provider) + .filter((model) => !model.isCustom) + .map((model) => model.slug), + ); + + const customModels = settings.providers[provider].customModels; + for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs, provider)) { if (seen.has(slug)) { continue; } @@ -171,52 +134,61 @@ export function getAppModelOptions( export function resolveAppModelSelection( provider: ProviderKind, - customModels: Record, + settings: UnifiedSettings, + providers: ReadonlyArray, selectedModel: string | null | undefined, ): string { - const customModelsForProvider = customModels[provider]; - const options = getAppModelOptions(provider, customModelsForProvider, selectedModel); - return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider); + const resolvedProvider = resolveSelectableProvider(providers, provider); + const options = getAppModelOptions(settings, providers, resolvedProvider, selectedModel); + return ( + resolveSelectableModel(resolvedProvider, selectedModel, options) ?? + getDefaultServerModel(providers, resolvedProvider) + ); } export function getCustomModelOptionsByProvider( - settings: CustomModelSettings, + settings: UnifiedSettings, + providers: ReadonlyArray, selectedProvider?: ProviderKind | null, selectedModel?: string | null, ): Record> { - const customModelsByProvider = getCustomModelsByProvider(settings); return { codex: getAppModelOptions( + settings, + providers, "codex", - customModelsByProvider.codex, selectedProvider === "codex" ? selectedModel : undefined, ), claudeAgent: getAppModelOptions( + settings, + providers, "claudeAgent", - customModelsByProvider.claudeAgent, selectedProvider === "claudeAgent" ? selectedModel : undefined, ), }; } export function resolveAppModelSelectionState( - settings: CustomModelSettings & { - textGenerationModelSelection: ModelSelection | undefined; - }, + settings: UnifiedSettings, + providers: ReadonlyArray, ): ModelSelection { const selection = settings.textGenerationModelSelection ?? { provider: "codex" as const, model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, }; - const provider = selection.provider; - const customModelsByProvider = getCustomModelsByProvider(settings); - const model = resolveAppModelSelection(provider, customModelsByProvider, selection.model); + const provider = resolveSelectableProvider(providers, selection.provider); + + // When the provider changed due to fallback (e.g. selected provider was disabled), + // don't carry over the old provider's model — use the fallback provider's default. + const selectedModel = provider === selection.provider ? selection.model : null; + const model = resolveAppModelSelection(provider, settings, providers, selectedModel); const { modelOptionsForDispatch } = getComposerProviderState({ provider, model, + models: getProviderModels(providers, provider), prompt: "", modelOptions: { - [provider]: selection.options, + [provider]: provider === selection.provider ? selection.options : undefined, }, }); diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts new file mode 100644 index 0000000000..a925ed690f --- /dev/null +++ b/apps/web/src/providerModels.ts @@ -0,0 +1,116 @@ +import { + DEFAULT_MODEL_BY_PROVIDER, + type ClaudeModelOptions, + type CodexModelOptions, + type ModelCapabilities, + type ProviderKind, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { + getDefaultEffort, + hasEffortLevel, + normalizeModelSlug, + trimOrNull, +} from "@t3tools/shared/model"; + +const EMPTY_CAPABILITIES: ModelCapabilities = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], +}; + +export function getProviderModels( + providers: ReadonlyArray, + provider: ProviderKind, +): ReadonlyArray { + return providers.find((candidate) => candidate.provider === provider)?.models ?? []; +} + +export function getProviderSnapshot( + providers: ReadonlyArray, + provider: ProviderKind, +): ServerProvider | undefined { + return providers.find((candidate) => candidate.provider === provider); +} + +export function isProviderEnabled( + providers: ReadonlyArray, + provider: ProviderKind, +): boolean { + return getProviderSnapshot(providers, provider)?.enabled ?? true; +} + +export function resolveSelectableProvider( + providers: ReadonlyArray, + provider: ProviderKind | null | undefined, +): ProviderKind { + const requested = provider ?? "codex"; + if (isProviderEnabled(providers, requested)) { + return requested; + } + return providers.find((candidate) => candidate.enabled)?.provider ?? requested; +} + +export function getProviderModelCapabilities( + models: ReadonlyArray, + model: string | null | undefined, + provider: ProviderKind, +): ModelCapabilities { + const slug = normalizeModelSlug(model, provider); + return models.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES; +} + +export function getDefaultServerModel( + providers: ReadonlyArray, + provider: ProviderKind, +): string { + const models = getProviderModels(providers, provider); + return ( + models.find((model) => !model.isCustom)?.slug ?? + models[0]?.slug ?? + DEFAULT_MODEL_BY_PROVIDER[provider] + ); +} + +export function normalizeCodexModelOptionsWithCapabilities( + caps: ModelCapabilities, + modelOptions: CodexModelOptions | null | undefined, +): CodexModelOptions | undefined { + const defaultReasoningEffort = getDefaultEffort(caps); + const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort; + const fastModeEnabled = modelOptions?.fastMode === true; + const nextOptions: CodexModelOptions = { + ...(reasoningEffort && reasoningEffort !== defaultReasoningEffort + ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] } + : {}), + ...(fastModeEnabled ? { fastMode: true } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + +export function normalizeClaudeModelOptionsWithCapabilities( + caps: ModelCapabilities, + modelOptions: ClaudeModelOptions | null | undefined, +): ClaudeModelOptions | undefined { + const defaultReasoningEffort = getDefaultEffort(caps); + const resolvedEffort = trimOrNull(modelOptions?.effort); + const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? ""); + const effort = + resolvedEffort && + !isPromptInjected && + hasEffortLevel(caps, resolvedEffort) && + resolvedEffort !== defaultReasoningEffort + ? resolvedEffort + : undefined; + const thinking = + caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; + const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; + const nextOptions: ClaudeModelOptions = { + ...(thinking === false ? { thinking: false } : {}), + ...(effort ? { effort } : {}), + ...(fastMode ? { fastMode: true } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 34f9c4b82f..5ebed20fba 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -20,7 +20,8 @@ import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDra import { useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; -import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi"; +import { onServerConfigUpdated, onServerProvidersUpdated, onServerWelcome } from "../wsNativeApi"; +import { migrateLocalSettingsToServer } from "../hooks/useSettings"; import { providerQueryKeys } from "../lib/providerReactQuery"; import { projectQueryKeys } from "../lib/projectReactQuery"; import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; @@ -230,6 +231,8 @@ function EventRouter() { ); }); const unsubWelcome = onServerWelcome((payload) => { + // Migrate old localStorage settings to server on first connect + migrateLocalSettingsToServer(); void (async () => { await syncSnapshot(); if (disposed) { @@ -260,8 +263,14 @@ function EventRouter() { // don't produce duplicate toasts. let subscribed = false; const unsubServerConfigUpdated = onServerConfigUpdated((payload) => { + // Invalidate the config query so active observers refetch fresh data. void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); + if (!subscribed) return; + + // Only show keybindings toasts for keybindings changes (no settings in payload) + if (payload.settings) return; + const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); if (!issue) { toastManager.add({ @@ -300,6 +309,9 @@ function EventRouter() { }, }); }); + const unsubProvidersUpdated = onServerProvidersUpdated(() => { + void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); + }); subscribed = true; return () => { disposed = true; @@ -309,6 +321,7 @@ function EventRouter() { unsubTerminalEvent(); unsubWelcome(); unsubServerConfigUpdated(); + unsubProvidersUpdated(); }; }, [ navigate, diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 62a27edba9..3e92891a54 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,16 +1,27 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useQuery } from "@tanstack/react-query"; -import { ChevronDownIcon, PlusIcon, RotateCcwIcon, Undo2Icon, XIcon } from "lucide-react"; -import { type ReactNode, useCallback, useState } from "react"; -import { type ProviderKind } from "@t3tools/contracts"; -import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; -import { useAppSettings } from "../appSettings"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ChevronDownIcon, + InfoIcon, + LoaderIcon, + PlusIcon, + RefreshCwIcon, + RotateCcwIcon, + Undo2Icon, + XIcon, +} from "lucide-react"; +import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import { + PROVIDER_DISPLAY_NAMES, + type ProviderKind, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { normalizeModelSlug } from "@t3tools/shared/model"; +import { useSettings, useUpdateSettings } from "../hooks/useSettings"; import { getCustomModelOptionsByProvider, - getCustomModelsForProvider, MAX_CUSTOM_MODEL_LENGTH, - MODEL_PROVIDER_SETTINGS, - patchCustomModels, resolveAppModelSelectionState, } from "../modelSelection"; import { APP_VERSION } from "../branding"; @@ -33,9 +44,12 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip" import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; -import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; import { cn } from "../lib/utils"; +import { formatRelativeTime } from "../timestampFormat"; import { ensureNativeApi, readNativeApi } from "../nativeApi"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { Equal } from "effect"; const THEME_OPTIONS = [ { @@ -61,11 +75,11 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; -type InstallBinarySettingsKey = "claudeBinaryPath" | "codexBinaryPath"; +const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; + type InstallProviderSettings = { provider: ProviderKind; title: string; - binaryPathKey: InstallBinarySettingsKey; binaryPlaceholder: string; binaryDescription: ReactNode; homePathKey?: "codexHomePath"; @@ -73,17 +87,12 @@ type InstallProviderSettings = { homeDescription?: ReactNode; }; -const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ +const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ { provider: "codex", title: "Codex", - binaryPathKey: "codexBinaryPath", binaryPlaceholder: "Codex binary path", - binaryDescription: ( - <> - Leave blank to use codex from your PATH. - - ), + binaryDescription: "Path to the Codex binary", homePathKey: "codexHomePath", homePlaceholder: "CODEX_HOME", homeDescription: "Optional custom Codex home and config directory.", @@ -91,22 +100,116 @@ const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ { provider: "claudeAgent", title: "Claude", - binaryPathKey: "claudeBinaryPath", binaryPlaceholder: "Claude binary path", - binaryDescription: ( - <> - Leave blank to use claude from your PATH. - - ), + binaryDescription: "Path to the Claude binary", }, ]; -function SettingsSection({ title, children }: { title: string; children: ReactNode }) { +const PROVIDER_STATUS_STYLES = { + disabled: { + dot: "bg-amber-400", + badge: "warning" as const, + }, + error: { + dot: "bg-destructive", + badge: "error" as const, + }, + ready: { + dot: "bg-success", + badge: "success" as const, + }, + warning: { + dot: "bg-warning", + badge: "warning" as const, + }, +} as const; + +function getProviderSummary(provider: ServerProvider | undefined): { + readonly headline: string; + readonly detail: string | null; +} { + if (!provider) { + return { + headline: "Checking provider status", + detail: "Waiting for the server to report installation and authentication details.", + }; + } + if (!provider.enabled) { + return { + headline: "Disabled", + detail: + provider.message ?? "This provider is installed but disabled for new sessions in T3 Code.", + }; + } + if (!provider.installed) { + return { + headline: "Not found", + detail: provider.message ?? "CLI not detected on PATH.", + }; + } + if (provider.authStatus === "authenticated") { + return { + headline: "Authenticated", + detail: provider.message ?? null, + }; + } + if (provider.authStatus === "unauthenticated") { + return { + headline: "Not authenticated", + detail: provider.message ?? null, + }; + } + if (provider.status === "warning") { + return { + headline: "Needs attention", + detail: + provider.message ?? "The provider is installed, but the server could not fully verify it.", + }; + } + if (provider.status === "error") { + return { + headline: "Unavailable", + detail: provider.message ?? "The provider failed its startup checks.", + }; + } + return { + headline: "Available", + detail: provider.message ?? "Installed and ready, but authentication could not be verified.", + }; +} + +function getProviderVersionLabel(version: string | null | undefined): string | null { + if (!version) return null; + return version.startsWith("v") ? version : `v${version}`; +} + +/** Returns a timestamp that updates on an interval, forcing re-renders to keep relative times fresh. */ +function useRelativeTimeTick(intervalMs = 1_000): number { + const [tick, setTick] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => setTick(Date.now()), intervalMs); + return () => clearInterval(id); + }, [intervalMs]); + return tick; +} + +function SettingsSection({ + title, + headerAction, + children, +}: { + title: string; + headerAction?: ReactNode; + children: ReactNode; +}) { return (
-

- {title} -

+
+

+ {title} +

+ {headerAction} +
{children}
@@ -121,7 +224,6 @@ function SettingsRow({ resetAction, control, children, - onClick, }: { title: string; description: string; @@ -129,20 +231,13 @@ function SettingsRow({ resetAction?: ReactNode; control?: ReactNode; children?: ReactNode; - onClick?: () => void; }) { return (
-
+

{title}

@@ -190,16 +285,23 @@ function SettingResetButton({ label, onClick }: { label: string; onClick: () => function SettingsRouteView() { const { theme, setTheme } = useTheme(); - const { settings, defaults, updateSettings, resetSettings } = useAppSettings(); + const settings = useSettings(); + const { updateSettings, resetSettings } = useUpdateSettings(); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); const [openKeybindingsError, setOpenKeybindingsError] = useState(null); - const [openInstallProviders, setOpenInstallProviders] = useState>({ - codex: Boolean(settings.codexBinaryPath || settings.codexHomePath), - claudeAgent: Boolean(settings.claudeBinaryPath), + const [openProviderDetails, setOpenProviderDetails] = useState>({ + codex: Boolean( + settings.providers.codex.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.binaryPath || + settings.providers.codex.homePath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.homePath || + settings.providers.codex.customModels.length > 0, + ), + claudeAgent: Boolean( + settings.providers.claudeAgent.binaryPath !== + DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath || + settings.providers.claudeAgent.customModels.length > 0, + ), }); - const [selectedCustomModelProvider, setSelectedCustomModelProvider] = - useState("codex"); const [customModelInputByProvider, setCustomModelInputByProvider] = useState< Record >({ @@ -209,63 +311,73 @@ function SettingsRouteView() { const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> >({}); - const [showAllCustomModels, setShowAllCustomModels] = useState(false); + const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); + const refreshingRef = useRef(false); + const queryClient = useQueryClient(); + useRelativeTimeTick(); + + const refreshProviders = useCallback(() => { + if (refreshingRef.current) return; + refreshingRef.current = true; + setIsRefreshingProviders(true); + const api = ensureNativeApi(); + api.server + .refreshProviders() + .then(() => queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() })) + .catch((error: unknown) => { + console.warn("Failed to refresh providers", error); + }) + .finally(() => { + refreshingRef.current = false; + setIsRefreshingProviders(false); + }); + }, [queryClient]); - const codexBinaryPath = settings.codexBinaryPath; - const codexHomePath = settings.codexHomePath; - const claudeBinaryPath = settings.claudeBinaryPath; + const modelListRefs = useRef>>({}); + + const codexHomePath = settings.providers.codex.homePath; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; + const serverProviders = serverConfigQuery.data?.providers ?? EMPTY_SERVER_PROVIDERS; - const textGenerationModelSelection = resolveAppModelSelectionState(settings); + const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); const textGenProvider = textGenerationModelSelection.provider; const textGenModel = textGenerationModelSelection.model; const textGenModelOptions = textGenerationModelSelection.options; const gitModelOptionsByProvider = getCustomModelOptionsByProvider( settings, + serverProviders, textGenProvider, textGenModel, ); - const selectedCustomModelProviderSettings = MODEL_PROVIDER_SETTINGS.find( - (providerSettings) => providerSettings.provider === selectedCustomModelProvider, - )!; - const selectedCustomModelInput = customModelInputByProvider[selectedCustomModelProvider]; - const selectedCustomModelError = customModelErrorByProvider[selectedCustomModelProvider] ?? null; - const totalCustomModels = settings.customCodexModels.length + settings.customClaudeModels.length; - const savedCustomModelRows = MODEL_PROVIDER_SETTINGS.flatMap((providerSettings) => - getCustomModelsForProvider(settings, providerSettings.provider).map((slug) => ({ - key: `${providerSettings.provider}:${slug}`, - provider: providerSettings.provider, - providerTitle: providerSettings.title, - slug, - })), + const areProviderSettingsDirty = PROVIDER_SETTINGS.some((providerSettings) => { + const currentSettings = settings.providers[providerSettings.provider]; + const defaultSettings = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider]; + return !Equal.equals(currentSettings, defaultSettings); + }); + const isGitWritingModelDirty = !Equal.equals( + settings.textGenerationModelSelection ?? null, + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, ); - const visibleCustomModelRows = showAllCustomModels - ? savedCustomModelRows - : savedCustomModelRows.slice(0, 5); - const isInstallSettingsDirty = - settings.claudeBinaryPath !== defaults.claudeBinaryPath || - settings.codexBinaryPath !== defaults.codexBinaryPath || - settings.codexHomePath !== defaults.codexHomePath; const changedSettingLabels = [ ...(theme !== "system" ? ["Theme"] : []), - ...(settings.timestampFormat !== defaults.timestampFormat ? ["Time format"] : []), - ...(settings.diffWordWrap !== defaults.diffWordWrap ? ["Diff line wrapping"] : []), - ...(settings.enableAssistantStreaming !== defaults.enableAssistantStreaming - ? ["Assistant output"] + ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat + ? ["Time format"] : []), - ...(settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ["New thread mode"] : []), - ...(settings.confirmThreadDelete !== defaults.confirmThreadDelete - ? ["Delete confirmation"] + ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap + ? ["Diff line wrapping"] + : []), + ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming + ? ["Assistant output"] : []), - ...(JSON.stringify(settings.textGenerationModelSelection ?? null) !== - JSON.stringify(defaults.textGenerationModelSelection ?? null) - ? ["Git writing model"] + ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode + ? ["New thread mode"] : []), - ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0 - ? ["Custom models"] + ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete + ? ["Delete confirmation"] : []), - ...(isInstallSettingsDirty ? ["Provider installs"] : []), + ...(isGitWritingModelDirty ? ["Git writing model"] : []), + ...(areProviderSettingsDirty ? ["Providers"] : []), ]; const openKeybindingsFile = useCallback(() => { @@ -294,7 +406,7 @@ function SettingsRouteView() { const addCustomModel = useCallback( (provider: ProviderKind) => { const customModelInput = customModelInputByProvider[provider]; - const customModels = getCustomModelsForProvider(settings, provider); + const customModels = settings.providers[provider].customModels; const normalized = normalizeModelSlug(customModelInput, provider); if (!normalized) { setCustomModelErrorByProvider((existing) => ({ @@ -303,7 +415,11 @@ function SettingsRouteView() { })); return; } - if (getModelOptions(provider).some((option) => option.slug === normalized)) { + if ( + serverProviders + .find((candidate) => candidate.provider === provider) + ?.models.some((option) => !option.isCustom && option.slug === normalized) + ) { setCustomModelErrorByProvider((existing) => ({ ...existing, [provider]: "That model is already built in.", @@ -325,7 +441,15 @@ function SettingsRouteView() { return; } - updateSettings(patchCustomModels(provider, [...customModels, normalized])); + updateSettings({ + providers: { + ...settings.providers, + [provider]: { + ...settings.providers[provider], + customModels: [...customModels, normalized], + }, + }, + }); setCustomModelInputByProvider((existing) => ({ ...existing, [provider]: "", @@ -334,19 +458,37 @@ function SettingsRouteView() { ...existing, [provider]: null, })); + // Watch for DOM changes (server may push updated model list) and scroll to bottom + const el = modelListRefs.current[provider]; + if (el) { + const scrollToEnd = () => el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); + // Immediate scroll for the optimistic update + requestAnimationFrame(scrollToEnd); + // Also observe mutations for when the server pushes an updated list + const observer = new MutationObserver(() => { + scrollToEnd(); + observer.disconnect(); + }); + observer.observe(el, { childList: true, subtree: true }); + // Clean up observer after a reasonable window + setTimeout(() => observer.disconnect(), 2000); + } }, - [customModelInputByProvider, settings, updateSettings], + [customModelInputByProvider, serverProviders, settings, updateSettings], ); const removeCustomModel = useCallback( (provider: ProviderKind, slug: string) => { - const customModels = getCustomModelsForProvider(settings, provider); - updateSettings( - patchCustomModels( - provider, - customModels.filter((model) => model !== slug), - ), - ); + const customModels = settings.providers[provider].customModels; + updateSettings({ + providers: { + ...settings.providers, + [provider]: { + ...settings.providers[provider], + customModels: customModels.filter((model) => model !== slug), + }, + }, + }); setCustomModelErrorByProvider((existing) => ({ ...existing, [provider]: null, @@ -355,6 +497,46 @@ function SettingsRouteView() { [settings, updateSettings], ); + const providerCards = PROVIDER_SETTINGS.map((providerSettings) => { + const liveProvider = serverProviders.find( + (candidate) => candidate.provider === providerSettings.provider, + ); + const providerConfig = settings.providers[providerSettings.provider]; + const defaultProviderConfig = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider]; + const statusKey = liveProvider?.status ?? (providerConfig.enabled ? "warning" : "disabled"); + const statusStyle = PROVIDER_STATUS_STYLES[statusKey]; + const summary = getProviderSummary(liveProvider); + const models: ReadonlyArray = + liveProvider?.models ?? + providerConfig.customModels.map((slug) => ({ + slug, + name: slug, + isCustom: true, + capabilities: null, + })); + const binaryPathValue = providerConfig.binaryPath; + const isDirty = !Equal.equals(providerConfig, defaultProviderConfig); + + return { + provider: providerSettings.provider, + title: providerSettings.title, + binaryPlaceholder: providerSettings.binaryPlaceholder, + binaryDescription: providerSettings.binaryDescription, + homePathKey: providerSettings.homePathKey, + homePlaceholder: providerSettings.homePlaceholder, + homeDescription: providerSettings.homeDescription, + binaryPathValue, + isDirty, + liveProvider, + models, + providerConfig, + statusKey, + statusStyle, + summary, + versionLabel: getProviderVersionLabel(liveProvider?.version), + }; + }); + async function restoreDefaults() { if (changedSettingLabels.length === 0) return; @@ -368,11 +550,10 @@ function SettingsRouteView() { setTheme("system"); resetSettings(); - setOpenInstallProviders({ + setOpenProviderDetails({ codex: false, claudeAgent: false, }); - setSelectedCustomModelProvider("codex"); setCustomModelInputByProvider({ codex: "", claudeAgent: "", @@ -461,12 +642,12 @@ function SettingsRouteView() { title="Time format" description="System default follows your browser or OS clock preference." resetAction={ - settings.timestampFormat !== defaults.timestampFormat ? ( + settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ( updateSettings({ - timestampFormat: defaults.timestampFormat, + timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, }) } /> @@ -506,12 +687,12 @@ function SettingsRouteView() { title="Diff line wrapping" description="Set the default wrap state when the diff panel opens. The in-panel wrap toggle only affects the current diff session." resetAction={ - settings.diffWordWrap !== defaults.diffWordWrap ? ( + settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap ? ( updateSettings({ - diffWordWrap: defaults.diffWordWrap, + diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap, }) } /> @@ -534,12 +715,14 @@ function SettingsRouteView() { title="Assistant output" description="Show token-by-token output while a response is in progress." resetAction={ - settings.enableAssistantStreaming !== defaults.enableAssistantStreaming ? ( + settings.enableAssistantStreaming !== + DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? ( updateSettings({ - enableAssistantStreaming: defaults.enableAssistantStreaming, + enableAssistantStreaming: + DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, }) } /> @@ -562,12 +745,13 @@ function SettingsRouteView() { title="New threads" description="Pick the default workspace mode for newly created draft threads." resetAction={ - settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ( + settings.defaultThreadEnvMode !== + DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ( updateSettings({ - defaultThreadEnvMode: defaults.defaultThreadEnvMode, + defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, }) } /> @@ -604,12 +788,12 @@ function SettingsRouteView() { title="Delete confirmation" description="Ask before deleting a thread and its chat history." resetAction={ - settings.confirmThreadDelete !== defaults.confirmThreadDelete ? ( + settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ( updateSettings({ - confirmThreadDelete: defaults.confirmThreadDelete, + confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, }) } /> @@ -627,20 +811,18 @@ function SettingsRouteView() { /> } /> - - - { updateSettings({ - textGenerationModelSelection: defaults.textGenerationModelSelection, + textGenerationModelSelection: + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, }); }} /> @@ -652,20 +834,28 @@ function SettingsRouteView() { provider={textGenProvider} model={textGenModel} lockedProvider={null} + providers={serverProviders} modelOptionsByProvider={gitModelOptionsByProvider} triggerVariant="outline" triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" onProviderModelChange={(provider, model) => { updateSettings({ - textGenerationModelSelection: resolveAppModelSelectionState({ - ...settings, - textGenerationModelSelection: { provider, model }, - }), + textGenerationModelSelection: resolveAppModelSelectionState( + { + ...settings, + textGenerationModelSelection: { provider, model }, + }, + serverProviders, + ), }); }} /> provider.provider === textGenProvider) + ?.models ?? [] + } model={textGenModel} prompt="" onPromptChange={() => {}} @@ -675,292 +865,407 @@ function SettingsRouteView() { triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" onModelOptionsChange={(nextOptions) => { updateSettings({ - textGenerationModelSelection: resolveAppModelSelectionState({ - ...settings, - textGenerationModelSelection: { - provider: textGenProvider, - model: textGenModel, - ...(nextOptions ? { options: nextOptions } : {}), + textGenerationModelSelection: resolveAppModelSelectionState( + { + ...settings, + textGenerationModelSelection: { + provider: textGenProvider, + model: textGenModel, + ...(nextOptions ? { options: nextOptions } : {}), + }, }, - }), + serverProviders, + ), }); }} />
} /> + - 0 ? ( - { - updateSettings({ - customCodexModels: defaults.customCodexModels, - customClaudeModels: defaults.customClaudeModels, - }); - setCustomModelErrorByProvider({}); - setShowAllCustomModels(false); - }} - /> - ) : null - } - > -
-
- - { - const value = event.target.value; - setCustomModelInputByProvider((existing) => ({ - ...existing, - [selectedCustomModelProvider]: value, - })); - if (selectedCustomModelError) { - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [selectedCustomModelProvider]: null, - })); - } - }} - onKeyDown={(event) => { - if (event.key !== "Enter") return; - event.preventDefault(); - addCustomModel(selectedCustomModelProvider); - }} - placeholder={selectedCustomModelProviderSettings.example} - spellCheck={false} - /> - -
- - {selectedCustomModelError ? ( -

{selectedCustomModelError}

+ + {serverProviders.length > 0 ? ( + + {(() => { + const rel = formatRelativeTime( + serverProviders.reduce( + (latest, provider) => + provider.checkedAt > latest ? provider.checkedAt : latest, + serverProviders[0]!.checkedAt, + ), + ); + return rel.suffix ? ( + <> + Checked {rel.value}{" "} + {rel.suffix} + + ) : ( + <>Checked {rel.value} + ); + })()} + ) : null} + + void refreshProviders()} + aria-label="Refresh provider status" + > + {isRefreshingProviders ? ( + + ) : ( + + )} + + } + /> + Refresh provider status + +
+ } + > + {providerCards.map((providerCard) => { + const customModelInput = customModelInputByProvider[providerCard.provider]; + const customModelError = customModelErrorByProvider[providerCard.provider] ?? null; + const providerDisplayName = + PROVIDER_DISPLAY_NAMES[providerCard.provider] ?? providerCard.title; - {totalCustomModels > 0 ? ( -
-
- {visibleCustomModelRows.map((row) => ( -
- - {row.providerTitle} + return ( +
+
+
+
+
+ +

+ {providerDisplayName} +

+ {providerCard.versionLabel ? ( + + {providerCard.versionLabel} + + ) : null} + + {providerCard.isDirty ? ( + { + updateSettings({ + providers: { + ...settings.providers, + [providerCard.provider]: + DEFAULT_UNIFIED_SETTINGS.providers[providerCard.provider], + }, + }); + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [providerCard.provider]: null, + })); + }} + /> + ) : null} - - {row.slug} - -
- ))} +

+ {providerCard.summary.headline} + {providerCard.summary.detail + ? ` — ${providerCard.summary.detail}` + : null} +

+
+
+ + { + const isDisabling = !checked; + // The resolved provider accounts for both explicit + // selection and the implicit default (codex). + const resolvedProvider = textGenProvider; + // When disabling the provider that's currently used for + // text generation, clear the selection so it falls back to + // the next available provider's default model. + const shouldClearModelSelection = + isDisabling && resolvedProvider === providerCard.provider; + updateSettings({ + providers: { + ...settings.providers, + [providerCard.provider]: { + ...settings.providers[providerCard.provider], + enabled: Boolean(checked), + }, + }, + ...(shouldClearModelSelection + ? { + textGenerationModelSelection: + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, + } + : {}), + }); + }} + aria-label={`Enable ${providerDisplayName}`} + /> +
- - {savedCustomModelRows.length > 5 ? ( - - ) : null}
- ) : null} -
- - - - { - updateSettings({ - claudeBinaryPath: defaults.claudeBinaryPath, - codexBinaryPath: defaults.codexBinaryPath, - codexHomePath: defaults.codexHomePath, - }); - setOpenInstallProviders({ - codex: false, - claudeAgent: false, - }); - }} - /> - ) : null - } - > -
-
- {INSTALL_PROVIDER_SETTINGS.map((providerSettings) => { - const isOpen = openInstallProviders[providerSettings.provider]; - const isDirty = - providerSettings.provider === "codex" - ? settings.codexBinaryPath !== defaults.codexBinaryPath || - settings.codexHomePath !== defaults.codexHomePath - : settings.claudeBinaryPath !== defaults.claudeBinaryPath; - const binaryPathValue = - providerSettings.binaryPathKey === "claudeBinaryPath" - ? claudeBinaryPath - : codexBinaryPath; - - return ( - - setOpenInstallProviders((existing) => ({ - ...existing, - [providerSettings.provider]: open, - })) - } - > -
- - - -
-
- +
+ + {/* Home path (Codex only) */} + {providerCard.homePathKey ? ( +
+ +
+ ) : null} + + {/* Models */} +
+
Models
+
+ {providerCard.models.length} model + {providerCard.models.length === 1 ? "" : "s"} available. +
+
{ + modelListRefs.current[providerCard.provider] = el; + }} + className="mt-2 max-h-40 overflow-y-auto pb-1" + > + {providerCard.models.map((model) => { + const caps = model.capabilities; + const capLabels: string[] = []; + if (caps?.supportsFastMode) capLabels.push("Fast mode"); + if (caps?.supportsThinkingToggle) capLabels.push("Thinking"); + if ( + caps?.reasoningEffortLevels && + caps.reasoningEffortLevels.length > 0 + ) + capLabels.push("Reasoning"); + const hasDetails = + capLabels.length > 0 || model.name !== model.slug; + + return ( +
- - {providerSettings.title} binary path + + {model.name} - - updateSettings( - providerSettings.binaryPathKey === "claudeBinaryPath" - ? { claudeBinaryPath: event.target.value } - : { codexBinaryPath: event.target.value }, - ) - } - placeholder={providerSettings.binaryPlaceholder} - spellCheck={false} - /> - - {providerSettings.binaryDescription} - - - - {providerSettings.homePathKey ? ( -
- + +
+ ) : null} +
+ ); + })} +
+
+ { + const value = event.target.value; + setCustomModelInputByProvider((existing) => ({ + ...existing, + [providerCard.provider]: value, + })); + if (customModelError) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [providerCard.provider]: null, + })); + } + }} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + addCustomModel(providerCard.provider); + }} + placeholder={ + providerCard.provider === "codex" + ? "gpt-6.7-codex-ultra-preview" + : "claude-sonnet-5-0" + } + spellCheck={false} + /> + +
+ {customModelError ? ( +

{customModelError}

+ ) : null}
-
- ); - })} +
+ +
-
- + ); + })} + + { const onWindowKeyDown = (event: KeyboardEvent) => { diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index d4ffa3c376..a75ab21019 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -1,4 +1,4 @@ -import { type TimestampFormat } from "./appSettings"; +import { type TimestampFormat } from "@t3tools/contracts/settings"; export function getTimestampFormatOptions( timestampFormat: TimestampFormat, @@ -47,3 +47,22 @@ export function formatTimestamp(isoDate: string, timestampFormat: TimestampForma export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { return getTimestampFormatter(timestampFormat, false).format(new Date(isoDate)); } + +/** + * Format a relative time string from an ISO date. + * Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }` + * so callers can style the numeric portion independently. + */ +export function formatRelativeTime(isoDate: string): { value: string; suffix: string | null } { + const diffMs = Date.now() - new Date(isoDate).getTime(); + if (diffMs < 0) return { value: "just now", suffix: null }; + const seconds = Math.floor(diffMs / 1000); + if (seconds < 5) return { value: "just now", suffix: null }; + if (seconds < 60) return { value: `${seconds}s`, suffix: "ago" }; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return { value: `${minutes}m`, suffix: "ago" }; + const hours = Math.floor(minutes / 60); + if (hours < 24) return { value: `${hours}h`, suffix: "ago" }; + const days = Math.floor(hours / 24); + return { value: `${days}d`, suffix: "ago" }; +} diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 86ac4e9ba6..9e612543d0 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -13,7 +13,7 @@ import { WS_CHANNELS, WS_METHODS, type WsPush, - type ServerProviderStatus, + type ServerProvider, } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -92,13 +92,16 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn return testGlobal.window; } -const defaultProviders: ReadonlyArray = [ +const defaultProviders: ReadonlyArray = [ { provider: "codex", + enabled: true, + installed: true, + version: "0.116.0", status: "ready", - available: true, authStatus: "authenticated", checkedAt: "2026-01-01T00:00:00.000Z", + models: [], }, ]; @@ -197,7 +200,6 @@ describe("wsNativeApi", () => { message: "Entry at index 1 is invalid.", }, ], - providers: defaultProviders, } as const; emitPush(WS_CHANNELS.serverConfigUpdated, payload); @@ -219,20 +221,38 @@ describe("wsNativeApi", () => { emitPush(WS_CHANNELS.serverConfigUpdated, { issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], - providers: defaultProviders, }); emitPush(WS_CHANNELS.serverConfigUpdated, { issues: [], - providers: defaultProviders, }); expect(listener).toHaveBeenCalledTimes(2); expect(listener).toHaveBeenLastCalledWith({ issues: [], - providers: defaultProviders, }); }); + it("delivers and caches valid server.providersUpdated payloads", async () => { + const { createWsNativeApi, onServerProvidersUpdated } = await import("./wsNativeApi"); + + createWsNativeApi(); + const listener = vi.fn(); + onServerProvidersUpdated(listener); + + const payload = { + providers: defaultProviders, + } as const; + emitPush(WS_CHANNELS.serverProvidersUpdated, payload); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(payload); + + const lateListener = vi.fn(); + onServerProvidersUpdated(lateListener); + expect(lateListener).toHaveBeenCalledTimes(1); + expect(lateListener).toHaveBeenCalledWith(payload); + }); + it("forwards valid terminal and orchestration events", async () => { const { createWsNativeApi } = await import("./wsNativeApi"); @@ -357,10 +377,6 @@ describe("wsNativeApi", () => { actionId: "action-1", cwd: "/repo", action: "commit", - modelSelection: { - provider: "codex", - model: "gpt-5.4-mini", - }, }); expect(requestMock).toHaveBeenCalledWith( @@ -369,10 +385,6 @@ describe("wsNativeApi", () => { actionId: "action-1", cwd: "/repo", action: "commit", - modelSelection: { - provider: "codex", - model: "gpt-5.4-mini", - }, }, { timeoutMs: null }, ); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 042875f6f7..7024ffdb47 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -5,6 +5,7 @@ import { type ContextMenuItem, type NativeApi, ServerConfigUpdatedPayload, + ServerProviderUpdatedPayload, WS_CHANNELS, WS_METHODS, type WsWelcomePayload, @@ -16,6 +17,7 @@ import { WsTransport } from "./wsTransport"; let instance: { api: NativeApi; transport: WsTransport } | null = null; const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>(); const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>(); +const providersUpdatedListeners = new Set<(payload: ServerProviderUpdatedPayload) => void>(); const gitActionProgressListeners = new Set<(payload: GitActionProgressEvent) => void>(); /** @@ -64,6 +66,26 @@ export function onServerConfigUpdated( }; } +export function onServerProvidersUpdated( + listener: (payload: ServerProviderUpdatedPayload) => void, +): () => void { + providersUpdatedListeners.add(listener); + + const latestProviders = + instance?.transport.getLatestPush(WS_CHANNELS.serverProvidersUpdated)?.data ?? null; + if (latestProviders) { + try { + listener(latestProviders); + } catch { + // Swallow listener errors + } + } + + return () => { + providersUpdatedListeners.delete(listener); + }; +} + export function createWsNativeApi(): NativeApi { if (instance) return instance.api; @@ -89,6 +111,16 @@ export function createWsNativeApi(): NativeApi { } } }); + transport.subscribe(WS_CHANNELS.serverProvidersUpdated, (message) => { + const payload = message.data; + for (const listener of providersUpdatedListeners) { + try { + listener(payload); + } catch { + // Swallow listener errors + } + } + }); transport.subscribe(WS_CHANNELS.gitActionProgress, (message) => { const payload = message.data; for (const listener of gitActionProgressListeners) { @@ -178,7 +210,10 @@ export function createWsNativeApi(): NativeApi { }, server: { getConfig: () => transport.request(WS_METHODS.serverGetConfig), + refreshProviders: () => transport.request(WS_METHODS.serverRefreshProviders), upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), + getSettings: () => transport.request(WS_METHODS.serverGetSettings), + updateSettings: (patch) => transport.request(WS_METHODS.serverUpdateSettings, { patch }), }, orchestration: { getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot), diff --git a/apps/web/src/wsTransport.test.ts b/apps/web/src/wsTransport.test.ts index 55ff2556e4..e66ed7fc0d 100644 --- a/apps/web/src/wsTransport.test.ts +++ b/apps/web/src/wsTransport.test.ts @@ -98,7 +98,7 @@ describe("WsTransport", () => { type: "push", sequence: 1, channel: WS_CHANNELS.serverConfigUpdated, - data: { issues: [], providers: [] }, + data: { issues: [] }, }), ); @@ -107,7 +107,7 @@ describe("WsTransport", () => { type: "push", sequence: 1, channel: WS_CHANNELS.serverConfigUpdated, - data: { issues: [], providers: [] }, + data: { issues: [] }, }); transport.dispose(); @@ -160,7 +160,7 @@ describe("WsTransport", () => { type: "push", sequence: 3, channel: WS_CHANNELS.serverConfigUpdated, - data: { issues: [], providers: [] }, + data: { issues: [] }, }), ); @@ -169,7 +169,7 @@ describe("WsTransport", () => { type: "push", sequence: 3, channel: WS_CHANNELS.serverConfigUpdated, - data: { issues: [], providers: [] }, + data: { issues: [] }, }); expect(warnSpy).toHaveBeenCalledTimes(2); expect(warnSpy).toHaveBeenNthCalledWith( diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 35f8b15498..29d0c1398f 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -10,6 +10,11 @@ "module": "./dist/index.mjs", "types": "./src/index.ts", "exports": { + "./settings": { + "types": "./src/settings.ts", + "import": "./src/settings.ts", + "require": "./src/settings.ts" + }, ".": { "types": "./src/index.ts", "import": "./src/index.ts", diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index e9446b540a..d2bfac6028 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -74,19 +74,4 @@ describe("GitRunStackedActionInput", () => { expect(parsed.actionId).toBe("action-1"); expect(parsed.action).toBe("commit"); }); - - it("accepts git text generation as a modelSelection", () => { - const parsed = decodeRunStackedActionInput({ - actionId: "action-1", - cwd: "/repo", - action: "commit_push_pr", - modelSelection: { - provider: "claudeAgent", - model: "claude-haiku-4-5", - }, - }); - - expect(parsed.modelSelection?.provider).toBe("claudeAgent"); - expect(parsed.modelSelection?.model).toBe("claude-haiku-4-5"); - }); }); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 93264626cd..f8b65abf2c 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,6 +1,5 @@ import { Schema } from "effect"; import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; -import { ModelSelection } from "./orchestration"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; @@ -80,7 +79,6 @@ export const GitRunStackedActionInput = Schema.Struct({ filePaths: Schema.optional( Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)), ), - modelSelection: ModelSelection, }); export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 0f37a93515..248b3a04f9 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -7,6 +7,7 @@ export * from "./model"; export * from "./ws"; export * from "./keybindings"; export * from "./server"; +export * from "./settings"; export * from "./git"; export * from "./orchestration"; export * from "./editor"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index ea73024de3..1d282ae721 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -25,7 +25,11 @@ import type { ProjectWriteFileInput, ProjectWriteFileResult, } from "./project"; -import type { ServerConfig } from "./server"; +import type { + ServerConfig, + ServerProviderUpdatedPayload, + ServerUpsertKeybindingResult, +} from "./server"; import type { TerminalClearInput, TerminalCloseInput, @@ -36,7 +40,7 @@ import type { TerminalSessionSnapshot, TerminalWriteInput, } from "./terminal"; -import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; +import type { ServerUpsertKeybindingInput } from "./server"; import type { ClientOrchestrationCommand, OrchestrationGetFullThreadDiffInput, @@ -47,6 +51,7 @@ import type { OrchestrationReadModel, } from "./orchestration"; import { EditorId } from "./editor"; +import { ServerSettings, ServerSettingsPatch } from "./settings"; export interface ContextMenuItem { id: T; @@ -160,7 +165,10 @@ export interface NativeApi { }; server: { getConfig: () => Promise; + refreshProviders: () => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; + getSettings: () => Promise; + updateSettings: (patch: ServerSettingsPatch) => Promise; }; orchestration: { getSnapshot: () => Promise; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 6e564fae06..68ca110473 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -1,4 +1,5 @@ import { Schema } from "effect"; +import { TrimmedNonEmptyString } from "./baseSchemas"; import type { ProviderKind } from "./orchestration"; export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const; @@ -26,178 +27,28 @@ export const ProviderModelOptions = Schema.Struct({ }); export type ProviderModelOptions = typeof ProviderModelOptions.Type; -export type EffortOption = { - readonly value: string; - readonly label: string; - readonly isDefault?: true; -}; - -export type ModelCapabilities = { - readonly reasoningEffortLevels: readonly EffortOption[]; - readonly supportsFastMode: boolean; - readonly supportsThinkingToggle: boolean; - readonly promptInjectedEffortLevels: readonly string[]; -}; - -type ModelDefinition = { - readonly slug: string; - readonly name: string; - readonly capabilities: ModelCapabilities; -}; +export const EffortOption = Schema.Struct({ + value: TrimmedNonEmptyString, + label: TrimmedNonEmptyString, + isDefault: Schema.optional(Schema.Boolean), +}); +export type EffortOption = typeof EffortOption.Type; -/** - * TODO: This should not be a static array, each provider - * should return its own model list over the WS API. - */ -export const MODEL_OPTIONS_BY_PROVIDER = { - codex: [ - { - slug: "gpt-5.4", - name: "GPT-5.4", - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.4-mini", - name: "GPT-5.4 Mini", - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.3-codex", - name: "GPT-5.3 Codex", - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.3-codex-spark", - name: "GPT-5.3 Codex Spark", - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.2-codex", - name: "GPT-5.2 Codex", - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.2", - name: "GPT-5.2", - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - promptInjectedEffortLevels: [], - }, - }, - ], - claudeAgent: [ - { - slug: "claude-opus-4-6", - name: "Claude Opus 4.6", - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - promptInjectedEffortLevels: ["ultrathink"], - }, - }, - { - slug: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "ultrathink", label: "Ultrathink" }, - ], - supportsFastMode: false, - supportsThinkingToggle: false, - promptInjectedEffortLevels: ["ultrathink"], - }, - }, - { - slug: "claude-haiku-4-5", - name: "Claude Haiku 4.5", - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: true, - promptInjectedEffortLevels: [], - }, - }, - ], -} as const satisfies Record; -export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; +export const ModelCapabilities = Schema.Struct({ + reasoningEffortLevels: Schema.Array(EffortOption), + supportsFastMode: Schema.Boolean, + supportsThinkingToggle: Schema.Boolean, + promptInjectedEffortLevels: Schema.Array(TrimmedNonEmptyString), +}); +export type ModelCapabilities = typeof ModelCapabilities.Type; -type BuiltInModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"]; -export type ModelSlug = BuiltInModelSlug | (string & {}); +export type ModelSlug = string & {}; export const DEFAULT_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4", claudeAgent: "claude-sonnet-4-6", }; -// Backward compatibility for existing Codex-only call sites. -export const MODEL_OPTIONS = MODEL_OPTIONS_BY_PROVIDER.codex; export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; /** Per-provider text generation model defaults. */ @@ -230,15 +81,6 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record [ - provider, - Object.fromEntries(models.map((m) => [m.slug, m.capabilities])), - ]), -) as unknown as Record>; - // ── Provider display names ──────────────────────────────────────────── export const PROVIDER_DISPLAY_NAMES: Record = { diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 333d5ca1eb..0b40bb6fdf 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -47,37 +47,20 @@ export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; export const CodexModelSelection = Schema.Struct({ provider: Schema.Literal("codex"), model: TrimmedNonEmptyString, - options: Schema.optional(CodexModelOptions), + options: Schema.optionalKey(CodexModelOptions), }); export type CodexModelSelection = typeof CodexModelSelection.Type; export const ClaudeModelSelection = Schema.Struct({ provider: Schema.Literal("claudeAgent"), model: TrimmedNonEmptyString, - options: Schema.optional(ClaudeModelOptions), + options: Schema.optionalKey(ClaudeModelOptions), }); export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]); export type ModelSelection = typeof ModelSelection.Type; -export const CodexProviderStartOptions = Schema.Struct({ - binaryPath: Schema.optional(TrimmedNonEmptyString), - homePath: Schema.optional(TrimmedNonEmptyString), -}); - -export const ClaudeProviderStartOptions = Schema.Struct({ - binaryPath: Schema.optional(TrimmedNonEmptyString), - permissionMode: Schema.optional(TrimmedNonEmptyString), - maxThinkingTokens: Schema.optional(NonNegativeInt), -}); - -export const ProviderStartOptions = Schema.Struct({ - codex: Schema.optional(CodexProviderStartOptions), - claudeAgent: Schema.optional(ClaudeProviderStartOptions), -}); -export type ProviderStartOptions = typeof ProviderStartOptions.Type; - export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); export type RuntimeMode = typeof RuntimeMode.Type; export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; @@ -402,8 +385,6 @@ export const ThreadTurnStartCommand = Schema.Struct({ attachments: Schema.Array(ChatAttachment), }), modelSelection: Schema.optional(ModelSelection), - providerOptions: Schema.optional(ProviderStartOptions), - assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), @@ -423,8 +404,6 @@ const ClientThreadTurnStartCommand = Schema.Struct({ attachments: Schema.Array(UploadChatAttachment), }), modelSelection: Schema.optional(ModelSelection), - providerOptions: Schema.optional(ProviderStartOptions), - assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, sourceProposedPlan: Schema.optional(SourceProposedPlanReference), @@ -702,7 +681,6 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ threadId: ThreadId, messageId: MessageId, modelSelection: Schema.optional(ModelSelection), - providerOptions: Schema.optional(ProviderStartOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 0c24b1da99..37469984de 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -21,12 +21,6 @@ describe("ProviderSessionStartInput", () => { }, }, runtimeMode: "full-access", - providerOptions: { - codex: { - binaryPath: "/usr/local/bin/codex", - homePath: "/tmp/.codex", - }, - }, }); expect(parsed.runtimeMode).toBe("full-access"); expect(parsed.modelSelection?.provider).toBe("codex"); @@ -36,8 +30,6 @@ describe("ProviderSessionStartInput", () => { } expect(parsed.modelSelection.options?.reasoningEffort).toBe("high"); expect(parsed.modelSelection.options?.fastMode).toBe(true); - expect(parsed.providerOptions?.codex?.binaryPath).toBe("/usr/local/bin/codex"); - expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex"); }); it("rejects payloads without runtime mode", () => { @@ -63,13 +55,6 @@ describe("ProviderSessionStartInput", () => { fastMode: true, }, }, - providerOptions: { - claudeAgent: { - binaryPath: "/usr/local/bin/claude", - permissionMode: "plan", - maxThinkingTokens: 12_000, - }, - }, runtimeMode: "full-access", }); expect(parsed.provider).toBe("claudeAgent"); @@ -81,9 +66,6 @@ describe("ProviderSessionStartInput", () => { expect(parsed.modelSelection.options?.thinking).toBe(true); expect(parsed.modelSelection.options?.effort).toBe("max"); expect(parsed.modelSelection.options?.fastMode).toBe(true); - expect(parsed.providerOptions?.claudeAgent?.binaryPath).toBe("/usr/local/bin/claude"); - expect(parsed.providerOptions?.claudeAgent?.permissionMode).toBe("plan"); - expect(parsed.providerOptions?.claudeAgent?.maxThinkingTokens).toBe(12_000); expect(parsed.runtimeMode).toBe("full-access"); }); }); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index e28088dc92..16102920d7 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -19,7 +19,6 @@ import { ProviderKind, ProviderRequestKind, ProviderSandboxMode, - ProviderStartOptions, ProviderUserInputAnswers, RuntimeMode, } from "./orchestration"; @@ -55,7 +54,6 @@ export const ProviderSessionStartInput = Schema.Struct({ resumeCursor: Schema.optional(Schema.Unknown), approvalPolicy: Schema.optional(ProviderApprovalPolicy), sandboxMode: Schema.optional(ProviderSandboxMode), - providerOptions: Schema.optional(ProviderStartOptions), runtimeMode: RuntimeMode, }); export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 96ea90c1f5..78d0879cd2 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -2,7 +2,9 @@ import { Schema } from "effect"; import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas"; import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; import { EditorId } from "./editor"; +import { ModelCapabilities } from "./model"; import { ProviderKind } from "./orchestration"; +import { ServerSettings } from "./settings"; const KeybindingsMalformedConfigIssue = Schema.Struct({ kind: Schema.Literal("keybindings.malformed-config"), @@ -23,8 +25,8 @@ export type ServerConfigIssue = typeof ServerConfigIssue.Type; const ServerConfigIssues = Schema.Array(ServerConfigIssue); -export const ServerProviderStatusState = Schema.Literals(["ready", "warning", "error"]); -export type ServerProviderStatusState = typeof ServerProviderStatusState.Type; +export const ServerProviderState = Schema.Literals(["ready", "warning", "error", "disabled"]); +export type ServerProviderState = typeof ServerProviderState.Type; export const ServerProviderAuthStatus = Schema.Literals([ "authenticated", @@ -33,25 +35,37 @@ export const ServerProviderAuthStatus = Schema.Literals([ ]); export type ServerProviderAuthStatus = typeof ServerProviderAuthStatus.Type; -export const ServerProviderStatus = Schema.Struct({ +export const ServerProviderModel = Schema.Struct({ + slug: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + isCustom: Schema.Boolean, + capabilities: Schema.NullOr(ModelCapabilities), +}); +export type ServerProviderModel = typeof ServerProviderModel.Type; + +export const ServerProvider = Schema.Struct({ provider: ProviderKind, - status: ServerProviderStatusState, - available: Schema.Boolean, + enabled: Schema.Boolean, + installed: Schema.Boolean, + version: Schema.NullOr(TrimmedNonEmptyString), + status: ServerProviderState, authStatus: ServerProviderAuthStatus, checkedAt: IsoDateTime, message: Schema.optional(TrimmedNonEmptyString), + models: Schema.Array(ServerProviderModel), }); -export type ServerProviderStatus = typeof ServerProviderStatus.Type; +export type ServerProvider = typeof ServerProvider.Type; -const ServerProviderStatuses = Schema.Array(ServerProviderStatus); +const ServerProviders = Schema.Array(ServerProvider); export const ServerConfig = Schema.Struct({ cwd: TrimmedNonEmptyString, keybindingsConfigPath: TrimmedNonEmptyString, keybindings: ResolvedKeybindingsConfig, issues: ServerConfigIssues, - providers: ServerProviderStatuses, + providers: ServerProviders, availableEditors: Schema.Array(EditorId), + settings: ServerSettings, }); export type ServerConfig = typeof ServerConfig.Type; @@ -66,6 +80,11 @@ export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.T export const ServerConfigUpdatedPayload = Schema.Struct({ issues: ServerConfigIssues, - providers: ServerProviderStatuses, + settings: Schema.optional(ServerSettings), }); export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type; + +export const ServerProviderUpdatedPayload = Schema.Struct({ + providers: ServerProviders, +}); +export type ServerProviderUpdatedPayload = typeof ServerProviderUpdatedPayload.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts new file mode 100644 index 0000000000..8ce01f630c --- /dev/null +++ b/packages/contracts/src/settings.ts @@ -0,0 +1,153 @@ +import { Effect } from "effect"; +import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; +import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas"; +import { + ClaudeModelOptions, + CodexModelOptions, + DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, +} from "./model"; +import { ModelSelection } from "./orchestration"; + +// ── Client Settings (local-only) ─────────────────────────────── + +export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); +export type TimestampFormat = typeof TimestampFormat.Type; +export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; + +export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); +export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; +export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; + +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"; + +export const ClientSettingsSchema = Schema.Struct({ + confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), + diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), + sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( + Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER), + ), + sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( + Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_THREAD_SORT_ORDER), + ), + timestampFormat: TimestampFormat.pipe(Schema.withDecodingDefault(() => DEFAULT_TIMESTAMP_FORMAT)), +}); +export type ClientSettings = typeof ClientSettingsSchema.Type; + +export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientSettingsSchema)({}); + +// ── Server Settings (server-authoritative) ──────────────────── + +export const ThreadEnvMode = Schema.Literals(["local", "worktree"]); +export type ThreadEnvMode = typeof ThreadEnvMode.Type; + +const makeBinaryPathSetting = (fallback: string) => + TrimmedString.pipe( + Schema.decodeTo( + Schema.String, + SchemaTransformation.transformOrFail({ + decode: (value) => Effect.succeed(value || fallback), + encode: (value) => Effect.succeed(value), + }), + ), + Schema.withDecodingDefault(() => fallback), + ); + +export const CodexSettings = Schema.Struct({ + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), + binaryPath: makeBinaryPathSetting("codex"), + homePath: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), + customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(() => [])), +}); +export type CodexSettings = typeof CodexSettings.Type; + +export const ClaudeSettings = Schema.Struct({ + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), + binaryPath: makeBinaryPathSetting("claude"), + customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(() => [])), +}); +export type ClaudeSettings = typeof ClaudeSettings.Type; + +export const ServerSettings = Schema.Struct({ + enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), + defaultThreadEnvMode: ThreadEnvMode.pipe( + Schema.withDecodingDefault(() => "local" as const satisfies ThreadEnvMode), + ), + textGenerationModelSelection: ModelSelection.pipe( + Schema.withDecodingDefault(() => ({ + provider: "codex" as const, + model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, + })), + ), + + // Provider specific settings + providers: Schema.Struct({ + codex: CodexSettings.pipe(Schema.withDecodingDefault(() => ({}))), + claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(() => ({}))), + }).pipe(Schema.withDecodingDefault(() => ({}))), +}); +export type ServerSettings = typeof ServerSettings.Type; + +export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerSettings)({}); + +// ── Unified type ───────────────────────────────────────────────────── + +export type UnifiedSettings = ServerSettings & ClientSettings; +export const DEFAULT_UNIFIED_SETTINGS: UnifiedSettings = { + ...DEFAULT_SERVER_SETTINGS, + ...DEFAULT_CLIENT_SETTINGS, +}; + +// ── Server Settings Patch (replace with a Schema.deepPartial if available) ────────────────────────────────────────── + +const CodexModelOptionsPatch = Schema.Struct({ + reasoningEffort: Schema.optionalKey(CodexModelOptions.fields.reasoningEffort), + fastMode: Schema.optionalKey(CodexModelOptions.fields.fastMode), +}); + +const ClaudeModelOptionsPatch = Schema.Struct({ + thinking: Schema.optionalKey(ClaudeModelOptions.fields.thinking), + effort: Schema.optionalKey(ClaudeModelOptions.fields.effort), + fastMode: Schema.optionalKey(ClaudeModelOptions.fields.fastMode), +}); + +const ModelSelectionPatch = Schema.Union([ + Schema.Struct({ + provider: Schema.optionalKey(Schema.Literal("codex")), + model: Schema.optionalKey(TrimmedNonEmptyString), + options: Schema.optionalKey(CodexModelOptionsPatch), + }), + Schema.Struct({ + provider: Schema.optionalKey(Schema.Literal("claudeAgent")), + model: Schema.optionalKey(TrimmedNonEmptyString), + options: Schema.optionalKey(ClaudeModelOptionsPatch), + }), +]); + +const CodexSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(Schema.String), + homePath: Schema.optionalKey(Schema.String), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + +const ClaudeSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(Schema.String), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + +export const ServerSettingsPatch = Schema.Struct({ + enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), + defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), + textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), + providers: Schema.optionalKey( + Schema.Struct({ + codex: Schema.optionalKey(CodexSettingsPatch), + claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), + }), + ), +}); +export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; diff --git a/packages/contracts/src/ws.test.ts b/packages/contracts/src/ws.test.ts index 2030dad4e5..0d8d4dbec2 100644 --- a/packages/contracts/src/ws.test.ts +++ b/packages/contracts/src/ws.test.ts @@ -119,6 +119,25 @@ it.effect("accepts git.actionProgress push envelopes", () => }), ); +it.effect("accepts server.providersUpdated push envelopes", () => + Effect.gen(function* () { + const parsed = yield* decodeWsResponse({ + type: "push", + sequence: 4, + channel: WS_CHANNELS.serverProvidersUpdated, + data: { + providers: [], + }, + }); + + if (!("type" in parsed) || parsed.type !== "push") { + assert.fail("expected websocket response to decode as a push envelope"); + } + + assert.strictEqual(parsed.channel, WS_CHANNELS.serverProvidersUpdated); + }), +); + it.effect("rejects push envelopes when channel payload does not match the channel schema", () => Effect.gen(function* () { const result = yield* Effect.exit( diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 45ef0512da..2bcc37f7c6 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -37,7 +37,8 @@ import { import { KeybindingRule } from "./keybindings"; import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; import { OpenInEditorInput } from "./editor"; -import { ServerConfigUpdatedPayload } from "./server"; +import { ServerConfigUpdatedPayload, ServerProviderUpdatedPayload } from "./server"; +import { ServerSettingsPatch } from "./settings"; // ── WebSocket RPC Method Names ─────────────────────────────────────── @@ -75,7 +76,10 @@ export const WS_METHODS = { // Server meta serverGetConfig: "server.getConfig", + serverRefreshProviders: "server.refreshProviders", serverUpsertKeybinding: "server.upsertKeybinding", + serverGetSettings: "server.getSettings", + serverUpdateSettings: "server.updateSettings", } as const; // ── Push Event Channels ────────────────────────────────────────────── @@ -85,6 +89,7 @@ export const WS_CHANNELS = { terminalEvent: "terminal.event", serverWelcome: "server.welcome", serverConfigUpdated: "server.configUpdated", + serverProvidersUpdated: "server.providersUpdated", } as const; // -- Tagged Union of all request body schemas ───────────────────────── @@ -140,7 +145,10 @@ const WebSocketRequestBody = Schema.Union([ // Server meta tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), + tagRequestBody(WS_METHODS.serverRefreshProviders, Schema.Struct({})), tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), + tagRequestBody(WS_METHODS.serverGetSettings, Schema.Struct({})), + tagRequestBody(WS_METHODS.serverUpdateSettings, Schema.Struct({ patch: ServerSettingsPatch })), ]); export const WebSocketRequest = Schema.Struct({ @@ -174,6 +182,7 @@ export type WsWelcomePayload = typeof WsWelcomePayload.Type; export interface WsPushPayloadByChannel { readonly [WS_CHANNELS.serverWelcome]: WsWelcomePayload; readonly [WS_CHANNELS.serverConfigUpdated]: typeof ServerConfigUpdatedPayload.Type; + readonly [WS_CHANNELS.serverProvidersUpdated]: typeof ServerProviderUpdatedPayload.Type; readonly [WS_CHANNELS.gitActionProgress]: typeof GitActionProgressEvent.Type; readonly [WS_CHANNELS.terminalEvent]: typeof TerminalEvent.Type; readonly [ORCHESTRATION_WS_CHANNELS.domainEvent]: OrchestrationEvent; @@ -198,6 +207,10 @@ export const WsPushServerConfigUpdated = makeWsPushSchema( WS_CHANNELS.serverConfigUpdated, ServerConfigUpdatedPayload, ); +export const WsPushServerProvidersUpdated = makeWsPushSchema( + WS_CHANNELS.serverProvidersUpdated, + ServerProviderUpdatedPayload, +); export const WsPushGitActionProgress = makeWsPushSchema( WS_CHANNELS.gitActionProgress, GitActionProgressEvent, @@ -212,6 +225,7 @@ export const WsPushChannelSchema = Schema.Literals([ WS_CHANNELS.gitActionProgress, WS_CHANNELS.serverWelcome, WS_CHANNELS.serverConfigUpdated, + WS_CHANNELS.serverProvidersUpdated, WS_CHANNELS.terminalEvent, ORCHESTRATION_WS_CHANNELS.domainEvent, ]); @@ -220,6 +234,7 @@ export type WsPushChannelSchema = typeof WsPushChannelSchema.Type; export const WsPush = Schema.Union([ WsPushServerWelcome, WsPushServerConfigUpdated, + WsPushServerProvidersUpdated, WsPushGitActionProgress, WsPushTerminalEvent, WsPushOrchestrationDomainEvent, diff --git a/packages/shared/package.json b/packages/shared/package.json index 02ae794d64..d34d1ce453 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -31,6 +31,10 @@ "./schemaJson": { "types": "./src/schemaJson.ts", "import": "./src/schemaJson.ts" + }, + "./Struct": { + "types": "./src/Struct.ts", + "import": "./src/Struct.ts" } }, "scripts": { diff --git a/packages/shared/src/Struct.ts b/packages/shared/src/Struct.ts new file mode 100644 index 0000000000..f703bcabfa --- /dev/null +++ b/packages/shared/src/Struct.ts @@ -0,0 +1,23 @@ +import * as P from "effect/Predicate"; + +export type DeepPartial = T extends readonly (infer U)[] + ? readonly DeepPartial[] + : T extends object + ? { [K in keyof T]?: DeepPartial } + : T; + +export function deepMerge>(current: T, patch: DeepPartial): T { + if (!P.isObject(current) || !P.isObject(patch)) { + return patch as T; + } + + const next = { ...current } as Record; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) continue; + + const existing = next[key]; + next[key] = P.isObject(existing) && P.isObject(value) ? deepMerge(existing, value) : value; + } + + return next as T; +} diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index d62a273c2c..31f0d0a112 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -2,31 +2,46 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_MODEL, DEFAULT_MODEL_BY_PROVIDER, - MODEL_OPTIONS, - MODEL_OPTIONS_BY_PROVIDER, - CODEX_REASONING_EFFORT_OPTIONS, + type ModelCapabilities, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, - getDefaultModel, - getModelCapabilities, - getModelOptions, + getDefaultEffort, + hasEffortLevel, isClaudeUltrathinkPrompt, - normalizeClaudeModelOptions, - normalizeCodexModelOptions, normalizeModelSlug, - resolveSelectableModel, resolveModelSlug, resolveModelSlugForProvider, - getDefaultEffort, - hasEffortLevel, + resolveSelectableModel, + trimOrNull, } from "./model"; +const codexCaps: ModelCapabilities = { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], +}; + +const claudeCaps: ModelCapabilities = { + reasoningEffortLevels: [ + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: ["ultrathink"], +}; + describe("normalizeModelSlug", () => { it("maps known aliases to canonical slugs", () => { expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); - expect(normalizeModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); + expect(normalizeModelSlug("sonnet", "claudeAgent")).toBe("claude-sonnet-4-6"); }); it("returns null for empty or missing values", () => { @@ -35,290 +50,62 @@ describe("normalizeModelSlug", () => { expect(normalizeModelSlug(null)).toBeNull(); expect(normalizeModelSlug(undefined)).toBeNull(); }); - - it("preserves non-aliased model slugs", () => { - expect(normalizeModelSlug("gpt-5.2")).toBe("gpt-5.2"); - expect(normalizeModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); - }); - - it("does not leak prototype properties as aliases", () => { - expect(normalizeModelSlug("toString")).toBe("toString"); - expect(normalizeModelSlug("constructor")).toBe("constructor"); - }); - - it("uses provider-specific aliases", () => { - expect(normalizeModelSlug("sonnet", "claudeAgent")).toBe("claude-sonnet-4-6"); - expect(normalizeModelSlug("opus-4.6", "claudeAgent")).toBe("claude-opus-4-6"); - expect(normalizeModelSlug("claude-haiku-4-5-20251001", "claudeAgent")).toBe("claude-haiku-4-5"); - }); }); describe("resolveModelSlug", () => { - it("returns default only when the model is missing", () => { + it("returns defaults when the model is missing", () => { expect(resolveModelSlug(undefined)).toBe(DEFAULT_MODEL); - expect(resolveModelSlug(null)).toBe(DEFAULT_MODEL); - }); - - it("preserves unknown custom models", () => { - expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL); - expect(resolveModelSlug("custom/internal-model")).toBe(DEFAULT_MODEL); - }); - - it("resolves only supported model options", () => { - for (const model of MODEL_OPTIONS) { - expect(resolveModelSlug(model.slug)).toBe(model.slug); - } - }); - - it("supports provider-aware resolution", () => { expect(resolveModelSlugForProvider("claudeAgent", undefined)).toBe( DEFAULT_MODEL_BY_PROVIDER.claudeAgent, ); - expect(resolveModelSlugForProvider("claudeAgent", "sonnet")).toBe("claude-sonnet-4-6"); - expect(resolveModelSlugForProvider("claudeAgent", "gpt-5.3-codex")).toBe( - DEFAULT_MODEL_BY_PROVIDER.claudeAgent, - ); }); - it("keeps codex defaults for backward compatibility", () => { - expect(getDefaultModel()).toBe(DEFAULT_MODEL); - expect(getModelOptions()).toEqual(MODEL_OPTIONS); - expect(getModelOptions("claudeAgent")).toEqual(MODEL_OPTIONS_BY_PROVIDER.claudeAgent); + it("preserves normalized unknown models", () => { + expect(resolveModelSlug("custom/internal-model")).toBe("custom/internal-model"); }); }); describe("resolveSelectableModel", () => { - it("resolves exact slug matches", () => { - expect( - resolveSelectableModel("codex", "gpt-5.3-codex", [ - { slug: "gpt-5.4", name: "GPT-5.4" }, - { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, - ]), - ).toBe("gpt-5.3-codex"); - }); - - it("resolves case-insensitive display-name matches", () => { - expect( - resolveSelectableModel("codex", "gpt-5.3 codex", [ - { slug: "gpt-5.4", name: "GPT-5.4" }, - { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, - ]), - ).toBe("gpt-5.3-codex"); - }); - - it("resolves provider-specific aliases after normalization", () => { - expect( - resolveSelectableModel("claudeAgent", "sonnet", [ - { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, - { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, - ]), - ).toBe("claude-sonnet-4-6"); - }); - - it("returns null for empty input", () => { - expect(resolveSelectableModel("codex", "", [{ slug: "gpt-5.4", name: "GPT-5.4" }])).toBeNull(); - expect( - resolveSelectableModel("codex", " ", [{ slug: "gpt-5.4", name: "GPT-5.4" }]), - ).toBeNull(); - expect( - resolveSelectableModel("codex", null, [{ slug: "gpt-5.4", name: "GPT-5.4" }]), - ).toBeNull(); - }); - - it("returns null for unknown values that are not present in options", () => { - expect( - resolveSelectableModel("codex", "gpt-4.1", [{ slug: "gpt-5.4", name: "GPT-5.4" }]), - ).toBeNull(); - }); - - it("does not accept normalized custom-looking slugs unless they exist in options", () => { - expect( - resolveSelectableModel("codex", "custom/internal-model", [ - { slug: "gpt-5.4", name: "GPT-5.4" }, - ]), - ).toBeNull(); - }); - - it("respects provider boundaries", () => { - expect( - resolveSelectableModel("codex", "sonnet", [{ slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }]), - ).toBeNull(); - expect( - resolveSelectableModel("claudeAgent", "5.3", [ - { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, - ]), - ).toBeNull(); - }); -}); - -describe("getModelCapabilities reasoningEffortLevels", () => { - const values = (provider: "codex" | "claudeAgent", model: string | null) => - getModelCapabilities(provider, model).reasoningEffortLevels.map((l) => l.value); - - it("returns codex reasoning options for codex", () => { - expect(values("codex", "gpt-5.4")).toEqual([...CODEX_REASONING_EFFORT_OPTIONS]); - }); - - it("returns claude effort options for Opus 4.6", () => { - expect(values("claudeAgent", "claude-opus-4-6")).toEqual([ - "low", - "medium", - "high", - "max", - "ultrathink", - ]); - }); - - it("returns claude effort options for Sonnet 4.6", () => { - expect(values("claudeAgent", "claude-sonnet-4-6")).toEqual([ - "low", - "medium", - "high", - "ultrathink", - ]); - }); - - it("returns no claude effort options for Haiku 4.5", () => { - expect(values("claudeAgent", "claude-haiku-4-5")).toEqual([]); - }); - - it("co-locates labels with effort values", () => { - const levels = getModelCapabilities("claudeAgent", "claude-opus-4-6").reasoningEffortLevels; - const high = levels.find((l) => l.value === "high"); - expect(high).toEqual({ value: "high", label: "High", isDefault: true }); - const xhigh = getModelCapabilities("codex", "gpt-5.4").reasoningEffortLevels.find( - (l) => l.value === "xhigh", - ); - expect(xhigh).toEqual({ value: "xhigh", label: "Extra High" }); + it("resolves exact slugs, labels, and aliases", () => { + const options = [ + { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + ]; + expect(resolveSelectableModel("codex", "gpt-5.3-codex", options)).toBe("gpt-5.3-codex"); + expect(resolveSelectableModel("codex", "gpt-5.3 codex", options)).toBe("gpt-5.3-codex"); + expect(resolveSelectableModel("claudeAgent", "sonnet", options)).toBe("claude-sonnet-4-6"); }); }); -describe("getDefaultEffort", () => { - it("returns the default effort from capabilities", () => { - expect(getDefaultEffort(getModelCapabilities("codex", "gpt-5.4"))).toBe("high"); - expect(getDefaultEffort(getModelCapabilities("claudeAgent", "claude-opus-4-6"))).toBe("high"); - expect(getDefaultEffort(getModelCapabilities("claudeAgent", "claude-haiku-4-5"))).toBeNull(); +describe("capability helpers", () => { + it("reads default efforts", () => { + expect(getDefaultEffort(codexCaps)).toBe("high"); + expect(getDefaultEffort(claudeCaps)).toBe("high"); }); -}); -describe("hasEffortLevel", () => { - it("validates effort against model capabilities", () => { - const opusCaps = getModelCapabilities("claudeAgent", "claude-opus-4-6"); - expect(hasEffortLevel(opusCaps, "max")).toBe(true); - expect(hasEffortLevel(opusCaps, "xhigh")).toBe(false); - - const codexCaps = getModelCapabilities("codex", "gpt-5.4"); + it("checks effort support", () => { expect(hasEffortLevel(codexCaps, "xhigh")).toBe(true); expect(hasEffortLevel(codexCaps, "max")).toBe(false); }); }); -describe("applyClaudePromptEffortPrefix", () => { - it("prefixes ultrathink prompts exactly once", () => { - expect(applyClaudePromptEffortPrefix("Investigate this", "ultrathink")).toBe( - "Ultrathink:\nInvestigate this", - ); - expect(applyClaudePromptEffortPrefix("Ultrathink:\nInvestigate this", "ultrathink")).toBe( - "Ultrathink:\nInvestigate this", - ); - }); - - it("leaves non-ultrathink prompts unchanged", () => { - expect(applyClaudePromptEffortPrefix("Investigate this", "high")).toBe("Investigate this"); - }); -}); - -describe("normalizeCodexModelOptions", () => { - it("drops default-only codex options", () => { - expect( - normalizeCodexModelOptions("gpt-5.4", { reasoningEffort: "high", fastMode: false }), - ).toBeUndefined(); - }); - - it("preserves non-default codex options", () => { - expect( - normalizeCodexModelOptions("gpt-5.4", { reasoningEffort: "xhigh", fastMode: true }), - ).toEqual({ - reasoningEffort: "xhigh", - fastMode: true, - }); - }); -}); - -describe("normalizeClaudeModelOptions", () => { - it("drops unsupported fast mode and max effort for Sonnet", () => { - expect( - normalizeClaudeModelOptions("claude-sonnet-4-6", { - effort: "max", - fastMode: true, - }), - ).toBeUndefined(); - }); - - it("keeps the Haiku thinking toggle and removes unsupported effort", () => { - expect( - normalizeClaudeModelOptions("claude-haiku-4-5", { - thinking: false, - effort: "high", - }), - ).toEqual({ - thinking: false, - }); - }); -}); - -describe("getModelCapabilities Claude capability flags", () => { - it("only enables adaptive reasoning for Opus 4.6 and Sonnet 4.6", () => { - const has = (m: string | undefined) => - getModelCapabilities("claudeAgent", m).reasoningEffortLevels.length > 0; - expect(has("claude-opus-4-6")).toBe(true); - expect(has("claude-sonnet-4-6")).toBe(true); - expect(has("claude-haiku-4-5")).toBe(false); - expect(has(undefined)).toBe(false); - }); - - it("only enables max effort for Opus 4.6", () => { - const has = (m: string | undefined) => - getModelCapabilities("claudeAgent", m).reasoningEffortLevels.some((l) => l.value === "max"); - expect(has("claude-opus-4-6")).toBe(true); - expect(has("claude-sonnet-4-6")).toBe(false); - expect(has("claude-haiku-4-5")).toBe(false); - expect(has(undefined)).toBe(false); - }); - - it("only enables Claude fast mode for Opus 4.6", () => { - const has = (m: string | undefined) => getModelCapabilities("claudeAgent", m).supportsFastMode; - expect(has("claude-opus-4-6")).toBe(true); - expect(has("opus")).toBe(true); - expect(has("claude-sonnet-4-6")).toBe(false); - expect(has("claude-haiku-4-5")).toBe(false); - expect(has(undefined)).toBe(false); - }); - - it("only enables ultrathink keyword handling for Opus 4.6 and Sonnet 4.6", () => { - const has = (m: string | undefined) => - getModelCapabilities("claudeAgent", m).reasoningEffortLevels.length > 0; - expect(has("claude-opus-4-6")).toBe(true); - expect(has("claude-sonnet-4-6")).toBe(true); - expect(has("claude-haiku-4-5")).toBe(false); +describe("misc helpers", () => { + it("detects ultrathink prompts", () => { + expect(isClaudeUltrathinkPrompt("Ultrathink:\nInvestigate")).toBe(true); + expect(isClaudeUltrathinkPrompt("Investigate")).toBe(false); }); - it("only enables the Claude thinking toggle for Haiku 4.5", () => { - const has = (m: string | undefined) => - getModelCapabilities("claudeAgent", m).supportsThinkingToggle; - expect(has("claude-opus-4-6")).toBe(false); - expect(has("claude-sonnet-4-6")).toBe(false); - expect(has("claude-haiku-4-5")).toBe(true); - expect(has("haiku")).toBe(true); - expect(has(undefined)).toBe(false); + it("prefixes ultrathink prompts once", () => { + expect(applyClaudePromptEffortPrefix("Investigate", "ultrathink")).toBe( + "Ultrathink:\nInvestigate", + ); + expect(applyClaudePromptEffortPrefix("Ultrathink:\nInvestigate", "ultrathink")).toBe( + "Ultrathink:\nInvestigate", + ); }); -}); -describe("isClaudeUltrathinkPrompt", () => { - it("detects ultrathink prompts case-insensitively", () => { - expect(isClaudeUltrathinkPrompt("Please ultrathink about this")).toBe(true); - expect(isClaudeUltrathinkPrompt("Ultrathink:\nInvestigate")).toBe(true); - expect(isClaudeUltrathinkPrompt("Think hard about this")).toBe(false); - expect(isClaudeUltrathinkPrompt(undefined)).toBe(false); + it("trims strings to null", () => { + expect(trimOrNull(" hi ")).toBe("hi"); + expect(trimOrNull(" ")).toBeNull(); }); }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 53ebc856fd..e633aeb293 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,31 +1,17 @@ import { DEFAULT_MODEL_BY_PROVIDER, - MODEL_CAPABILITIES_INDEX, - MODEL_OPTIONS_BY_PROVIDER, MODEL_SLUG_ALIASES_BY_PROVIDER, - type ClaudeModelOptions, type ClaudeCodeEffort, - type CodexModelOptions, type ModelCapabilities, type ModelSlug, type ProviderKind, - CodexReasoningEffort, } from "@t3tools/contracts"; -const MODEL_SLUG_SET_BY_PROVIDER: Record> = { - claudeAgent: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeAgent.map((option) => option.slug)), - codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), -}; - export interface SelectableModelOption { slug: string; name: string; } -export function getModelOptions(provider: ProviderKind = "codex") { - return MODEL_OPTIONS_BY_PROVIDER[provider]; -} - export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug { return DEFAULT_MODEL_BY_PROVIDER[provider]; } @@ -42,24 +28,6 @@ export function getDefaultEffort(caps: ModelCapabilities): string | null { return caps.reasoningEffortLevels.find((l) => l.isDefault)?.value ?? null; } -// ── Data-driven capability resolver ─────────────────────────────────── - -export function getModelCapabilities( - provider: ProviderKind, - model: string | null | undefined, -): ModelCapabilities { - const slug = normalizeModelSlug(model, provider); - if (slug && MODEL_CAPABILITIES_INDEX[provider]?.[slug]) { - return MODEL_CAPABILITIES_INDEX[provider][slug]; - } - return { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - promptInjectedEffortLevels: [], - }; -} - export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { return typeof text === "string" && /\bultrathink\b/i.test(text); } @@ -125,10 +93,7 @@ export function resolveModelSlug( if (!normalized) { return DEFAULT_MODEL_BY_PROVIDER[provider]; } - - return MODEL_SLUG_SET_BY_PROVIDER[provider].has(normalized) - ? normalized - : DEFAULT_MODEL_BY_PROVIDER[provider]; + return normalized; } export function resolveModelSlugForProvider( @@ -145,47 +110,6 @@ export function trimOrNull(value: T | null | undefined): T | n return trimmed || null; } -export function normalizeCodexModelOptions( - model: string | null | undefined, - modelOptions: CodexModelOptions | null | undefined, -): CodexModelOptions | undefined { - const caps = getModelCapabilities("codex", model); - const defaultReasoningEffort = getDefaultEffort(caps) as CodexReasoningEffort; - const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort; - const fastModeEnabled = modelOptions?.fastMode === true; - const nextOptions: CodexModelOptions = { - ...(reasoningEffort !== defaultReasoningEffort ? { reasoningEffort } : {}), - ...(fastModeEnabled ? { fastMode: true } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; -} - -export function normalizeClaudeModelOptions( - model: string | null | undefined, - modelOptions: ClaudeModelOptions | null | undefined, -): ClaudeModelOptions | undefined { - const caps = getModelCapabilities("claudeAgent", model); - const defaultReasoningEffort = getDefaultEffort(caps); - const resolvedEffort = trimOrNull(modelOptions?.effort); - const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? ""); - const effort = - resolvedEffort && - !isPromptInjected && - hasEffortLevel(caps, resolvedEffort) && - resolvedEffort !== defaultReasoningEffort - ? resolvedEffort - : undefined; - const thinking = - caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; - const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; - const nextOptions: ClaudeModelOptions = { - ...(thinking === false ? { thinking: false } : {}), - ...(effort ? { effort } : {}), - ...(fastMode ? { fastMode: true } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; -} - export function applyClaudePromptEffortPrefix( text: string, effort: ClaudeCodeEffort | null | undefined, diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index 190dc097e8..9e38b1f8b8 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -1,4 +1,14 @@ -import { Cause, Exit, Result, Schema, SchemaIssue } from "effect"; +import { + Cause, + Effect, + Exit, + Option, + Result, + Schema, + SchemaGetter, + SchemaIssue, + SchemaTransformation, +} from "effect"; export const decodeJsonResult = >( schema: S, @@ -32,3 +42,55 @@ export const formatSchemaError = (cause: Cause.Cause) => { ? SchemaIssue.makeFormatterDefault()(squashed.issue) : Cause.pretty(cause); }; + +/** + * A `Getter` that parses a lenient JSON string (tolerating trailing commas + * and JS-style comments) into an unknown value. + * + * Mirrors `SchemaGetter.parseJson()` but uses `parseLenientJson` instead + * of `JSON.parse`. + */ +const parseLenientJsonGetter = SchemaGetter.onSome((input: string) => + Effect.try({ + try: () => { + // Strip single-line comments — alternation preserves quoted strings. + let stripped = input.replace( + /("(?:[^"\\]|\\.)*")|\/\/[^\n]*/g, + (match, stringLiteral: string | undefined) => (stringLiteral ? match : ""), + ); + + // Strip multi-line comments. + stripped = stripped.replace( + /("(?:[^"\\]|\\.)*")|\/\*[\s\S]*?\*\//g, + (match, stringLiteral: string | undefined) => (stringLiteral ? match : ""), + ); + + // Strip trailing commas before `}` or `]`. + stripped = stripped.replace(/,(\s*[}\]])/g, "$1"); + + return Option.some(JSON.parse(stripped)); + }, + catch: (e) => new SchemaIssue.InvalidValue(Option.some(input), { message: String(e) }), + }), +); + +/** + * Schema transformation: lenient JSONC string ↔ unknown. + * + * Same API as `SchemaTransformation.fromJsonString`, but the decode side + * strips trailing commas and JS-style comments before parsing. + * Encoding produces strict JSON via `JSON.stringify`. + */ +export const fromLenientJsonString = new SchemaTransformation.Transformation( + parseLenientJsonGetter, + SchemaGetter.stringifyJson(), +); + +/** + * Build a schema that decodes a lenient JSON string into `A`. + * + * Drop-in replacement for `Schema.fromJsonString(schema)` that tolerates + * trailing commas and comments in the input. + */ +export const fromLenientJson = (schema: S) => + Schema.String.pipe(Schema.decodeTo(schema, fromLenientJsonString));