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/ampServerManager.ts b/apps/server/src/ampServerManager.ts index 438a7ad200..e1b679c2fe 100644 --- a/apps/server/src/ampServerManager.ts +++ b/apps/server/src/ampServerManager.ts @@ -187,8 +187,7 @@ export class AmpServerManager extends EventEmitter<{ } } - const ampOpts = input.providerOptions?.amp as AmpProviderOptions | undefined; - const binaryPath = ampOpts?.binaryPath ?? defaultBinaryPath(); + const binaryPath = defaultBinaryPath(); const cwd = input.cwd ?? process.cwd(); const model = input.modelSelection?.model; const now = new Date().toISOString(); diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index ad33503e4d..a1a966491f 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -371,6 +371,7 @@ describe("startSession", () => { manager.startSession({ threadId: asThreadId("thread-1"), provider: "codex", + binaryPath: "codex", runtimeMode: "full-access", }), ).rejects.toThrow("cwd missing"); @@ -419,6 +420,7 @@ describe("startSession", () => { manager.startSession({ threadId: asThreadId("thread-1"), provider: "codex", + binaryPath: "codex", runtimeMode: "full-access", }), ).rejects.toThrow( @@ -1011,12 +1013,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({ @@ -1046,12 +1044,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 2adec89eb4..dd5cb45dcf 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, @@ -132,7 +131,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; } @@ -543,9 +543,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; @@ -1709,7 +1694,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/geminiCliServerManager.ts b/apps/server/src/geminiCliServerManager.ts index f09ac20caa..9acb99fd86 100644 --- a/apps/server/src/geminiCliServerManager.ts +++ b/apps/server/src/geminiCliServerManager.ts @@ -217,8 +217,7 @@ export class GeminiCliServerManager extends EventEmitter<{ throw new Error(`Gemini CLI session already exists for thread ${threadId}`); } - const geminiOpts = input.providerOptions?.geminiCli as GeminiCliProviderOptions | undefined; - const binaryPath = geminiOpts?.binaryPath ?? defaultBinaryPath(); + const binaryPath = defaultBinaryPath(); const cwd = input.cwd ?? process.cwd(); const resumeSessionId = readGeminiResumeSessionId(input.resumeCursor); const now = new Date().toISOString(); 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..919c3a323d 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -11,7 +11,7 @@ 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 { resolveApiModelId } from "@t3tools/shared/model"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { TextGenerationError } from "../Errors.ts"; @@ -27,6 +27,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 +42,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 +89,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", @@ -96,7 +104,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { "--json-schema", jsonSchemaStr, "--model", - modelSelection.model, + resolveApiModelId(modelSelection), ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []), ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []), "--dangerously-skip-permissions", 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 6dfc2744c5..0f6677bc31 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -33,11 +33,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[]; @@ -476,7 +472,6 @@ function runStackedAction( { ...input, actionId: input.actionId ?? "test-action-id", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, }, options, ); @@ -533,6 +528,8 @@ function makeManager(input?: { prefix: "t3-git-manager-test-", }); + const serverSettingsLayer = ServerSettingsService.layerTest(); + const gitCoreLayer = GitCoreLive.pipe( Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ServerConfigLayer), @@ -543,6 +540,7 @@ function makeManager(input?: { Layer.succeed(TextGeneration, textGeneration), Layer.succeed(SessionTextGeneration, sessionTextGeneration), 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 98d3b648c6..60c8a837f7 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -22,6 +22,7 @@ import { GitCore } from "../Services/GitCore.ts"; import { GitHubCli } from "../Services/GitHubCli.ts"; import { TextGeneration, type TextGenerationShape } from "../Services/TextGeneration.ts"; import { SessionTextGeneration } from "../Services/SessionTextGeneration.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; @@ -362,6 +363,7 @@ export const makeGitManager = Effect.gen(function* () { const gitHubCli = yield* GitHubCli; const textGeneration = yield* TextGeneration; const sessionTextGeneration = yield* SessionTextGeneration; + const serverSettingsService = yield* ServerSettingsService; const createProgressEmitter = ( input: { cwd: string; action: "commit" | "commit_push" | "commit_push_pr" }, @@ -1216,6 +1218,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({ @@ -1224,7 +1233,7 @@ export const makeGitManager = Effect.gen(function* () { label: "Preparing feature branch...", }); const result = yield* runFeatureBranchStep( - input.modelSelection, + modelSelection, input.cwd, initialStatus.branch, input.commitMessage, @@ -1243,7 +1252,7 @@ export const makeGitManager = Effect.gen(function* () { currentPhase = "commit"; const commit = yield* runCommitStep( - input.modelSelection, + modelSelection, input.cwd, input.action, currentBranch, @@ -1285,7 +1294,7 @@ export const makeGitManager = Effect.gen(function* () { Effect.gen(function* () { currentPhase = "pr"; return yield* runPrStep( - input.modelSelection, + modelSelection, input.cwd, currentBranch, input.provider, diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 9714ee3018..66b2b6d6b9 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", @@ -409,7 +411,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 }), @@ -680,6 +682,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({ @@ -825,16 +828,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/kilo/types.ts b/apps/server/src/kilo/types.ts index 1b1c119b87..158929d98c 100644 --- a/apps/server/src/kilo/types.ts +++ b/apps/server/src/kilo/types.ts @@ -29,9 +29,7 @@ export type KiloProviderOptions = { }; export type KiloSessionStartInput = ProviderSessionStartInput & { - readonly providerOptions?: ProviderSessionStartInput["providerOptions"] & { - readonly kilo?: KiloProviderOptions; - }; + readonly kilo?: KiloProviderOptions; }; export type KiloAdapterOptions = { diff --git a/apps/server/src/kiloServerManager.ts b/apps/server/src/kiloServerManager.ts index 5f0adc639d..703ce1408b 100644 --- a/apps/server/src/kiloServerManager.ts +++ b/apps/server/src/kiloServerManager.ts @@ -71,7 +71,7 @@ export class KiloServerManager extends EventEmitter { } const directory = kiloInput.cwd ?? process.cwd(); - const options = kiloInput.providerOptions?.kilo; + const options = kiloInput.kilo; const workspace = options?.workspace; const sharedServer = await this.ensureServer(options); const client = await createClient({ diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 2d54bbbd72..af47aa5e24 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/open.test.ts b/apps/server/src/open.test.ts index 0e79d4aa5f..abc821de83 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -45,6 +45,24 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { args: ["/tmp/workspace"], }); + const vscodeInsidersLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "vscode-insiders" }, + "darwin", + ); + assert.deepEqual(vscodeInsidersLaunch, { + command: "code-insiders", + args: ["/tmp/workspace"], + }); + + const vscodiumLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "vscodium" }, + "darwin", + ); + assert.deepEqual(vscodiumLaunch, { + command: "codium", + args: ["/tmp/workspace"], + }); + const zedLaunch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "zed" }, "linux", @@ -181,6 +199,24 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], }); + const vscodeInsidersLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode-insiders" }, + "darwin", + ); + assert.deepEqual(vscodeInsidersLineAndColumn, { + command: "code-insiders", + args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], + }); + + const vscodiumLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscodium" }, + "darwin", + ); + assert.deepEqual(vscodiumLineAndColumn, { + command: "codium", + args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], + }); + const zedLineAndColumn = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "zed" }, "linux", @@ -339,13 +375,14 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { const path = yield* Path.Path; const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - yield* fs.writeFileString(path.join(dir, "cursor.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n"); yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ"); const editors = resolveAvailableEditors("win32", { PATH: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD", }); - assert.deepEqual(editors, ["cursor", "file-manager"]); + assert.deepEqual(editors, ["vscode-insiders", "vscodium", "file-manager"]); }), ); }); diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index dd683f91dd..35f54ce987 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -40,11 +40,8 @@ interface CommandAvailabilityOptions { const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; -/** Editors that accept `--goto file:line:column` for jump-to-line support. */ -const GOTO_FLAG_EDITORS = new Set(["cursor", "windsurf", "vscode", "positron"]); - -function shouldUseGotoFlag(editorId: EditorId, target: string): boolean { - return GOTO_FLAG_EDITORS.has(editorId) && LINE_COLUMN_SUFFIX_PATTERN.test(target); +function shouldUseGotoFlag(editor: (typeof EDITORS)[number], target: string): boolean { + return editor.supportsGoto && LINE_COLUMN_SUFFIX_PATTERN.test(target); } /** Editors that are terminals requiring --working-directory instead of a positional path arg. */ @@ -77,6 +74,8 @@ const MAC_APP_NAMES: Partial> = { cursor: "Cursor", windsurf: "Windsurf", vscode: "Visual Studio Code", + "vscode-insiders": "Visual Studio Code - Insiders", + vscodium: "VSCodium", zed: "Zed", positron: "Positron", sublime: "Sublime Text", @@ -270,7 +269,7 @@ export const resolveEditorLaunch = Effect.fnUntraced(function* ( } if (editorDef.command) { - if (shouldUseGotoFlag(editorDef.id, input.cwd)) { + if (shouldUseGotoFlag(editorDef, input.cwd)) { if (platform === "darwin" && !isCommandAvailable(editorDef.command)) { const macApp = MAC_APP_NAMES[editorDef.id]; if (macApp && isMacAppInstalled(macApp)) { @@ -281,10 +280,6 @@ export const resolveEditorLaunch = Effect.fnUntraced(function* ( } if (WORKING_DIRECTORY_EDITORS.has(editorDef.id)) { const workingDirectory = resolveWorkingDirectoryTarget(input.cwd); - // On macOS, use `open -na ` so the running .app instance opens a - // new window/tab with the given working directory. The `-n` flag is - // required: without it `open -a` merely activates the existing instance - // and the `--args` are silently ignored. if (platform === "darwin") { const macApp = MAC_APP_NAMES[editorDef.id]; if (macApp) { @@ -296,9 +291,6 @@ export const resolveEditorLaunch = Effect.fnUntraced(function* ( } return { command: editorDef.command, args: [`--working-directory=${workingDirectory}`] }; } - // On macOS, fall back to `open -a ` when the CLI tool is not in - // PATH but the .app bundle is installed (e.g. app installed via DMG - // without shell integration). if (platform === "darwin" && !isCommandAvailable(editorDef.command)) { const macApp = MAC_APP_NAMES[editorDef.id]; if (macApp && isMacAppInstalled(macApp)) { diff --git a/apps/server/src/opencode/types.ts b/apps/server/src/opencode/types.ts index 90923c5b19..e08cb83a94 100644 --- a/apps/server/src/opencode/types.ts +++ b/apps/server/src/opencode/types.ts @@ -27,9 +27,7 @@ export type OpenCodeProviderOptions = { }; export type OpenCodeSessionStartInput = ProviderSessionStartInput & { - readonly providerOptions?: ProviderSessionStartInput["providerOptions"] & { - readonly opencode?: OpenCodeProviderOptions; - }; + readonly opencode?: OpenCodeProviderOptions; }; export type OpencodeAdapterOptions = { diff --git a/apps/server/src/opencodeServerManager.ts b/apps/server/src/opencodeServerManager.ts index 835cc9bc7f..573a56c3ac 100644 --- a/apps/server/src/opencodeServerManager.ts +++ b/apps/server/src/opencodeServerManager.ts @@ -74,7 +74,7 @@ export class OpenCodeServerManager extends EventEmitter { } const directory = openCodeInput.cwd ?? process.cwd(); - const options = openCodeInput.providerOptions?.opencode; + const options = openCodeInput.opencode; const workspace = options?.workspace; const sharedServer = await this.ensureServer(options); const client = await createClient({ diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index d1a49de55f..0bc178fe29 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 => @@ -221,6 +222,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 ed49083054..fffbb1e942 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, @@ -27,6 +25,7 @@ import { ProviderCommandReactor, type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; type ProviderIntentEvent = Extract< OrchestrationEvent, @@ -139,18 +138,13 @@ 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, lookup: () => Effect.succeed(true), }); - // NOTE: Provider options stored here are only consumed at session start time - // (inside `startProviderSession`). If a thread already has a live session and - // only `providerOptions` change, `ensureSessionForThread` keeps the existing - // session — the updated options won't take effect until the next session restart. - const threadProviderOptions = new Map(); - const hasHandledTurnStartRecently = (key: string) => Cache.getOption(handledTurnStartKeys, key).pipe( Effect.flatMap((cached) => @@ -216,7 +210,6 @@ const make = Effect.gen(function* () { createdAt: string, options?: { readonly modelSelection?: ModelSelection; - readonly providerOptions?: ProviderStartOptions; }, ) { const readModel = yield* orchestrationEngine.getReadModel(); @@ -255,12 +248,6 @@ const make = Effect.gen(function* () { .listSessions() .pipe(Effect.map((sessions) => sessions.find((session) => session.threadId === threadId))); - const effectiveProviderOptions = - options?.providerOptions ?? threadProviderOptions.get(threadId); - if (options?.providerOptions !== undefined) { - threadProviderOptions.set(threadId, options.providerOptions); - } - const startProviderSession = (input?: { readonly resumeCursor?: unknown; readonly provider?: ProviderKind; @@ -270,9 +257,6 @@ const make = Effect.gen(function* () { ...(preferredProvider ? { provider: preferredProvider } : {}), ...(effectiveCwd ? { cwd: effectiveCwd } : {}), modelSelection: desiredModelSelection, - ...(effectiveProviderOptions !== undefined - ? { providerOptions: effectiveProviderOptions } - : {}), ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, }); @@ -376,7 +360,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; }) { @@ -384,13 +367,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); } @@ -454,48 +435,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* ( @@ -540,9 +512,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( @@ -733,8 +702,6 @@ const make = Effect.gen(function* () { } } - threadProviderOptions.delete(thread.id); - yield* setThreadSession({ threadId: thread.id, session: { @@ -759,7 +726,6 @@ const make = Effect.gen(function* () { yield* providerService .stopSession({ threadId }) .pipe(Effect.catchCause(() => Effect.void)); - threadProviderOptions.delete(threadId); return; } case "thread.runtime-mode-set": { @@ -767,14 +733,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 560dd62d4d..b554c2bd6e 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"; @@ -39,8 +40,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); @@ -156,7 +162,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(); @@ -170,6 +176,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), ); @@ -1380,7 +1387,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( @@ -1394,7 +1401,6 @@ describe("ProviderRuntimeIngestion", () => { text: "stream please", attachments: [], }, - assistantDeliveryMode: "streaming", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -1473,7 +1479,7 @@ describe("ProviderRuntimeIngestion", () => { }); it("completes streaming assistant messages even when read model lookup lags completion", async () => { - const harness = await createHarness(); + const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); const now = new Date().toISOString(); await Effect.runPromise( @@ -1487,7 +1493,6 @@ describe("ProviderRuntimeIngestion", () => { text: "stream with lag", attachments: [], }, - assistantDeliveryMode: "streaming", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -1565,7 +1570,7 @@ describe("ProviderRuntimeIngestion", () => { }); it("splits streaming assistant text into separate messages around tool activity", async () => { - const harness = await createHarness(); + const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); const turnStartedAt = "2026-03-09T10:00:00.000Z"; const beforeToolAt = "2026-03-09T10:00:01.000Z"; const toolAt = "2026-03-09T10:00:02.000Z"; @@ -1583,7 +1588,6 @@ describe("ProviderRuntimeIngestion", () => { text: "show interleaving", attachments: [], }, - assistantDeliveryMode: "streaming", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: turnStartedAt, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 388adfee35..60f7a777bb 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; @@ -570,10 +570,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, @@ -1385,7 +1382,10 @@ const make = Effect.gen(function* () { } yield* markAssistantMessageSawDelta(assistantMessageId); - const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef); + const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( + serverSettingsService.getSettings, + (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"), + ); if (assistantDeliveryMode === "buffered") { const spillResult = yield* appendBufferedAssistantText( assistantMessageId, @@ -1632,11 +1632,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 36a4d90a49..47df8f3dfc 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 3d227cdc78..30fbc77c32 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, @@ -302,7 +304,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; @@ -310,16 +312,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), @@ -351,7 +348,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("ignores unsupported max effort for Sonnet 4.6", () => { + it.effect("falls back to default effort when unsupported max is requested for Sonnet 4.6", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -369,7 +366,7 @@ describe("ClaudeAdapterLive", () => { }); const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, undefined); + assert.equal(createInput?.options.effort, "high"); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -536,7 +533,7 @@ describe("ClaudeAdapterLive", () => { }); const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, undefined); + assert.equal(createInput?.options.effort, "high"); const promptText = yield* Effect.promise(() => readFirstPromptText(createInput)); assert.equal(promptText, "Ultrathink:\nInvestigate the edge cases"); }).pipe( @@ -1196,6 +1193,7 @@ describe("ClaudeAdapterLive", () => { }, }).pipe( Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(NodeServices.layer), ); @@ -2387,6 +2385,85 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect( + "does not re-set the Claude model when the session already uses the same effective API model", + () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const modelSelection = { + provider: "claudeAgent" as const, + model: "claude-opus-4-6", + }; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + modelSelection, + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + modelSelection, + attachments: [], + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello again", + modelSelection, + attachments: [], + }); + + assert.deepEqual(harness.query.setModelCalls, []); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + + it.effect("re-sets the Claude model when the effective API model changes", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { + contextWindow: "1m", + }, + }, + attachments: [], + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello again", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + }, + attachments: [], + }); + + assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6[1m]", "claude-opus-4-6"]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("sets plan permission mode on sendTurn when interactionMode is plan", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 32f7008f9b..582341ca4f 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -41,9 +41,9 @@ import { ClaudeCodeEffort, } from "@t3tools/contracts"; import { - hasEffortLevel, applyClaudePromptEffortPrefix, - getModelCapabilities, + resolveApiModelId, + resolveEffort, trimOrNull, } from "@t3tools/shared/model"; import { @@ -63,6 +63,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, @@ -162,6 +164,7 @@ interface ClaudeSessionContext { streamFiber: Fiber.Fiber | undefined; readonly startedAt: string; readonly basePermissionMode: PermissionMode | undefined; + currentApiModelId: string | undefined; resumeSessionId: string | undefined; readonly pendingApprovals: Map; readonly pendingUserInputs: Map; @@ -369,19 +372,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; @@ -538,16 +528,15 @@ const CLAUDE_SETTING_SOURCES = [ function buildPromptText(input: ProviderSendTurnInput): string { const rawEffort = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null; - const requestedEffort = trimOrNull(rawEffort); const claudeModel = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined; - const caps = getModelCapabilities("claudeAgent", claudeModel); + const caps = getClaudeModelCapabilities(claudeModel); + + // For prompt injection, we check if the raw effort is a prompt-injected level (e.g. "ultrathink"). + // resolveEffort strips prompt-injected values (returning the default instead), so we check the raw value directly. + const trimmedEffort = trimOrNull(rawEffort); const promptEffort = - requestedEffort === "ultrathink" && caps.reasoningEffortLevels.length > 0 - ? "ultrathink" - : requestedEffort && hasEffortLevel(caps, requestedEffort) - ? requestedEffort - : null; + trimmedEffort && caps.promptInjectedEffortLevels.includes(trimmedEffort) ? trimmedEffort : null; return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); } @@ -959,6 +948,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)); @@ -2753,13 +2743,32 @@ 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, + }), + ), + ); + if (!claudeSettings.enabled) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "Claude provider is disabled in server settings.", + }); + } + const claudeBinaryPath = claudeSettings.binaryPath.trim() || "claude"; const modelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; - const requestedEffort = trimOrNull(modelSelection?.options?.effort ?? null); - const caps = getModelCapabilities("claudeAgent", modelSelection?.model); - const effort = - requestedEffort && hasEffortLevel(caps, requestedEffort) ? requestedEffort : null; + const caps = getClaudeModelCapabilities(modelSelection?.model); + const apiModelId = modelSelection ? resolveApiModelId(modelSelection) : undefined; + const effort = (resolveEffort(caps, modelSelection?.options?.effort) ?? + null) as ClaudeCodeEffort | null; const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode; const thinking = typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle @@ -2767,8 +2776,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 } : {}), @@ -2776,17 +2784,14 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), - ...(modelSelection?.model ? { model: modelSelection.model } : {}), - pathToClaudeCodeExecutable: providerOptions?.binaryPath ?? "claude", + ...(apiModelId ? { model: apiModelId } : {}), + 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 } : {}), @@ -2838,6 +2843,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { streamFiber: undefined, startedAt, basePermissionMode: permissionMode, + currentApiModelId: apiModelId, resumeSessionId: sessionId, pendingApprovals, pendingUserInputs, @@ -2873,13 +2879,10 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { threadId, payload: { config: { - ...(modelSelection?.model ? { model: modelSelection.model } : {}), + ...(apiModelId ? { model: apiModelId } : {}), ...(input.cwd ? { cwd: input.cwd } : {}), ...(effectiveEffort ? { effort: effectiveEffort } : {}), ...(permissionMode ? { permissionMode } : {}), - ...(providerOptions?.maxThinkingTokens !== undefined - ? { maxThinkingTokens: providerOptions.maxThinkingTokens } - : {}), ...(fastMode ? { fastMode: true } : {}), }, }, @@ -2929,10 +2932,18 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { } if (modelSelection?.model) { - yield* Effect.tryPromise({ - try: () => context.query.setModel(modelSelection.model), - catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause), - }); + const apiModelId = resolveApiModelId(modelSelection); + if (context.currentApiModelId !== apiModelId) { + yield* Effect.tryPromise({ + try: () => context.query.setModel(apiModelId), + catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause), + }); + context.currentApiModelId = apiModelId; + } + context.session = { + ...context.session, + model: modelSelection.model, + }; } // Apply interaction mode by switching the SDK's permission mode. diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts new file mode 100644 index 0000000000..2e9733084f --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -0,0 +1,400 @@ +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 { resolveContextWindow, resolveEffort } 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, + contextWindowOptions: [ + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, + ], + 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, + contextWindowOptions: [ + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, + ], + promptInjectedEffortLevels: ["ultrathink"], + } satisfies ModelCapabilities, + }, + { + slug: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: true, + contextWindowOptions: [], + 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, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + } + ); +} + +export function normalizeClaudeModelOptions( + model: string | null | undefined, + modelOptions: ClaudeModelOptions | null | undefined, +): ClaudeModelOptions | undefined { + const caps = getClaudeModelCapabilities(model); + const effort = resolveEffort(caps, modelOptions?.effort); + const thinking = + caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; + const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; + const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); + const nextOptions: ClaudeModelOptions = { + ...(thinking === false ? { thinking: false } : {}), + ...(effort ? { effort: effort as ClaudeModelOptions["effort"] } : {}), + ...(fastMode ? { fastMode: true } : {}), + ...(contextWindow ? { contextWindow } : {}), + }; + 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.trim() || "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); + +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 bde3a8fcb9..350d0538d5 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"; @@ -161,6 +162,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), ), @@ -212,6 +214,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", @@ -261,6 +264,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), ), @@ -329,6 +333,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 1685677299..d7474c67b9 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -39,6 +39,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"; import { toMessage } from "../toMessage.ts"; @@ -1340,16 +1341,15 @@ 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}'.`, + }); } if (input.modelSelection && input.modelSelection.provider !== "codex") { @@ -1359,13 +1359,28 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => ); } + 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 } : {}), @@ -1374,7 +1389,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => : {}), }; - return Effect.tryPromise({ + return yield* Effect.tryPromise({ try: () => manager.startSession(managerInput), catch: (cause) => new ProviderAdapterProcessError({ @@ -1383,8 +1398,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..6497469a2a --- /dev/null +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -0,0 +1,537 @@ +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 { resolveEffort } 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, + contextWindowOptions: [], + 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, + contextWindowOptions: [], + 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, + contextWindowOptions: [], + 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, + contextWindowOptions: [], + 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, + contextWindowOptions: [], + 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, + contextWindowOptions: [], + 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, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + } + ); +} + +export function normalizeCodexModelOptions( + model: string | null | undefined, + modelOptions: CodexModelOptions | null | undefined, +): CodexModelOptions | undefined { + const caps = getCodexModelCapabilities(model); + const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort); + const fastModeEnabled = modelOptions?.fastMode === true; + const nextOptions: CodexModelOptions = { + ...(reasoningEffort + ? { 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/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 794085ee6b..a475486d69 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1282,10 +1282,8 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => } satisfies ProviderSession; } - const cliPath = - normalizeCopilotCliPathOverride(input.providerOptions?.copilot?.cliPath) ?? - resolveBundledCopilotCliPath(); - const configDir = trimToUndefined(input.providerOptions?.copilot?.configDir); + const cliPath = resolveBundledCopilotCliPath(); + const configDir: string | undefined = undefined; const resumeSessionId = extractResumeSessionId(input.resumeCursor); const clientOptions: CopilotClientOptions = { ...(cliPath ? { cliPath } : {}), diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index df4f2bb44a..c74bb9b729 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -1197,8 +1197,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const startedAt = yield* nowIso; const cwd = input.cwd ?? process.cwd(); - const cursorOptions = input.providerOptions?.cursor as { binaryPath?: string } | undefined; - const binaryPath = cursorOptions?.binaryPath ?? "agent"; + const binaryPath = "agent"; const resumeState = readCursorResumeState(input.resumeCursor); const child = yield* Effect.try({ diff --git a/apps/server/src/provider/Layers/ProviderAdapterConformance.test.ts b/apps/server/src/provider/Layers/ProviderAdapterConformance.test.ts index a3bcee6758..1ef7bf40a6 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterConformance.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterConformance.test.ts @@ -12,6 +12,7 @@ import { makeCodexAdapterLive } from "./CodexAdapter.ts"; import { makeCopilotAdapterLive } from "./CopilotAdapter.ts"; import { makeCursorAdapterLive } from "./CursorAdapter.ts"; import { makeGeminiCliAdapterLive } from "./GeminiCliAdapter.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { getProviderCapabilities, @@ -36,6 +37,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory const codexLayer = makeCodexAdapterLive({ manager: new CodexAppServerManager() }).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(NodeServices.layer), ); @@ -71,6 +73,7 @@ const claudeLayer = makeClaudeAdapterLive({ }) as never, }).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(NodeServices.layer), ); 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 86c0050ca2..0000000000 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ /dev/null @@ -1,897 +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, - ServerProviderModel, - ServerProviderQuotaSnapshot, - ServerProviderStatus, - ServerProviderStatusState, -} from "@t3tools/contracts"; -import { CopilotClient, type ModelInfo } from "@github/copilot-sdk"; -import { - Effect, - Fiber, - FileSystem, - Layer, - Option, - Path, - PlatformError, - Result, - Stream, -} from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; - -import { resolveBundledCopilotCliPath, withSanitizedCopilotDesktopEnv } from "./copilotCliPath.ts"; - -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 GEMINI_CLI_PROVIDER = "geminiCli" as const; -const COPILOT_PROVIDER = "copilot" 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.catchTag("PlatformError", (e) => - e.reason instanceof PlatformError.SystemError && e.reason._tag === "NotFound" - ? Effect.void - : Effect.gen(function* () { - yield* Effect.logWarning(`Failed to read Codex config at ${configPath}: ${e.message}`); - return 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. - 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("[")) break; - - 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; -}); - -// ── Gemini CLI health check ────────────────────────────────────────── - -const runGeminiCommand = (args: ReadonlyArray) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make("gemini", [...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 checkGeminiCliProviderStatus: Effect.Effect< - ServerProviderStatus, - never, - ChildProcessSpawner.ChildProcessSpawner -> = Effect.gen(function* () { - const checkedAt = new Date().toISOString(); - - const versionProbe = yield* runGeminiCommand(["--version"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); - - if (Result.isFailure(versionProbe)) { - const error = versionProbe.failure; - const lower = error instanceof Error ? error.message.toLowerCase() : ""; - const isMissing = - lower.includes("enoent") || lower.includes("notfound") || lower.includes("command not found"); - return { - provider: GEMINI_CLI_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: isMissing - ? "Gemini CLI (`gemini`) is not installed or not on PATH." - : `Failed to execute Gemini CLI health check: ${error instanceof Error ? error.message : String(error)}.`, - }; - } - - if (Option.isNone(versionProbe.success)) { - return { - provider: GEMINI_CLI_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: "Gemini CLI is installed but timed out while running --version.", - }; - } - - const version = versionProbe.success.value; - if (version.code !== 0) { - const detail = detailFromResult(version); - return { - provider: GEMINI_CLI_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: detail - ? `Gemini CLI is installed but failed to run. ${detail}` - : "Gemini CLI is installed but failed to run.", - }; - } - - return { - provider: GEMINI_CLI_PROVIDER, - status: "ready" as const, - available: true, - authStatus: "unknown" as const, - checkedAt, - }; -}); - -// ── Copilot health check ───────────────────────────────────────────── - -interface CopilotHealthProbeError { - readonly _tag: "CopilotHealthProbeError"; - readonly cause: unknown; -} - -const COPILOT_QUOTA_PRIORITY = ["premium_interactions", "chat", "completions"] as const; - -export function mapCopilotModel(model: ModelInfo): ServerProviderModel { - return { - id: model.id, - name: model.name, - supportsReasoningEffort: (model.supportedReasoningEfforts?.length ?? 0) > 0, - ...(model.supportedReasoningEfforts && model.supportedReasoningEfforts.length > 0 - ? { supportedReasoningEfforts: [...model.supportedReasoningEfforts] } - : {}), - ...(model.defaultReasoningEffort - ? { defaultReasoningEffort: model.defaultReasoningEffort } - : {}), - ...(typeof model.billing?.multiplier === "number" - ? { billingMultiplier: model.billing.multiplier } - : {}), - } satisfies ServerProviderModel; -} - -interface CopilotQuotaSnapshotInfo { - readonly entitlementRequests: number; - readonly usedRequests: number; - readonly remainingPercentage: number; - readonly overage: number; - readonly overageAllowedWithExhaustedQuota: boolean; - readonly resetDate?: string; -} - -function compareCopilotQuotaKeys(left: string, right: string): number { - const leftPriority = COPILOT_QUOTA_PRIORITY.indexOf( - left as (typeof COPILOT_QUOTA_PRIORITY)[number], - ); - const rightPriority = COPILOT_QUOTA_PRIORITY.indexOf( - right as (typeof COPILOT_QUOTA_PRIORITY)[number], - ); - const normalizedLeftPriority = leftPriority === -1 ? Number.POSITIVE_INFINITY : leftPriority; - const normalizedRightPriority = rightPriority === -1 ? Number.POSITIVE_INFINITY : rightPriority; - return normalizedLeftPriority - normalizedRightPriority || left.localeCompare(right); -} - -export function mapCopilotQuotaSnapshots( - quotaSnapshots: Record | undefined, -): ReadonlyArray { - if (!quotaSnapshots) return []; - return Object.entries(quotaSnapshots) - .toSorted(([leftKey], [rightKey]) => compareCopilotQuotaKeys(leftKey, rightKey)) - .map(([key, snapshot]) => { - const entitlementRequests = Math.max(0, Math.trunc(snapshot.entitlementRequests)); - const usedRequests = Math.max(0, Math.trunc(snapshot.usedRequests)); - const base = { - key, - entitlementRequests, - usedRequests, - remainingRequests: Math.max(0, entitlementRequests - usedRequests), - remainingPercentage: Math.max(0, Math.min(100, snapshot.remainingPercentage)), - overage: Math.max(0, Math.trunc(snapshot.overage)), - overageAllowedWithExhaustedQuota: snapshot.overageAllowedWithExhaustedQuota, - }; - return ( - snapshot.resetDate ? Object.assign(base, { resetDate: snapshot.resetDate }) : base - ) satisfies ServerProviderQuotaSnapshot; - }); -} - -export const checkCopilotProviderStatus: Effect.Effect = Effect.gen( - function* () { - const checkedAt = new Date().toISOString(); - const probe = yield* Effect.tryPromise({ - try: async () => { - const cliPath = resolveBundledCopilotCliPath(); - const client = new CopilotClient({ - ...(cliPath ? { cliPath } : {}), - logLevel: "error", - }); - try { - await withSanitizedCopilotDesktopEnv(() => client.start()); - const [status, authStatus] = await withSanitizedCopilotDesktopEnv(() => - Promise.all([ - (client as unknown as { getStatus(): Promise<{ version?: string }> }) - .getStatus() - .catch(() => undefined), - ( - client as unknown as { - getAuthStatus(): Promise<{ isAuthenticated?: boolean; statusMessage?: string }>; - } - ) - .getAuthStatus() - .catch(() => undefined), - ]), - ); - const [models, quota] = - authStatus?.isAuthenticated === true - ? await withSanitizedCopilotDesktopEnv(() => - Promise.all([ - client.listModels().catch(() => undefined), - ( - client as unknown as { - rpc: { account: { getQuota: () => Promise<{ quotaSnapshots?: unknown }> } }; - } - ).rpc.account - .getQuota() - .catch(() => undefined), - ]), - ) - : [undefined, undefined]; - return { status, authStatus, models, quota }; - } finally { - await client.stop().catch(() => []); - } - }, - catch: (cause) => - ({ - _tag: "CopilotHealthProbeError", - cause, - }) satisfies CopilotHealthProbeError, - }).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result); - - if (Result.isFailure(probe)) { - const error = probe.failure.cause; - return { - provider: COPILOT_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: - error instanceof Error - ? `Failed to start GitHub Copilot CLI health check: ${error.message}.` - : "Failed to start GitHub Copilot CLI health check.", - }; - } - - if (Option.isNone(probe.success)) { - return { - provider: COPILOT_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: "GitHub Copilot CLI health check timed out while starting the SDK client.", - }; - } - - const authStatus: ServerProviderAuthStatus = - probe.success.value.authStatus?.isAuthenticated === true - ? "authenticated" - : probe.success.value.authStatus?.isAuthenticated === false - ? "unauthenticated" - : "unknown"; - const status: ServerProviderStatusState = - authStatus === "unauthenticated" ? "error" : authStatus === "unknown" ? "warning" : "ready"; - const quotaSnapshots = mapCopilotQuotaSnapshots( - probe.success.value.quota?.quotaSnapshots as - | Record - | undefined, - ); - - return { - provider: COPILOT_PROVIDER, - status, - available: true, - authStatus, - checkedAt, - ...(probe.success.value.models && probe.success.value.models.length > 0 - ? { models: probe.success.value.models.map(mapCopilotModel) } - : {}), - ...(quotaSnapshots.length > 0 ? { quotaSnapshots } : {}), - ...(probe.success.value.authStatus?.statusMessage - ? { message: probe.success.value.authStatus.statusMessage } - : probe.success.value.status?.version - ? { message: `GitHub Copilot CLI ${probe.success.value.status.version}` } - : {}), - } 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 healthCheckFiber = yield* Effect.all( - [ - checkCodexProviderStatus, - checkGeminiCliProviderStatus, - checkCopilotProviderStatus, - checkClaudeProviderStatus, - ], - { concurrency: "unbounded" }, - ).pipe(Effect.forkScoped); - - return { - getStatuses: Fiber.join(healthCheckFiber), - } 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 292b875f3f..c575084381 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -43,8 +43,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); @@ -250,6 +253,7 @@ function makeProviderServiceLayer() { makeProviderServiceLive().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), Layer.provideMerge(AnalyticsService.layerTest), ), directoryLayer, @@ -266,6 +270,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* () { @@ -298,6 +351,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), ); @@ -357,6 +411,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 = { @@ -408,6 +463,7 @@ it.effect( const secondProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), Layer.provide(secondDirectoryLayer), + Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), ); @@ -768,6 +824,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), ); @@ -800,6 +857,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 5c6c89b6a4..f192c036c2 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -21,20 +21,19 @@ import { ProviderStopSessionInput, type ProviderRuntimeEvent, type ProviderSession, - type ProviderStartOptions, } from "@t3tools/contracts"; import { Effect, Layer, Option, PubSub, Queue, Schema, SchemaIssue, Stream } from "effect"; import { ProviderValidationError } from "../Errors.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; -import { redactProviderStartOptions } from "../../orchestration/redactEvent.ts"; import { ProviderSessionDirectory, type ProviderRuntimeBinding, } 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; @@ -89,31 +88,20 @@ function toRuntimeStatus(session: ProviderSession): "starting" | "running" | "st } } -function redactProviderOptions(providerOptions: unknown): unknown { - if (!providerOptions || typeof providerOptions !== "object" || Array.isArray(providerOptions)) { - return providerOptions; - } - return redactProviderStartOptions(providerOptions as ProviderStartOptions); -} - function toRuntimePayloadFromSession( session: ProviderSession, extra?: { readonly modelSelection?: unknown; - readonly providerOptions?: unknown; readonly lastRuntimeEvent?: string; readonly lastRuntimeEventAt?: string; }, ): Record { - const safeProviderOptions = - extra?.providerOptions !== undefined ? redactProviderOptions(extra.providerOptions) : undefined; return { cwd: session.cwd ?? null, model: session.model ?? null, activeTurnId: session.activeTurnId ?? null, lastError: session.lastError ?? null, ...(extra?.modelSelection !== undefined ? { modelSelection: extra.modelSelection } : {}), - ...(safeProviderOptions !== undefined ? { providerOptions: safeProviderOptions } : {}), ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}), ...(extra?.lastRuntimeEventAt !== undefined ? { lastRuntimeEventAt: extra.lastRuntimeEventAt } @@ -131,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 { @@ -157,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 @@ -192,7 +170,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => threadId: ThreadId, extra?: { readonly modelSelection?: unknown; - readonly providerOptions?: unknown; readonly lastRuntimeEvent?: string; readonly lastRuntimeEventAt?: string; }, @@ -239,16 +216,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => (session) => session.threadId === input.binding.threadId, ); if (existing) { - const existingProviderOptions = readPersistedProviderOptions( - input.binding.runtimePayload, - ); - yield* upsertSessionBinding( - existing, - input.binding.threadId, - existingProviderOptions !== undefined - ? { providerOptions: existingProviderOptions } - : undefined, - ); + yield* upsertSessionBinding(existing, input.binding.threadId); yield* analytics.record("provider.session.recovered", { provider: existing.provider, strategy: "adopt-existing", @@ -267,16 +235,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 !== undefined - ? { providerOptions: persistedProviderOptions } - : {}), ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), runtimeMode: input.binding.runtimeMode ?? "full-access", }); @@ -287,13 +251,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => ); } - yield* upsertSessionBinding( - resumed, - input.binding.threadId, - persistedProviderOptions !== undefined - ? { providerOptions: persistedProviderOptions } - : undefined, - ); + yield* upsertSessionBinding(resumed, input.binding.threadId); yield* analytics.record("provider.session.recovered", { provider: resumed.provider, strategy: "resume-thread", @@ -344,6 +302,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 ?? @@ -365,7 +338,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => yield* upsertSessionBinding(session, threadId, { modelSelection: input.modelSelection, - providerOptions: input.providerOptions, }); yield* analytics.record("provider.session.started", { provider: session.provider, @@ -404,14 +376,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => allowRecovery: true, }); const turn = yield* routed.adapter.sendTurn(input); - const sendTurnBinding = yield* directory.getBinding(input.threadId); - const sendTurnProviderOptions = readPersistedProviderOptions( - Option.getOrUndefined(sendTurnBinding)?.runtimePayload, - ); - const safeSendTurnProviderOptions = - sendTurnProviderOptions !== undefined - ? redactProviderOptions(sendTurnProviderOptions) - : undefined; yield* directory.upsert({ threadId: input.threadId, provider: routed.adapter.provider, @@ -422,9 +386,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => activeTurnId: turn.turnId, lastRuntimeEvent: "provider.sendTurn", lastRuntimeEventAt: new Date().toISOString(), - ...(safeSendTurnProviderOptions !== undefined - ? { providerOptions: safeSendTurnProviderOptions } - : {}), }, }); yield* analytics.record("provider.turn.sent", { @@ -606,11 +567,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => Effect.flatMap((bindingOption) => { const binding = Option.getOrUndefined(bindingOption); if (!binding) return Effect.void; - const existingProviderOptions = readPersistedProviderOptions(binding.runtimePayload); - const safeExistingProviderOptions = - existingProviderOptions !== undefined - ? redactProviderOptions(existingProviderOptions) - : undefined; return directory.upsert({ threadId, provider: binding.provider, @@ -619,9 +575,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => activeTurnId: null, lastRuntimeEvent: "provider.stopAll", lastRuntimeEventAt: new Date().toISOString(), - ...(safeExistingProviderOptions !== undefined - ? { providerOptions: safeExistingProviderOptions } - : {}), }, }); }), 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 9fc87977af..badf8ff978 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -30,6 +30,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"; @@ -61,7 +62,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..2c646af71e --- /dev/null +++ b/apps/server/src/serverSettings.ts @@ -0,0 +1,348 @@ +/** + * 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", + "copilot", + "cursor", + "opencode", + "geminiCli", + "amp", + "kilo", +]; + +/** + * 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 5953f5a6b2..7c7ef00ac2 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -14,18 +14,20 @@ import { getProviderCapabilities } from "./provider/Services/ProviderAdapter.ts" 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, @@ -46,7 +48,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"; @@ -54,6 +56,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); @@ -65,20 +68,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>(); @@ -488,11 +498,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) { @@ -505,9 +516,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, { @@ -544,8 +555,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), @@ -859,6 +871,7 @@ describe("WebSocket Server", () => { issues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), + settings: defaultServerSettings, }); expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); }); @@ -884,6 +897,7 @@ describe("WebSocket Server", () => { issues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), + settings: defaultServerSettings, }); expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); @@ -920,6 +934,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"); @@ -953,7 +968,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"); @@ -1001,7 +1016,6 @@ describe("WebSocket Server", () => { ); expect(malformedPush.data).toEqual({ issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }], - providers: defaultProviderStatuses, }); const successPush = await rewriteKeybindingsAndWaitForPush( @@ -1010,7 +1024,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 () => { @@ -1070,6 +1084,7 @@ describe("WebSocket Server", () => { issues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), + settings: defaultServerSettings, }); expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); }); @@ -1118,6 +1133,7 @@ describe("WebSocket Server", () => { issues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), + settings: defaultServerSettings, }); expectAvailableEditors( (configResponse.result as { availableEditors: unknown }).availableEditors, @@ -1274,6 +1290,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; @@ -1324,7 +1341,6 @@ describe("WebSocket Server", () => { text: "hello", attachments: [], }, - assistantDeliveryMode: "streaming", runtimeMode: "approval-required", interactionMode: "default", createdAt, @@ -1851,10 +1867,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 4ea7ca805b..684d7321d6 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -53,12 +53,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"; @@ -220,7 +221,7 @@ export type ServerCoreRuntimeServices = | CheckpointDiffQuery | OrchestrationReactor | ProviderService - | ProviderHealth; + | ProviderRegistry; export type ServerRuntimeServices = | ServerCoreRuntimeServices @@ -228,6 +229,7 @@ export type ServerRuntimeServices = | GitCore | TerminalManager | Keybindings + | ServerSettingsService | Open | AnalyticsService; @@ -265,7 +267,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; @@ -280,7 +283,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"); @@ -307,6 +310,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; @@ -626,7 +634,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)); @@ -1037,16 +1060,26 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { content }; } - 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); @@ -1062,6 +1095,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.ts b/apps/web/src/appSettings.ts index a69c36691d..5e6a63ff11 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,90 +1,60 @@ import { useCallback, useMemo } from "react"; import { Option, Schema } from "effect"; -import { - DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, - type ProviderKind, - type ProviderStartOptions, -} from "@t3tools/contracts"; -import { - getDefaultModel, - getModelOptions, - normalizeModelSlug, - resolveSelectableModel, -} from "@t3tools/shared/model"; +import type { ProviderStartOptions } from "@t3tools/contracts"; import { DEFAULT_ACCENT_COLOR, isValidAccentColor, normalizeAccentColor } from "./accentColor"; import { useLocalStorage } from "./hooks/useLocalStorage"; -const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; -const MAX_CUSTOM_MODEL_COUNT = 32; -export const MAX_CUSTOM_MODEL_LENGTH = 256; -export const APP_PROVIDER_LOGO_APPEARANCE_OPTIONS = [ - { - value: "original", - label: "Default color", - description: "Use each provider's native logo colors.", - }, - { - value: "grayscale", - label: "Grayscale", - description: "Desaturate provider logos while keeping their original shapes.", - }, - { - value: "accent", - label: "Accent color", - description: "Tint every provider logo with your global or per-provider accent color.", - }, -] as const; -export type AppProviderLogoAppearance = - (typeof APP_PROVIDER_LOGO_APPEARANCE_OPTIONS)[number]["value"]; -const AppProviderLogoAppearanceSchema = Schema.Literals(["original", "grayscale", "accent"]); -export const TIMESTAMP_FORMAT_OPTIONS = ["locale", "12-hour", "24-hour"] as const; -export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number]; -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"; -type CustomModelSettingsKey = - | "customCodexModels" - | "customCopilotModels" - | "customClaudeModels" - | "customCursorModels" - | "customOpencodeModels" - | "customGeminiCliModels" - | "customAmpModels" - | "customKiloModels"; -export type ProviderCustomModelConfig = { - provider: ProviderKind; - settingsKey: CustomModelSettingsKey; - defaultSettingsKey: CustomModelSettingsKey; - title: string; - description: string; - placeholder: string; - example: string; -}; +// Domain modules +import { + AppProviderLogoAppearanceSchema, + DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, + DEFAULT_SIDEBAR_THREAD_SORT_ORDER, + DEFAULT_TIMESTAMP_FORMAT, + SidebarProjectSortOrder, + SidebarThreadSortOrder, +} from "./appearance"; +import { normalizeCustomModelSlugs } from "./customModels"; +import { normalizeGitTextGenerationModelByProvider } from "./gitTextGeneration"; + +// Re-export everything from domain modules for backwards compatibility +export { + APP_PROVIDER_LOGO_APPEARANCE_OPTIONS, + type AppProviderLogoAppearance, + AppProviderLogoAppearanceSchema, + TIMESTAMP_FORMAT_OPTIONS, + type TimestampFormat, + DEFAULT_TIMESTAMP_FORMAT, + SidebarProjectSortOrder, + DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, + SidebarThreadSortOrder, + DEFAULT_SIDEBAR_THREAD_SORT_ORDER, +} from "./appearance"; + +export { + MAX_CUSTOM_MODEL_LENGTH, + type CustomModelSettingsKey, + type ProviderCustomModelConfig, + type ProviderCustomModelSettings, + MODEL_PROVIDER_SETTINGS, + type AppModelOption, + normalizeCustomModelSlugs, + getCustomModelsForProvider, + patchCustomModels, + getDefaultCustomModelsForProvider, + getCustomModelsByProvider, + getAppModelOptions, + resolveAppModelSelection, + getCustomModelOptionsByProvider, + getSlashModelOptions, +} from "./customModels"; + +export { + getGitTextGenerationModelOverride, + patchGitTextGenerationModelOverrides, + resolveGitTextGenerationModelSelection, +} from "./gitTextGeneration"; -const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { - codex: new Set(getModelOptions("codex").map((option) => option.slug)), - copilot: new Set(getModelOptions("copilot").map((option) => option.slug)), - claudeAgent: new Set(getModelOptions("claudeAgent").map((option) => option.slug)), - cursor: new Set(getModelOptions("cursor").map((option) => option.slug)), - opencode: new Set(getModelOptions("opencode").map((option) => option.slug)), - geminiCli: new Set(getModelOptions("geminiCli").map((option) => option.slug)), - amp: new Set(getModelOptions("amp").map((option) => option.slug)), - kilo: new Set(getModelOptions("kilo").map((option) => option.slug)), -}; -const PROVIDER_KINDS = [ - "codex", - "copilot", - "claudeAgent", - "cursor", - "opencode", - "geminiCli", - "amp", - "kilo", -] as const satisfies readonly ProviderKind[]; +const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const withDefaults = < @@ -159,211 +129,8 @@ export const AppSettingsSchema = Schema.Struct({ translucency: Schema.Boolean.pipe(withDefaults(() => false)), }); export type AppSettings = typeof AppSettingsSchema.Type; -export interface AppModelOption { - slug: string; - name: string; - isCustom: boolean; -} -type ProviderCustomModelSettings = Pick< - AppSettings, - | "customCodexModels" - | "customCopilotModels" - | "customClaudeModels" - | "customCursorModels" - | "customOpencodeModels" - | "customGeminiCliModels" - | "customAmpModels" - | "customKiloModels" ->; const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); -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", - example: "gpt-6.7-codex-ultra-preview", - }, - copilot: { - provider: "copilot", - settingsKey: "customCopilotModels", - defaultSettingsKey: "customCopilotModels", - title: "Copilot", - description: "Save additional Copilot model slugs for the picker and `/model` command.", - placeholder: "your-copilot-model-slug", - example: "gpt-4o-copilot", - }, - claudeAgent: { - provider: "claudeAgent", - settingsKey: "customClaudeModels", - defaultSettingsKey: "customClaudeModels", - title: "Claude", - description: "Save additional Claude model slugs for the picker and `/model` command.", - placeholder: "your-claude-model-slug", - example: "claude-sonnet-5-0", - }, - cursor: { - provider: "cursor", - settingsKey: "customCursorModels", - defaultSettingsKey: "customCursorModels", - title: "Cursor", - description: "Save additional Cursor model slugs for the picker and `/model` command.", - placeholder: "your-cursor-model-slug", - example: "cursor-fast", - }, - opencode: { - provider: "opencode", - settingsKey: "customOpencodeModels", - defaultSettingsKey: "customOpencodeModels", - title: "OpenCode", - description: "Save additional OpenCode model slugs for the picker and `/model` command.", - placeholder: "your-opencode-model-slug", - example: "opencode-pro", - }, - geminiCli: { - provider: "geminiCli", - settingsKey: "customGeminiCliModels", - defaultSettingsKey: "customGeminiCliModels", - title: "Gemini CLI", - description: "Save additional Gemini CLI model slugs for the picker and `/model` command.", - placeholder: "your-gemini-model-slug", - example: "gemini-2.0-ultra", - }, - amp: { - provider: "amp", - settingsKey: "customAmpModels", - defaultSettingsKey: "customAmpModels", - title: "Amp", - description: "Save additional Amp model slugs for the picker and `/model` command.", - placeholder: "your-amp-model-slug", - example: "amp-pro", - }, - kilo: { - provider: "kilo", - settingsKey: "customKiloModels", - defaultSettingsKey: "customKiloModels", - title: "Kilo", - description: "Save additional Kilo model slugs for the picker and `/model` command.", - placeholder: "your-kilo-model-slug", - example: "kilo-advanced", - }, -}; -export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG); - -export function normalizeCustomModelSlugs( - models: Iterable, - 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); - if ( - !normalized || - normalized.length > MAX_CUSTOM_MODEL_LENGTH || - builtInModelSlugs.has(normalized) || - seen.has(normalized) - ) { - continue; - } - - seen.add(normalized); - normalizedModels.push(normalized); - if (normalizedModels.length >= MAX_CUSTOM_MODEL_COUNT) { - break; - } - } - - return normalizedModels; -} - -function normalizeGitTextGenerationModelByProvider( - overrides: Record, -): Record { - const normalizedOverrides: Partial> = {}; - for (const provider of PROVIDER_KINDS) { - const normalized = normalizeModelSlug(overrides[provider], provider); - if (!normalized) { - continue; - } - normalizedOverrides[provider] = normalized; - } - return normalizedOverrides; -} - -export function getCustomModelsForProvider( - settings: ProviderCustomModelSettings, - provider: ProviderKind, -): readonly string[] { - switch (provider) { - case "copilot": - return settings.customCopilotModels; - case "claudeAgent": - return settings.customClaudeModels; - case "cursor": - return settings.customCursorModels; - case "opencode": - return settings.customOpencodeModels; - case "geminiCli": - return settings.customGeminiCliModels; - case "amp": - return settings.customAmpModels; - case "kilo": - return settings.customKiloModels; - case "codex": - default: - return settings.customCodexModels; - } -} - -export function patchCustomModels(provider: ProviderKind, models: string[]): Partial { - switch (provider) { - case "copilot": - return { customCopilotModels: models }; - case "claudeAgent": - return { customClaudeModels: models }; - case "cursor": - return { customCursorModels: models }; - case "opencode": - return { customOpencodeModels: models }; - case "geminiCli": - return { customGeminiCliModels: models }; - case "amp": - return { customAmpModels: models }; - case "kilo": - return { customKiloModels: models }; - case "codex": - default: - return { customCodexModels: models }; - } -} - -export function getGitTextGenerationModelOverride( - settings: Pick, - provider: ProviderKind, -): string | null { - return normalizeModelSlug(settings.gitTextGenerationModelByProvider[provider], provider); -} - -export function patchGitTextGenerationModelOverrides( - overrides: AppSettings["gitTextGenerationModelByProvider"], - provider: ProviderKind, - model: string | null | undefined, -): Pick { - const normalized = normalizeModelSlug(model, provider); - const nextOverrides = { ...overrides }; - if (normalized) { - nextOverrides[provider] = normalized; - } else { - delete nextOverrides[provider]; - } - return { gitTextGenerationModelByProvider: nextOverrides }; -} function normalizeAppSettings(settings: AppSettings): AppSettings { return { @@ -388,142 +155,6 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { }; } -export function getDefaultCustomModelsForProvider( - defaults: Pick, - provider: ProviderKind, -): readonly string[] { - return defaults[PROVIDER_CUSTOM_MODEL_CONFIG[provider].defaultSettingsKey]; -} - -export function getCustomModelsByProvider( - settings: Pick, -): Record { - return { - codex: getCustomModelsForProvider(settings, "codex"), - copilot: getCustomModelsForProvider(settings, "copilot"), - claudeAgent: getCustomModelsForProvider(settings, "claudeAgent"), - cursor: getCustomModelsForProvider(settings, "cursor"), - opencode: getCustomModelsForProvider(settings, "opencode"), - geminiCli: getCustomModelsForProvider(settings, "geminiCli"), - amp: getCustomModelsForProvider(settings, "amp"), - kilo: getCustomModelsForProvider(settings, "kilo"), - }; -} - -export function getAppModelOptions( - provider: ProviderKind, - customModels: readonly string[], - selectedModel?: string | null, -): AppModelOption[] { - const options: AppModelOption[] = getModelOptions(provider).map(({ slug, name }) => ({ - slug, - name, - isCustom: false, - })); - const seen = new Set(options.map((option) => option.slug)); - const trimmedSelectedModel = selectedModel?.trim().toLowerCase(); - - for (const slug of normalizeCustomModelSlugs(customModels, provider)) { - if (seen.has(slug)) { - continue; - } - - seen.add(slug); - options.push({ - slug, - name: slug, - isCustom: true, - }); - } - - const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider); - const selectedModelMatchesExistingName = - typeof trimmedSelectedModel === "string" && - options.some((option) => option.name.toLowerCase() === trimmedSelectedModel); - if ( - normalizedSelectedModel && - !seen.has(normalizedSelectedModel) && - !selectedModelMatchesExistingName - ) { - options.push({ - slug: normalizedSelectedModel, - name: normalizedSelectedModel, - isCustom: true, - }); - } - - return options; -} - -export function resolveAppModelSelection( - provider: ProviderKind, - customModels: Record, - selectedModel: string | null | undefined, -): string { - const customModelsForProvider = customModels[provider]; - const options = getAppModelOptions(provider, customModelsForProvider, selectedModel); - return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider); -} - -export function getCustomModelOptionsByProvider( - settings: Pick, -): Record> { - const customModelsByProvider = getCustomModelsByProvider(settings); - return { - codex: getAppModelOptions("codex", customModelsByProvider.codex), - copilot: getAppModelOptions("copilot", customModelsByProvider.copilot), - claudeAgent: getAppModelOptions("claudeAgent", customModelsByProvider.claudeAgent), - cursor: getAppModelOptions("cursor", customModelsByProvider.cursor), - opencode: getAppModelOptions("opencode", customModelsByProvider.opencode), - geminiCli: getAppModelOptions("geminiCli", customModelsByProvider.geminiCli), - amp: getAppModelOptions("amp", customModelsByProvider.amp), - kilo: getAppModelOptions("kilo", customModelsByProvider.kilo), - }; -} - -export function resolveGitTextGenerationModelSelection( - provider: ProviderKind, - settings: Pick< - AppSettings, - keyof ProviderCustomModelSettings | "gitTextGenerationModelByProvider" - >, - activeModel: string | null | undefined, -): string { - const customModelsByProvider = getCustomModelsByProvider(settings); - const overrideModel = getGitTextGenerationModelOverride(settings, provider); - if (overrideModel) { - return resolveAppModelSelection(provider, customModelsByProvider, overrideModel); - } - const normalizedActiveModel = normalizeModelSlug(activeModel, provider); - if (normalizedActiveModel) { - return resolveAppModelSelection(provider, customModelsByProvider, normalizedActiveModel); - } - return resolveAppModelSelection( - provider, - customModelsByProvider, - DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[provider], - ); -} - -export function getSlashModelOptions( - provider: ProviderKind, - customModels: readonly string[], - query: string, - selectedModel?: string | null, -): AppModelOption[] { - const normalizedQuery = query.trim().toLowerCase(); - const options = getAppModelOptions(provider, customModels, selectedModel); - if (!normalizedQuery) { - return options; - } - - return options.filter((option) => { - const searchSlug = option.slug.toLowerCase(); - const searchName = option.name.toLowerCase(); - return searchSlug.includes(normalizedQuery) || searchName.includes(normalizedQuery); - }); -} - export function getProviderStartOptions( settings: Pick, ): ProviderStartOptions | undefined { diff --git a/apps/web/src/appearance.ts b/apps/web/src/appearance.ts new file mode 100644 index 0000000000..aa63aa48d0 --- /dev/null +++ b/apps/web/src/appearance.ts @@ -0,0 +1,36 @@ +import { Schema } from "effect"; + +export const APP_PROVIDER_LOGO_APPEARANCE_OPTIONS = [ + { + value: "original", + label: "Default color", + description: "Use each provider's native logo colors.", + }, + { + value: "grayscale", + label: "Grayscale", + description: "Desaturate provider logos while keeping their original shapes.", + }, + { + value: "accent", + label: "Accent color", + description: "Tint every provider logo with your global or per-provider accent color.", + }, +] as const; + +export type AppProviderLogoAppearance = + (typeof APP_PROVIDER_LOGO_APPEARANCE_OPTIONS)[number]["value"]; + +export const AppProviderLogoAppearanceSchema = Schema.Literals(["original", "grayscale", "accent"]); + +export const TIMESTAMP_FORMAT_OPTIONS = ["locale", "12-hour", "24-hour"] as const; +export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number]; +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"; diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 5aef38ccf6..2f66e063ad 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -6,6 +6,7 @@ import { resolveBranchSelectionTarget, resolveDraftEnvModeAfterBranchChange, resolveBranchToolbarValue, + shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; describe("resolveDraftEnvModeAfterBranchChange", () => { @@ -267,3 +268,38 @@ describe("resolveBranchSelectionTarget", () => { }); }); }); + +describe("shouldIncludeBranchPickerItem", () => { + it("keeps the synthetic checkout PR item visible for gh pr checkout input", () => { + expect( + shouldIncludeBranchPickerItem({ + itemValue: "__checkout_pull_request__:1359", + normalizedQuery: "gh pr checkout 1359", + createBranchItemValue: "__create_new_branch__:gh pr checkout 1359", + checkoutPullRequestItemValue: "__checkout_pull_request__:1359", + }), + ).toBe(true); + }); + + it("keeps the synthetic create-branch item visible for arbitrary branch input", () => { + expect( + shouldIncludeBranchPickerItem({ + itemValue: "__create_new_branch__:feature/demo", + normalizedQuery: "feature/demo", + createBranchItemValue: "__create_new_branch__:feature/demo", + checkoutPullRequestItemValue: null, + }), + ).toBe(true); + }); + + it("still filters ordinary branch items by query text", () => { + expect( + shouldIncludeBranchPickerItem({ + itemValue: "main", + normalizedQuery: "gh pr checkout 1359", + createBranchItemValue: "__create_new_branch__:gh pr checkout 1359", + checkoutPullRequestItemValue: "__checkout_pull_request__:1359", + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 2215569c83..99928441c7 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -123,3 +123,26 @@ export function resolveBranchSelectionTarget(input: { reuseExistingWorktree: false, }; } + +export function shouldIncludeBranchPickerItem(input: { + itemValue: string; + normalizedQuery: string; + createBranchItemValue: string | null; + checkoutPullRequestItemValue: string | null; +}): boolean { + const { itemValue, normalizedQuery, createBranchItemValue, checkoutPullRequestItemValue } = input; + + if (normalizedQuery.length === 0) { + return true; + } + + if (createBranchItemValue && itemValue === createBranchItemValue) { + return true; + } + + if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { + return true; + } + + return itemValue.toLowerCase().includes(normalizedQuery); +} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 9446279161..b41ea0a6e1 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -28,6 +28,7 @@ import { EnvMode, resolveBranchSelectionTarget, resolveBranchToolbarValue, + shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; import { Button } from "./ui/button"; import { @@ -134,11 +135,20 @@ export function BranchToolbarBranchSelector({ () => normalizedDeferredBranchQuery.length === 0 ? branchPickerItems - : branchPickerItems.filter((itemValue) => { - if (createBranchItemValue && itemValue === createBranchItemValue) return true; - return itemValue.toLowerCase().includes(normalizedDeferredBranchQuery); - }), - [branchPickerItems, createBranchItemValue, normalizedDeferredBranchQuery], + : branchPickerItems.filter((itemValue) => + shouldIncludeBranchPickerItem({ + itemValue, + normalizedQuery: normalizedDeferredBranchQuery, + createBranchItemValue, + checkoutPullRequestItemValue, + }), + ), + [ + branchPickerItems, + checkoutPullRequestItemValue, + createBranchItemValue, + normalizedDeferredBranchQuery, + ], ); const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic( canonicalActiveBranch, diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 4e18092463..7bf3ecf26c 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}$/; @@ -54,6 +56,7 @@ interface TestFixture { let fixture: TestFixture; const wsRequests: WsRequestEnvelope["body"][] = []; +let customWsRpcResolver: ((body: WsRequestEnvelope["body"]) => unknown | undefined) | null = null; const wsLink = ws.link(/ws(s)?:\/\/.*/); interface ViewportSpec { @@ -110,13 +113,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, + }, }; } @@ -347,6 +357,25 @@ function withProjectScripts( }; } +function setDraftThreadWithoutWorktree(): void { + useComposerDraftStore.setState({ + draftThreadsByThreadId: { + [THREAD_ID]: { + projectId: PROJECT_ID, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + envMode: "local", + }, + }, + projectDraftThreadIdByProjectId: { + [PROJECT_ID]: THREAD_ID, + }, + }); +} + function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { const snapshot = createSnapshotForTargetUser({ targetMessageId: "msg-user-plan-target" as MessageId, @@ -405,6 +434,10 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { } function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { + const customResult = customWsRpcResolver?.(body); + if (customResult !== undefined) { + return customResult; + } const tag = body._tag; if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { return fixture.snapshot; @@ -739,9 +772,11 @@ async function mountChatView(options: { viewport: ViewportSpec; snapshot: OrchestrationReadModel; configureFixture?: (fixture: TestFixture) => void; + resolveRpc?: (body: WsRequestEnvelope["body"]) => unknown | undefined; }): Promise { fixture = buildFixture(options.snapshot); options.configureFixture?.(fixture); + customWsRpcResolver = options.resolveRpc ?? null; await setViewport(options.viewport); await waitForProductionStyles(); @@ -767,6 +802,7 @@ async function mountChatView(options: { await waitForLayout(); const cleanup = async () => { + customWsRpcResolver = null; await screen.unmount(); host.remove(); }; @@ -826,6 +862,7 @@ describe("ChatView timeline estimator parity (full app)", () => { localStorage.clear(); document.body.innerHTML = ""; wsRequests.length = 0; + customWsRpcResolver = null; useComposerDraftStore.setState({ draftsByThreadId: {}, draftThreadsByThreadId: {}, @@ -841,6 +878,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); afterEach(() => { + customWsRpcResolver = null; document.body.innerHTML = ""; }); @@ -1002,30 +1040,162 @@ describe("ChatView timeline estimator parity (full app)", () => { ); it("opens the project cwd for draft threads without a worktree path", async () => { - useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { - projectId: PROJECT_ID, - createdAt: NOW_ISO, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - envMode: "local", + setDraftThreadWithoutWorktree(); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + availableEditors: ["vscode"], + }; + }, + }); + + try { + const openButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Open", + ) as HTMLButtonElement | null, + "Unable to find Open button.", + ); + openButton.click(); + + await vi.waitFor( + () => { + const openRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.shellOpenInEditor, + ); + expect(openRequest).toMatchObject({ + _tag: WS_METHODS.shellOpenInEditor, + cwd: "/repo/project", + editor: "vscode", + }); }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("opens the project cwd with VS Code Insiders when it is the only available editor", async () => { + setDraftThreadWithoutWorktree(); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + availableEditors: ["vscode-insiders"], + }; }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + }); + + try { + const openButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Open", + ) as HTMLButtonElement | null, + "Unable to find Open button.", + ); + openButton.click(); + + await vi.waitFor( + () => { + const openRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.shellOpenInEditor, + ); + expect(openRequest).toMatchObject({ + _tag: WS_METHODS.shellOpenInEditor, + cwd: "/repo/project", + editor: "vscode-insiders", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("filters the open picker menu and opens VSCodium from the menu", async () => { + setDraftThreadWithoutWorktree(); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + availableEditors: ["vscode-insiders", "vscodium"], + }; }, }); + try { + const menuButton = await waitForElement( + () => document.querySelector('button[aria-label="Copy options"]'), + "Unable to find Open picker button.", + ); + (menuButton as HTMLButtonElement).click(); + + await waitForElement( + () => + Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find((item) => + item.textContent?.includes("VS Code Insiders"), + ) ?? null, + "Unable to find VS Code Insiders menu item.", + ); + + expect( + Array.from(document.querySelectorAll('[data-slot="menu-item"]')).some((item) => + item.textContent?.includes("Zed"), + ), + ).toBe(false); + + const vscodiumItem = await waitForElement( + () => + Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find((item) => + item.textContent?.includes("VSCodium"), + ) ?? null, + "Unable to find VSCodium menu item.", + ); + (vscodiumItem as HTMLElement).click(); + + await vi.waitFor( + () => { + const openRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.shellOpenInEditor, + ); + expect(openRequest).toMatchObject({ + _tag: WS_METHODS.shellOpenInEditor, + cwd: "/repo/project", + editor: "vscodium", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("falls back to the first installed editor when the stored favorite is unavailable", async () => { + localStorage.setItem("t3code:last-editor", "vscodium"); + setDraftThreadWithoutWorktree(); + const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createDraftOnlySnapshot(), configureFixture: (nextFixture) => { nextFixture.serverConfig = { ...nextFixture.serverConfig, - availableEditors: ["vscode"], + availableEditors: ["vscode-insiders"], }; }, }); @@ -1048,7 +1218,7 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(openRequest).toMatchObject({ _tag: WS_METHODS.shellOpenInEditor, cwd: "/repo/project", - editor: "vscode", + editor: "vscode-insiders", }); }, { timeout: 8_000, interval: 16 }, @@ -1197,6 +1367,154 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("runs setup scripts after preparing a pull request worktree thread", async () => { + useComposerDraftStore.setState({ + draftThreadsByThreadId: { + [THREAD_ID]: { + projectId: PROJECT_ID, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + envMode: "local", + }, + }, + projectDraftThreadIdByProjectId: { + [PROJECT_ID]: THREAD_ID, + }, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: withProjectScripts(createDraftOnlySnapshot(), [ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]), + resolveRpc: (body) => { + if (body._tag === WS_METHODS.gitResolvePullRequest) { + return { + pullRequest: { + number: 1359, + title: "Add thread archiving and settings navigation", + url: "https://github.com/pingdotgg/t3code/pull/1359", + baseBranch: "main", + headBranch: "archive-settings-overhaul", + state: "open", + }, + }; + } + if (body._tag === WS_METHODS.gitPreparePullRequestThread) { + return { + pullRequest: { + number: 1359, + title: "Add thread archiving and settings navigation", + url: "https://github.com/pingdotgg/t3code/pull/1359", + baseBranch: "main", + headBranch: "archive-settings-overhaul", + state: "open", + }, + branch: "archive-settings-overhaul", + worktreePath: "/repo/worktrees/pr-1359", + }; + } + return undefined; + }, + }); + + try { + const branchButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "main", + ) as HTMLButtonElement | null, + "Unable to find branch selector button.", + ); + branchButton.click(); + + const branchInput = await waitForElement( + () => document.querySelector('input[placeholder="Search branches..."]'), + "Unable to find branch search input.", + ); + branchInput.focus(); + await page.getByPlaceholder("Search branches...").fill("1359"); + + const checkoutItem = await waitForElement( + () => + Array.from(document.querySelectorAll("span")).find( + (element) => element.textContent?.trim() === "Checkout Pull Request", + ) as HTMLSpanElement | null, + "Unable to find checkout pull request option.", + ); + checkoutItem.click(); + + const worktreeButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Worktree", + ) as HTMLButtonElement | null, + "Unable to find Worktree button.", + ); + worktreeButton.click(); + + await vi.waitFor( + () => { + const prepareRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.gitPreparePullRequestThread, + ); + expect(prepareRequest).toMatchObject({ + _tag: WS_METHODS.gitPreparePullRequestThread, + cwd: "/repo/project", + reference: "1359", + mode: "worktree", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + + await vi.waitFor( + () => { + const openRequest = wsRequests.find( + (request) => + request._tag === WS_METHODS.terminalOpen && request.cwd === "/repo/worktrees/pr-1359", + ); + expect(openRequest).toMatchObject({ + _tag: WS_METHODS.terminalOpen, + threadId: expect.any(String), + cwd: "/repo/worktrees/pr-1359", + env: { + T3CODE_PROJECT_ROOT: "/repo/project", + T3CODE_WORKTREE_PATH: "/repo/worktrees/pr-1359", + }, + }); + }, + { timeout: 8_000, interval: 16 }, + ); + + await vi.waitFor( + () => { + const writeRequest = wsRequests.find( + (request) => + request._tag === WS_METHODS.terminalWrite && request.data === "bun install\r", + ); + expect(writeRequest).toMatchObject({ + _tag: WS_METHODS.terminalWrite, + threadId: expect.any(String), + data: "bun install\r", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("toggles plan mode with Shift+Tab only while the composer is focused", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2f5864a5bc..6a2647b5a8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -9,7 +9,6 @@ import { type MessageId, type ModelSelection, type ProjectScript, - type ModelSlug, type ProviderKind, type ProjectEntry, type ProjectId, @@ -17,7 +16,7 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ResolvedKeybindingsConfig, - type ServerProviderStatus, + type ServerProvider, type ThreadId, type TurnId, OrchestrationThreadActivity, @@ -128,7 +127,13 @@ import { import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -import { getProviderStartOptions, useAppSettings } from "../appSettings"; +import { useAppSettings } from "../appSettings"; +import { useSettings } from "../hooks/useSettings"; +import { + getProviderModelCapabilities, + getProviderModels, + resolveSelectableProvider, +} from "../providerModels"; import { getCustomModelsByProvider, resolveAppModelSelection } from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; import { @@ -173,7 +178,7 @@ import { renderProviderTraitsMenuContent, renderProviderTraitsPicker, } from "./chat/composerProviderRegistry"; -import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; +import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { buildExpiredTerminalContextToastCopy, @@ -202,16 +207,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); } @@ -253,6 +259,12 @@ interface ChatViewProps { threadId: ThreadId; } +interface PendingPullRequestSetupRequest { + threadId: ThreadId; + worktreePath: string; + scriptId: string; +} + export default function ChatView({ threadId }: ChatViewProps) { const threads = useStore((store) => store.threads); const projects = useStore((store) => store.projects); @@ -260,7 +272,8 @@ 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 { settings: appSettings } = useAppSettings(); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); @@ -365,6 +378,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); + const [pendingPullRequestSetupRequest, setPendingPullRequestSetupRequest] = + useState(null); const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< Record >({}); @@ -542,14 +557,14 @@ export default function ChatView({ threadId }: ChatViewProps) { params: { threadId: storedDraftThread.threadId }, }); } - return; + return storedDraftThread.threadId; } const activeDraftThread = getDraftThread(threadId); if (!isServerThread && activeDraftThread?.projectId === activeProject.id) { setDraftThreadContext(threadId, input); setProjectDraftThreadId(activeProject.id, threadId, input); - return; + return threadId; } clearProjectDraftThreadId(activeProject.id); @@ -564,6 +579,7 @@ export default function ChatView({ threadId }: ChatViewProps) { to: "/$threadId", params: { threadId: nextThreadId }, }); + return nextThreadId; }, [ activeProject, @@ -580,13 +596,24 @@ export default function ChatView({ threadId }: ChatViewProps) { const handlePreparedPullRequestThread = useCallback( async (input: { branch: string; worktreePath: string | null }) => { - await openOrReuseProjectDraftThread({ + const targetThreadId = await openOrReuseProjectDraftThread({ branch: input.branch, worktreePath: input.worktreePath, envMode: input.worktreePath ? "worktree" : "local", }); + const setupScript = + input.worktreePath && activeProject ? setupProjectScript(activeProject.scripts) : null; + if (targetThreadId && input.worktreePath && setupScript) { + setPendingPullRequestSetupRequest({ + threadId: targetThreadId, + worktreePath: input.worktreePath, + scriptId: setupScript.id, + }); + } else { + setPendingPullRequestSetupRequest(null); + } }, - [openOrReuseProjectDraftThread], + [activeProject, openOrReuseProjectDraftThread], ); useEffect(() => { @@ -620,25 +647,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; @@ -679,10 +713,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const kiloModelsQuery = useQuery(providerListModelsQueryOptions("kilo")); const geminiCliModelsQuery = useQuery(providerListModelsQueryOptions("geminiCli")); const ampModelsQuery = useQuery(providerListModelsQueryOptions("amp")); - const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]); const modelOptionsByProvider = useMemo( () => - mergeDiscoveredModels(getCustomModelOptionsByProvider(settings), { + mergeDiscoveredModels(getCustomModelOptionsByProvider(appSettings), { copilot: copilotModelsQuery.data, cursor: cursorModelsQuery.data, opencode: opencodeModelsQuery.data, @@ -691,7 +724,7 @@ export default function ChatView({ threadId }: ChatViewProps) { amp: ampModelsQuery.data, }), [ - settings, + appSettings, copilotModelsQuery.data, cursorModelsQuery.data, opencodeModelsQuery.data, @@ -1104,7 +1137,8 @@ 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 workspaceEntriesQuery = useQuery( projectSearchEntriesQueryOptions({ cwd: gitCwd, @@ -1192,9 +1226,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], @@ -1488,6 +1519,45 @@ export default function ChatView({ threadId }: ChatViewProps) { terminalState.terminalIds, ], ); + + useEffect(() => { + if (!pendingPullRequestSetupRequest || !activeProject || !activeThreadId || !activeThread) { + return; + } + if (pendingPullRequestSetupRequest.threadId !== activeThreadId) { + return; + } + if (activeThread.worktreePath !== pendingPullRequestSetupRequest.worktreePath) { + return; + } + + const setupScript = + activeProject.scripts.find( + (script) => script.id === pendingPullRequestSetupRequest.scriptId, + ) ?? null; + setPendingPullRequestSetupRequest(null); + if (!setupScript) { + return; + } + + void runProjectScript(setupScript, { + cwd: pendingPullRequestSetupRequest.worktreePath, + worktreePath: pendingPullRequestSetupRequest.worktreePath, + rememberAsLastInvoked: false, + }).catch((error) => { + toastManager.add({ + type: "error", + title: "Failed to run setup script.", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }); + }, [ + activeProject, + activeThread, + activeThreadId, + pendingPullRequestSetupRequest, + runProjectScript, + ]); const persistProjectScripts = useCallback( async (input: { projectId: ProjectId; @@ -2539,6 +2609,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const outgoingMessageText = formatOutgoingPrompt({ provider: selectedProvider, model: selectedModel, + models: selectedProviderModels, effort: selectedPromptEffort, text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, }); @@ -2724,8 +2795,6 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: turnAttachments, }, ...(selectedModelSelection ? { modelSelection: selectedModelSelection } : {}), - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode, createdAt: messageCreatedAt, @@ -2962,6 +3031,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const outgoingMessageText = formatOutgoingPrompt({ provider: selectedProvider, model: selectedModel, + models: selectedProviderModels, effort: selectedPromptEffort, text: trimmed, }); @@ -3006,8 +3076,6 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: nextInteractionMode, ...(nextInteractionMode === "default" && activeProposedPlan @@ -3053,11 +3121,10 @@ export default function ChatView({ threadId }: ChatViewProps) { runtimeMode, selectedPromptEffort, selectedModelSelection, - providerOptionsForDispatch, selectedProvider, + selectedProviderModels, setComposerDraftInteractionMode, setThreadError, - settings.enableAssistantStreaming, selectedModel, ], ); @@ -3084,6 +3151,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const outgoingImplementationPrompt = formatOutgoingPrompt({ provider: selectedProvider, model: selectedModel, + models: selectedProviderModels, effort: selectedPromptEffort, text: implementationPrompt, }); @@ -3126,8 +3194,6 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, ...(selectedModelSelection ? { modelSelection: selectedModelSelection } : {}), - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: "default", createdAt, @@ -3178,23 +3244,27 @@ export default function ChatView({ threadId }: ChatViewProps) { runtimeMode, selectedPromptEffort, selectedModelSelection, - providerOptionsForDispatch, selectedProvider, - settings.enableAssistantStreaming, + selectedProviderModels, syncServerReadModel, selectedModel, ]); const onProviderModelSelect = useCallback( - (provider: ProviderKind, model: ModelSlug) => { + (provider: ProviderKind, model: string) => { if (!activeThread) return; if (lockedProvider !== null && provider !== lockedProvider) { scheduleComposerFocus(); return; } - const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model); + const resolvedProvider = resolveSelectableProvider(providerStatuses, provider); + const resolvedModel = resolveAppModelSelection( + resolvedProvider, + getCustomModelsByProvider(appSettings), + model, + ); const nextModelSelection: ModelSelection = { - provider, + provider: resolvedProvider, model: resolvedModel, }; setComposerDraftModelSelection(activeThread.id, nextModelSelection); @@ -3207,7 +3277,8 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus, setComposerDraftModelSelection, setStickyComposerModelSelection, - customModelsByProvider, + providerStatuses, + appSettings, ], ); const setPromptFromTraits = useCallback( @@ -3272,6 +3343,7 @@ export default function ChatView({ threadId }: ChatViewProps) { provider: selectedProvider, threadId, model: selectedModel, + models: selectedProviderModels, modelOptions: composerModelOptions?.[selectedProvider], prompt, onPromptChange: setPromptFromTraits, @@ -3280,6 +3352,7 @@ export default function ChatView({ threadId }: ChatViewProps) { provider: selectedProvider, threadId, model: selectedModel, + models: selectedProviderModels, modelOptions: composerModelOptions?.[selectedProvider], prompt, onPromptChange: setPromptFromTraits, @@ -3645,7 +3718,7 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Error banner */} - + setThreadError(activeThread.id, null)} @@ -3922,6 +3995,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/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index c361d55bbd..ab6e8562a8 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -270,7 +270,6 @@ export default function GitActionsControl({ gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient, - modelSelection: { provider, model: gitTextGenerationModel }, }), ); const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); @@ -779,7 +778,7 @@ export default function GitActionsControl({ } > - + {quickAction.label} @@ -795,12 +794,12 @@ export default function GitActionsControl({ onClick={runQuickAction} > - + {quickAction.label} )} - + { if (open) void invalidateGitQueries(queryClient); diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index c8d8b87936..14472e30cd 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -46,13 +46,31 @@ 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: [] }, + copilot: { enabled: true, customModels: [] }, + cursor: { enabled: true, customModels: [] }, + opencode: { enabled: true, customModels: [] }, + geminiCli: { enabled: true, customModels: [] }, + amp: { enabled: true, customModels: [] }, + kilo: { enabled: true, 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/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 6acac9b03f..c86ca9d2a0 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -281,11 +281,11 @@ export default function ProjectScriptsControl({ title={`Run ${primaryScript.name}`} > - + {primaryScript.name} - + } @@ -346,7 +346,7 @@ export default function ProjectScriptsControl({ ) : ( diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index e30282bf2f..c1c88e59ae 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -160,9 +160,9 @@ export function PullRequestThreadDialog({ const validationMessage = !referenceDirty ? null : reference.trim().length === 0 - ? "Paste a GitHub pull request URL or enter 123 / #123." + ? "Paste a GitHub pull request URL, `gh pr checkout 123`, or enter 123 / #123." : parsedReference === null - ? "Use a GitHub pull request URL, 123, or #123." + ? "Use a GitHub pull request URL, `gh pr checkout 123`, 123, or #123." : null; const errorMessage = validationMessage ?? @@ -199,7 +199,7 @@ export function PullRequestThreadDialog({ Pull request { setReferenceDirty(true); 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 1ea4aa1513..4003ed9758 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -45,8 +45,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 { resolveThreadProvider } from "../lib/threadProvider"; @@ -117,6 +116,7 @@ import { } from "./Sidebar.logic"; import { ProviderLogo } from "./ProviderLogo"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -567,7 +567,7 @@ function ProviderUsageSection() { }); }, []); - const { settings: usageSettings } = useAppSettings(); + const usageSettings = useSettings(); const copilotUsage = useProviderUsage("copilot"); const codexUsage = useProviderUsage("codex"); @@ -591,7 +591,11 @@ function ProviderUsageSection() { const showCount = provider === "copilot"; const hidePlanLabel = provider === "copilot"; const hidePercentLabel = false; - const providerColor = usageSettings.providerAccentColors[provider] || null; + const accentMap = (usageSettings as Record)["providerAccentColors"]; + const providerColor = + accentMap && typeof accentMap === "object" + ? ((accentMap as Record)[provider] ?? null) + : null; const colorProp = providerColor ? { accentColor: providerColor } : {}; // Multiple quotas (e.g. Codex session + weekly) if (data?.quotas && data.quotas.length > 0) { @@ -804,7 +808,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, @@ -1722,7 +1727,7 @@ export default function Sidebar() { diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 33086207e3..f09b4907d8 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -67,7 +67,7 @@ export const ChatHeader = memo(function ChatHeader({ onToggleDiff, }: ChatHeaderProps) { return ( -
+

{activeProjectName && ( - - {activeProjectName} + + {activeProjectName} )} {activeProjectName && !isGitRepo && ( @@ -87,7 +87,7 @@ export const ChatHeader = memo(function ChatHeader({ )}

-
+
{activeProjectScripts && ( openInEditor(preferredEditor)} > {primaryOption?.Icon &&