| author | |
| committer | |
| log | 9403429a7af114a1b96a79c96ecda218f9e0c0b2 |
| tree | 88ef5f97e09eedbb5be88e68433ffe77b7e20087 |
| parent | 37cf0c1144595faa5a17d6d4b2c1fd98d03376bf |
| signature | Signed by PGP key B5690EEEBB952194 |
97 files changed, 5407 insertions(+), 3370 deletions(-)
apps/server/integration/OrchestrationEngineHarness.integration.ts+5| ... | ... | @@ -38,6 +38,7 @@ import { ProjectionPendingApprovalRepository } from "../src/persistence/Services |
| 38 | 38 | import { ProviderUnsupportedError } from "../src/provider/Errors.ts"; |
| 39 | 39 | import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; |
| 40 | 40 | import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; |
| 41 | import { ServerSettingsService } from "../src/serverSettings.ts"; | |
| 41 | 42 | import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; |
| 42 | 43 | import { makeCodexAdapterLive } from "../src/provider/Layers/CodexAdapter.ts"; |
| 43 | 44 | import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts"; |
| ... | ... | @@ -295,8 +296,10 @@ export const makeOrchestrationIntegrationHarness = ( |
| 295 | 296 | providerLayer, |
| 296 | 297 | RuntimeReceiptBusLive, |
| 297 | 298 | ); |
| 299 | const serverSettingsLayer = ServerSettingsService.layerTest(); | |
| 298 | 300 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( |
| 299 | 301 | Layer.provideMerge(runtimeServicesLayer), |
| 302 | Layer.provideMerge(serverSettingsLayer), | |
| 300 | 303 | ); |
| 301 | 304 | const gitCoreLayer = Layer.succeed(GitCore, { |
| 302 | 305 | renameBranch: (input: Parameters<GitCoreShape["renameBranch"]>[0]) => |
| ... | ... | @@ -309,6 +312,7 @@ export const makeOrchestrationIntegrationHarness = ( |
| 309 | 312 | Layer.provideMerge(runtimeServicesLayer), |
| 310 | 313 | Layer.provideMerge(gitCoreLayer), |
| 311 | 314 | Layer.provideMerge(textGenerationLayer), |
| 315 | Layer.provideMerge(serverSettingsLayer), | |
| 312 | 316 | ); |
| 313 | 317 | const checkpointReactorLayer = CheckpointReactorLive.pipe( |
| 314 | 318 | Layer.provideMerge(runtimeServicesLayer), |
| ... | ... | @@ -320,6 +324,7 @@ export const makeOrchestrationIntegrationHarness = ( |
| 320 | 324 | ); |
| 321 | 325 | const layer = orchestrationReactorLayer.pipe( |
| 322 | 326 | Layer.provide(persistenceLayer), |
| 327 | Layer.provideMerge(ServerSettingsService.layerTest()), | |
| 323 | 328 | Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), |
| 324 | 329 | Layer.provideMerge(NodeServices.layer), |
| 325 | 330 | ); |
apps/server/integration/providerService.integration.test.ts+3| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import type { ProviderRuntimeEvent } from "@t3tools/contracts"; |
| 2 | 2 | import { ThreadId } from "@t3tools/contracts"; |
| 3 | import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings"; | |
| 3 | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | 5 | import { it, assert } from "@effect/vitest"; |
| 5 | 6 | import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect"; |
| ... | ... | @@ -12,6 +13,7 @@ import { |
| 12 | 13 | ProviderService, |
| 13 | 14 | type ProviderServiceShape, |
| 14 | 15 | } from "../src/provider/Services/ProviderService.ts"; |
| 16 | import { ServerSettingsService } from "../src/serverSettings.ts"; | |
| 15 | 17 | import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; |
| 16 | 18 | import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; |
| 17 | 19 | import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; |
| ... | ... | @@ -60,6 +62,7 @@ const makeIntegrationFixture = Effect.gen(function* () { |
| 60 | 62 | const shared = Layer.mergeAll( |
| 61 | 63 | directoryLayer, |
| 62 | 64 | Layer.succeed(ProviderAdapterRegistry, registry), |
| 65 | ServerSettingsService.layerTest(DEFAULT_SERVER_SETTINGS), | |
| 63 | 66 | AnalyticsService.layerTest, |
| 64 | 67 | ).pipe(Layer.provide(SqlitePersistenceMemory)); |
| 65 | 68 |
apps/server/src/codexAppServerManager.test.ts+6-12| ... | ... | @@ -342,6 +342,7 @@ describe("startSession", () => { |
| 342 | 342 | manager.startSession({ |
| 343 | 343 | threadId: asThreadId("thread-1"), |
| 344 | 344 | provider: "codex", |
| 345 | binaryPath: "codex", | |
| 345 | 346 | runtimeMode: "full-access", |
| 346 | 347 | }), |
| 347 | 348 | ).rejects.toThrow("cwd missing"); |
| ... | ... | @@ -390,6 +391,7 @@ describe("startSession", () => { |
| 390 | 391 | manager.startSession({ |
| 391 | 392 | threadId: asThreadId("thread-1"), |
| 392 | 393 | provider: "codex", |
| 394 | binaryPath: "codex", | |
| 393 | 395 | runtimeMode: "full-access", |
| 394 | 396 | }), |
| 395 | 397 | ).rejects.toThrow( |
| ... | ... | @@ -948,12 +950,8 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume" |
| 948 | 950 | provider: "codex", |
| 949 | 951 | cwd: workspaceDir, |
| 950 | 952 | runtimeMode: "full-access", |
| 951 | providerOptions: { | |
| 952 | codex: { | |
| 953 | ...(process.env.CODEX_BINARY_PATH ? { binaryPath: process.env.CODEX_BINARY_PATH } : {}), | |
| 954 | ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}), | |
| 955 | }, | |
| 956 | }, | |
| 953 | binaryPath: process.env.CODEX_BINARY_PATH!, | |
| 954 | ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}), | |
| 957 | 955 | }); |
| 958 | 956 | |
| 959 | 957 | const firstTurn = await manager.sendTurn({ |
| ... | ... | @@ -983,12 +981,8 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume" |
| 983 | 981 | cwd: workspaceDir, |
| 984 | 982 | runtimeMode: "approval-required", |
| 985 | 983 | resumeCursor: firstSession.resumeCursor, |
| 986 | providerOptions: { | |
| 987 | codex: { | |
| 988 | ...(process.env.CODEX_BINARY_PATH ? { binaryPath: process.env.CODEX_BINARY_PATH } : {}), | |
| 989 | ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}), | |
| 990 | }, | |
| 991 | }, | |
| 984 | binaryPath: process.env.CODEX_BINARY_PATH!, | |
| 985 | ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}), | |
| 992 | 986 | }); |
| 993 | 987 | |
| 994 | 988 | expect(resumedSession.threadId).toBe(originalThreadId); |
apps/server/src/codexAppServerManager.ts+9-20| ... | ... | @@ -14,7 +14,6 @@ import { |
| 14 | 14 | type ProviderApprovalDecision, |
| 15 | 15 | type ProviderEvent, |
| 16 | 16 | type ProviderSession, |
| 17 | type ProviderSessionStartInput, | |
| 18 | 17 | type ProviderTurnStartResult, |
| 19 | 18 | RuntimeMode, |
| 20 | 19 | ProviderInteractionMode, |
| ... | ... | @@ -131,7 +130,8 @@ export interface CodexAppServerStartSessionInput { |
| 131 | 130 | readonly model?: string; |
| 132 | 131 | readonly serviceTier?: string; |
| 133 | 132 | readonly resumeCursor?: unknown; |
| 134 | readonly providerOptions?: ProviderSessionStartInput["providerOptions"]; | |
| 133 | readonly binaryPath: string; | |
| 134 | readonly homePath?: string; | |
| 135 | 135 | readonly runtimeMode: RuntimeMode; |
| 136 | 136 | } |
| 137 | 137 | |
| ... | ... | @@ -541,9 +541,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 541 | 541 | updatedAt: now, |
| 542 | 542 | }; |
| 543 | 543 | |
| 544 | const codexOptions = readCodexProviderOptions(input); | |
| 545 | const codexBinaryPath = codexOptions.binaryPath ?? "codex"; | |
| 546 | const codexHomePath = codexOptions.homePath; | |
| 544 | const codexBinaryPath = input.binaryPath; | |
| 545 | const codexHomePath = input.homePath; | |
| 547 | 546 | this.assertSupportedCodexCliVersion({ |
| 548 | 547 | binaryPath: codexBinaryPath, |
| 549 | 548 | cwd: resolvedCwd, |
| ... | ... | @@ -1591,20 +1590,6 @@ function normalizeProviderThreadId(value: string | undefined): string | undefine |
| 1591 | 1590 | return brandIfNonEmpty(value, (normalized) => normalized); |
| 1592 | 1591 | } |
| 1593 | 1592 | |
| 1594 | function readCodexProviderOptions(input: CodexAppServerStartSessionInput): { | |
| 1595 | readonly binaryPath?: string; | |
| 1596 | readonly homePath?: string; | |
| 1597 | } { | |
| 1598 | const options = input.providerOptions?.codex; | |
| 1599 | if (!options) { | |
| 1600 | return {}; | |
| 1601 | } | |
| 1602 | return { | |
| 1603 | ...(options.binaryPath ? { binaryPath: options.binaryPath } : {}), | |
| 1604 | ...(options.homePath ? { homePath: options.homePath } : {}), | |
| 1605 | }; | |
| 1606 | } | |
| 1607 | ||
| 1608 | 1593 | function assertSupportedCodexCliVersion(input: { |
| 1609 | 1594 | readonly binaryPath: string; |
| 1610 | 1595 | readonly cwd: string; |
| ... | ... | @@ -1658,7 +1643,11 @@ function readResumeCursorThreadId(resumeCursor: unknown): string | undefined { |
| 1658 | 1643 | return typeof rawThreadId === "string" ? normalizeProviderThreadId(rawThreadId) : undefined; |
| 1659 | 1644 | } |
| 1660 | 1645 | |
| 1661 | function readResumeThreadId(input: CodexAppServerStartSessionInput): string | undefined { | |
| 1646 | function readResumeThreadId(input: { | |
| 1647 | readonly resumeCursor?: unknown; | |
| 1648 | readonly threadId?: ThreadId; | |
| 1649 | readonly runtimeMode?: RuntimeMode; | |
| 1650 | }): string | undefined { | |
| 1662 | 1651 | return readResumeCursorThreadId(input.resumeCursor); |
| 1663 | 1652 | } |
| 1664 | 1653 |
apps/server/src/config.ts+2| ... | ... | @@ -19,6 +19,7 @@ export interface ServerDerivedPaths { |
| 19 | 19 | readonly stateDir: string; |
| 20 | 20 | readonly dbPath: string; |
| 21 | 21 | readonly keybindingsConfigPath: string; |
| 22 | readonly settingsPath: string; | |
| 22 | 23 | readonly worktreesDir: string; |
| 23 | 24 | readonly attachmentsDir: string; |
| 24 | 25 | readonly logsDir: string; |
| ... | ... | @@ -60,6 +61,7 @@ export const deriveServerPaths = Effect.fn(function* ( |
| 60 | 61 | stateDir, |
| 61 | 62 | dbPath, |
| 62 | 63 | keybindingsConfigPath: join(stateDir, "keybindings.json"), |
| 64 | settingsPath: join(stateDir, "settings.json"), | |
| 63 | 65 | worktreesDir: join(baseDir, "worktrees"), |
| 64 | 66 | attachmentsDir, |
| 65 | 67 | logsDir, |
apps/server/src/git/Layers/ClaudeTextGeneration.test.ts+2| ... | ... | @@ -6,8 +6,10 @@ import { expect } from "vitest"; |
| 6 | 6 | import { ServerConfig } from "../../config.ts"; |
| 7 | 7 | import { TextGeneration } from "../Services/TextGeneration.ts"; |
| 8 | 8 | import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; |
| 9 | import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 9 | 10 | |
| 10 | 11 | const ClaudeTextGenerationTestLayer = ClaudeTextGenerationLive.pipe( |
| 12 | Layer.provideMerge(ServerSettingsService.layerTest()), | |
| 11 | 13 | Layer.provideMerge( |
| 12 | 14 | ServerConfig.layerTest(process.cwd(), { |
| 13 | 15 | prefix: "t3code-claude-text-generation-test-", |
apps/server/src/git/Layers/ClaudeTextGeneration.ts+9-2| ... | ... | @@ -11,7 +11,6 @@ import { Effect, Layer, Option, Schema, Stream } from "effect"; |
| 11 | 11 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 12 | 12 | |
| 13 | 13 | import { ClaudeModelSelection } from "@t3tools/contracts"; |
| 14 | import { normalizeClaudeModelOptions } from "@t3tools/shared/model"; | |
| 15 | 14 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 16 | 15 | |
| 17 | 16 | import { TextGenerationError } from "../Errors.ts"; |
| ... | ... | @@ -27,6 +26,8 @@ import { |
| 27 | 26 | sanitizePrTitle, |
| 28 | 27 | toJsonSchemaObject, |
| 29 | 28 | } from "../Utils.ts"; |
| 29 | import { normalizeClaudeModelOptions } from "../../provider/Layers/ClaudeProvider.ts"; | |
| 30 | import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 30 | 31 | |
| 31 | 32 | const CLAUDE_TIMEOUT_MS = 180_000; |
| 32 | 33 | |
| ... | ... | @@ -40,6 +41,7 @@ const ClaudeOutputEnvelope = Schema.Struct({ |
| 40 | 41 | |
| 41 | 42 | const makeClaudeTextGeneration = Effect.gen(function* () { |
| 42 | 43 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 44 | const serverSettingsService = yield* Effect.service(ServerSettingsService); | |
| 43 | 45 | |
| 44 | 46 | const readStreamAsString = <E>( |
| 45 | 47 | operation: string, |
| ... | ... | @@ -86,9 +88,14 @@ const makeClaudeTextGeneration = Effect.gen(function* () { |
| 86 | 88 | ...(normalizedOptions?.fastMode ? { fastMode: true } : {}), |
| 87 | 89 | }; |
| 88 | 90 | |
| 91 | const claudeSettings = yield* Effect.map( | |
| 92 | serverSettingsService.getSettings, | |
| 93 | (settings) => settings.providers.claudeAgent, | |
| 94 | ).pipe(Effect.catch(() => Effect.undefined)); | |
| 95 | ||
| 89 | 96 | const runClaudeCommand = Effect.gen(function* () { |
| 90 | 97 | const command = ChildProcess.make( |
| 91 | "claude", | |
| 98 | claudeSettings?.binaryPath || "claude", | |
| 92 | 99 | [ |
| 93 | 100 | "-p", |
| 94 | 101 | "--output-format", |
apps/server/src/git/Layers/CodexTextGeneration.test.ts+99-172| ... | ... | @@ -7,6 +7,7 @@ import { ServerConfig } from "../../config.ts"; |
| 7 | 7 | import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; |
| 8 | 8 | import { TextGenerationError } from "../Errors.ts"; |
| 9 | 9 | import { TextGeneration } from "../Services/TextGeneration.ts"; |
| 10 | import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 10 | 11 | |
| 11 | 12 | const DEFAULT_TEST_MODEL_SELECTION = { |
| 12 | 13 | provider: "codex" as const, |
| ... | ... | @@ -14,6 +15,7 @@ const DEFAULT_TEST_MODEL_SELECTION = { |
| 14 | 15 | }; |
| 15 | 16 | |
| 16 | 17 | const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe( |
| 18 | Layer.provideMerge(ServerSettingsService.layerTest()), | |
| 17 | 19 | Layer.provideMerge( |
| 18 | 20 | ServerConfig.layerTest(process.cwd(), { |
| 19 | 21 | prefix: "t3code-codex-text-generation-test-", |
| ... | ... | @@ -22,7 +24,20 @@ const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe( |
| 22 | 24 | Layer.provideMerge(NodeServices.layer), |
| 23 | 25 | ); |
| 24 | 26 | |
| 25 | function makeFakeCodexBinary(dir: string) { | |
| 27 | function makeFakeCodexBinary( | |
| 28 | dir: string, | |
| 29 | input: { | |
| 30 | output: string; | |
| 31 | exitCode?: number; | |
| 32 | stderr?: string; | |
| 33 | requireImage?: boolean; | |
| 34 | requireFastServiceTier?: boolean; | |
| 35 | requireReasoningEffort?: string; | |
| 36 | forbidReasoningEffort?: boolean; | |
| 37 | stdinMustContain?: string; | |
| 38 | stdinMustNotContain?: string; | |
| 39 | }, | |
| 40 | ) { | |
| 26 | 41 | return Effect.gen(function* () { |
| 27 | 42 | const fs = yield* FileSystem.FileSystem; |
| 28 | 43 | const path = yield* Path.Path; |
| ... | ... | @@ -35,12 +50,16 @@ function makeFakeCodexBinary(dir: string) { |
| 35 | 50 | [ |
| 36 | 51 | "#!/bin/sh", |
| 37 | 52 | 'output_path=""', |
| 53 | 'seen_image="0"', | |
| 54 | 'seen_fast_service_tier="0"', | |
| 55 | 'seen_reasoning_effort=""', | |
| 38 | 56 | "while [ $# -gt 0 ]; do", |
| 39 | 57 | ' if [ "$1" = "--image" ]; then', |
| 40 | 58 | " shift", |
| 41 | 59 | ' if [ -n "$1" ]; then', |
| 42 | 60 | ' seen_image="1"', |
| 43 | 61 | " fi", |
| 62 | " shift", | |
| 44 | 63 | " continue", |
| 45 | 64 | " fi", |
| 46 | 65 | ' if [ "$1" = "--config" ]; then', |
| ... | ... | @@ -53,55 +72,80 @@ function makeFakeCodexBinary(dir: string) { |
| 53 | 72 | ' seen_reasoning_effort="$1"', |
| 54 | 73 | " ;;", |
| 55 | 74 | " esac", |
| 75 | " shift", | |
| 56 | 76 | " continue", |
| 57 | 77 | " fi", |
| 58 | 78 | ' if [ "$1" = "--output-last-message" ]; then', |
| 59 | 79 | " shift", |
| 60 | 80 | ' output_path="$1"', |
| 81 | " shift", | |
| 82 | " continue", | |
| 61 | 83 | " fi", |
| 62 | 84 | " shift", |
| 63 | 85 | "done", |
| 64 | 86 | 'stdin_content="$(cat)"', |
| 65 | 'if [ "$T3_FAKE_CODEX_REQUIRE_IMAGE" = "1" ] && [ "$seen_image" != "1" ]; then', | |
| 66 | ' printf "%s\\n" "missing --image input" >&2', | |
| 67 | " exit 2", | |
| 68 | "fi", | |
| 69 | 'if [ "$T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER" = "1" ] && [ "$seen_fast_service_tier" != "1" ]; then', | |
| 70 | ' printf "%s\\n" "missing fast service tier config" >&2', | |
| 71 | " exit 5", | |
| 72 | "fi", | |
| 73 | 'if [ -n "$T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT" ] && [ "$seen_reasoning_effort" != "model_reasoning_effort=\\"$T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT\\"" ]; then', | |
| 74 | ' printf "%s\\n" "unexpected reasoning effort config: $seen_reasoning_effort" >&2', | |
| 75 | " exit 6", | |
| 76 | "fi", | |
| 77 | 'if [ "$T3_FAKE_CODEX_FORBID_REASONING_EFFORT" = "1" ] && [ -n "$seen_reasoning_effort" ]; then', | |
| 78 | ' printf "%s\\n" "reasoning effort config should be omitted: $seen_reasoning_effort" >&2', | |
| 79 | " exit 7", | |
| 80 | "fi", | |
| 81 | 'if [ -n "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" ]; then', | |
| 82 | ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" >/dev/null || {', | |
| 83 | ' printf "%s\\n" "stdin missing expected content" >&2', | |
| 84 | " exit 3", | |
| 85 | " }", | |
| 86 | "fi", | |
| 87 | 'if [ -n "$T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN" ]; then', | |
| 88 | ' if printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN" >/dev/null; then', | |
| 89 | ' printf "%s\\n" "stdin contained forbidden content" >&2', | |
| 90 | " exit 4", | |
| 91 | " fi", | |
| 92 | "fi", | |
| 93 | 'if [ -n "$T3_FAKE_CODEX_STDERR" ]; then', | |
| 94 | ' printf "%s\\n" "$T3_FAKE_CODEX_STDERR" >&2', | |
| 95 | "fi", | |
| 87 | ...(input.requireImage | |
| 88 | ? [ | |
| 89 | 'if [ "$seen_image" != "1" ]; then', | |
| 90 | ' printf "%s\\n" "missing --image input" >&2', | |
| 91 | ` exit 2`, | |
| 92 | "fi", | |
| 93 | ] | |
| 94 | : []), | |
| 95 | ...(input.requireFastServiceTier | |
| 96 | ? [ | |
| 97 | 'if [ "$seen_fast_service_tier" != "1" ]; then', | |
| 98 | ' printf "%s\\n" "missing fast service tier config" >&2', | |
| 99 | ` exit 5`, | |
| 100 | "fi", | |
| 101 | ] | |
| 102 | : []), | |
| 103 | ...(input.requireReasoningEffort !== undefined | |
| 104 | ? [ | |
| 105 | `if [ "$seen_reasoning_effort" != "model_reasoning_effort=\\"${input.requireReasoningEffort}\\"" ]; then`, | |
| 106 | ' printf "%s\\n" "unexpected reasoning effort config: $seen_reasoning_effort" >&2', | |
| 107 | ` exit 6`, | |
| 108 | "fi", | |
| 109 | ] | |
| 110 | : []), | |
| 111 | ...(input.forbidReasoningEffort | |
| 112 | ? [ | |
| 113 | 'if [ -n "$seen_reasoning_effort" ]; then', | |
| 114 | ' printf "%s\\n" "reasoning effort config should be omitted: $seen_reasoning_effort" >&2', | |
| 115 | ` exit 7`, | |
| 116 | "fi", | |
| 117 | ] | |
| 118 | : []), | |
| 119 | ...(input.stdinMustContain !== undefined | |
| 120 | ? [ | |
| 121 | `if ! printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustContain)} >/dev/null; then`, | |
| 122 | ' printf "%s\\n" "stdin missing expected content" >&2', | |
| 123 | ` exit 3`, | |
| 124 | "fi", | |
| 125 | ] | |
| 126 | : []), | |
| 127 | ...(input.stdinMustNotContain !== undefined | |
| 128 | ? [ | |
| 129 | `if printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustNotContain)} >/dev/null; then`, | |
| 130 | ' printf "%s\\n" "stdin contained forbidden content" >&2', | |
| 131 | ` exit 4`, | |
| 132 | "fi", | |
| 133 | ] | |
| 134 | : []), | |
| 135 | ...(input.stderr !== undefined | |
| 136 | ? [`printf "%s\\n" ${JSON.stringify(input.stderr)} >&2`] | |
| 137 | : []), | |
| 96 | 138 | 'if [ -n "$output_path" ]; then', |
| 97 | ' 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=}"', | |
| 139 | " cat > \"$output_path\" <<'__T3CODE_FAKE_CODEX_OUTPUT__'", | |
| 140 | input.output, | |
| 141 | "__T3CODE_FAKE_CODEX_OUTPUT__", | |
| 98 | 142 | "fi", |
| 99 | 'exit "${T3_FAKE_CODEX_EXIT_CODE:-0}"', | |
| 143 | `exit ${input.exitCode ?? 0}`, | |
| 100 | 144 | "", |
| 101 | 145 | ].join("\n"), |
| 102 | 146 | ); |
| 103 | 147 | yield* fs.chmod(codexPath, 0o755); |
| 104 | return binDir; | |
| 148 | return codexPath; | |
| 105 | 149 | }); |
| 106 | 150 | } |
| 107 | 151 | |
| ... | ... | @@ -123,146 +167,29 @@ function withFakeCodexEnv<A, E, R>( |
| 123 | 167 | Effect.gen(function* () { |
| 124 | 168 | const fs = yield* FileSystem.FileSystem; |
| 125 | 169 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); |
| 126 | const binDir = yield* makeFakeCodexBinary(tempDir); | |
| 127 | const previousPath = process.env.PATH; | |
| 128 | const previousOutput = process.env.T3_FAKE_CODEX_OUTPUT_B64; | |
| 129 | const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE; | |
| 130 | const previousStderr = process.env.T3_FAKE_CODEX_STDERR; | |
| 131 | const previousRequireImage = process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; | |
| 132 | const previousRequireFastServiceTier = process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER; | |
| 133 | const previousRequireReasoningEffort = process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT; | |
| 134 | const previousForbidReasoningEffort = process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT; | |
| 135 | const previousStdinMustContain = process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN; | |
| 136 | const previousStdinMustNotContain = process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN; | |
| 137 | ||
| 138 | yield* Effect.sync(() => { | |
| 139 | process.env.PATH = `${binDir}:${previousPath ?? ""}`; | |
| 140 | process.env.T3_FAKE_CODEX_OUTPUT_B64 = Buffer.from(input.output, "utf8").toString("base64"); | |
| 141 | ||
| 142 | if (input.exitCode !== undefined) { | |
| 143 | process.env.T3_FAKE_CODEX_EXIT_CODE = String(input.exitCode); | |
| 144 | } else { | |
| 145 | delete process.env.T3_FAKE_CODEX_EXIT_CODE; | |
| 146 | } | |
| 147 | ||
| 148 | if (input.stderr !== undefined) { | |
| 149 | process.env.T3_FAKE_CODEX_STDERR = input.stderr; | |
| 150 | } else { | |
| 151 | delete process.env.T3_FAKE_CODEX_STDERR; | |
| 152 | } | |
| 153 | ||
| 154 | if (input.requireImage) { | |
| 155 | process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = "1"; | |
| 156 | } else { | |
| 157 | delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; | |
| 158 | } | |
| 159 | ||
| 160 | if (input.requireFastServiceTier) { | |
| 161 | process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER = "1"; | |
| 162 | } else { | |
| 163 | delete process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER; | |
| 164 | } | |
| 165 | ||
| 166 | if (input.requireReasoningEffort !== undefined) { | |
| 167 | process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT = input.requireReasoningEffort; | |
| 168 | } else { | |
| 169 | delete process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT; | |
| 170 | } | |
| 171 | ||
| 172 | if (input.forbidReasoningEffort) { | |
| 173 | process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT = "1"; | |
| 174 | } else { | |
| 175 | delete process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT; | |
| 176 | } | |
| 177 | ||
| 178 | if (input.stdinMustContain !== undefined) { | |
| 179 | process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN = input.stdinMustContain; | |
| 180 | } else { | |
| 181 | delete process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN; | |
| 182 | } | |
| 183 | ||
| 184 | if (input.stdinMustNotContain !== undefined) { | |
| 185 | process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN = input.stdinMustNotContain; | |
| 186 | } else { | |
| 187 | delete process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN; | |
| 188 | } | |
| 170 | const codexPath = yield* makeFakeCodexBinary(tempDir, input); | |
| 171 | const serverSettings = yield* ServerSettingsService; | |
| 172 | const previousSettings = yield* serverSettings.getSettings; | |
| 173 | yield* serverSettings.updateSettings({ | |
| 174 | providers: { | |
| 175 | codex: { | |
| 176 | binaryPath: codexPath, | |
| 177 | }, | |
| 178 | }, | |
| 189 | 179 | }); |
| 190 | ||
| 191 | return { | |
| 192 | previousPath, | |
| 193 | previousOutput, | |
| 194 | previousExitCode, | |
| 195 | previousStderr, | |
| 196 | previousRequireImage, | |
| 197 | previousRequireFastServiceTier, | |
| 198 | previousRequireReasoningEffort, | |
| 199 | previousForbidReasoningEffort, | |
| 200 | previousStdinMustContain, | |
| 201 | previousStdinMustNotContain, | |
| 202 | }; | |
| 180 | return { serverSettings, previousBinaryPath: previousSettings.providers.codex.binaryPath }; | |
| 203 | 181 | }), |
| 204 | 182 | () => effect, |
| 205 | (previous) => | |
| 206 | Effect.sync(() => { | |
| 207 | process.env.PATH = previous.previousPath; | |
| 208 | ||
| 209 | if (previous.previousOutput === undefined) { | |
| 210 | delete process.env.T3_FAKE_CODEX_OUTPUT_B64; | |
| 211 | } else { | |
| 212 | process.env.T3_FAKE_CODEX_OUTPUT_B64 = previous.previousOutput; | |
| 213 | } | |
| 214 | ||
| 215 | if (previous.previousExitCode === undefined) { | |
| 216 | delete process.env.T3_FAKE_CODEX_EXIT_CODE; | |
| 217 | } else { | |
| 218 | process.env.T3_FAKE_CODEX_EXIT_CODE = previous.previousExitCode; | |
| 219 | } | |
| 220 | ||
| 221 | if (previous.previousStderr === undefined) { | |
| 222 | delete process.env.T3_FAKE_CODEX_STDERR; | |
| 223 | } else { | |
| 224 | process.env.T3_FAKE_CODEX_STDERR = previous.previousStderr; | |
| 225 | } | |
| 226 | ||
| 227 | if (previous.previousRequireImage === undefined) { | |
| 228 | delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; | |
| 229 | } else { | |
| 230 | process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = previous.previousRequireImage; | |
| 231 | } | |
| 232 | ||
| 233 | if (previous.previousRequireFastServiceTier === undefined) { | |
| 234 | delete process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER; | |
| 235 | } else { | |
| 236 | process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER = | |
| 237 | previous.previousRequireFastServiceTier; | |
| 238 | } | |
| 239 | ||
| 240 | if (previous.previousRequireReasoningEffort === undefined) { | |
| 241 | delete process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT; | |
| 242 | } else { | |
| 243 | process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT = | |
| 244 | previous.previousRequireReasoningEffort; | |
| 245 | } | |
| 246 | ||
| 247 | if (previous.previousForbidReasoningEffort === undefined) { | |
| 248 | delete process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT; | |
| 249 | } else { | |
| 250 | process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT = | |
| 251 | previous.previousForbidReasoningEffort; | |
| 252 | } | |
| 253 | ||
| 254 | if (previous.previousStdinMustContain === undefined) { | |
| 255 | delete process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN; | |
| 256 | } else { | |
| 257 | process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN = previous.previousStdinMustContain; | |
| 258 | } | |
| 259 | ||
| 260 | if (previous.previousStdinMustNotContain === undefined) { | |
| 261 | delete process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN; | |
| 262 | } else { | |
| 263 | process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN = previous.previousStdinMustNotContain; | |
| 264 | } | |
| 265 | }), | |
| 183 | ({ serverSettings, previousBinaryPath }) => | |
| 184 | serverSettings | |
| 185 | .updateSettings({ | |
| 186 | providers: { | |
| 187 | codex: { | |
| 188 | binaryPath: previousBinaryPath, | |
| 189 | }, | |
| 190 | }, | |
| 191 | }) | |
| 192 | .pipe(Effect.asVoid), | |
| 266 | 193 | ); |
| 267 | 194 | } |
| 268 | 195 |
apps/server/src/git/Layers/CodexTextGeneration.ts+13-2| ... | ... | @@ -4,7 +4,6 @@ import { Effect, FileSystem, Layer, Option, Path, Schema, Scope, Stream } from " |
| 4 | 4 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 5 | 5 | |
| 6 | 6 | import { CodexModelSelection } from "@t3tools/contracts"; |
| 7 | import { normalizeCodexModelOptions } from "@t3tools/shared/model"; | |
| 8 | 7 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 9 | 8 | |
| 10 | 9 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| ... | ... | @@ -26,6 +25,8 @@ import { |
| 26 | 25 | sanitizePrTitle, |
| 27 | 26 | toJsonSchemaObject, |
| 28 | 27 | } from "../Utils.ts"; |
| 28 | import { normalizeCodexModelOptions } from "../../provider/Layers/CodexProvider.ts"; | |
| 29 | import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 29 | 30 | |
| 30 | 31 | const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; |
| 31 | 32 | const CODEX_TIMEOUT_MS = 180_000; |
| ... | ... | @@ -35,6 +36,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 35 | 36 | const path = yield* Path.Path; |
| 36 | 37 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 37 | 38 | const serverConfig = yield* Effect.service(ServerConfig); |
| 39 | const serverSettingsService = yield* Effect.service(ServerSettingsService); | |
| 38 | 40 | |
| 39 | 41 | type MaterializedImageAttachments = { |
| 40 | 42 | readonly imagePaths: ReadonlyArray<string>; |
| ... | ... | @@ -138,6 +140,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 138 | 140 | ); |
| 139 | 141 | const outputPath = yield* writeTempFile(operation, "codex-output", ""); |
| 140 | 142 | |
| 143 | const codexSettings = yield* Effect.map( | |
| 144 | serverSettingsService.getSettings, | |
| 145 | (settings) => settings.providers.codex, | |
| 146 | ).pipe(Effect.catch(() => Effect.undefined)); | |
| 147 | ||
| 141 | 148 | const runCodexCommand = Effect.gen(function* () { |
| 142 | 149 | const normalizedOptions = normalizeCodexModelOptions( |
| 143 | 150 | modelSelection.model, |
| ... | ... | @@ -146,7 +153,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 146 | 153 | const reasoningEffort = |
| 147 | 154 | modelSelection.options?.reasoningEffort ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; |
| 148 | 155 | const command = ChildProcess.make( |
| 149 | "codex", | |
| 156 | codexSettings?.binaryPath || "codex", | |
| 150 | 157 | [ |
| 151 | 158 | "exec", |
| 152 | 159 | "--ephemeral", |
| ... | ... | @@ -165,6 +172,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 165 | 172 | "-", |
| 166 | 173 | ], |
| 167 | 174 | { |
| 175 | env: { | |
| 176 | ...process.env, | |
| 177 | ...(codexSettings?.homePath ? { CODEX_HOME: codexSettings.homePath } : {}), | |
| 178 | }, | |
| 168 | 179 | cwd, |
| 169 | 180 | shell: process.platform === "win32", |
| 170 | 181 | stdin: { |
apps/server/src/git/Layers/GitManager.test.ts+4-6| ... | ... | @@ -20,11 +20,7 @@ import { GitCoreLive } from "./GitCore.ts"; |
| 20 | 20 | import { GitCore } from "../Services/GitCore.ts"; |
| 21 | 21 | import { makeGitManager } from "./GitManager.ts"; |
| 22 | 22 | import { ServerConfig } from "../../config.ts"; |
| 23 | ||
| 24 | const DEFAULT_TEST_MODEL_SELECTION = { | |
| 25 | provider: "codex", | |
| 26 | model: "gpt-5.4-mini", | |
| 27 | } as const; | |
| 23 | import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 28 | 24 | |
| 29 | 25 | interface FakeGhScenario { |
| 30 | 26 | prListSequence?: string[]; |
| ... | ... | @@ -466,7 +462,6 @@ function runStackedAction( |
| 466 | 462 | { |
| 467 | 463 | ...input, |
| 468 | 464 | actionId: input.actionId ?? "test-action-id", |
| 469 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 470 | 465 | }, |
| 471 | 466 | options, |
| 472 | 467 | ); |
| ... | ... | @@ -493,6 +488,8 @@ function makeManager(input?: { |
| 493 | 488 | prefix: "t3-git-manager-test-", |
| 494 | 489 | }); |
| 495 | 490 | |
| 491 | const serverSettingsLayer = ServerSettingsService.layerTest(); | |
| 492 | ||
| 496 | 493 | const gitCoreLayer = GitCoreLive.pipe( |
| 497 | 494 | Layer.provideMerge(NodeServices.layer), |
| 498 | 495 | Layer.provideMerge(ServerConfigLayer), |
| ... | ... | @@ -502,6 +499,7 @@ function makeManager(input?: { |
| 502 | 499 | Layer.succeed(GitHubCli, gitHubCli), |
| 503 | 500 | Layer.succeed(TextGeneration, textGeneration), |
| 504 | 501 | gitCoreLayer, |
| 502 | serverSettingsLayer, | |
| 505 | 503 | ).pipe(Layer.provideMerge(NodeServices.layer)); |
| 506 | 504 | |
| 507 | 505 | return makeGitManager.pipe( |
apps/server/src/git/Layers/GitManager.ts+12-3| ... | ... | @@ -19,6 +19,7 @@ import { |
| 19 | 19 | import { GitCore } from "../Services/GitCore.ts"; |
| 20 | 20 | import { GitHubCli } from "../Services/GitHubCli.ts"; |
| 21 | 21 | import { TextGeneration } from "../Services/TextGeneration.ts"; |
| 22 | import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 22 | 23 | |
| 23 | 24 | const COMMIT_TIMEOUT_MS = 10 * 60_000; |
| 24 | 25 | const MAX_PROGRESS_TEXT_LENGTH = 500; |
| ... | ... | @@ -358,6 +359,7 @@ export const makeGitManager = Effect.gen(function* () { |
| 358 | 359 | const gitCore = yield* GitCore; |
| 359 | 360 | const gitHubCli = yield* GitHubCli; |
| 360 | 361 | const textGeneration = yield* TextGeneration; |
| 362 | const serverSettingsService = yield* ServerSettingsService; | |
| 361 | 363 | |
| 362 | 364 | const createProgressEmitter = ( |
| 363 | 365 | input: { cwd: string; action: "commit" | "commit_push" | "commit_push_pr" }, |
| ... | ... | @@ -1173,6 +1175,13 @@ export const makeGitManager = Effect.gen(function* () { |
| 1173 | 1175 | let commitMessageForStep = input.commitMessage; |
| 1174 | 1176 | let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; |
| 1175 | 1177 | |
| 1178 | const modelSelection = yield* serverSettingsService.getSettings.pipe( | |
| 1179 | Effect.map((settings) => settings.textGenerationModelSelection), | |
| 1180 | Effect.mapError((cause) => | |
| 1181 | gitManagerError("runStackedAction", "Failed to get server settings.", cause), | |
| 1182 | ), | |
| 1183 | ); | |
| 1184 | ||
| 1176 | 1185 | if (input.featureBranch) { |
| 1177 | 1186 | currentPhase = "branch"; |
| 1178 | 1187 | yield* progress.emit({ |
| ... | ... | @@ -1181,7 +1190,7 @@ export const makeGitManager = Effect.gen(function* () { |
| 1181 | 1190 | label: "Preparing feature branch...", |
| 1182 | 1191 | }); |
| 1183 | 1192 | const result = yield* runFeatureBranchStep( |
| 1184 | input.modelSelection, | |
| 1193 | modelSelection, | |
| 1185 | 1194 | input.cwd, |
| 1186 | 1195 | initialStatus.branch, |
| 1187 | 1196 | input.commitMessage, |
| ... | ... | @@ -1198,7 +1207,7 @@ export const makeGitManager = Effect.gen(function* () { |
| 1198 | 1207 | |
| 1199 | 1208 | currentPhase = "commit"; |
| 1200 | 1209 | const commit = yield* runCommitStep( |
| 1201 | input.modelSelection, | |
| 1210 | modelSelection, | |
| 1202 | 1211 | input.cwd, |
| 1203 | 1212 | input.action, |
| 1204 | 1213 | currentBranch, |
| ... | ... | @@ -1237,7 +1246,7 @@ export const makeGitManager = Effect.gen(function* () { |
| 1237 | 1246 | Effect.flatMap(() => |
| 1238 | 1247 | Effect.gen(function* () { |
| 1239 | 1248 | currentPhase = "pr"; |
| 1240 | return yield* runPrStep(input.modelSelection, input.cwd, currentBranch); | |
| 1249 | return yield* runPrStep(modelSelection, input.cwd, currentBranch); | |
| 1241 | 1250 | }), |
| 1242 | 1251 | ), |
| 1243 | 1252 | ) |
apps/server/src/keybindings.ts+23-11| ... | ... | @@ -23,6 +23,7 @@ import { |
| 23 | 23 | Cache, |
| 24 | 24 | Cause, |
| 25 | 25 | Deferred, |
| 26 | Duration, | |
| 26 | 27 | Effect, |
| 27 | 28 | Exit, |
| 28 | 29 | FileSystem, |
| ... | ... | @@ -42,6 +43,7 @@ import { |
| 42 | 43 | } from "effect"; |
| 43 | 44 | import * as Semaphore from "effect/Semaphore"; |
| 44 | 45 | import { ServerConfig } from "./config"; |
| 46 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; | |
| 45 | 47 | |
| 46 | 48 | export class KeybindingsConfigError extends Schema.TaggedErrorClass<KeybindingsConfigError>()( |
| 47 | 49 | "KeybindingsConfigParseError", |
| ... | ... | @@ -408,7 +410,7 @@ function encodeWhenAst(node: KeybindingWhenNode): string { |
| 408 | 410 | |
| 409 | 411 | const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS); |
| 410 | 412 | |
| 411 | const RawKeybindingsEntries = Schema.fromJsonString(Schema.Array(Schema.Unknown)); | |
| 413 | const RawKeybindingsEntries = fromLenientJson(Schema.Array(Schema.Unknown)); | |
| 412 | 414 | const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); |
| 413 | 415 | const PrettyJsonString = SchemaGetter.parseJson<string>().compose( |
| 414 | 416 | SchemaGetter.stringifyJson({ space: 2 }), |
| ... | ... | @@ -672,6 +674,7 @@ const makeKeybindings = Effect.gen(function* () { |
| 672 | 674 | Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })), |
| 673 | 675 | Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), |
| 674 | 676 | Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)), |
| 677 | Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))), | |
| 675 | 678 | Effect.mapError( |
| 676 | 679 | (cause) => |
| 677 | 680 | new KeybindingsConfigError({ |
| ... | ... | @@ -817,16 +820,25 @@ const makeKeybindings = Effect.gen(function* () { |
| 817 | 820 | |
| 818 | 821 | const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); |
| 819 | 822 | |
| 820 | yield* Stream.runForEach(fs.watch(keybindingsConfigDir), (event) => { | |
| 821 | const isTargetConfigEvent = | |
| 822 | event.path === keybindingsConfigFile || | |
| 823 | event.path === keybindingsConfigPath || | |
| 824 | path.resolve(keybindingsConfigDir, event.path) === keybindingsConfigPathResolved; | |
| 825 | if (!isTargetConfigEvent) { | |
| 826 | return Effect.void; | |
| 827 | } | |
| 828 | return revalidateAndEmitSafely; | |
| 829 | }).pipe(Effect.ignoreCause({ log: true }), Effect.forkIn(watcherScope), Effect.asVoid); | |
| 823 | // Debounce watch events so the file is fully written before we read it. | |
| 824 | // Editors emit multiple events per save (truncate, write, rename) and | |
| 825 | // `fs.watch` can fire before the content has been flushed to disk. | |
| 826 | const debouncedKeybindingsEvents = fs.watch(keybindingsConfigDir).pipe( | |
| 827 | Stream.filter((event) => { | |
| 828 | return ( | |
| 829 | event.path === keybindingsConfigFile || | |
| 830 | event.path === keybindingsConfigPath || | |
| 831 | path.resolve(keybindingsConfigDir, event.path) === keybindingsConfigPathResolved | |
| 832 | ); | |
| 833 | }), | |
| 834 | Stream.debounce(Duration.millis(100)), | |
| 835 | ); | |
| 836 | ||
| 837 | yield* Stream.runForEach(debouncedKeybindingsEvents, () => revalidateAndEmitSafely).pipe( | |
| 838 | Effect.ignoreCause({ log: true }), | |
| 839 | Effect.forkIn(watcherScope), | |
| 840 | Effect.asVoid, | |
| 841 | ); | |
| 830 | 842 | }); |
| 831 | 843 | |
| 832 | 844 | const start = Effect.gen(function* () { |
apps/server/src/main.test.ts+2| ... | ... | @@ -17,6 +17,7 @@ import { Open, type OpenShape } from "./open"; |
| 17 | 17 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; |
| 18 | 18 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; |
| 19 | 19 | import { Server, type ServerShape } from "./wsServer"; |
| 20 | import { ServerSettingsService } from "./serverSettings"; | |
| 20 | 21 | |
| 21 | 22 | const start = vi.fn(() => undefined); |
| 22 | 23 | const stop = vi.fn(() => undefined); |
| ... | ... | @@ -52,6 +53,7 @@ const testLayer = Layer.mergeAll( |
| 52 | 53 | openBrowser: (_target: string) => Effect.void, |
| 53 | 54 | openInEditor: () => Effect.void, |
| 54 | 55 | } satisfies OpenShape), |
| 56 | ServerSettingsService.layerTest(), | |
| 55 | 57 | AnalyticsService.layerTest, |
| 56 | 58 | FetchHttpClient.layer, |
| 57 | 59 | NodeServices.layer, |
apps/server/src/main.ts+12-5| ... | ... | @@ -22,12 +22,13 @@ import { Open } from "./open"; |
| 22 | 22 | import * as SqlitePersistence from "./persistence/Layers/Sqlite"; |
| 23 | 23 | import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; |
| 24 | 24 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; |
| 25 | import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; | |
| 25 | import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry"; | |
| 26 | 26 | import { Server } from "./wsServer"; |
| 27 | 27 | import { ServerLoggerLive } from "./serverLogger"; |
| 28 | 28 | import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; |
| 29 | 29 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; |
| 30 | 30 | import { readBootstrapEnvelope } from "./bootstrap"; |
| 31 | import { ServerSettingsLive } from "./serverSettings"; | |
| 31 | 32 | |
| 32 | 33 | export class StartupError extends Data.TaggedError("StartupError")<{ |
| 33 | 34 | readonly message: string; |
| ... | ... | @@ -293,10 +294,11 @@ const LayerLive = (input: CliInput) => |
| 293 | 294 | Layer.empty.pipe( |
| 294 | 295 | Layer.provideMerge(makeServerRuntimeServicesLayer()), |
| 295 | 296 | Layer.provideMerge(makeServerProviderLayer()), |
| 296 | Layer.provideMerge(ProviderHealthLive), | |
| 297 | Layer.provideMerge(ProviderRegistryLive), | |
| 297 | 298 | Layer.provideMerge(SqlitePersistence.layerConfig), |
| 298 | 299 | Layer.provideMerge(ServerLoggerLive), |
| 299 | 300 | Layer.provideMerge(AnalyticsServiceLayerLive), |
| 301 | Layer.provideMerge(ServerSettingsLive), | |
| 300 | 302 | Layer.provideMerge(ServerConfigLive(input)), |
| 301 | 303 | ); |
| 302 | 304 | |
| ... | ... | @@ -331,12 +333,10 @@ export const recordStartupHeartbeat = Effect.gen(function* () { |
| 331 | 333 | }); |
| 332 | 334 | }); |
| 333 | 335 | |
| 334 | const makeServerProgram = (input: CliInput) => | |
| 336 | const makeServerRuntimeProgram = (input: CliInput) => | |
| 335 | 337 | Effect.gen(function* () { |
| 336 | const cliConfig = yield* CliConfig; | |
| 337 | 338 | const { start, stopSignal } = yield* Server; |
| 338 | 339 | const openDeps = yield* Open; |
| 339 | yield* cliConfig.fixPath; | |
| 340 | 340 | |
| 341 | 341 | const config = yield* ServerConfig; |
| 342 | 342 | |
| ... | ... | @@ -378,6 +378,13 @@ const makeServerProgram = (input: CliInput) => |
| 378 | 378 | return yield* stopSignal; |
| 379 | 379 | }).pipe(Effect.provide(LayerLive(input))); |
| 380 | 380 | |
| 381 | const makeServerProgram = (input: CliInput) => | |
| 382 | Effect.gen(function* () { | |
| 383 | const cliConfig = yield* CliConfig; | |
| 384 | yield* cliConfig.fixPath; | |
| 385 | return yield* makeServerRuntimeProgram(input); | |
| 386 | }); | |
| 387 | ||
| 381 | 388 | /** |
| 382 | 389 | * These flags mirrors the environment variables and the config shape. |
| 383 | 390 | */ |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+2| ... | ... | @@ -34,6 +34,7 @@ import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts"; |
| 34 | 34 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 35 | 35 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; |
| 36 | 36 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 37 | import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 37 | 38 | |
| 38 | 39 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 39 | 40 | const asApprovalRequestId = (value: string): ApprovalRequestId => |
| ... | ... | @@ -214,6 +215,7 @@ describe("ProviderCommandReactor", () => { |
| 214 | 215 | Layer.provideMerge( |
| 215 | 216 | Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape), |
| 216 | 217 | ), |
| 218 | Layer.provideMerge(ServerSettingsService.layerTest()), | |
| 217 | 219 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), |
| 218 | 220 | Layer.provideMerge(NodeServices.layer), |
| 219 | 221 | ); |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+41-63| ... | ... | @@ -1,12 +1,10 @@ |
| 1 | 1 | import { |
| 2 | 2 | type ChatAttachment, |
| 3 | 3 | CommandId, |
| 4 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, | |
| 5 | 4 | EventId, |
| 6 | 5 | type ModelSelection, |
| 7 | 6 | type OrchestrationEvent, |
| 8 | 7 | ProviderKind, |
| 9 | type ProviderStartOptions, | |
| 10 | 8 | type OrchestrationSession, |
| 11 | 9 | ThreadId, |
| 12 | 10 | type ProviderSession, |
| ... | ... | @@ -26,6 +24,7 @@ import { |
| 26 | 24 | ProviderCommandReactor, |
| 27 | 25 | type ProviderCommandReactorShape, |
| 28 | 26 | } from "../Services/ProviderCommandReactor.ts"; |
| 27 | import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 29 | 28 | |
| 30 | 29 | type ProviderIntentEvent = Extract< |
| 31 | 30 | OrchestrationEvent, |
| ... | ... | @@ -138,6 +137,7 @@ const make = Effect.gen(function* () { |
| 138 | 137 | const providerService = yield* ProviderService; |
| 139 | 138 | const git = yield* GitCore; |
| 140 | 139 | const textGeneration = yield* TextGeneration; |
| 140 | const serverSettingsService = yield* ServerSettingsService; | |
| 141 | 141 | const handledTurnStartKeys = yield* Cache.make<string, true>({ |
| 142 | 142 | capacity: HANDLED_TURN_START_KEY_MAX, |
| 143 | 143 | timeToLive: HANDLED_TURN_START_KEY_TTL, |
| ... | ... | @@ -151,7 +151,6 @@ const make = Effect.gen(function* () { |
| 151 | 151 | ), |
| 152 | 152 | ); |
| 153 | 153 | |
| 154 | const threadProviderOptions = new Map<string, ProviderStartOptions>(); | |
| 155 | 154 | const threadModelSelections = new Map<string, ModelSelection>(); |
| 156 | 155 | |
| 157 | 156 | const appendProviderFailureActivity = (input: { |
| ... | ... | @@ -210,7 +209,6 @@ const make = Effect.gen(function* () { |
| 210 | 209 | createdAt: string, |
| 211 | 210 | options?: { |
| 212 | 211 | readonly modelSelection?: ModelSelection; |
| 213 | readonly providerOptions?: ProviderStartOptions; | |
| 214 | 212 | }, |
| 215 | 213 | ) { |
| 216 | 214 | const readModel = yield* orchestrationEngine.getReadModel(); |
| ... | ... | @@ -258,9 +256,6 @@ const make = Effect.gen(function* () { |
| 258 | 256 | ...(preferredProvider ? { provider: preferredProvider } : {}), |
| 259 | 257 | ...(effectiveCwd ? { cwd: effectiveCwd } : {}), |
| 260 | 258 | modelSelection: desiredModelSelection, |
| 261 | ...(options?.providerOptions !== undefined | |
| 262 | ? { providerOptions: options.providerOptions } | |
| 263 | : {}), | |
| 264 | 259 | ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), |
| 265 | 260 | runtimeMode: desiredRuntimeMode, |
| 266 | 261 | }); |
| ... | ... | @@ -354,7 +349,6 @@ const make = Effect.gen(function* () { |
| 354 | 349 | readonly messageText: string; |
| 355 | 350 | readonly attachments?: ReadonlyArray<ChatAttachment>; |
| 356 | 351 | readonly modelSelection?: ModelSelection; |
| 357 | readonly providerOptions?: ProviderStartOptions; | |
| 358 | 352 | readonly interactionMode?: "default" | "plan"; |
| 359 | 353 | readonly createdAt: string; |
| 360 | 354 | }) { |
| ... | ... | @@ -362,13 +356,11 @@ const make = Effect.gen(function* () { |
| 362 | 356 | if (!thread) { |
| 363 | 357 | return; |
| 364 | 358 | } |
| 365 | yield* ensureSessionForThread(input.threadId, input.createdAt, { | |
| 366 | ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), | |
| 367 | ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), | |
| 368 | }); | |
| 369 | if (input.providerOptions !== undefined) { | |
| 370 | threadProviderOptions.set(input.threadId, input.providerOptions); | |
| 371 | } | |
| 359 | yield* ensureSessionForThread( | |
| 360 | input.threadId, | |
| 361 | input.createdAt, | |
| 362 | input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}, | |
| 363 | ); | |
| 372 | 364 | if (input.modelSelection !== undefined) { |
| 373 | 365 | threadModelSelections.set(input.threadId, input.modelSelection); |
| 374 | 366 | } |
| ... | ... | @@ -432,48 +424,39 @@ const make = Effect.gen(function* () { |
| 432 | 424 | const oldBranch = input.branch; |
| 433 | 425 | const cwd = input.worktreePath; |
| 434 | 426 | const attachments = input.attachments ?? []; |
| 435 | yield* textGeneration | |
| 436 | .generateBranchName({ | |
| 427 | yield* Effect.gen(function* () { | |
| 428 | const { textGenerationModelSelection: modelSelection } = | |
| 429 | yield* serverSettingsService.getSettings; | |
| 430 | ||
| 431 | const generated = yield* textGeneration.generateBranchName({ | |
| 437 | 432 | cwd, |
| 438 | 433 | message: input.messageText, |
| 439 | 434 | ...(attachments.length > 0 ? { attachments } : {}), |
| 440 | modelSelection: { | |
| 441 | provider: "codex", | |
| 442 | model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, | |
| 443 | }, | |
| 444 | }) | |
| 445 | .pipe( | |
| 446 | Effect.catch((error) => | |
| 447 | Effect.logWarning( | |
| 448 | "provider command reactor failed to generate worktree branch name; skipping rename", | |
| 449 | { threadId: input.threadId, cwd, oldBranch, reason: error.message }, | |
| 450 | ), | |
| 451 | ), | |
| 452 | Effect.flatMap((generated) => { | |
| 453 | if (!generated) return Effect.void; | |
| 454 | ||
| 455 | const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); | |
| 456 | if (targetBranch === oldBranch) return Effect.void; | |
| 457 | ||
| 458 | return Effect.flatMap( | |
| 459 | git.renameBranch({ cwd, oldBranch, newBranch: targetBranch }), | |
| 460 | (renamed) => | |
| 461 | orchestrationEngine.dispatch({ | |
| 462 | type: "thread.meta.update", | |
| 463 | commandId: serverCommandId("worktree-branch-rename"), | |
| 464 | threadId: input.threadId, | |
| 465 | branch: renamed.branch, | |
| 466 | worktreePath: cwd, | |
| 467 | }), | |
| 468 | ); | |
| 435 | modelSelection, | |
| 436 | }); | |
| 437 | if (!generated) return; | |
| 438 | ||
| 439 | const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); | |
| 440 | if (targetBranch === oldBranch) return; | |
| 441 | ||
| 442 | const renamed = yield* git.renameBranch({ cwd, oldBranch, newBranch: targetBranch }); | |
| 443 | yield* orchestrationEngine.dispatch({ | |
| 444 | type: "thread.meta.update", | |
| 445 | commandId: serverCommandId("worktree-branch-rename"), | |
| 446 | threadId: input.threadId, | |
| 447 | branch: renamed.branch, | |
| 448 | worktreePath: cwd, | |
| 449 | }); | |
| 450 | }).pipe( | |
| 451 | Effect.catchCause((cause) => | |
| 452 | Effect.logWarning("provider command reactor failed to generate or rename worktree branch", { | |
| 453 | threadId: input.threadId, | |
| 454 | cwd, | |
| 455 | oldBranch, | |
| 456 | cause: Cause.pretty(cause), | |
| 469 | 457 | }), |
| 470 | Effect.catchCause((cause) => | |
| 471 | Effect.logWarning( | |
| 472 | "provider command reactor failed to generate or rename worktree branch", | |
| 473 | { threadId: input.threadId, cwd, oldBranch, cause: Cause.pretty(cause) }, | |
| 474 | ), | |
| 475 | ), | |
| 476 | ); | |
| 458 | ), | |
| 459 | ); | |
| 477 | 460 | }); |
| 478 | 461 | |
| 479 | 462 | const processTurnStartRequested = Effect.fnUntraced(function* ( |
| ... | ... | @@ -518,9 +501,6 @@ const make = Effect.gen(function* () { |
| 518 | 501 | ...(event.payload.modelSelection !== undefined |
| 519 | 502 | ? { modelSelection: event.payload.modelSelection } |
| 520 | 503 | : {}), |
| 521 | ...(event.payload.providerOptions !== undefined | |
| 522 | ? { providerOptions: event.payload.providerOptions } | |
| 523 | : {}), | |
| 524 | 504 | interactionMode: event.payload.interactionMode, |
| 525 | 505 | createdAt: event.payload.createdAt, |
| 526 | 506 | }).pipe( |
| ... | ... | @@ -686,14 +666,12 @@ const make = Effect.gen(function* () { |
| 686 | 666 | if (!thread?.session || thread.session.status === "stopped") { |
| 687 | 667 | return; |
| 688 | 668 | } |
| 689 | const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId); | |
| 690 | 669 | const cachedModelSelection = threadModelSelections.get(event.payload.threadId); |
| 691 | yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, { | |
| 692 | ...(cachedProviderOptions !== undefined | |
| 693 | ? { providerOptions: cachedProviderOptions } | |
| 694 | : {}), | |
| 695 | ...(cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {}), | |
| 696 | }); | |
| 670 | yield* ensureSessionForThread( | |
| 671 | event.payload.threadId, | |
| 672 | event.occurredAt, | |
| 673 | cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {}, | |
| 674 | ); | |
| 697 | 675 | return; |
| 698 | 676 | } |
| 699 | 677 | case "thread.turn-start-requested": |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+9-3| ... | ... | @@ -16,6 +16,7 @@ import { |
| 16 | 16 | MessageId, |
| 17 | 17 | ProjectId, |
| 18 | 18 | ProviderItemId, |
| 19 | type ServerSettings, | |
| 19 | 20 | ThreadId, |
| 20 | 21 | TurnId, |
| 21 | 22 | } from "@t3tools/contracts"; |
| ... | ... | @@ -38,8 +39,13 @@ import { |
| 38 | 39 | } from "../Services/OrchestrationEngine.ts"; |
| 39 | 40 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; |
| 40 | 41 | import { ServerConfig } from "../../config.ts"; |
| 42 | import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 41 | 43 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 42 | 44 | |
| 45 | function makeTestServerSettingsLayer(overrides: Partial<ServerSettings> = {}) { | |
| 46 | return ServerSettingsService.layerTest(overrides); | |
| 47 | } | |
| 48 | ||
| 43 | 49 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 44 | 50 | const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); |
| 45 | 51 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| ... | ... | @@ -155,7 +161,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 155 | 161 | } |
| 156 | 162 | }); |
| 157 | 163 | |
| 158 | async function createHarness() { | |
| 164 | async function createHarness(options?: { serverSettings?: Partial<ServerSettings> }) { | |
| 159 | 165 | const workspaceRoot = makeTempDir("t3-provider-project-"); |
| 160 | 166 | fs.mkdirSync(path.join(workspaceRoot, ".git")); |
| 161 | 167 | const provider = createProviderServiceHarness(); |
| ... | ... | @@ -169,6 +175,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 169 | 175 | Layer.provideMerge(orchestrationLayer), |
| 170 | 176 | Layer.provideMerge(SqlitePersistenceMemory), |
| 171 | 177 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), |
| 178 | Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), | |
| 172 | 179 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| 173 | 180 | Layer.provideMerge(NodeServices.layer), |
| 174 | 181 | ); |
| ... | ... | @@ -1357,7 +1364,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1357 | 1364 | }); |
| 1358 | 1365 | |
| 1359 | 1366 | it("streams assistant deltas when thread.turn.start requests streaming mode", async () => { |
| 1360 | const harness = await createHarness(); | |
| 1367 | const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); | |
| 1361 | 1368 | const now = new Date().toISOString(); |
| 1362 | 1369 | |
| 1363 | 1370 | await Effect.runPromise( |
| ... | ... | @@ -1371,7 +1378,6 @@ describe("ProviderRuntimeIngestion", () => { |
| 1371 | 1378 | text: "stream please", |
| 1372 | 1379 | attachments: [], |
| 1373 | 1380 | }, |
| 1374 | assistantDeliveryMode: "streaming", | |
| 1375 | 1381 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 1376 | 1382 | runtimeMode: "approval-required", |
| 1377 | 1383 | createdAt: now, |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+8-12| ... | ... | @@ -13,7 +13,7 @@ import { |
| 13 | 13 | type OrchestrationThreadActivity, |
| 14 | 14 | type ProviderRuntimeEvent, |
| 15 | 15 | } from "@t3tools/contracts"; |
| 16 | import { Cache, Cause, Duration, Effect, Layer, Option, Ref, Stream } from "effect"; | |
| 16 | import { Cache, Cause, Duration, Effect, Layer, Option, Stream } from "effect"; | |
| 17 | 17 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; |
| 18 | 18 | |
| 19 | 19 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| ... | ... | @@ -26,12 +26,12 @@ import { |
| 26 | 26 | ProviderRuntimeIngestionService, |
| 27 | 27 | type ProviderRuntimeIngestionShape, |
| 28 | 28 | } from "../Services/ProviderRuntimeIngestion.ts"; |
| 29 | import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 29 | 30 | |
| 30 | 31 | const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; |
| 31 | 32 | const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId => |
| 32 | 33 | CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`); |
| 33 | 34 | |
| 34 | const DEFAULT_ASSISTANT_DELIVERY_MODE: AssistantDeliveryMode = "buffered"; | |
| 35 | 35 | const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000; |
| 36 | 36 | const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120); |
| 37 | 37 | const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; |
| ... | ... | @@ -537,10 +537,7 @@ const make = Effect.gen(function* () { |
| 537 | 537 | const orchestrationEngine = yield* OrchestrationEngineService; |
| 538 | 538 | const providerService = yield* ProviderService; |
| 539 | 539 | const projectionTurnRepository = yield* ProjectionTurnRepository; |
| 540 | ||
| 541 | const assistantDeliveryModeRef = yield* Ref.make<AssistantDeliveryMode>( | |
| 542 | DEFAULT_ASSISTANT_DELIVERY_MODE, | |
| 543 | ); | |
| 540 | const serverSettingsService = yield* ServerSettingsService; | |
| 544 | 541 | |
| 545 | 542 | const turnMessageIdsByTurnKey = yield* Cache.make<string, Set<MessageId>>({ |
| 546 | 543 | capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY, |
| ... | ... | @@ -1048,7 +1045,10 @@ const make = Effect.gen(function* () { |
| 1048 | 1045 | yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); |
| 1049 | 1046 | } |
| 1050 | 1047 | |
| 1051 | const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef); | |
| 1048 | const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( | |
| 1049 | serverSettingsService.getSettings, | |
| 1050 | (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"), | |
| 1051 | ); | |
| 1052 | 1052 | if (assistantDeliveryMode === "buffered") { |
| 1053 | 1053 | const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); |
| 1054 | 1054 | if (spillChunk.length > 0) { |
| ... | ... | @@ -1256,11 +1256,7 @@ const make = Effect.gen(function* () { |
| 1256 | 1256 | ).pipe(Effect.asVoid); |
| 1257 | 1257 | }); |
| 1258 | 1258 | |
| 1259 | const processDomainEvent = (event: TurnStartRequestedDomainEvent) => | |
| 1260 | Ref.set( | |
| 1261 | assistantDeliveryModeRef, | |
| 1262 | event.payload.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, | |
| 1263 | ); | |
| 1259 | const processDomainEvent = (_event: TurnStartRequestedDomainEvent) => Effect.void; | |
| 1264 | 1260 | |
| 1265 | 1261 | const processInput = (input: RuntimeIngestionInput) => |
| 1266 | 1262 | input.source === "runtime" ? processRuntimeEvent(input.event) : processDomainEvent(input.event); |
apps/server/src/orchestration/decider.projectScripts.test.ts-1| ... | ... | @@ -188,7 +188,6 @@ describe("decider project scripts", () => { |
| 188 | 188 | if (turnStartEvent?.type !== "thread.turn-start-requested") { |
| 189 | 189 | return; |
| 190 | 190 | } |
| 191 | expect(turnStartEvent.payload.assistantDeliveryMode).toBe("buffered"); | |
| 192 | 191 | expect(turnStartEvent.payload).toMatchObject({ |
| 193 | 192 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 194 | 193 | messageId: asMessageId("message-user-1"), |
apps/server/src/orchestration/decider.ts-5| ... | ... | @@ -14,7 +14,6 @@ import { |
| 14 | 14 | } from "./commandInvariants.ts"; |
| 15 | 15 | |
| 16 | 16 | const nowIso = () => new Date().toISOString(); |
| 17 | const DEFAULT_ASSISTANT_DELIVERY_MODE = "buffered" as const; | |
| 18 | 17 | |
| 19 | 18 | const defaultMetadata: Omit<OrchestrationEvent, "sequence" | "type" | "payload"> = { |
| 20 | 19 | eventId: crypto.randomUUID() as OrchestrationEvent["eventId"], |
| ... | ... | @@ -330,10 +329,6 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 330 | 329 | ...(command.modelSelection !== undefined |
| 331 | 330 | ? { modelSelection: command.modelSelection } |
| 332 | 331 | : {}), |
| 333 | ...(command.providerOptions !== undefined | |
| 334 | ? { providerOptions: command.providerOptions } | |
| 335 | : {}), | |
| 336 | assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, | |
| 337 | 332 | runtimeMode: targetThread.runtimeMode, |
| 338 | 333 | interactionMode: targetThread.interactionMode, |
| 339 | 334 | ...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}), |
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+6-8| ... | ... | @@ -21,6 +21,7 @@ import { Effect, Fiber, Layer, Random, Stream } from "effect"; |
| 21 | 21 | |
| 22 | 22 | import { attachmentRelativePath } from "../../attachmentStore.ts"; |
| 23 | 23 | import { ServerConfig } from "../../config.ts"; |
| 24 | import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 24 | 25 | import { ProviderAdapterValidationError } from "../Errors.ts"; |
| 25 | 26 | import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; |
| 26 | 27 | import { makeClaudeAdapterLive, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; |
| ... | ... | @@ -168,6 +169,7 @@ function makeHarness(config?: { |
| 168 | 169 | config?.baseDir ?? "/tmp", |
| 169 | 170 | ), |
| 170 | 171 | ), |
| 172 | Layer.provideMerge(ServerSettingsService.layerTest()), | |
| 171 | 173 | Layer.provideMerge(NodeServices.layer), |
| 172 | 174 | ), |
| 173 | 175 | query, |
| ... | ... | @@ -301,7 +303,7 @@ describe("ClaudeAdapterLive", () => { |
| 301 | 303 | ); |
| 302 | 304 | }); |
| 303 | 305 | |
| 304 | it.effect("keeps explicit claude permission mode over runtime-derived defaults", () => { | |
| 306 | it.effect("uses bypass permissions for full-access claude sessions", () => { | |
| 305 | 307 | const harness = makeHarness(); |
| 306 | 308 | return Effect.gen(function* () { |
| 307 | 309 | const adapter = yield* ClaudeAdapter; |
| ... | ... | @@ -309,16 +311,11 @@ describe("ClaudeAdapterLive", () => { |
| 309 | 311 | threadId: THREAD_ID, |
| 310 | 312 | provider: "claudeAgent", |
| 311 | 313 | runtimeMode: "full-access", |
| 312 | providerOptions: { | |
| 313 | claudeAgent: { | |
| 314 | permissionMode: "plan", | |
| 315 | }, | |
| 316 | }, | |
| 317 | 314 | }); |
| 318 | 315 | |
| 319 | 316 | const createInput = harness.getLastCreateQueryInput(); |
| 320 | assert.equal(createInput?.options.permissionMode, "plan"); | |
| 321 | assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); | |
| 317 | assert.equal(createInput?.options.permissionMode, "bypassPermissions"); | |
| 318 | assert.equal(createInput?.options.allowDangerouslySkipPermissions, true); | |
| 322 | 319 | }).pipe( |
| 323 | 320 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| 324 | 321 | Effect.provide(harness.layer), |
| ... | ... | @@ -1195,6 +1192,7 @@ describe("ClaudeAdapterLive", () => { |
| 1195 | 1192 | }, |
| 1196 | 1193 | }).pipe( |
| 1197 | 1194 | Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), |
| 1195 | Layer.provideMerge(ServerSettingsService.layerTest()), | |
| 1198 | 1196 | Layer.provideMerge(NodeServices.layer), |
| 1199 | 1197 | ); |
| 1200 | 1198 |
apps/server/src/provider/Layers/ClaudeAdapter.ts+21-31| ... | ... | @@ -40,12 +40,7 @@ import { |
| 40 | 40 | type UserInputQuestion, |
| 41 | 41 | ClaudeCodeEffort, |
| 42 | 42 | } from "@t3tools/contracts"; |
| 43 | import { | |
| 44 | hasEffortLevel, | |
| 45 | applyClaudePromptEffortPrefix, | |
| 46 | getModelCapabilities, | |
| 47 | trimOrNull, | |
| 48 | } from "@t3tools/shared/model"; | |
| 43 | import { hasEffortLevel, applyClaudePromptEffortPrefix, trimOrNull } from "@t3tools/shared/model"; | |
| 49 | 44 | import { |
| 50 | 45 | Cause, |
| 51 | 46 | DateTime, |
| ... | ... | @@ -63,6 +58,8 @@ import { |
| 63 | 58 | |
| 64 | 59 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 65 | 60 | import { ServerConfig } from "../../config.ts"; |
| 61 | import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 62 | import { getClaudeModelCapabilities } from "./ClaudeProvider.ts"; | |
| 66 | 63 | import { |
| 67 | 64 | ProviderAdapterProcessError, |
| 68 | 65 | ProviderAdapterRequestError, |
| ... | ... | @@ -353,19 +350,6 @@ function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId { |
| 353 | 350 | return RuntimeRequestId.makeUnsafe(value); |
| 354 | 351 | } |
| 355 | 352 | |
| 356 | function toPermissionMode(value: unknown): PermissionMode | undefined { | |
| 357 | switch (value) { | |
| 358 | case "default": | |
| 359 | case "acceptEdits": | |
| 360 | case "bypassPermissions": | |
| 361 | case "plan": | |
| 362 | case "dontAsk": | |
| 363 | return value; | |
| 364 | default: | |
| 365 | return undefined; | |
| 366 | } | |
| 367 | } | |
| 368 | ||
| 369 | 353 | function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undefined { |
| 370 | 354 | if (!resumeCursor || typeof resumeCursor !== "object") { |
| 371 | 355 | return undefined; |
| ... | ... | @@ -525,7 +509,7 @@ function buildPromptText(input: ProviderSendTurnInput): string { |
| 525 | 509 | const requestedEffort = trimOrNull(rawEffort); |
| 526 | 510 | const claudeModel = |
| 527 | 511 | input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined; |
| 528 | const caps = getModelCapabilities("claudeAgent", claudeModel); | |
| 512 | const caps = getClaudeModelCapabilities(claudeModel); | |
| 529 | 513 | const promptEffort = |
| 530 | 514 | requestedEffort === "ultrathink" && caps.reasoningEffortLevels.length > 0 |
| 531 | 515 | ? "ultrathink" |
| ... | ... | @@ -943,6 +927,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { |
| 943 | 927 | |
| 944 | 928 | const sessions = new Map<ThreadId, ClaudeSessionContext>(); |
| 945 | 929 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); |
| 930 | const serverSettingsService = yield* ServerSettingsService; | |
| 946 | 931 | |
| 947 | 932 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 948 | 933 | const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); |
| ... | ... | @@ -2727,11 +2712,23 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { |
| 2727 | 2712 | }), |
| 2728 | 2713 | ); |
| 2729 | 2714 | |
| 2730 | const providerOptions = input.providerOptions?.claudeAgent; | |
| 2715 | const claudeSettings = yield* serverSettingsService.getSettings.pipe( | |
| 2716 | Effect.map((settings) => settings.providers.claudeAgent), | |
| 2717 | Effect.mapError( | |
| 2718 | (error) => | |
| 2719 | new ProviderAdapterProcessError({ | |
| 2720 | provider: PROVIDER, | |
| 2721 | threadId: input.threadId, | |
| 2722 | detail: error.message, | |
| 2723 | cause: error, | |
| 2724 | }), | |
| 2725 | ), | |
| 2726 | ); | |
| 2727 | const claudeBinaryPath = claudeSettings.binaryPath; | |
| 2731 | 2728 | const modelSelection = |
| 2732 | 2729 | input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; |
| 2733 | 2730 | const requestedEffort = trimOrNull(modelSelection?.options?.effort ?? null); |
| 2734 | const caps = getModelCapabilities("claudeAgent", modelSelection?.model); | |
| 2731 | const caps = getClaudeModelCapabilities(modelSelection?.model); | |
| 2735 | 2732 | const effort = |
| 2736 | 2733 | requestedEffort && hasEffortLevel(caps, requestedEffort) ? requestedEffort : null; |
| 2737 | 2734 | const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode; |
| ... | ... | @@ -2741,8 +2738,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { |
| 2741 | 2738 | : undefined; |
| 2742 | 2739 | const effectiveEffort = getEffectiveClaudeCodeEffort(effort); |
| 2743 | 2740 | const permissionMode = |
| 2744 | toPermissionMode(providerOptions?.permissionMode) ?? | |
| 2745 | (input.runtimeMode === "full-access" ? "bypassPermissions" : undefined); | |
| 2741 | input.runtimeMode === "full-access" ? "bypassPermissions" : undefined; | |
| 2746 | 2742 | const settings = { |
| 2747 | 2743 | ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), |
| 2748 | 2744 | ...(fastMode ? { fastMode: true } : {}), |
| ... | ... | @@ -2751,16 +2747,13 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { |
| 2751 | 2747 | const queryOptions: ClaudeQueryOptions = { |
| 2752 | 2748 | ...(input.cwd ? { cwd: input.cwd } : {}), |
| 2753 | 2749 | ...(modelSelection?.model ? { model: modelSelection.model } : {}), |
| 2754 | pathToClaudeCodeExecutable: providerOptions?.binaryPath ?? "claude", | |
| 2750 | pathToClaudeCodeExecutable: claudeBinaryPath, | |
| 2755 | 2751 | settingSources: [...CLAUDE_SETTING_SOURCES], |
| 2756 | 2752 | ...(effectiveEffort ? { effort: effectiveEffort } : {}), |
| 2757 | 2753 | ...(permissionMode ? { permissionMode } : {}), |
| 2758 | 2754 | ...(permissionMode === "bypassPermissions" |
| 2759 | 2755 | ? { allowDangerouslySkipPermissions: true } |
| 2760 | 2756 | : {}), |
| 2761 | ...(providerOptions?.maxThinkingTokens !== undefined | |
| 2762 | ? { maxThinkingTokens: providerOptions.maxThinkingTokens } | |
| 2763 | : {}), | |
| 2764 | 2757 | ...(Object.keys(settings).length > 0 ? { settings } : {}), |
| 2765 | 2758 | ...(existingResumeSessionId ? { resume: existingResumeSessionId } : {}), |
| 2766 | 2759 | ...(newSessionId ? { sessionId: newSessionId } : {}), |
| ... | ... | @@ -2851,9 +2844,6 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { |
| 2851 | 2844 | ...(input.cwd ? { cwd: input.cwd } : {}), |
| 2852 | 2845 | ...(effectiveEffort ? { effort: effectiveEffort } : {}), |
| 2853 | 2846 | ...(permissionMode ? { permissionMode } : {}), |
| 2854 | ...(providerOptions?.maxThinkingTokens !== undefined | |
| 2855 | ? { maxThinkingTokens: providerOptions.maxThinkingTokens } | |
| 2856 | : {}), | |
| 2857 | 2847 | ...(fastMode ? { fastMode: true } : {}), |
| 2858 | 2848 | }, |
| 2859 | 2849 | }, |
apps/server/src/provider/Layers/ClaudeProvider.ts created+397| ... | ... | @@ -0,0 +1,397 @@ |
| 1 | import type { | |
| 2 | ClaudeSettings, | |
| 3 | ClaudeModelOptions, | |
| 4 | ModelCapabilities, | |
| 5 | ServerProvider, | |
| 6 | ServerProviderModel, | |
| 7 | ServerProviderAuthStatus, | |
| 8 | ServerProviderState, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import { Effect, Equal, Layer, Option, Result, Stream } from "effect"; | |
| 11 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 12 | import { getDefaultEffort, hasEffortLevel, trimOrNull } from "@t3tools/shared/model"; | |
| 13 | ||
| 14 | import { | |
| 15 | buildServerProvider, | |
| 16 | collectStreamAsString, | |
| 17 | DEFAULT_TIMEOUT_MS, | |
| 18 | detailFromResult, | |
| 19 | extractAuthBoolean, | |
| 20 | isCommandMissingCause, | |
| 21 | parseGenericCliVersion, | |
| 22 | providerModelsFromSettings, | |
| 23 | type CommandResult, | |
| 24 | } from "../providerSnapshot"; | |
| 25 | import { makeManagedServerProvider } from "../makeManagedServerProvider"; | |
| 26 | import { ClaudeProvider } from "../Services/ClaudeProvider"; | |
| 27 | import { ServerSettingsError, ServerSettingsService } from "../../serverSettings"; | |
| 28 | ||
| 29 | const PROVIDER = "claudeAgent" as const; | |
| 30 | const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [ | |
| 31 | { | |
| 32 | slug: "claude-opus-4-6", | |
| 33 | name: "Claude Opus 4.6", | |
| 34 | isCustom: false, | |
| 35 | capabilities: { | |
| 36 | reasoningEffortLevels: [ | |
| 37 | { value: "low", label: "Low" }, | |
| 38 | { value: "medium", label: "Medium" }, | |
| 39 | { value: "high", label: "High", isDefault: true }, | |
| 40 | { value: "max", label: "Max" }, | |
| 41 | { value: "ultrathink", label: "Ultrathink" }, | |
| 42 | ], | |
| 43 | supportsFastMode: true, | |
| 44 | supportsThinkingToggle: false, | |
| 45 | promptInjectedEffortLevels: ["ultrathink"], | |
| 46 | } satisfies ModelCapabilities, | |
| 47 | }, | |
| 48 | { | |
| 49 | slug: "claude-sonnet-4-6", | |
| 50 | name: "Claude Sonnet 4.6", | |
| 51 | isCustom: false, | |
| 52 | capabilities: { | |
| 53 | reasoningEffortLevels: [ | |
| 54 | { value: "low", label: "Low" }, | |
| 55 | { value: "medium", label: "Medium" }, | |
| 56 | { value: "high", label: "High", isDefault: true }, | |
| 57 | { value: "ultrathink", label: "Ultrathink" }, | |
| 58 | ], | |
| 59 | supportsFastMode: false, | |
| 60 | supportsThinkingToggle: false, | |
| 61 | promptInjectedEffortLevels: ["ultrathink"], | |
| 62 | } satisfies ModelCapabilities, | |
| 63 | }, | |
| 64 | { | |
| 65 | slug: "claude-haiku-4-5", | |
| 66 | name: "Claude Haiku 4.5", | |
| 67 | isCustom: false, | |
| 68 | capabilities: { | |
| 69 | reasoningEffortLevels: [], | |
| 70 | supportsFastMode: false, | |
| 71 | supportsThinkingToggle: true, | |
| 72 | promptInjectedEffortLevels: [], | |
| 73 | } satisfies ModelCapabilities, | |
| 74 | }, | |
| 75 | ]; | |
| 76 | ||
| 77 | export function getClaudeModelCapabilities(model: string | null | undefined): ModelCapabilities { | |
| 78 | const slug = model?.trim(); | |
| 79 | return ( | |
| 80 | BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? { | |
| 81 | reasoningEffortLevels: [], | |
| 82 | supportsFastMode: false, | |
| 83 | supportsThinkingToggle: false, | |
| 84 | promptInjectedEffortLevels: [], | |
| 85 | } | |
| 86 | ); | |
| 87 | } | |
| 88 | ||
| 89 | export function normalizeClaudeModelOptions( | |
| 90 | model: string | null | undefined, | |
| 91 | modelOptions: ClaudeModelOptions | null | undefined, | |
| 92 | ): ClaudeModelOptions | undefined { | |
| 93 | const caps = getClaudeModelCapabilities(model); | |
| 94 | const defaultReasoningEffort = getDefaultEffort(caps); | |
| 95 | const resolvedEffort = trimOrNull(modelOptions?.effort); | |
| 96 | const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? ""); | |
| 97 | const effort = | |
| 98 | resolvedEffort && | |
| 99 | !isPromptInjected && | |
| 100 | hasEffortLevel(caps, resolvedEffort) && | |
| 101 | resolvedEffort !== defaultReasoningEffort | |
| 102 | ? resolvedEffort | |
| 103 | : undefined; | |
| 104 | const thinking = | |
| 105 | caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; | |
| 106 | const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; | |
| 107 | const nextOptions: ClaudeModelOptions = { | |
| 108 | ...(thinking === false ? { thinking: false } : {}), | |
| 109 | ...(effort ? { effort } : {}), | |
| 110 | ...(fastMode ? { fastMode: true } : {}), | |
| 111 | }; | |
| 112 | return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; | |
| 113 | } | |
| 114 | ||
| 115 | export function parseClaudeAuthStatusFromOutput(result: CommandResult): { | |
| 116 | readonly status: Exclude<ServerProviderState, "disabled">; | |
| 117 | readonly authStatus: ServerProviderAuthStatus; | |
| 118 | readonly message?: string; | |
| 119 | } { | |
| 120 | const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); | |
| 121 | ||
| 122 | if ( | |
| 123 | lowerOutput.includes("unknown command") || | |
| 124 | lowerOutput.includes("unrecognized command") || | |
| 125 | lowerOutput.includes("unexpected argument") | |
| 126 | ) { | |
| 127 | return { | |
| 128 | status: "warning", | |
| 129 | authStatus: "unknown", | |
| 130 | message: | |
| 131 | "Claude Agent authentication status command is unavailable in this version of Claude.", | |
| 132 | }; | |
| 133 | } | |
| 134 | ||
| 135 | if ( | |
| 136 | lowerOutput.includes("not logged in") || | |
| 137 | lowerOutput.includes("login required") || | |
| 138 | lowerOutput.includes("authentication required") || | |
| 139 | lowerOutput.includes("run `claude login`") || | |
| 140 | lowerOutput.includes("run claude login") | |
| 141 | ) { | |
| 142 | return { | |
| 143 | status: "error", | |
| 144 | authStatus: "unauthenticated", | |
| 145 | message: "Claude is not authenticated. Run `claude auth login` and try again.", | |
| 146 | }; | |
| 147 | } | |
| 148 | ||
| 149 | const parsedAuth = (() => { | |
| 150 | const trimmed = result.stdout.trim(); | |
| 151 | if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { | |
| 152 | return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; | |
| 153 | } | |
| 154 | try { | |
| 155 | return { | |
| 156 | attemptedJsonParse: true as const, | |
| 157 | auth: extractAuthBoolean(JSON.parse(trimmed)), | |
| 158 | }; | |
| 159 | } catch { | |
| 160 | return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; | |
| 161 | } | |
| 162 | })(); | |
| 163 | ||
| 164 | if (parsedAuth.auth === true) { | |
| 165 | return { status: "ready", authStatus: "authenticated" }; | |
| 166 | } | |
| 167 | if (parsedAuth.auth === false) { | |
| 168 | return { | |
| 169 | status: "error", | |
| 170 | authStatus: "unauthenticated", | |
| 171 | message: "Claude is not authenticated. Run `claude auth login` and try again.", | |
| 172 | }; | |
| 173 | } | |
| 174 | if (parsedAuth.attemptedJsonParse) { | |
| 175 | return { | |
| 176 | status: "warning", | |
| 177 | authStatus: "unknown", | |
| 178 | message: | |
| 179 | "Could not verify Claude authentication status from JSON output (missing auth marker).", | |
| 180 | }; | |
| 181 | } | |
| 182 | if (result.code === 0) { | |
| 183 | return { status: "ready", authStatus: "authenticated" }; | |
| 184 | } | |
| 185 | ||
| 186 | const detail = detailFromResult(result); | |
| 187 | return { | |
| 188 | status: "warning", | |
| 189 | authStatus: "unknown", | |
| 190 | message: detail | |
| 191 | ? `Could not verify Claude authentication status. ${detail}` | |
| 192 | : "Could not verify Claude authentication status.", | |
| 193 | }; | |
| 194 | } | |
| 195 | ||
| 196 | const runClaudeCommand = (args: ReadonlyArray<string>) => | |
| 197 | Effect.gen(function* () { | |
| 198 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 199 | const claudeSettings = yield* Effect.service(ServerSettingsService).pipe( | |
| 200 | Effect.flatMap((service) => service.getSettings), | |
| 201 | Effect.map((settings) => settings.providers.claudeAgent), | |
| 202 | ); | |
| 203 | const command = ChildProcess.make(claudeSettings.binaryPath, [...args], { | |
| 204 | shell: process.platform === "win32", | |
| 205 | }); | |
| 206 | ||
| 207 | const child = yield* spawner.spawn(command); | |
| 208 | const [stdout, stderr, exitCode] = yield* Effect.all( | |
| 209 | [ | |
| 210 | collectStreamAsString(child.stdout), | |
| 211 | collectStreamAsString(child.stderr), | |
| 212 | child.exitCode.pipe(Effect.map(Number)), | |
| 213 | ], | |
| 214 | { concurrency: "unbounded" }, | |
| 215 | ); | |
| 216 | ||
| 217 | return { stdout, stderr, code: exitCode } satisfies CommandResult; | |
| 218 | }).pipe(Effect.scoped); | |
| 219 | ||
| 220 | export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( | |
| 221 | function* (): Effect.fn.Return< | |
| 222 | ServerProvider, | |
| 223 | ServerSettingsError, | |
| 224 | ChildProcessSpawner.ChildProcessSpawner | ServerSettingsService | |
| 225 | > { | |
| 226 | const claudeSettings = yield* Effect.service(ServerSettingsService).pipe( | |
| 227 | Effect.flatMap((service) => service.getSettings), | |
| 228 | Effect.map((settings) => settings.providers.claudeAgent), | |
| 229 | ); | |
| 230 | const checkedAt = new Date().toISOString(); | |
| 231 | const models = providerModelsFromSettings( | |
| 232 | BUILT_IN_MODELS, | |
| 233 | PROVIDER, | |
| 234 | claudeSettings.customModels, | |
| 235 | ); | |
| 236 | ||
| 237 | if (!claudeSettings.enabled) { | |
| 238 | return buildServerProvider({ | |
| 239 | provider: PROVIDER, | |
| 240 | enabled: false, | |
| 241 | checkedAt, | |
| 242 | models, | |
| 243 | probe: { | |
| 244 | installed: false, | |
| 245 | version: null, | |
| 246 | status: "warning", | |
| 247 | authStatus: "unknown", | |
| 248 | message: "Claude is disabled in T3 Code settings.", | |
| 249 | }, | |
| 250 | }); | |
| 251 | } | |
| 252 | ||
| 253 | const versionProbe = yield* runClaudeCommand(["--version"]).pipe( | |
| 254 | Effect.timeoutOption(DEFAULT_TIMEOUT_MS), | |
| 255 | Effect.result, | |
| 256 | ); | |
| 257 | ||
| 258 | if (Result.isFailure(versionProbe)) { | |
| 259 | const error = versionProbe.failure; | |
| 260 | return buildServerProvider({ | |
| 261 | provider: PROVIDER, | |
| 262 | enabled: claudeSettings.enabled, | |
| 263 | checkedAt, | |
| 264 | models, | |
| 265 | probe: { | |
| 266 | installed: !isCommandMissingCause(error), | |
| 267 | version: null, | |
| 268 | status: "error", | |
| 269 | authStatus: "unknown", | |
| 270 | message: isCommandMissingCause(error) | |
| 271 | ? "Claude Agent CLI (`claude`) is not installed or not on PATH." | |
| 272 | : `Failed to execute Claude Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, | |
| 273 | }, | |
| 274 | }); | |
| 275 | } | |
| 276 | ||
| 277 | if (Option.isNone(versionProbe.success)) { | |
| 278 | return buildServerProvider({ | |
| 279 | provider: PROVIDER, | |
| 280 | enabled: claudeSettings.enabled, | |
| 281 | checkedAt, | |
| 282 | models, | |
| 283 | probe: { | |
| 284 | installed: true, | |
| 285 | version: null, | |
| 286 | status: "error", | |
| 287 | authStatus: "unknown", | |
| 288 | message: | |
| 289 | "Claude Agent CLI is installed but failed to run. Timed out while running command.", | |
| 290 | }, | |
| 291 | }); | |
| 292 | } | |
| 293 | ||
| 294 | const version = versionProbe.success.value; | |
| 295 | const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); | |
| 296 | if (version.code !== 0) { | |
| 297 | const detail = detailFromResult(version); | |
| 298 | return buildServerProvider({ | |
| 299 | provider: PROVIDER, | |
| 300 | enabled: claudeSettings.enabled, | |
| 301 | checkedAt, | |
| 302 | models, | |
| 303 | probe: { | |
| 304 | installed: true, | |
| 305 | version: parsedVersion, | |
| 306 | status: "error", | |
| 307 | authStatus: "unknown", | |
| 308 | message: detail | |
| 309 | ? `Claude Agent CLI is installed but failed to run. ${detail}` | |
| 310 | : "Claude Agent CLI is installed but failed to run.", | |
| 311 | }, | |
| 312 | }); | |
| 313 | } | |
| 314 | ||
| 315 | const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe( | |
| 316 | Effect.timeoutOption(DEFAULT_TIMEOUT_MS), | |
| 317 | Effect.result, | |
| 318 | ); | |
| 319 | ||
| 320 | if (Result.isFailure(authProbe)) { | |
| 321 | const error = authProbe.failure; | |
| 322 | return buildServerProvider({ | |
| 323 | provider: PROVIDER, | |
| 324 | enabled: claudeSettings.enabled, | |
| 325 | checkedAt, | |
| 326 | models, | |
| 327 | probe: { | |
| 328 | installed: true, | |
| 329 | version: parsedVersion, | |
| 330 | status: "warning", | |
| 331 | authStatus: "unknown", | |
| 332 | message: | |
| 333 | error instanceof Error | |
| 334 | ? `Could not verify Claude authentication status: ${error.message}.` | |
| 335 | : "Could not verify Claude authentication status.", | |
| 336 | }, | |
| 337 | }); | |
| 338 | } | |
| 339 | ||
| 340 | if (Option.isNone(authProbe.success)) { | |
| 341 | return buildServerProvider({ | |
| 342 | provider: PROVIDER, | |
| 343 | enabled: claudeSettings.enabled, | |
| 344 | checkedAt, | |
| 345 | models, | |
| 346 | probe: { | |
| 347 | installed: true, | |
| 348 | version: parsedVersion, | |
| 349 | status: "warning", | |
| 350 | authStatus: "unknown", | |
| 351 | message: | |
| 352 | "Could not verify Claude authentication status. Timed out while running command.", | |
| 353 | }, | |
| 354 | }); | |
| 355 | } | |
| 356 | ||
| 357 | const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value); | |
| 358 | return buildServerProvider({ | |
| 359 | provider: PROVIDER, | |
| 360 | enabled: claudeSettings.enabled, | |
| 361 | checkedAt, | |
| 362 | models, | |
| 363 | probe: { | |
| 364 | installed: true, | |
| 365 | version: parsedVersion, | |
| 366 | status: parsed.status, | |
| 367 | authStatus: parsed.authStatus, | |
| 368 | ...(parsed.message ? { message: parsed.message } : {}), | |
| 369 | }, | |
| 370 | }); | |
| 371 | }, | |
| 372 | ); | |
| 373 | ||
| 374 | export const ClaudeProviderLive = Layer.effect( | |
| 375 | ClaudeProvider, | |
| 376 | Effect.gen(function* () { | |
| 377 | const serverSettings = yield* ServerSettingsService; | |
| 378 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 379 | ||
| 380 | const checkProvider = checkClaudeProviderStatus().pipe( | |
| 381 | Effect.provideService(ServerSettingsService, serverSettings), | |
| 382 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 383 | ); | |
| 384 | ||
| 385 | return yield* makeManagedServerProvider<ClaudeSettings>({ | |
| 386 | getSettings: serverSettings.getSettings.pipe( | |
| 387 | Effect.map((settings) => settings.providers.claudeAgent), | |
| 388 | Effect.orDie, | |
| 389 | ), | |
| 390 | streamSettings: serverSettings.streamChanges.pipe( | |
| 391 | Stream.map((settings) => settings.providers.claudeAgent), | |
| 392 | ), | |
| 393 | haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), | |
| 394 | checkProvider, | |
| 395 | }); | |
| 396 | }), | |
| 397 | ); |
apps/server/src/provider/Layers/CodexAdapter.test.ts+5| ... | ... | @@ -22,6 +22,7 @@ import { |
| 22 | 22 | type CodexAppServerSendTurnInput, |
| 23 | 23 | } from "../../codexAppServerManager.ts"; |
| 24 | 24 | import { ServerConfig } from "../../config.ts"; |
| 25 | import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 25 | 26 | import { ProviderAdapterValidationError } from "../Errors.ts"; |
| 26 | 27 | import { CodexAdapter } from "../Services/CodexAdapter.ts"; |
| 27 | 28 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; |
| ... | ... | @@ -151,6 +152,7 @@ const validationManager = new FakeCodexManager(); |
| 151 | 152 | const validationLayer = it.layer( |
| 152 | 153 | makeCodexAdapterLive({ manager: validationManager }).pipe( |
| 153 | 154 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| 155 | Layer.provideMerge(ServerSettingsService.layerTest()), | |
| 154 | 156 | Layer.provideMerge(providerSessionDirectoryTestLayer), |
| 155 | 157 | Layer.provideMerge(NodeServices.layer), |
| 156 | 158 | ), |
| ... | ... | @@ -201,6 +203,7 @@ validationLayer("CodexAdapterLive validation", (it) => { |
| 201 | 203 | assert.deepStrictEqual(validationManager.startSessionImpl.mock.calls[0]?.[0], { |
| 202 | 204 | provider: "codex", |
| 203 | 205 | threadId: asThreadId("thread-1"), |
| 206 | binaryPath: "codex", | |
| 204 | 207 | model: "gpt-5.3-codex", |
| 205 | 208 | serviceTier: "fast", |
| 206 | 209 | runtimeMode: "full-access", |
| ... | ... | @@ -216,6 +219,7 @@ sessionErrorManager.sendTurnImpl.mockImplementation(async () => { |
| 216 | 219 | const sessionErrorLayer = it.layer( |
| 217 | 220 | makeCodexAdapterLive({ manager: sessionErrorManager }).pipe( |
| 218 | 221 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| 222 | Layer.provideMerge(ServerSettingsService.layerTest()), | |
| 219 | 223 | Layer.provideMerge(providerSessionDirectoryTestLayer), |
| 220 | 224 | Layer.provideMerge(NodeServices.layer), |
| 221 | 225 | ), |
| ... | ... | @@ -284,6 +288,7 @@ const lifecycleManager = new FakeCodexManager(); |
| 284 | 288 | const lifecycleLayer = it.layer( |
| 285 | 289 | makeCodexAdapterLive({ manager: lifecycleManager }).pipe( |
| 286 | 290 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| 291 | Layer.provideMerge(ServerSettingsService.layerTest()), | |
| 287 | 292 | Layer.provideMerge(providerSessionDirectoryTestLayer), |
| 288 | 293 | Layer.provideMerge(NodeServices.layer), |
| 289 | 294 | ), |
apps/server/src/provider/Layers/CodexAdapter.ts+27-12| ... | ... | @@ -38,6 +38,7 @@ import { |
| 38 | 38 | } from "../../codexAppServerManager.ts"; |
| 39 | 39 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 40 | 40 | import { ServerConfig } from "../../config.ts"; |
| 41 | import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 41 | 42 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 42 | 43 | |
| 43 | 44 | const PROVIDER = "codex" as const; |
| ... | ... | @@ -1342,25 +1343,39 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 1342 | 1343 | } |
| 1343 | 1344 | }), |
| 1344 | 1345 | ); |
| 1346 | const serverSettingsService = yield* ServerSettingsService; | |
| 1345 | 1347 | |
| 1346 | const startSession: CodexAdapterShape["startSession"] = (input) => { | |
| 1348 | const startSession: CodexAdapterShape["startSession"] = Effect.fn(function* (input) { | |
| 1347 | 1349 | if (input.provider !== undefined && input.provider !== PROVIDER) { |
| 1348 | return Effect.fail( | |
| 1349 | new ProviderAdapterValidationError({ | |
| 1350 | provider: PROVIDER, | |
| 1351 | operation: "startSession", | |
| 1352 | issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, | |
| 1353 | }), | |
| 1354 | ); | |
| 1350 | return yield* new ProviderAdapterValidationError({ | |
| 1351 | provider: PROVIDER, | |
| 1352 | operation: "startSession", | |
| 1353 | issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, | |
| 1354 | }); | |
| 1355 | 1355 | } |
| 1356 | 1356 | |
| 1357 | const codexSettings = yield* serverSettingsService.getSettings.pipe( | |
| 1358 | Effect.map((settings) => settings.providers.codex), | |
| 1359 | Effect.mapError( | |
| 1360 | (error) => | |
| 1361 | new ProviderAdapterProcessError({ | |
| 1362 | provider: PROVIDER, | |
| 1363 | threadId: input.threadId, | |
| 1364 | detail: error.message, | |
| 1365 | cause: error, | |
| 1366 | }), | |
| 1367 | ), | |
| 1368 | ); | |
| 1369 | const binaryPath = codexSettings.binaryPath; | |
| 1370 | const homePath = codexSettings.homePath; | |
| 1357 | 1371 | const managerInput: CodexAppServerStartSessionInput = { |
| 1358 | 1372 | threadId: input.threadId, |
| 1359 | 1373 | provider: "codex", |
| 1360 | 1374 | ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), |
| 1361 | 1375 | ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), |
| 1362 | ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), | |
| 1363 | 1376 | runtimeMode: input.runtimeMode, |
| 1377 | binaryPath, | |
| 1378 | ...(homePath ? { homePath } : {}), | |
| 1364 | 1379 | ...(input.modelSelection?.provider === "codex" |
| 1365 | 1380 | ? { model: input.modelSelection.model } |
| 1366 | 1381 | : {}), |
| ... | ... | @@ -1369,7 +1384,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 1369 | 1384 | : {}), |
| 1370 | 1385 | }; |
| 1371 | 1386 | |
| 1372 | return Effect.tryPromise({ | |
| 1387 | return yield* Effect.tryPromise({ | |
| 1373 | 1388 | try: () => manager.startSession(managerInput), |
| 1374 | 1389 | catch: (cause) => |
| 1375 | 1390 | new ProviderAdapterProcessError({ |
| ... | ... | @@ -1378,8 +1393,8 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 1378 | 1393 | detail: toMessage(cause, "Failed to start Codex adapter session."), |
| 1379 | 1394 | cause, |
| 1380 | 1395 | }), |
| 1381 | }).pipe(Effect.map((session) => session)); | |
| 1382 | }; | |
| 1396 | }); | |
| 1397 | }); | |
| 1383 | 1398 | |
| 1384 | 1399 | const sendTurn: CodexAdapterShape["sendTurn"] = (input) => |
| 1385 | 1400 | Effect.gen(function* () { |
apps/server/src/provider/Layers/CodexProvider.ts created+531| ... | ... | @@ -0,0 +1,531 @@ |
| 1 | import * as OS from "node:os"; | |
| 2 | import type { | |
| 3 | ModelCapabilities, | |
| 4 | CodexModelOptions, | |
| 5 | CodexSettings, | |
| 6 | ServerProvider, | |
| 7 | ServerProviderModel, | |
| 8 | ServerProviderAuthStatus, | |
| 9 | ServerProviderState, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import { Effect, Equal, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; | |
| 12 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 13 | import { getDefaultEffort, trimOrNull } from "@t3tools/shared/model"; | |
| 14 | ||
| 15 | import { | |
| 16 | buildServerProvider, | |
| 17 | collectStreamAsString, | |
| 18 | DEFAULT_TIMEOUT_MS, | |
| 19 | detailFromResult, | |
| 20 | extractAuthBoolean, | |
| 21 | isCommandMissingCause, | |
| 22 | parseGenericCliVersion, | |
| 23 | providerModelsFromSettings, | |
| 24 | type CommandResult, | |
| 25 | } from "../providerSnapshot"; | |
| 26 | import { makeManagedServerProvider } from "../makeManagedServerProvider"; | |
| 27 | import { | |
| 28 | formatCodexCliUpgradeMessage, | |
| 29 | isCodexCliVersionSupported, | |
| 30 | parseCodexCliVersion, | |
| 31 | } from "../codexCliVersion"; | |
| 32 | import { CodexProvider } from "../Services/CodexProvider"; | |
| 33 | import { ServerSettingsError, ServerSettingsService } from "../../serverSettings"; | |
| 34 | ||
| 35 | const PROVIDER = "codex" as const; | |
| 36 | const OPENAI_AUTH_PROVIDERS = new Set(["openai"]); | |
| 37 | const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [ | |
| 38 | { | |
| 39 | slug: "gpt-5.4", | |
| 40 | name: "GPT-5.4", | |
| 41 | isCustom: false, | |
| 42 | capabilities: { | |
| 43 | reasoningEffortLevels: [ | |
| 44 | { value: "xhigh", label: "Extra High" }, | |
| 45 | { value: "high", label: "High", isDefault: true }, | |
| 46 | { value: "medium", label: "Medium" }, | |
| 47 | { value: "low", label: "Low" }, | |
| 48 | ], | |
| 49 | supportsFastMode: true, | |
| 50 | supportsThinkingToggle: false, | |
| 51 | promptInjectedEffortLevels: [], | |
| 52 | }, | |
| 53 | }, | |
| 54 | { | |
| 55 | slug: "gpt-5.4-mini", | |
| 56 | name: "GPT-5.4 Mini", | |
| 57 | isCustom: false, | |
| 58 | capabilities: { | |
| 59 | reasoningEffortLevels: [ | |
| 60 | { value: "xhigh", label: "Extra High" }, | |
| 61 | { value: "high", label: "High", isDefault: true }, | |
| 62 | { value: "medium", label: "Medium" }, | |
| 63 | { value: "low", label: "Low" }, | |
| 64 | ], | |
| 65 | supportsFastMode: true, | |
| 66 | supportsThinkingToggle: false, | |
| 67 | promptInjectedEffortLevels: [], | |
| 68 | }, | |
| 69 | }, | |
| 70 | { | |
| 71 | slug: "gpt-5.3-codex", | |
| 72 | name: "GPT-5.3 Codex", | |
| 73 | isCustom: false, | |
| 74 | capabilities: { | |
| 75 | reasoningEffortLevels: [ | |
| 76 | { value: "xhigh", label: "Extra High" }, | |
| 77 | { value: "high", label: "High", isDefault: true }, | |
| 78 | { value: "medium", label: "Medium" }, | |
| 79 | { value: "low", label: "Low" }, | |
| 80 | ], | |
| 81 | supportsFastMode: true, | |
| 82 | supportsThinkingToggle: false, | |
| 83 | promptInjectedEffortLevels: [], | |
| 84 | }, | |
| 85 | }, | |
| 86 | { | |
| 87 | slug: "gpt-5.3-codex-spark", | |
| 88 | name: "GPT-5.3 Codex Spark", | |
| 89 | isCustom: false, | |
| 90 | capabilities: { | |
| 91 | reasoningEffortLevels: [ | |
| 92 | { value: "xhigh", label: "Extra High" }, | |
| 93 | { value: "high", label: "High", isDefault: true }, | |
| 94 | { value: "medium", label: "Medium" }, | |
| 95 | { value: "low", label: "Low" }, | |
| 96 | ], | |
| 97 | supportsFastMode: true, | |
| 98 | supportsThinkingToggle: false, | |
| 99 | promptInjectedEffortLevels: [], | |
| 100 | }, | |
| 101 | }, | |
| 102 | { | |
| 103 | slug: "gpt-5.2-codex", | |
| 104 | name: "GPT-5.2 Codex", | |
| 105 | isCustom: false, | |
| 106 | capabilities: { | |
| 107 | reasoningEffortLevels: [ | |
| 108 | { value: "xhigh", label: "Extra High" }, | |
| 109 | { value: "high", label: "High", isDefault: true }, | |
| 110 | { value: "medium", label: "Medium" }, | |
| 111 | { value: "low", label: "Low" }, | |
| 112 | ], | |
| 113 | supportsFastMode: true, | |
| 114 | supportsThinkingToggle: false, | |
| 115 | promptInjectedEffortLevels: [], | |
| 116 | }, | |
| 117 | }, | |
| 118 | { | |
| 119 | slug: "gpt-5.2", | |
| 120 | name: "GPT-5.2", | |
| 121 | isCustom: false, | |
| 122 | capabilities: { | |
| 123 | reasoningEffortLevels: [ | |
| 124 | { value: "xhigh", label: "Extra High" }, | |
| 125 | { value: "high", label: "High", isDefault: true }, | |
| 126 | { value: "medium", label: "Medium" }, | |
| 127 | { value: "low", label: "Low" }, | |
| 128 | ], | |
| 129 | supportsFastMode: true, | |
| 130 | supportsThinkingToggle: false, | |
| 131 | promptInjectedEffortLevels: [], | |
| 132 | }, | |
| 133 | }, | |
| 134 | ]; | |
| 135 | ||
| 136 | export function getCodexModelCapabilities(model: string | null | undefined): ModelCapabilities { | |
| 137 | const slug = model?.trim(); | |
| 138 | return ( | |
| 139 | BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? { | |
| 140 | reasoningEffortLevels: [], | |
| 141 | supportsFastMode: false, | |
| 142 | supportsThinkingToggle: false, | |
| 143 | promptInjectedEffortLevels: [], | |
| 144 | } | |
| 145 | ); | |
| 146 | } | |
| 147 | ||
| 148 | export function normalizeCodexModelOptions( | |
| 149 | model: string | null | undefined, | |
| 150 | modelOptions: CodexModelOptions | null | undefined, | |
| 151 | ): CodexModelOptions | undefined { | |
| 152 | const caps = getCodexModelCapabilities(model); | |
| 153 | const defaultReasoningEffort = getDefaultEffort(caps); | |
| 154 | const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort; | |
| 155 | const fastModeEnabled = modelOptions?.fastMode === true; | |
| 156 | const nextOptions: CodexModelOptions = { | |
| 157 | ...(reasoningEffort && reasoningEffort !== defaultReasoningEffort | |
| 158 | ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] } | |
| 159 | : {}), | |
| 160 | ...(fastModeEnabled ? { fastMode: true } : {}), | |
| 161 | }; | |
| 162 | return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; | |
| 163 | } | |
| 164 | ||
| 165 | export function parseAuthStatusFromOutput(result: CommandResult): { | |
| 166 | readonly status: Exclude<ServerProviderState, "disabled">; | |
| 167 | readonly authStatus: ServerProviderAuthStatus; | |
| 168 | readonly message?: string; | |
| 169 | } { | |
| 170 | const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); | |
| 171 | ||
| 172 | if ( | |
| 173 | lowerOutput.includes("unknown command") || | |
| 174 | lowerOutput.includes("unrecognized command") || | |
| 175 | lowerOutput.includes("unexpected argument") | |
| 176 | ) { | |
| 177 | return { | |
| 178 | status: "warning", | |
| 179 | authStatus: "unknown", | |
| 180 | message: "Codex CLI authentication status command is unavailable in this Codex version.", | |
| 181 | }; | |
| 182 | } | |
| 183 | ||
| 184 | if ( | |
| 185 | lowerOutput.includes("not logged in") || | |
| 186 | lowerOutput.includes("login required") || | |
| 187 | lowerOutput.includes("authentication required") || | |
| 188 | lowerOutput.includes("run `codex login`") || | |
| 189 | lowerOutput.includes("run codex login") | |
| 190 | ) { | |
| 191 | return { | |
| 192 | status: "error", | |
| 193 | authStatus: "unauthenticated", | |
| 194 | message: "Codex CLI is not authenticated. Run `codex login` and try again.", | |
| 195 | }; | |
| 196 | } | |
| 197 | ||
| 198 | const parsedAuth = (() => { | |
| 199 | const trimmed = result.stdout.trim(); | |
| 200 | if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { | |
| 201 | return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; | |
| 202 | } | |
| 203 | try { | |
| 204 | return { | |
| 205 | attemptedJsonParse: true as const, | |
| 206 | auth: extractAuthBoolean(JSON.parse(trimmed)), | |
| 207 | }; | |
| 208 | } catch { | |
| 209 | return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; | |
| 210 | } | |
| 211 | })(); | |
| 212 | ||
| 213 | if (parsedAuth.auth === true) { | |
| 214 | return { status: "ready", authStatus: "authenticated" }; | |
| 215 | } | |
| 216 | if (parsedAuth.auth === false) { | |
| 217 | return { | |
| 218 | status: "error", | |
| 219 | authStatus: "unauthenticated", | |
| 220 | message: "Codex CLI is not authenticated. Run `codex login` and try again.", | |
| 221 | }; | |
| 222 | } | |
| 223 | if (parsedAuth.attemptedJsonParse) { | |
| 224 | return { | |
| 225 | status: "warning", | |
| 226 | authStatus: "unknown", | |
| 227 | message: | |
| 228 | "Could not verify Codex authentication status from JSON output (missing auth marker).", | |
| 229 | }; | |
| 230 | } | |
| 231 | if (result.code === 0) { | |
| 232 | return { status: "ready", authStatus: "authenticated" }; | |
| 233 | } | |
| 234 | ||
| 235 | const detail = detailFromResult(result); | |
| 236 | return { | |
| 237 | status: "warning", | |
| 238 | authStatus: "unknown", | |
| 239 | message: detail | |
| 240 | ? `Could not verify Codex authentication status. ${detail}` | |
| 241 | : "Could not verify Codex authentication status.", | |
| 242 | }; | |
| 243 | } | |
| 244 | ||
| 245 | export const readCodexConfigModelProvider = Effect.fn("readCodexConfigModelProvider")(function* () { | |
| 246 | const fileSystem = yield* FileSystem.FileSystem; | |
| 247 | const path = yield* Path.Path; | |
| 248 | const settingsService = yield* ServerSettingsService; | |
| 249 | const codexHome = yield* settingsService.getSettings.pipe( | |
| 250 | Effect.map( | |
| 251 | (settings) => | |
| 252 | settings.providers.codex.homePath || | |
| 253 | process.env.CODEX_HOME || | |
| 254 | path.join(OS.homedir(), ".codex"), | |
| 255 | ), | |
| 256 | ); | |
| 257 | const configPath = path.join(codexHome, "config.toml"); | |
| 258 | ||
| 259 | const content = yield* fileSystem | |
| 260 | .readFileString(configPath) | |
| 261 | .pipe(Effect.orElseSucceed(() => undefined)); | |
| 262 | if (content === undefined) { | |
| 263 | return undefined; | |
| 264 | } | |
| 265 | ||
| 266 | let inTopLevel = true; | |
| 267 | for (const line of content.split("\n")) { | |
| 268 | const trimmed = line.trim(); | |
| 269 | if (!trimmed || trimmed.startsWith("#")) continue; | |
| 270 | if (trimmed.startsWith("[")) { | |
| 271 | inTopLevel = false; | |
| 272 | continue; | |
| 273 | } | |
| 274 | if (!inTopLevel) continue; | |
| 275 | ||
| 276 | const match = trimmed.match(/^model_provider\s*=\s*["']([^"']+)["']/); | |
| 277 | if (match) return match[1]; | |
| 278 | } | |
| 279 | return undefined; | |
| 280 | }); | |
| 281 | ||
| 282 | export const hasCustomModelProvider = readCodexConfigModelProvider().pipe( | |
| 283 | Effect.map((provider) => provider !== undefined && !OPENAI_AUTH_PROVIDERS.has(provider)), | |
| 284 | Effect.orElseSucceed(() => false), | |
| 285 | ); | |
| 286 | ||
| 287 | const runCodexCommand = (args: ReadonlyArray<string>) => | |
| 288 | Effect.gen(function* () { | |
| 289 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 290 | const settingsService = yield* ServerSettingsService; | |
| 291 | const codexSettings = yield* settingsService.getSettings.pipe( | |
| 292 | Effect.map((settings) => settings.providers.codex), | |
| 293 | ); | |
| 294 | const command = ChildProcess.make(codexSettings.binaryPath, [...args], { | |
| 295 | shell: process.platform === "win32", | |
| 296 | env: { | |
| 297 | ...process.env, | |
| 298 | ...(codexSettings.homePath ? { CODEX_HOME: codexSettings.homePath } : {}), | |
| 299 | }, | |
| 300 | }); | |
| 301 | ||
| 302 | const child = yield* spawner.spawn(command); | |
| 303 | const [stdout, stderr, exitCode] = yield* Effect.all( | |
| 304 | [ | |
| 305 | collectStreamAsString(child.stdout), | |
| 306 | collectStreamAsString(child.stderr), | |
| 307 | child.exitCode.pipe(Effect.map(Number)), | |
| 308 | ], | |
| 309 | { concurrency: "unbounded" }, | |
| 310 | ); | |
| 311 | ||
| 312 | return { stdout, stderr, code: exitCode } satisfies CommandResult; | |
| 313 | }).pipe(Effect.scoped); | |
| 314 | ||
| 315 | export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")( | |
| 316 | function* (): Effect.fn.Return< | |
| 317 | ServerProvider, | |
| 318 | ServerSettingsError, | |
| 319 | | ChildProcessSpawner.ChildProcessSpawner | |
| 320 | | FileSystem.FileSystem | |
| 321 | | Path.Path | |
| 322 | | ServerSettingsService | |
| 323 | > { | |
| 324 | const codexSettings = yield* Effect.service(ServerSettingsService).pipe( | |
| 325 | Effect.flatMap((service) => service.getSettings), | |
| 326 | Effect.map((settings) => settings.providers.codex), | |
| 327 | ); | |
| 328 | const checkedAt = new Date().toISOString(); | |
| 329 | const models = providerModelsFromSettings( | |
| 330 | BUILT_IN_MODELS, | |
| 331 | PROVIDER, | |
| 332 | codexSettings.customModels, | |
| 333 | ); | |
| 334 | ||
| 335 | if (!codexSettings.enabled) { | |
| 336 | return buildServerProvider({ | |
| 337 | provider: PROVIDER, | |
| 338 | enabled: false, | |
| 339 | checkedAt, | |
| 340 | models, | |
| 341 | probe: { | |
| 342 | installed: false, | |
| 343 | version: null, | |
| 344 | status: "warning", | |
| 345 | authStatus: "unknown", | |
| 346 | message: "Codex is disabled in T3 Code settings.", | |
| 347 | }, | |
| 348 | }); | |
| 349 | } | |
| 350 | ||
| 351 | const versionProbe = yield* runCodexCommand(["--version"]).pipe( | |
| 352 | Effect.timeoutOption(DEFAULT_TIMEOUT_MS), | |
| 353 | Effect.result, | |
| 354 | ); | |
| 355 | ||
| 356 | if (Result.isFailure(versionProbe)) { | |
| 357 | const error = versionProbe.failure; | |
| 358 | return buildServerProvider({ | |
| 359 | provider: PROVIDER, | |
| 360 | enabled: codexSettings.enabled, | |
| 361 | checkedAt, | |
| 362 | models, | |
| 363 | probe: { | |
| 364 | installed: !isCommandMissingCause(error), | |
| 365 | version: null, | |
| 366 | status: "error", | |
| 367 | authStatus: "unknown", | |
| 368 | message: isCommandMissingCause(error) | |
| 369 | ? "Codex CLI (`codex`) is not installed or not on PATH." | |
| 370 | : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`, | |
| 371 | }, | |
| 372 | }); | |
| 373 | } | |
| 374 | ||
| 375 | if (Option.isNone(versionProbe.success)) { | |
| 376 | return buildServerProvider({ | |
| 377 | provider: PROVIDER, | |
| 378 | enabled: codexSettings.enabled, | |
| 379 | checkedAt, | |
| 380 | models, | |
| 381 | probe: { | |
| 382 | installed: true, | |
| 383 | version: null, | |
| 384 | status: "error", | |
| 385 | authStatus: "unknown", | |
| 386 | message: "Codex CLI is installed but failed to run. Timed out while running command.", | |
| 387 | }, | |
| 388 | }); | |
| 389 | } | |
| 390 | ||
| 391 | const version = versionProbe.success.value; | |
| 392 | const parsedVersion = | |
| 393 | parseCodexCliVersion(`${version.stdout}\n${version.stderr}`) ?? | |
| 394 | parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); | |
| 395 | if (version.code !== 0) { | |
| 396 | const detail = detailFromResult(version); | |
| 397 | return buildServerProvider({ | |
| 398 | provider: PROVIDER, | |
| 399 | enabled: codexSettings.enabled, | |
| 400 | checkedAt, | |
| 401 | models, | |
| 402 | probe: { | |
| 403 | installed: true, | |
| 404 | version: parsedVersion, | |
| 405 | status: "error", | |
| 406 | authStatus: "unknown", | |
| 407 | message: detail | |
| 408 | ? `Codex CLI is installed but failed to run. ${detail}` | |
| 409 | : "Codex CLI is installed but failed to run.", | |
| 410 | }, | |
| 411 | }); | |
| 412 | } | |
| 413 | ||
| 414 | if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) { | |
| 415 | return buildServerProvider({ | |
| 416 | provider: PROVIDER, | |
| 417 | enabled: codexSettings.enabled, | |
| 418 | checkedAt, | |
| 419 | models, | |
| 420 | probe: { | |
| 421 | installed: true, | |
| 422 | version: parsedVersion, | |
| 423 | status: "error", | |
| 424 | authStatus: "unknown", | |
| 425 | message: formatCodexCliUpgradeMessage(parsedVersion), | |
| 426 | }, | |
| 427 | }); | |
| 428 | } | |
| 429 | ||
| 430 | if (yield* hasCustomModelProvider) { | |
| 431 | return buildServerProvider({ | |
| 432 | provider: PROVIDER, | |
| 433 | enabled: codexSettings.enabled, | |
| 434 | checkedAt, | |
| 435 | models, | |
| 436 | probe: { | |
| 437 | installed: true, | |
| 438 | version: parsedVersion, | |
| 439 | status: "ready", | |
| 440 | authStatus: "unknown", | |
| 441 | message: "Using a custom Codex model provider; OpenAI login check skipped.", | |
| 442 | }, | |
| 443 | }); | |
| 444 | } | |
| 445 | ||
| 446 | const authProbe = yield* runCodexCommand(["login", "status"]).pipe( | |
| 447 | Effect.timeoutOption(DEFAULT_TIMEOUT_MS), | |
| 448 | Effect.result, | |
| 449 | ); | |
| 450 | ||
| 451 | if (Result.isFailure(authProbe)) { | |
| 452 | const error = authProbe.failure; | |
| 453 | return buildServerProvider({ | |
| 454 | provider: PROVIDER, | |
| 455 | enabled: codexSettings.enabled, | |
| 456 | checkedAt, | |
| 457 | models, | |
| 458 | probe: { | |
| 459 | installed: true, | |
| 460 | version: parsedVersion, | |
| 461 | status: "warning", | |
| 462 | authStatus: "unknown", | |
| 463 | message: | |
| 464 | error instanceof Error | |
| 465 | ? `Could not verify Codex authentication status: ${error.message}.` | |
| 466 | : "Could not verify Codex authentication status.", | |
| 467 | }, | |
| 468 | }); | |
| 469 | } | |
| 470 | ||
| 471 | if (Option.isNone(authProbe.success)) { | |
| 472 | return buildServerProvider({ | |
| 473 | provider: PROVIDER, | |
| 474 | enabled: codexSettings.enabled, | |
| 475 | checkedAt, | |
| 476 | models, | |
| 477 | probe: { | |
| 478 | installed: true, | |
| 479 | version: parsedVersion, | |
| 480 | status: "warning", | |
| 481 | authStatus: "unknown", | |
| 482 | message: "Could not verify Codex authentication status. Timed out while running command.", | |
| 483 | }, | |
| 484 | }); | |
| 485 | } | |
| 486 | ||
| 487 | const parsed = parseAuthStatusFromOutput(authProbe.success.value); | |
| 488 | return buildServerProvider({ | |
| 489 | provider: PROVIDER, | |
| 490 | enabled: codexSettings.enabled, | |
| 491 | checkedAt, | |
| 492 | models, | |
| 493 | probe: { | |
| 494 | installed: true, | |
| 495 | version: parsedVersion, | |
| 496 | status: parsed.status, | |
| 497 | authStatus: parsed.authStatus, | |
| 498 | ...(parsed.message ? { message: parsed.message } : {}), | |
| 499 | }, | |
| 500 | }); | |
| 501 | }, | |
| 502 | ); | |
| 503 | ||
| 504 | export const CodexProviderLive = Layer.effect( | |
| 505 | CodexProvider, | |
| 506 | Effect.gen(function* () { | |
| 507 | const serverSettings = yield* ServerSettingsService; | |
| 508 | const fileSystem = yield* FileSystem.FileSystem; | |
| 509 | const path = yield* Path.Path; | |
| 510 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 511 | ||
| 512 | const checkProvider = checkCodexProviderStatus().pipe( | |
| 513 | Effect.provideService(ServerSettingsService, serverSettings), | |
| 514 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 515 | Effect.provideService(Path.Path, path), | |
| 516 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 517 | ); | |
| 518 | ||
| 519 | return yield* makeManagedServerProvider<CodexSettings>({ | |
| 520 | getSettings: serverSettings.getSettings.pipe( | |
| 521 | Effect.map((settings) => settings.providers.codex), | |
| 522 | Effect.orDie, | |
| 523 | ), | |
| 524 | streamSettings: serverSettings.streamChanges.pipe( | |
| 525 | Stream.map((settings) => settings.providers.codex), | |
| 526 | ), | |
| 527 | haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), | |
| 528 | checkProvider, | |
| 529 | }); | |
| 530 | }), | |
| 531 | ); |
apps/server/src/provider/Layers/ProviderHealth.test.ts deleted-640| ... | ... | @@ -1,640 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { describe, it, assert } from "@effect/vitest"; | |
| 3 | import { Effect, FileSystem, Layer, Path, Sink, Stream } from "effect"; | |
| 4 | import * as PlatformError from "effect/PlatformError"; | |
| 5 | import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 6 | ||
| 7 | import { | |
| 8 | checkClaudeProviderStatus, | |
| 9 | checkCodexProviderStatus, | |
| 10 | hasCustomModelProvider, | |
| 11 | parseAuthStatusFromOutput, | |
| 12 | parseClaudeAuthStatusFromOutput, | |
| 13 | readCodexConfigModelProvider, | |
| 14 | } from "./ProviderHealth"; | |
| 15 | ||
| 16 | // ── Test helpers ──────────────────────────────────────────────────── | |
| 17 | ||
| 18 | const encoder = new TextEncoder(); | |
| 19 | ||
| 20 | function mockHandle(result: { stdout: string; stderr: string; code: number }) { | |
| 21 | return ChildProcessSpawner.makeHandle({ | |
| 22 | pid: ChildProcessSpawner.ProcessId(1), | |
| 23 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code)), | |
| 24 | isRunning: Effect.succeed(false), | |
| 25 | kill: () => Effect.void, | |
| 26 | stdin: Sink.drain, | |
| 27 | stdout: Stream.make(encoder.encode(result.stdout)), | |
| 28 | stderr: Stream.make(encoder.encode(result.stderr)), | |
| 29 | all: Stream.empty, | |
| 30 | getInputFd: () => Sink.drain, | |
| 31 | getOutputFd: () => Stream.empty, | |
| 32 | }); | |
| 33 | } | |
| 34 | ||
| 35 | function mockSpawnerLayer( | |
| 36 | handler: (args: ReadonlyArray<string>) => { stdout: string; stderr: string; code: number }, | |
| 37 | ) { | |
| 38 | return Layer.succeed( | |
| 39 | ChildProcessSpawner.ChildProcessSpawner, | |
| 40 | ChildProcessSpawner.make((command) => { | |
| 41 | const cmd = command as unknown as { args: ReadonlyArray<string> }; | |
| 42 | return Effect.succeed(mockHandle(handler(cmd.args))); | |
| 43 | }), | |
| 44 | ); | |
| 45 | } | |
| 46 | ||
| 47 | function failingSpawnerLayer(description: string) { | |
| 48 | return Layer.succeed( | |
| 49 | ChildProcessSpawner.ChildProcessSpawner, | |
| 50 | ChildProcessSpawner.make(() => | |
| 51 | Effect.fail( | |
| 52 | PlatformError.systemError({ | |
| 53 | _tag: "NotFound", | |
| 54 | module: "ChildProcess", | |
| 55 | method: "spawn", | |
| 56 | description, | |
| 57 | }), | |
| 58 | ), | |
| 59 | ), | |
| 60 | ); | |
| 61 | } | |
| 62 | ||
| 63 | /** | |
| 64 | * Create a temporary CODEX_HOME scoped to the current Effect test. | |
| 65 | * Cleanup is registered in the test scope rather than via Vitest hooks. | |
| 66 | */ | |
| 67 | function withTempCodexHome(configContent?: string) { | |
| 68 | return Effect.gen(function* () { | |
| 69 | const fileSystem = yield* FileSystem.FileSystem; | |
| 70 | const path = yield* Path.Path; | |
| 71 | const tmpDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-test-codex-" }); | |
| 72 | ||
| 73 | yield* Effect.acquireRelease( | |
| 74 | Effect.sync(() => { | |
| 75 | const originalCodexHome = process.env.CODEX_HOME; | |
| 76 | process.env.CODEX_HOME = tmpDir; | |
| 77 | return originalCodexHome; | |
| 78 | }), | |
| 79 | (originalCodexHome) => | |
| 80 | Effect.sync(() => { | |
| 81 | if (originalCodexHome !== undefined) { | |
| 82 | process.env.CODEX_HOME = originalCodexHome; | |
| 83 | } else { | |
| 84 | delete process.env.CODEX_HOME; | |
| 85 | } | |
| 86 | }), | |
| 87 | ); | |
| 88 | ||
| 89 | if (configContent !== undefined) { | |
| 90 | yield* fileSystem.writeFileString(path.join(tmpDir, "config.toml"), configContent); | |
| 91 | } | |
| 92 | ||
| 93 | return { tmpDir } as const; | |
| 94 | }); | |
| 95 | } | |
| 96 | ||
| 97 | it.layer(NodeServices.layer)("ProviderHealth", (it) => { | |
| 98 | // ── checkCodexProviderStatus tests ──────────────────────────────── | |
| 99 | // | |
| 100 | // These tests control CODEX_HOME to ensure the custom-provider detection | |
| 101 | // in hasCustomModelProvider() does not interfere with the auth-probe | |
| 102 | // path being tested. | |
| 103 | ||
| 104 | describe("checkCodexProviderStatus", () => { | |
| 105 | it.effect("returns ready when codex is installed and authenticated", () => | |
| 106 | Effect.gen(function* () { | |
| 107 | // Point CODEX_HOME at an empty tmp dir (no config.toml) so the | |
| 108 | // default code path (OpenAI provider, auth probe runs) is exercised. | |
| 109 | yield* withTempCodexHome(); | |
| 110 | const status = yield* checkCodexProviderStatus; | |
| 111 | assert.strictEqual(status.provider, "codex"); | |
| 112 | assert.strictEqual(status.status, "ready"); | |
| 113 | assert.strictEqual(status.available, true); | |
| 114 | assert.strictEqual(status.authStatus, "authenticated"); | |
| 115 | }).pipe( | |
| 116 | Effect.provide( | |
| 117 | mockSpawnerLayer((args) => { | |
| 118 | const joined = args.join(" "); | |
| 119 | if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; | |
| 120 | if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; | |
| 121 | throw new Error(`Unexpected args: ${joined}`); | |
| 122 | }), | |
| 123 | ), | |
| 124 | ), | |
| 125 | ); | |
| 126 | ||
| 127 | it.effect("returns unavailable when codex is missing", () => | |
| 128 | Effect.gen(function* () { | |
| 129 | yield* withTempCodexHome(); | |
| 130 | const status = yield* checkCodexProviderStatus; | |
| 131 | assert.strictEqual(status.provider, "codex"); | |
| 132 | assert.strictEqual(status.status, "error"); | |
| 133 | assert.strictEqual(status.available, false); | |
| 134 | assert.strictEqual(status.authStatus, "unknown"); | |
| 135 | assert.strictEqual(status.message, "Codex CLI (`codex`) is not installed or not on PATH."); | |
| 136 | }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), | |
| 137 | ); | |
| 138 | ||
| 139 | it.effect("returns unavailable when codex is below the minimum supported version", () => | |
| 140 | Effect.gen(function* () { | |
| 141 | yield* withTempCodexHome(); | |
| 142 | const status = yield* checkCodexProviderStatus; | |
| 143 | assert.strictEqual(status.provider, "codex"); | |
| 144 | assert.strictEqual(status.status, "error"); | |
| 145 | assert.strictEqual(status.available, false); | |
| 146 | assert.strictEqual(status.authStatus, "unknown"); | |
| 147 | assert.strictEqual( | |
| 148 | status.message, | |
| 149 | "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", | |
| 150 | ); | |
| 151 | }).pipe( | |
| 152 | Effect.provide( | |
| 153 | mockSpawnerLayer((args) => { | |
| 154 | const joined = args.join(" "); | |
| 155 | if (joined === "--version") return { stdout: "codex 0.36.0\n", stderr: "", code: 0 }; | |
| 156 | throw new Error(`Unexpected args: ${joined}`); | |
| 157 | }), | |
| 158 | ), | |
| 159 | ), | |
| 160 | ); | |
| 161 | ||
| 162 | it.effect("returns unauthenticated when auth probe reports login required", () => | |
| 163 | Effect.gen(function* () { | |
| 164 | yield* withTempCodexHome(); | |
| 165 | const status = yield* checkCodexProviderStatus; | |
| 166 | assert.strictEqual(status.provider, "codex"); | |
| 167 | assert.strictEqual(status.status, "error"); | |
| 168 | assert.strictEqual(status.available, true); | |
| 169 | assert.strictEqual(status.authStatus, "unauthenticated"); | |
| 170 | assert.strictEqual( | |
| 171 | status.message, | |
| 172 | "Codex CLI is not authenticated. Run `codex login` and try again.", | |
| 173 | ); | |
| 174 | }).pipe( | |
| 175 | Effect.provide( | |
| 176 | mockSpawnerLayer((args) => { | |
| 177 | const joined = args.join(" "); | |
| 178 | if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; | |
| 179 | if (joined === "login status") { | |
| 180 | return { stdout: "", stderr: "Not logged in. Run codex login.", code: 1 }; | |
| 181 | } | |
| 182 | throw new Error(`Unexpected args: ${joined}`); | |
| 183 | }), | |
| 184 | ), | |
| 185 | ), | |
| 186 | ); | |
| 187 | ||
| 188 | it.effect("returns unauthenticated when login status output includes 'not logged in'", () => | |
| 189 | Effect.gen(function* () { | |
| 190 | yield* withTempCodexHome(); | |
| 191 | const status = yield* checkCodexProviderStatus; | |
| 192 | assert.strictEqual(status.provider, "codex"); | |
| 193 | assert.strictEqual(status.status, "error"); | |
| 194 | assert.strictEqual(status.available, true); | |
| 195 | assert.strictEqual(status.authStatus, "unauthenticated"); | |
| 196 | assert.strictEqual( | |
| 197 | status.message, | |
| 198 | "Codex CLI is not authenticated. Run `codex login` and try again.", | |
| 199 | ); | |
| 200 | }).pipe( | |
| 201 | Effect.provide( | |
| 202 | mockSpawnerLayer((args) => { | |
| 203 | const joined = args.join(" "); | |
| 204 | if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; | |
| 205 | if (joined === "login status") | |
| 206 | return { stdout: "Not logged in\n", stderr: "", code: 1 }; | |
| 207 | throw new Error(`Unexpected args: ${joined}`); | |
| 208 | }), | |
| 209 | ), | |
| 210 | ), | |
| 211 | ); | |
| 212 | ||
| 213 | it.effect("returns warning when login status command is unsupported", () => | |
| 214 | Effect.gen(function* () { | |
| 215 | yield* withTempCodexHome(); | |
| 216 | const status = yield* checkCodexProviderStatus; | |
| 217 | assert.strictEqual(status.provider, "codex"); | |
| 218 | assert.strictEqual(status.status, "warning"); | |
| 219 | assert.strictEqual(status.available, true); | |
| 220 | assert.strictEqual(status.authStatus, "unknown"); | |
| 221 | assert.strictEqual( | |
| 222 | status.message, | |
| 223 | "Codex CLI authentication status command is unavailable in this Codex version.", | |
| 224 | ); | |
| 225 | }).pipe( | |
| 226 | Effect.provide( | |
| 227 | mockSpawnerLayer((args) => { | |
| 228 | const joined = args.join(" "); | |
| 229 | if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; | |
| 230 | if (joined === "login status") { | |
| 231 | return { stdout: "", stderr: "error: unknown command 'login'", code: 2 }; | |
| 232 | } | |
| 233 | throw new Error(`Unexpected args: ${joined}`); | |
| 234 | }), | |
| 235 | ), | |
| 236 | ), | |
| 237 | ); | |
| 238 | }); | |
| 239 | ||
| 240 | // ── Custom model provider: checkCodexProviderStatus integration ─── | |
| 241 | ||
| 242 | describe("checkCodexProviderStatus with custom model provider", () => { | |
| 243 | it.effect("skips auth probe and returns ready when a custom model provider is configured", () => | |
| 244 | Effect.gen(function* () { | |
| 245 | yield* withTempCodexHome( | |
| 246 | [ | |
| 247 | 'model_provider = "portkey"', | |
| 248 | "", | |
| 249 | "[model_providers.portkey]", | |
| 250 | 'base_url = "https://api.portkey.ai/v1"', | |
| 251 | 'env_key = "PORTKEY_API_KEY"', | |
| 252 | ].join("\n"), | |
| 253 | ); | |
| 254 | const status = yield* checkCodexProviderStatus; | |
| 255 | assert.strictEqual(status.provider, "codex"); | |
| 256 | assert.strictEqual(status.status, "ready"); | |
| 257 | assert.strictEqual(status.available, true); | |
| 258 | assert.strictEqual(status.authStatus, "unknown"); | |
| 259 | assert.strictEqual( | |
| 260 | status.message, | |
| 261 | "Using a custom Codex model provider; OpenAI login check skipped.", | |
| 262 | ); | |
| 263 | }).pipe( | |
| 264 | Effect.provide( | |
| 265 | // The spawner only handles --version; if the test attempts | |
| 266 | // "login status" the throw proves the auth probe was NOT skipped. | |
| 267 | mockSpawnerLayer((args) => { | |
| 268 | const joined = args.join(" "); | |
| 269 | if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; | |
| 270 | throw new Error(`Auth probe should have been skipped but got args: ${joined}`); | |
| 271 | }), | |
| 272 | ), | |
| 273 | ), | |
| 274 | ); | |
| 275 | ||
| 276 | it.effect("still reports error when codex CLI is missing even with custom provider", () => | |
| 277 | Effect.gen(function* () { | |
| 278 | yield* withTempCodexHome( | |
| 279 | [ | |
| 280 | 'model_provider = "portkey"', | |
| 281 | "", | |
| 282 | "[model_providers.portkey]", | |
| 283 | 'base_url = "https://api.portkey.ai/v1"', | |
| 284 | 'env_key = "PORTKEY_API_KEY"', | |
| 285 | ].join("\n"), | |
| 286 | ); | |
| 287 | const status = yield* checkCodexProviderStatus; | |
| 288 | assert.strictEqual(status.status, "error"); | |
| 289 | assert.strictEqual(status.available, false); | |
| 290 | }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), | |
| 291 | ); | |
| 292 | }); | |
| 293 | ||
| 294 | describe("checkCodexProviderStatus with openai model provider", () => { | |
| 295 | it.effect("still runs auth probe when model_provider is openai", () => | |
| 296 | Effect.gen(function* () { | |
| 297 | yield* withTempCodexHome('model_provider = "openai"\n'); | |
| 298 | const status = yield* checkCodexProviderStatus; | |
| 299 | // The auth probe runs and sees "not logged in" → error | |
| 300 | assert.strictEqual(status.status, "error"); | |
| 301 | assert.strictEqual(status.authStatus, "unauthenticated"); | |
| 302 | }).pipe( | |
| 303 | Effect.provide( | |
| 304 | mockSpawnerLayer((args) => { | |
| 305 | const joined = args.join(" "); | |
| 306 | if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; | |
| 307 | if (joined === "login status") | |
| 308 | return { stdout: "Not logged in\n", stderr: "", code: 1 }; | |
| 309 | throw new Error(`Unexpected args: ${joined}`); | |
| 310 | }), | |
| 311 | ), | |
| 312 | ), | |
| 313 | ); | |
| 314 | }); | |
| 315 | ||
| 316 | // ── parseAuthStatusFromOutput pure tests ────────────────────────── | |
| 317 | ||
| 318 | describe("parseAuthStatusFromOutput", () => { | |
| 319 | it("exit code 0 with no auth markers is ready", () => { | |
| 320 | const parsed = parseAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); | |
| 321 | assert.strictEqual(parsed.status, "ready"); | |
| 322 | assert.strictEqual(parsed.authStatus, "authenticated"); | |
| 323 | }); | |
| 324 | ||
| 325 | it("JSON with authenticated=false is unauthenticated", () => { | |
| 326 | const parsed = parseAuthStatusFromOutput({ | |
| 327 | stdout: '[{"authenticated":false}]\n', | |
| 328 | stderr: "", | |
| 329 | code: 0, | |
| 330 | }); | |
| 331 | assert.strictEqual(parsed.status, "error"); | |
| 332 | assert.strictEqual(parsed.authStatus, "unauthenticated"); | |
| 333 | }); | |
| 334 | ||
| 335 | it("JSON without auth marker is warning", () => { | |
| 336 | const parsed = parseAuthStatusFromOutput({ | |
| 337 | stdout: '[{"ok":true}]\n', | |
| 338 | stderr: "", | |
| 339 | code: 0, | |
| 340 | }); | |
| 341 | assert.strictEqual(parsed.status, "warning"); | |
| 342 | assert.strictEqual(parsed.authStatus, "unknown"); | |
| 343 | }); | |
| 344 | }); | |
| 345 | ||
| 346 | // ── readCodexConfigModelProvider tests ───────────────────────────── | |
| 347 | ||
| 348 | describe("readCodexConfigModelProvider", () => { | |
| 349 | it.effect("returns undefined when config file does not exist", () => | |
| 350 | Effect.gen(function* () { | |
| 351 | yield* withTempCodexHome(); | |
| 352 | assert.strictEqual(yield* readCodexConfigModelProvider, undefined); | |
| 353 | }), | |
| 354 | ); | |
| 355 | ||
| 356 | it.effect("returns undefined when config has no model_provider key", () => | |
| 357 | Effect.gen(function* () { | |
| 358 | yield* withTempCodexHome('model = "gpt-5-codex"\n'); | |
| 359 | assert.strictEqual(yield* readCodexConfigModelProvider, undefined); | |
| 360 | }), | |
| 361 | ); | |
| 362 | ||
| 363 | it.effect("returns the provider when model_provider is set at top level", () => | |
| 364 | Effect.gen(function* () { | |
| 365 | yield* withTempCodexHome('model = "gpt-5-codex"\nmodel_provider = "portkey"\n'); | |
| 366 | assert.strictEqual(yield* readCodexConfigModelProvider, "portkey"); | |
| 367 | }), | |
| 368 | ); | |
| 369 | ||
| 370 | it.effect("returns openai when model_provider is openai", () => | |
| 371 | Effect.gen(function* () { | |
| 372 | yield* withTempCodexHome('model_provider = "openai"\n'); | |
| 373 | assert.strictEqual(yield* readCodexConfigModelProvider, "openai"); | |
| 374 | }), | |
| 375 | ); | |
| 376 | ||
| 377 | it.effect("ignores model_provider inside section headers", () => | |
| 378 | Effect.gen(function* () { | |
| 379 | yield* withTempCodexHome( | |
| 380 | [ | |
| 381 | 'model = "gpt-5-codex"', | |
| 382 | "", | |
| 383 | "[model_providers.portkey]", | |
| 384 | 'base_url = "https://api.portkey.ai/v1"', | |
| 385 | 'model_provider = "should-be-ignored"', | |
| 386 | "", | |
| 387 | ].join("\n"), | |
| 388 | ); | |
| 389 | assert.strictEqual(yield* readCodexConfigModelProvider, undefined); | |
| 390 | }), | |
| 391 | ); | |
| 392 | ||
| 393 | it.effect("handles comments and whitespace", () => | |
| 394 | Effect.gen(function* () { | |
| 395 | yield* withTempCodexHome( | |
| 396 | [ | |
| 397 | "# This is a comment", | |
| 398 | "", | |
| 399 | ' model_provider = "azure" ', | |
| 400 | "", | |
| 401 | "[profiles.deep-review]", | |
| 402 | 'model = "gpt-5-pro"', | |
| 403 | ].join("\n"), | |
| 404 | ); | |
| 405 | assert.strictEqual(yield* readCodexConfigModelProvider, "azure"); | |
| 406 | }), | |
| 407 | ); | |
| 408 | ||
| 409 | it.effect("handles single-quoted values in TOML", () => | |
| 410 | Effect.gen(function* () { | |
| 411 | yield* withTempCodexHome("model_provider = 'mistral'\n"); | |
| 412 | assert.strictEqual(yield* readCodexConfigModelProvider, "mistral"); | |
| 413 | }), | |
| 414 | ); | |
| 415 | }); | |
| 416 | ||
| 417 | // ── hasCustomModelProvider tests ─────────────────────────────────── | |
| 418 | ||
| 419 | describe("hasCustomModelProvider", () => { | |
| 420 | it.effect("returns false when no config file exists", () => | |
| 421 | Effect.gen(function* () { | |
| 422 | yield* withTempCodexHome(); | |
| 423 | assert.strictEqual(yield* hasCustomModelProvider, false); | |
| 424 | }), | |
| 425 | ); | |
| 426 | ||
| 427 | it.effect("returns false when model_provider is not set", () => | |
| 428 | Effect.gen(function* () { | |
| 429 | yield* withTempCodexHome('model = "gpt-5-codex"\n'); | |
| 430 | assert.strictEqual(yield* hasCustomModelProvider, false); | |
| 431 | }), | |
| 432 | ); | |
| 433 | ||
| 434 | it.effect("returns false when model_provider is openai", () => | |
| 435 | Effect.gen(function* () { | |
| 436 | yield* withTempCodexHome('model_provider = "openai"\n'); | |
| 437 | assert.strictEqual(yield* hasCustomModelProvider, false); | |
| 438 | }), | |
| 439 | ); | |
| 440 | ||
| 441 | it.effect("returns true when model_provider is portkey", () => | |
| 442 | Effect.gen(function* () { | |
| 443 | yield* withTempCodexHome('model_provider = "portkey"\n'); | |
| 444 | assert.strictEqual(yield* hasCustomModelProvider, true); | |
| 445 | }), | |
| 446 | ); | |
| 447 | ||
| 448 | it.effect("returns true when model_provider is azure", () => | |
| 449 | Effect.gen(function* () { | |
| 450 | yield* withTempCodexHome('model_provider = "azure"\n'); | |
| 451 | assert.strictEqual(yield* hasCustomModelProvider, true); | |
| 452 | }), | |
| 453 | ); | |
| 454 | ||
| 455 | it.effect("returns true when model_provider is ollama", () => | |
| 456 | Effect.gen(function* () { | |
| 457 | yield* withTempCodexHome('model_provider = "ollama"\n'); | |
| 458 | assert.strictEqual(yield* hasCustomModelProvider, true); | |
| 459 | }), | |
| 460 | ); | |
| 461 | ||
| 462 | it.effect("returns true when model_provider is a custom proxy", () => | |
| 463 | Effect.gen(function* () { | |
| 464 | yield* withTempCodexHome('model_provider = "my-company-proxy"\n'); | |
| 465 | assert.strictEqual(yield* hasCustomModelProvider, true); | |
| 466 | }), | |
| 467 | ); | |
| 468 | }); | |
| 469 | ||
| 470 | // ── checkClaudeProviderStatus tests ────────────────────────── | |
| 471 | ||
| 472 | describe("checkClaudeProviderStatus", () => { | |
| 473 | it.effect("returns ready when claude is installed and authenticated", () => | |
| 474 | Effect.gen(function* () { | |
| 475 | const status = yield* checkClaudeProviderStatus; | |
| 476 | assert.strictEqual(status.provider, "claudeAgent"); | |
| 477 | assert.strictEqual(status.status, "ready"); | |
| 478 | assert.strictEqual(status.available, true); | |
| 479 | assert.strictEqual(status.authStatus, "authenticated"); | |
| 480 | }).pipe( | |
| 481 | Effect.provide( | |
| 482 | mockSpawnerLayer((args) => { | |
| 483 | const joined = args.join(" "); | |
| 484 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | |
| 485 | if (joined === "auth status") | |
| 486 | return { | |
| 487 | stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', | |
| 488 | stderr: "", | |
| 489 | code: 0, | |
| 490 | }; | |
| 491 | throw new Error(`Unexpected args: ${joined}`); | |
| 492 | }), | |
| 493 | ), | |
| 494 | ), | |
| 495 | ); | |
| 496 | ||
| 497 | it.effect("returns unavailable when claude is missing", () => | |
| 498 | Effect.gen(function* () { | |
| 499 | const status = yield* checkClaudeProviderStatus; | |
| 500 | assert.strictEqual(status.provider, "claudeAgent"); | |
| 501 | assert.strictEqual(status.status, "error"); | |
| 502 | assert.strictEqual(status.available, false); | |
| 503 | assert.strictEqual(status.authStatus, "unknown"); | |
| 504 | assert.strictEqual( | |
| 505 | status.message, | |
| 506 | "Claude Agent CLI (`claude`) is not installed or not on PATH.", | |
| 507 | ); | |
| 508 | }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), | |
| 509 | ); | |
| 510 | ||
| 511 | it.effect("returns error when version check fails with non-zero exit code", () => | |
| 512 | Effect.gen(function* () { | |
| 513 | const status = yield* checkClaudeProviderStatus; | |
| 514 | assert.strictEqual(status.provider, "claudeAgent"); | |
| 515 | assert.strictEqual(status.status, "error"); | |
| 516 | assert.strictEqual(status.available, false); | |
| 517 | }).pipe( | |
| 518 | Effect.provide( | |
| 519 | mockSpawnerLayer((args) => { | |
| 520 | const joined = args.join(" "); | |
| 521 | if (joined === "--version") | |
| 522 | return { stdout: "", stderr: "Something went wrong", code: 1 }; | |
| 523 | throw new Error(`Unexpected args: ${joined}`); | |
| 524 | }), | |
| 525 | ), | |
| 526 | ), | |
| 527 | ); | |
| 528 | ||
| 529 | it.effect("returns unauthenticated when auth status reports not logged in", () => | |
| 530 | Effect.gen(function* () { | |
| 531 | const status = yield* checkClaudeProviderStatus; | |
| 532 | assert.strictEqual(status.provider, "claudeAgent"); | |
| 533 | assert.strictEqual(status.status, "error"); | |
| 534 | assert.strictEqual(status.available, true); | |
| 535 | assert.strictEqual(status.authStatus, "unauthenticated"); | |
| 536 | assert.strictEqual( | |
| 537 | status.message, | |
| 538 | "Claude is not authenticated. Run `claude auth login` and try again.", | |
| 539 | ); | |
| 540 | }).pipe( | |
| 541 | Effect.provide( | |
| 542 | mockSpawnerLayer((args) => { | |
| 543 | const joined = args.join(" "); | |
| 544 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | |
| 545 | if (joined === "auth status") | |
| 546 | return { | |
| 547 | stdout: '{"loggedIn":false}\n', | |
| 548 | stderr: "", | |
| 549 | code: 1, | |
| 550 | }; | |
| 551 | throw new Error(`Unexpected args: ${joined}`); | |
| 552 | }), | |
| 553 | ), | |
| 554 | ), | |
| 555 | ); | |
| 556 | ||
| 557 | it.effect("returns unauthenticated when output includes 'not logged in'", () => | |
| 558 | Effect.gen(function* () { | |
| 559 | const status = yield* checkClaudeProviderStatus; | |
| 560 | assert.strictEqual(status.provider, "claudeAgent"); | |
| 561 | assert.strictEqual(status.status, "error"); | |
| 562 | assert.strictEqual(status.available, true); | |
| 563 | assert.strictEqual(status.authStatus, "unauthenticated"); | |
| 564 | }).pipe( | |
| 565 | Effect.provide( | |
| 566 | mockSpawnerLayer((args) => { | |
| 567 | const joined = args.join(" "); | |
| 568 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | |
| 569 | if (joined === "auth status") return { stdout: "Not logged in\n", stderr: "", code: 1 }; | |
| 570 | throw new Error(`Unexpected args: ${joined}`); | |
| 571 | }), | |
| 572 | ), | |
| 573 | ), | |
| 574 | ); | |
| 575 | ||
| 576 | it.effect("returns warning when auth status command is unsupported", () => | |
| 577 | Effect.gen(function* () { | |
| 578 | const status = yield* checkClaudeProviderStatus; | |
| 579 | assert.strictEqual(status.provider, "claudeAgent"); | |
| 580 | assert.strictEqual(status.status, "warning"); | |
| 581 | assert.strictEqual(status.available, true); | |
| 582 | assert.strictEqual(status.authStatus, "unknown"); | |
| 583 | assert.strictEqual( | |
| 584 | status.message, | |
| 585 | "Claude Agent authentication status command is unavailable in this version of Claude.", | |
| 586 | ); | |
| 587 | }).pipe( | |
| 588 | Effect.provide( | |
| 589 | mockSpawnerLayer((args) => { | |
| 590 | const joined = args.join(" "); | |
| 591 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | |
| 592 | if (joined === "auth status") | |
| 593 | return { stdout: "", stderr: "error: unknown command 'auth'", code: 2 }; | |
| 594 | throw new Error(`Unexpected args: ${joined}`); | |
| 595 | }), | |
| 596 | ), | |
| 597 | ), | |
| 598 | ); | |
| 599 | }); | |
| 600 | ||
| 601 | // ── parseClaudeAuthStatusFromOutput pure tests ──────────────────── | |
| 602 | ||
| 603 | describe("parseClaudeAuthStatusFromOutput", () => { | |
| 604 | it("exit code 0 with no auth markers is ready", () => { | |
| 605 | const parsed = parseClaudeAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); | |
| 606 | assert.strictEqual(parsed.status, "ready"); | |
| 607 | assert.strictEqual(parsed.authStatus, "authenticated"); | |
| 608 | }); | |
| 609 | ||
| 610 | it("JSON with loggedIn=true is authenticated", () => { | |
| 611 | const parsed = parseClaudeAuthStatusFromOutput({ | |
| 612 | stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', | |
| 613 | stderr: "", | |
| 614 | code: 0, | |
| 615 | }); | |
| 616 | assert.strictEqual(parsed.status, "ready"); | |
| 617 | assert.strictEqual(parsed.authStatus, "authenticated"); | |
| 618 | }); | |
| 619 | ||
| 620 | it("JSON with loggedIn=false is unauthenticated", () => { | |
| 621 | const parsed = parseClaudeAuthStatusFromOutput({ | |
| 622 | stdout: '{"loggedIn":false}\n', | |
| 623 | stderr: "", | |
| 624 | code: 0, | |
| 625 | }); | |
| 626 | assert.strictEqual(parsed.status, "error"); | |
| 627 | assert.strictEqual(parsed.authStatus, "unauthenticated"); | |
| 628 | }); | |
| 629 | ||
| 630 | it("JSON without auth marker is warning", () => { | |
| 631 | const parsed = parseClaudeAuthStatusFromOutput({ | |
| 632 | stdout: '{"ok":true}\n', | |
| 633 | stderr: "", | |
| 634 | code: 0, | |
| 635 | }); | |
| 636 | assert.strictEqual(parsed.status, "warning"); | |
| 637 | assert.strictEqual(parsed.authStatus, "unknown"); | |
| 638 | }); | |
| 639 | }); | |
| 640 | }); |
apps/server/src/provider/Layers/ProviderHealth.ts deleted-603| ... | ... | @@ -1,603 +0,0 @@ |
| 1 | /** | |
| 2 | * ProviderHealthLive - Startup-time provider health checks. | |
| 3 | * | |
| 4 | * Performs one-time provider readiness probes when the server starts and | |
| 5 | * keeps the resulting snapshot in memory for `server.getConfig`. | |
| 6 | * | |
| 7 | * Uses effect's ChildProcessSpawner to run CLI probes natively. | |
| 8 | * | |
| 9 | * @module ProviderHealthLive | |
| 10 | */ | |
| 11 | import * as OS from "node:os"; | |
| 12 | import type { | |
| 13 | ServerProviderAuthStatus, | |
| 14 | ServerProviderStatus, | |
| 15 | ServerProviderStatusState, | |
| 16 | } from "@t3tools/contracts"; | |
| 17 | import { Array, Effect, Fiber, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; | |
| 18 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 19 | ||
| 20 | import { | |
| 21 | formatCodexCliUpgradeMessage, | |
| 22 | isCodexCliVersionSupported, | |
| 23 | parseCodexCliVersion, | |
| 24 | } from "../codexCliVersion"; | |
| 25 | import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth"; | |
| 26 | ||
| 27 | const DEFAULT_TIMEOUT_MS = 4_000; | |
| 28 | const CODEX_PROVIDER = "codex" as const; | |
| 29 | const CLAUDE_AGENT_PROVIDER = "claudeAgent" as const; | |
| 30 | ||
| 31 | // ── Pure helpers ──────────────────────────────────────────────────── | |
| 32 | ||
| 33 | export interface CommandResult { | |
| 34 | readonly stdout: string; | |
| 35 | readonly stderr: string; | |
| 36 | readonly code: number; | |
| 37 | } | |
| 38 | ||
| 39 | function nonEmptyTrimmed(value: string | undefined): string | undefined { | |
| 40 | if (!value) return undefined; | |
| 41 | const trimmed = value.trim(); | |
| 42 | return trimmed.length > 0 ? trimmed : undefined; | |
| 43 | } | |
| 44 | ||
| 45 | function isCommandMissingCause(error: unknown): boolean { | |
| 46 | if (!(error instanceof Error)) return false; | |
| 47 | const lower = error.message.toLowerCase(); | |
| 48 | return lower.includes("enoent") || lower.includes("notfound"); | |
| 49 | } | |
| 50 | ||
| 51 | function detailFromResult( | |
| 52 | result: CommandResult & { readonly timedOut?: boolean }, | |
| 53 | ): string | undefined { | |
| 54 | if (result.timedOut) return "Timed out while running command."; | |
| 55 | const stderr = nonEmptyTrimmed(result.stderr); | |
| 56 | if (stderr) return stderr; | |
| 57 | const stdout = nonEmptyTrimmed(result.stdout); | |
| 58 | if (stdout) return stdout; | |
| 59 | if (result.code !== 0) { | |
| 60 | return `Command exited with code ${result.code}.`; | |
| 61 | } | |
| 62 | return undefined; | |
| 63 | } | |
| 64 | ||
| 65 | function extractAuthBoolean(value: unknown): boolean | undefined { | |
| 66 | if (Array.isArray(value)) { | |
| 67 | for (const entry of value) { | |
| 68 | const nested = extractAuthBoolean(entry); | |
| 69 | if (nested !== undefined) return nested; | |
| 70 | } | |
| 71 | return undefined; | |
| 72 | } | |
| 73 | ||
| 74 | if (!value || typeof value !== "object") return undefined; | |
| 75 | ||
| 76 | const record = value as Record<string, unknown>; | |
| 77 | for (const key of ["authenticated", "isAuthenticated", "loggedIn", "isLoggedIn"] as const) { | |
| 78 | if (typeof record[key] === "boolean") return record[key]; | |
| 79 | } | |
| 80 | for (const key of ["auth", "status", "session", "account"] as const) { | |
| 81 | const nested = extractAuthBoolean(record[key]); | |
| 82 | if (nested !== undefined) return nested; | |
| 83 | } | |
| 84 | return undefined; | |
| 85 | } | |
| 86 | ||
| 87 | export function parseAuthStatusFromOutput(result: CommandResult): { | |
| 88 | readonly status: ServerProviderStatusState; | |
| 89 | readonly authStatus: ServerProviderAuthStatus; | |
| 90 | readonly message?: string; | |
| 91 | } { | |
| 92 | const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); | |
| 93 | ||
| 94 | if ( | |
| 95 | lowerOutput.includes("unknown command") || | |
| 96 | lowerOutput.includes("unrecognized command") || | |
| 97 | lowerOutput.includes("unexpected argument") | |
| 98 | ) { | |
| 99 | return { | |
| 100 | status: "warning", | |
| 101 | authStatus: "unknown", | |
| 102 | message: "Codex CLI authentication status command is unavailable in this Codex version.", | |
| 103 | }; | |
| 104 | } | |
| 105 | ||
| 106 | if ( | |
| 107 | lowerOutput.includes("not logged in") || | |
| 108 | lowerOutput.includes("login required") || | |
| 109 | lowerOutput.includes("authentication required") || | |
| 110 | lowerOutput.includes("run `codex login`") || | |
| 111 | lowerOutput.includes("run codex login") | |
| 112 | ) { | |
| 113 | return { | |
| 114 | status: "error", | |
| 115 | authStatus: "unauthenticated", | |
| 116 | message: "Codex CLI is not authenticated. Run `codex login` and try again.", | |
| 117 | }; | |
| 118 | } | |
| 119 | ||
| 120 | const parsedAuth = (() => { | |
| 121 | const trimmed = result.stdout.trim(); | |
| 122 | if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { | |
| 123 | return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; | |
| 124 | } | |
| 125 | try { | |
| 126 | return { | |
| 127 | attemptedJsonParse: true as const, | |
| 128 | auth: extractAuthBoolean(JSON.parse(trimmed)), | |
| 129 | }; | |
| 130 | } catch { | |
| 131 | return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; | |
| 132 | } | |
| 133 | })(); | |
| 134 | ||
| 135 | if (parsedAuth.auth === true) { | |
| 136 | return { status: "ready", authStatus: "authenticated" }; | |
| 137 | } | |
| 138 | if (parsedAuth.auth === false) { | |
| 139 | return { | |
| 140 | status: "error", | |
| 141 | authStatus: "unauthenticated", | |
| 142 | message: "Codex CLI is not authenticated. Run `codex login` and try again.", | |
| 143 | }; | |
| 144 | } | |
| 145 | if (parsedAuth.attemptedJsonParse) { | |
| 146 | return { | |
| 147 | status: "warning", | |
| 148 | authStatus: "unknown", | |
| 149 | message: | |
| 150 | "Could not verify Codex authentication status from JSON output (missing auth marker).", | |
| 151 | }; | |
| 152 | } | |
| 153 | if (result.code === 0) { | |
| 154 | return { status: "ready", authStatus: "authenticated" }; | |
| 155 | } | |
| 156 | ||
| 157 | const detail = detailFromResult(result); | |
| 158 | return { | |
| 159 | status: "warning", | |
| 160 | authStatus: "unknown", | |
| 161 | message: detail | |
| 162 | ? `Could not verify Codex authentication status. ${detail}` | |
| 163 | : "Could not verify Codex authentication status.", | |
| 164 | }; | |
| 165 | } | |
| 166 | ||
| 167 | // ── Codex CLI config detection ────────────────────────────────────── | |
| 168 | ||
| 169 | /** | |
| 170 | * Providers that use OpenAI-native authentication via `codex login`. | |
| 171 | * When the configured `model_provider` is one of these, the `codex login | |
| 172 | * status` probe still runs. For any other provider value the auth probe | |
| 173 | * is skipped because authentication is handled externally (e.g. via | |
| 174 | * environment variables like `PORTKEY_API_KEY` or `AZURE_API_KEY`). | |
| 175 | */ | |
| 176 | const OPENAI_AUTH_PROVIDERS = new Set(["openai"]); | |
| 177 | ||
| 178 | /** | |
| 179 | * Read the `model_provider` value from the Codex CLI config file. | |
| 180 | * | |
| 181 | * Looks for the file at `$CODEX_HOME/config.toml` (falls back to | |
| 182 | * `~/.codex/config.toml`). Uses a simple line-by-line scan rather than | |
| 183 | * a full TOML parser to avoid adding a dependency for a single key. | |
| 184 | * | |
| 185 | * Returns `undefined` when the file does not exist or does not set | |
| 186 | * `model_provider`. | |
| 187 | */ | |
| 188 | export const readCodexConfigModelProvider = Effect.gen(function* () { | |
| 189 | const fileSystem = yield* FileSystem.FileSystem; | |
| 190 | const path = yield* Path.Path; | |
| 191 | const codexHome = process.env.CODEX_HOME || path.join(OS.homedir(), ".codex"); | |
| 192 | const configPath = path.join(codexHome, "config.toml"); | |
| 193 | ||
| 194 | const content = yield* fileSystem | |
| 195 | .readFileString(configPath) | |
| 196 | .pipe(Effect.orElseSucceed(() => undefined)); | |
| 197 | if (content === undefined) { | |
| 198 | return undefined; | |
| 199 | } | |
| 200 | ||
| 201 | // We need to find `model_provider = "..."` at the top level of the | |
| 202 | // TOML file (i.e. before any `[section]` header). Lines inside | |
| 203 | // `[profiles.*]`, `[model_providers.*]`, etc. are ignored. | |
| 204 | let inTopLevel = true; | |
| 205 | for (const line of content.split("\n")) { | |
| 206 | const trimmed = line.trim(); | |
| 207 | // Skip comments and empty lines. | |
| 208 | if (!trimmed || trimmed.startsWith("#")) continue; | |
| 209 | // Detect section headers — once we leave the top level, stop. | |
| 210 | if (trimmed.startsWith("[")) { | |
| 211 | inTopLevel = false; | |
| 212 | continue; | |
| 213 | } | |
| 214 | if (!inTopLevel) continue; | |
| 215 | ||
| 216 | const match = trimmed.match(/^model_provider\s*=\s*["']([^"']+)["']/); | |
| 217 | if (match) return match[1]; | |
| 218 | } | |
| 219 | return undefined; | |
| 220 | }); | |
| 221 | ||
| 222 | /** | |
| 223 | * Returns `true` when the Codex CLI is configured with a custom | |
| 224 | * (non-OpenAI) model provider, meaning `codex login` auth is not | |
| 225 | * required because authentication is handled through provider-specific | |
| 226 | * environment variables. | |
| 227 | */ | |
| 228 | export const hasCustomModelProvider = Effect.map( | |
| 229 | readCodexConfigModelProvider, | |
| 230 | (provider) => provider !== undefined && !OPENAI_AUTH_PROVIDERS.has(provider), | |
| 231 | ); | |
| 232 | ||
| 233 | // ── Effect-native command execution ───────────────────────────────── | |
| 234 | ||
| 235 | const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> => | |
| 236 | Stream.runFold( | |
| 237 | stream, | |
| 238 | () => "", | |
| 239 | (acc, chunk) => acc + new TextDecoder().decode(chunk), | |
| 240 | ); | |
| 241 | ||
| 242 | const runCodexCommand = (args: ReadonlyArray<string>) => | |
| 243 | Effect.gen(function* () { | |
| 244 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 245 | const command = ChildProcess.make("codex", [...args], { | |
| 246 | shell: process.platform === "win32", | |
| 247 | }); | |
| 248 | ||
| 249 | const child = yield* spawner.spawn(command); | |
| 250 | ||
| 251 | const [stdout, stderr, exitCode] = yield* Effect.all( | |
| 252 | [ | |
| 253 | collectStreamAsString(child.stdout), | |
| 254 | collectStreamAsString(child.stderr), | |
| 255 | child.exitCode.pipe(Effect.map(Number)), | |
| 256 | ], | |
| 257 | { concurrency: "unbounded" }, | |
| 258 | ); | |
| 259 | ||
| 260 | return { stdout, stderr, code: exitCode } satisfies CommandResult; | |
| 261 | }).pipe(Effect.scoped); | |
| 262 | ||
| 263 | const runClaudeCommand = (args: ReadonlyArray<string>) => | |
| 264 | Effect.gen(function* () { | |
| 265 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 266 | const command = ChildProcess.make("claude", [...args], { | |
| 267 | shell: process.platform === "win32", | |
| 268 | }); | |
| 269 | ||
| 270 | const child = yield* spawner.spawn(command); | |
| 271 | ||
| 272 | const [stdout, stderr, exitCode] = yield* Effect.all( | |
| 273 | [ | |
| 274 | collectStreamAsString(child.stdout), | |
| 275 | collectStreamAsString(child.stderr), | |
| 276 | child.exitCode.pipe(Effect.map(Number)), | |
| 277 | ], | |
| 278 | { concurrency: "unbounded" }, | |
| 279 | ); | |
| 280 | ||
| 281 | return { stdout, stderr, code: exitCode } satisfies CommandResult; | |
| 282 | }).pipe(Effect.scoped); | |
| 283 | ||
| 284 | // ── Health check ──────────────────────────────────────────────────── | |
| 285 | ||
| 286 | export const checkCodexProviderStatus: Effect.Effect< | |
| 287 | ServerProviderStatus, | |
| 288 | never, | |
| 289 | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | |
| 290 | > = Effect.gen(function* () { | |
| 291 | const checkedAt = new Date().toISOString(); | |
| 292 | ||
| 293 | // Probe 1: `codex --version` — is the CLI reachable? | |
| 294 | const versionProbe = yield* runCodexCommand(["--version"]).pipe( | |
| 295 | Effect.timeoutOption(DEFAULT_TIMEOUT_MS), | |
| 296 | Effect.result, | |
| 297 | ); | |
| 298 | ||
| 299 | if (Result.isFailure(versionProbe)) { | |
| 300 | const error = versionProbe.failure; | |
| 301 | return { | |
| 302 | provider: CODEX_PROVIDER, | |
| 303 | status: "error" as const, | |
| 304 | available: false, | |
| 305 | authStatus: "unknown" as const, | |
| 306 | checkedAt, | |
| 307 | message: isCommandMissingCause(error) | |
| 308 | ? "Codex CLI (`codex`) is not installed or not on PATH." | |
| 309 | : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`, | |
| 310 | }; | |
| 311 | } | |
| 312 | ||
| 313 | if (Option.isNone(versionProbe.success)) { | |
| 314 | return { | |
| 315 | provider: CODEX_PROVIDER, | |
| 316 | status: "error" as const, | |
| 317 | available: false, | |
| 318 | authStatus: "unknown" as const, | |
| 319 | checkedAt, | |
| 320 | message: "Codex CLI is installed but failed to run. Timed out while running command.", | |
| 321 | }; | |
| 322 | } | |
| 323 | ||
| 324 | const version = versionProbe.success.value; | |
| 325 | if (version.code !== 0) { | |
| 326 | const detail = detailFromResult(version); | |
| 327 | return { | |
| 328 | provider: CODEX_PROVIDER, | |
| 329 | status: "error" as const, | |
| 330 | available: false, | |
| 331 | authStatus: "unknown" as const, | |
| 332 | checkedAt, | |
| 333 | message: detail | |
| 334 | ? `Codex CLI is installed but failed to run. ${detail}` | |
| 335 | : "Codex CLI is installed but failed to run.", | |
| 336 | }; | |
| 337 | } | |
| 338 | ||
| 339 | const parsedVersion = parseCodexCliVersion(`${version.stdout}\n${version.stderr}`); | |
| 340 | if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) { | |
| 341 | return { | |
| 342 | provider: CODEX_PROVIDER, | |
| 343 | status: "error" as const, | |
| 344 | available: false, | |
| 345 | authStatus: "unknown" as const, | |
| 346 | checkedAt, | |
| 347 | message: formatCodexCliUpgradeMessage(parsedVersion), | |
| 348 | }; | |
| 349 | } | |
| 350 | ||
| 351 | // Probe 2: `codex login status` — is the user authenticated? | |
| 352 | // | |
| 353 | // Custom model providers (e.g. Portkey, Azure OpenAI proxy) handle | |
| 354 | // authentication through their own environment variables, so `codex | |
| 355 | // login status` will report "not logged in" even when the CLI works | |
| 356 | // fine. Skip the auth probe entirely for non-OpenAI providers. | |
| 357 | if (yield* hasCustomModelProvider) { | |
| 358 | return { | |
| 359 | provider: CODEX_PROVIDER, | |
| 360 | status: "ready" as const, | |
| 361 | available: true, | |
| 362 | authStatus: "unknown" as const, | |
| 363 | checkedAt, | |
| 364 | message: "Using a custom Codex model provider; OpenAI login check skipped.", | |
| 365 | } satisfies ServerProviderStatus; | |
| 366 | } | |
| 367 | ||
| 368 | const authProbe = yield* runCodexCommand(["login", "status"]).pipe( | |
| 369 | Effect.timeoutOption(DEFAULT_TIMEOUT_MS), | |
| 370 | Effect.result, | |
| 371 | ); | |
| 372 | ||
| 373 | if (Result.isFailure(authProbe)) { | |
| 374 | const error = authProbe.failure; | |
| 375 | return { | |
| 376 | provider: CODEX_PROVIDER, | |
| 377 | status: "warning" as const, | |
| 378 | available: true, | |
| 379 | authStatus: "unknown" as const, | |
| 380 | checkedAt, | |
| 381 | message: | |
| 382 | error instanceof Error | |
| 383 | ? `Could not verify Codex authentication status: ${error.message}.` | |
| 384 | : "Could not verify Codex authentication status.", | |
| 385 | }; | |
| 386 | } | |
| 387 | ||
| 388 | if (Option.isNone(authProbe.success)) { | |
| 389 | return { | |
| 390 | provider: CODEX_PROVIDER, | |
| 391 | status: "warning" as const, | |
| 392 | available: true, | |
| 393 | authStatus: "unknown" as const, | |
| 394 | checkedAt, | |
| 395 | message: "Could not verify Codex authentication status. Timed out while running command.", | |
| 396 | }; | |
| 397 | } | |
| 398 | ||
| 399 | const parsed = parseAuthStatusFromOutput(authProbe.success.value); | |
| 400 | return { | |
| 401 | provider: CODEX_PROVIDER, | |
| 402 | status: parsed.status, | |
| 403 | available: true, | |
| 404 | authStatus: parsed.authStatus, | |
| 405 | checkedAt, | |
| 406 | ...(parsed.message ? { message: parsed.message } : {}), | |
| 407 | } satisfies ServerProviderStatus; | |
| 408 | }); | |
| 409 | ||
| 410 | // ── Claude Agent health check ─────────────────────────────────────── | |
| 411 | ||
| 412 | export function parseClaudeAuthStatusFromOutput(result: CommandResult): { | |
| 413 | readonly status: ServerProviderStatusState; | |
| 414 | readonly authStatus: ServerProviderAuthStatus; | |
| 415 | readonly message?: string; | |
| 416 | } { | |
| 417 | const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); | |
| 418 | ||
| 419 | if ( | |
| 420 | lowerOutput.includes("unknown command") || | |
| 421 | lowerOutput.includes("unrecognized command") || | |
| 422 | lowerOutput.includes("unexpected argument") | |
| 423 | ) { | |
| 424 | return { | |
| 425 | status: "warning", | |
| 426 | authStatus: "unknown", | |
| 427 | message: | |
| 428 | "Claude Agent authentication status command is unavailable in this version of Claude.", | |
| 429 | }; | |
| 430 | } | |
| 431 | ||
| 432 | if ( | |
| 433 | lowerOutput.includes("not logged in") || | |
| 434 | lowerOutput.includes("login required") || | |
| 435 | lowerOutput.includes("authentication required") || | |
| 436 | lowerOutput.includes("run `claude login`") || | |
| 437 | lowerOutput.includes("run claude login") | |
| 438 | ) { | |
| 439 | return { | |
| 440 | status: "error", | |
| 441 | authStatus: "unauthenticated", | |
| 442 | message: "Claude is not authenticated. Run `claude auth login` and try again.", | |
| 443 | }; | |
| 444 | } | |
| 445 | ||
| 446 | // `claude auth status` returns JSON with a `loggedIn` boolean. | |
| 447 | const parsedAuth = (() => { | |
| 448 | const trimmed = result.stdout.trim(); | |
| 449 | if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { | |
| 450 | return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; | |
| 451 | } | |
| 452 | try { | |
| 453 | return { | |
| 454 | attemptedJsonParse: true as const, | |
| 455 | auth: extractAuthBoolean(JSON.parse(trimmed)), | |
| 456 | }; | |
| 457 | } catch { | |
| 458 | return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; | |
| 459 | } | |
| 460 | })(); | |
| 461 | ||
| 462 | if (parsedAuth.auth === true) { | |
| 463 | return { status: "ready", authStatus: "authenticated" }; | |
| 464 | } | |
| 465 | if (parsedAuth.auth === false) { | |
| 466 | return { | |
| 467 | status: "error", | |
| 468 | authStatus: "unauthenticated", | |
| 469 | message: "Claude is not authenticated. Run `claude auth login` and try again.", | |
| 470 | }; | |
| 471 | } | |
| 472 | if (parsedAuth.attemptedJsonParse) { | |
| 473 | return { | |
| 474 | status: "warning", | |
| 475 | authStatus: "unknown", | |
| 476 | message: | |
| 477 | "Could not verify Claude authentication status from JSON output (missing auth marker).", | |
| 478 | }; | |
| 479 | } | |
| 480 | if (result.code === 0) { | |
| 481 | return { status: "ready", authStatus: "authenticated" }; | |
| 482 | } | |
| 483 | ||
| 484 | const detail = detailFromResult(result); | |
| 485 | return { | |
| 486 | status: "warning", | |
| 487 | authStatus: "unknown", | |
| 488 | message: detail | |
| 489 | ? `Could not verify Claude authentication status. ${detail}` | |
| 490 | : "Could not verify Claude authentication status.", | |
| 491 | }; | |
| 492 | } | |
| 493 | ||
| 494 | export const checkClaudeProviderStatus: Effect.Effect< | |
| 495 | ServerProviderStatus, | |
| 496 | never, | |
| 497 | ChildProcessSpawner.ChildProcessSpawner | |
| 498 | > = Effect.gen(function* () { | |
| 499 | const checkedAt = new Date().toISOString(); | |
| 500 | ||
| 501 | // Probe 1: `claude --version` — is the CLI reachable? | |
| 502 | const versionProbe = yield* runClaudeCommand(["--version"]).pipe( | |
| 503 | Effect.timeoutOption(DEFAULT_TIMEOUT_MS), | |
| 504 | Effect.result, | |
| 505 | ); | |
| 506 | ||
| 507 | if (Result.isFailure(versionProbe)) { | |
| 508 | const error = versionProbe.failure; | |
| 509 | return { | |
| 510 | provider: CLAUDE_AGENT_PROVIDER, | |
| 511 | status: "error" as const, | |
| 512 | available: false, | |
| 513 | authStatus: "unknown" as const, | |
| 514 | checkedAt, | |
| 515 | message: isCommandMissingCause(error) | |
| 516 | ? "Claude Agent CLI (`claude`) is not installed or not on PATH." | |
| 517 | : `Failed to execute Claude Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, | |
| 518 | }; | |
| 519 | } | |
| 520 | ||
| 521 | if (Option.isNone(versionProbe.success)) { | |
| 522 | return { | |
| 523 | provider: CLAUDE_AGENT_PROVIDER, | |
| 524 | status: "error" as const, | |
| 525 | available: false, | |
| 526 | authStatus: "unknown" as const, | |
| 527 | checkedAt, | |
| 528 | message: "Claude Agent CLI is installed but failed to run. Timed out while running command.", | |
| 529 | }; | |
| 530 | } | |
| 531 | ||
| 532 | const version = versionProbe.success.value; | |
| 533 | if (version.code !== 0) { | |
| 534 | const detail = detailFromResult(version); | |
| 535 | return { | |
| 536 | provider: CLAUDE_AGENT_PROVIDER, | |
| 537 | status: "error" as const, | |
| 538 | available: false, | |
| 539 | authStatus: "unknown" as const, | |
| 540 | checkedAt, | |
| 541 | message: detail | |
| 542 | ? `Claude Agent CLI is installed but failed to run. ${detail}` | |
| 543 | : "Claude Agent CLI is installed but failed to run.", | |
| 544 | }; | |
| 545 | } | |
| 546 | ||
| 547 | // Probe 2: `claude auth status` — is the user authenticated? | |
| 548 | const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe( | |
| 549 | Effect.timeoutOption(DEFAULT_TIMEOUT_MS), | |
| 550 | Effect.result, | |
| 551 | ); | |
| 552 | ||
| 553 | if (Result.isFailure(authProbe)) { | |
| 554 | const error = authProbe.failure; | |
| 555 | return { | |
| 556 | provider: CLAUDE_AGENT_PROVIDER, | |
| 557 | status: "warning" as const, | |
| 558 | available: true, | |
| 559 | authStatus: "unknown" as const, | |
| 560 | checkedAt, | |
| 561 | message: | |
| 562 | error instanceof Error | |
| 563 | ? `Could not verify Claude authentication status: ${error.message}.` | |
| 564 | : "Could not verify Claude authentication status.", | |
| 565 | }; | |
| 566 | } | |
| 567 | ||
| 568 | if (Option.isNone(authProbe.success)) { | |
| 569 | return { | |
| 570 | provider: CLAUDE_AGENT_PROVIDER, | |
| 571 | status: "warning" as const, | |
| 572 | available: true, | |
| 573 | authStatus: "unknown" as const, | |
| 574 | checkedAt, | |
| 575 | message: "Could not verify Claude authentication status. Timed out while running command.", | |
| 576 | }; | |
| 577 | } | |
| 578 | ||
| 579 | const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value); | |
| 580 | return { | |
| 581 | provider: CLAUDE_AGENT_PROVIDER, | |
| 582 | status: parsed.status, | |
| 583 | available: true, | |
| 584 | authStatus: parsed.authStatus, | |
| 585 | checkedAt, | |
| 586 | ...(parsed.message ? { message: parsed.message } : {}), | |
| 587 | } satisfies ServerProviderStatus; | |
| 588 | }); | |
| 589 | ||
| 590 | // ── Layer ─────────────────────────────────────────────────────────── | |
| 591 | ||
| 592 | export const ProviderHealthLive = Layer.effect( | |
| 593 | ProviderHealth, | |
| 594 | Effect.gen(function* () { | |
| 595 | const statusesFiber = yield* Effect.all([checkCodexProviderStatus, checkClaudeProviderStatus], { | |
| 596 | concurrency: "unbounded", | |
| 597 | }).pipe(Effect.forkScoped); | |
| 598 | ||
| 599 | return { | |
| 600 | getStatuses: Fiber.join(statusesFiber), | |
| 601 | } satisfies ProviderHealthShape; | |
| 602 | }), | |
| 603 | ); |
apps/server/src/provider/Layers/ProviderRegistry.test.ts created+879| ... | ... | @@ -0,0 +1,879 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { describe, it, assert } from "@effect/vitest"; | |
| 3 | import { | |
| 4 | Effect, | |
| 5 | Exit, | |
| 6 | FileSystem, | |
| 7 | Layer, | |
| 8 | Path, | |
| 9 | PubSub, | |
| 10 | Ref, | |
| 11 | Schema, | |
| 12 | Scope, | |
| 13 | Sink, | |
| 14 | Stream, | |
| 15 | } from "effect"; | |
| 16 | import { | |
| 17 | DEFAULT_SERVER_SETTINGS, | |
| 18 | ServerSettings, | |
| 19 | type ServerProvider, | |
| 20 | type ServerSettings as ContractServerSettings, | |
| 21 | } from "@t3tools/contracts"; | |
| 22 | import * as PlatformError from "effect/PlatformError"; | |
| 23 | import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 24 | import { deepMerge } from "@t3tools/shared/Struct"; | |
| 25 | ||
| 26 | import { | |
| 27 | checkCodexProviderStatus, | |
| 28 | hasCustomModelProvider, | |
| 29 | parseAuthStatusFromOutput, | |
| 30 | readCodexConfigModelProvider, | |
| 31 | } from "./CodexProvider"; | |
| 32 | import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider"; | |
| 33 | import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry"; | |
| 34 | import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings"; | |
| 35 | import { ProviderRegistry } from "../Services/ProviderRegistry"; | |
| 36 | ||
| 37 | // ── Test helpers ──────────────────────────────────────────────────── | |
| 38 | ||
| 39 | const encoder = new TextEncoder(); | |
| 40 | ||
| 41 | function mockHandle(result: { stdout: string; stderr: string; code: number }) { | |
| 42 | return ChildProcessSpawner.makeHandle({ | |
| 43 | pid: ChildProcessSpawner.ProcessId(1), | |
| 44 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code)), | |
| 45 | isRunning: Effect.succeed(false), | |
| 46 | kill: () => Effect.void, | |
| 47 | stdin: Sink.drain, | |
| 48 | stdout: Stream.make(encoder.encode(result.stdout)), | |
| 49 | stderr: Stream.make(encoder.encode(result.stderr)), | |
| 50 | all: Stream.empty, | |
| 51 | getInputFd: () => Sink.drain, | |
| 52 | getOutputFd: () => Stream.empty, | |
| 53 | }); | |
| 54 | } | |
| 55 | ||
| 56 | function mockSpawnerLayer( | |
| 57 | handler: (args: ReadonlyArray<string>) => { stdout: string; stderr: string; code: number }, | |
| 58 | ) { | |
| 59 | return Layer.succeed( | |
| 60 | ChildProcessSpawner.ChildProcessSpawner, | |
| 61 | ChildProcessSpawner.make((command) => { | |
| 62 | const cmd = command as unknown as { args: ReadonlyArray<string> }; | |
| 63 | return Effect.succeed(mockHandle(handler(cmd.args))); | |
| 64 | }), | |
| 65 | ); | |
| 66 | } | |
| 67 | ||
| 68 | function mockCommandSpawnerLayer( | |
| 69 | handler: ( | |
| 70 | command: string, | |
| 71 | args: ReadonlyArray<string>, | |
| 72 | ) => { stdout: string; stderr: string; code: number }, | |
| 73 | ) { | |
| 74 | return Layer.succeed( | |
| 75 | ChildProcessSpawner.ChildProcessSpawner, | |
| 76 | ChildProcessSpawner.make((command) => { | |
| 77 | const cmd = command as unknown as { command: string; args: ReadonlyArray<string> }; | |
| 78 | return Effect.succeed(mockHandle(handler(cmd.command, cmd.args))); | |
| 79 | }), | |
| 80 | ); | |
| 81 | } | |
| 82 | ||
| 83 | function failingSpawnerLayer(description: string) { | |
| 84 | return Layer.succeed( | |
| 85 | ChildProcessSpawner.ChildProcessSpawner, | |
| 86 | ChildProcessSpawner.make(() => | |
| 87 | Effect.fail( | |
| 88 | PlatformError.systemError({ | |
| 89 | _tag: "NotFound", | |
| 90 | module: "ChildProcess", | |
| 91 | method: "spawn", | |
| 92 | description, | |
| 93 | }), | |
| 94 | ), | |
| 95 | ), | |
| 96 | ); | |
| 97 | } | |
| 98 | ||
| 99 | function makeMutableServerSettingsService( | |
| 100 | initial: ContractServerSettings = DEFAULT_SERVER_SETTINGS, | |
| 101 | ) { | |
| 102 | return Effect.gen(function* () { | |
| 103 | const settingsRef = yield* Ref.make(initial); | |
| 104 | const changes = yield* PubSub.unbounded<ContractServerSettings>(); | |
| 105 | ||
| 106 | return { | |
| 107 | start: Effect.void, | |
| 108 | ready: Effect.void, | |
| 109 | getSettings: Ref.get(settingsRef), | |
| 110 | updateSettings: (patch) => | |
| 111 | Effect.gen(function* () { | |
| 112 | const current = yield* Ref.get(settingsRef); | |
| 113 | const next = Schema.decodeSync(ServerSettings)(deepMerge(current, patch)); | |
| 114 | yield* Ref.set(settingsRef, next); | |
| 115 | yield* PubSub.publish(changes, next); | |
| 116 | return next; | |
| 117 | }), | |
| 118 | streamChanges: Stream.fromPubSub(changes), | |
| 119 | } satisfies ServerSettingsShape; | |
| 120 | }); | |
| 121 | } | |
| 122 | ||
| 123 | /** | |
| 124 | * Create a temporary CODEX_HOME scoped to the current Effect test. | |
| 125 | * Cleanup is registered in the test scope rather than via Vitest hooks. | |
| 126 | */ | |
| 127 | function withTempCodexHome(configContent?: string) { | |
| 128 | return Effect.gen(function* () { | |
| 129 | const fileSystem = yield* FileSystem.FileSystem; | |
| 130 | const path = yield* Path.Path; | |
| 131 | const tmpDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-test-codex-" }); | |
| 132 | ||
| 133 | yield* Effect.acquireRelease( | |
| 134 | Effect.sync(() => { | |
| 135 | const originalCodexHome = process.env.CODEX_HOME; | |
| 136 | process.env.CODEX_HOME = tmpDir; | |
| 137 | return originalCodexHome; | |
| 138 | }), | |
| 139 | (originalCodexHome) => | |
| 140 | Effect.sync(() => { | |
| 141 | if (originalCodexHome !== undefined) { | |
| 142 | process.env.CODEX_HOME = originalCodexHome; | |
| 143 | } else { | |
| 144 | delete process.env.CODEX_HOME; | |
| 145 | } | |
| 146 | }), | |
| 147 | ); | |
| 148 | ||
| 149 | if (configContent !== undefined) { | |
| 150 | yield* fileSystem.writeFileString(path.join(tmpDir, "config.toml"), configContent); | |
| 151 | } | |
| 152 | ||
| 153 | return { tmpDir } as const; | |
| 154 | }); | |
| 155 | } | |
| 156 | ||
| 157 | it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | |
| 158 | "ProviderRegistry", | |
| 159 | (it) => { | |
| 160 | // ── checkCodexProviderStatus tests ──────────────────────────────── | |
| 161 | // | |
| 162 | // These tests control CODEX_HOME to ensure the custom-provider detection | |
| 163 | // in hasCustomModelProvider() does not interfere with the auth-probe | |
| 164 | // path being tested. | |
| 165 | ||
| 166 | describe("checkCodexProviderStatus", () => { | |
| 167 | it.effect("returns ready when codex is installed and authenticated", () => | |
| 168 | Effect.gen(function* () { | |
| 169 | // Point CODEX_HOME at an empty tmp dir (no config.toml) so the | |
| 170 | // default code path (OpenAI provider, auth probe runs) is exercised. | |
| 171 | yield* withTempCodexHome(); | |
| 172 | const status = yield* checkCodexProviderStatus(); | |
| 173 | assert.strictEqual(status.provider, "codex"); | |
| 174 | assert.strictEqual(status.status, "ready"); | |
| 175 | assert.strictEqual(status.installed, true); | |
| 176 | assert.strictEqual(status.authStatus, "authenticated"); | |
| 177 | }).pipe( | |
| 178 | Effect.provide( | |
| 179 | mockSpawnerLayer((args) => { | |
| 180 | const joined = args.join(" "); | |
| 181 | if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; | |
| 182 | if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; | |
| 183 | throw new Error(`Unexpected args: ${joined}`); | |
| 184 | }), | |
| 185 | ), | |
| 186 | ), | |
| 187 | ); | |
| 188 | ||
| 189 | it.effect("inherits PATH when launching the codex probe with a CODEX_HOME override", () => | |
| 190 | Effect.gen(function* () { | |
| 191 | const fileSystem = yield* FileSystem.FileSystem; | |
| 192 | const path = yield* Path.Path; | |
| 193 | const binDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 194 | prefix: "t3-test-codex-bin-", | |
| 195 | }); | |
| 196 | const codexPath = path.join(binDir, "codex"); | |
| 197 | yield* fileSystem.writeFileString( | |
| 198 | codexPath, | |
| 199 | [ | |
| 200 | "#!/bin/sh", | |
| 201 | 'if [ "$1" = "--version" ]; then', | |
| 202 | ' echo "codex-cli 1.0.0"', | |
| 203 | " exit 0", | |
| 204 | "fi", | |
| 205 | 'if [ "$1" = "login" ] && [ "$2" = "status" ]; then', | |
| 206 | ' echo "Logged in using ChatGPT"', | |
| 207 | " exit 0", | |
| 208 | "fi", | |
| 209 | 'echo "unexpected args: $*" >&2', | |
| 210 | "exit 1", | |
| 211 | "", | |
| 212 | ].join("\n"), | |
| 213 | ); | |
| 214 | yield* fileSystem.chmod(codexPath, 0o755); | |
| 215 | const customCodexHome = yield* fileSystem.makeTempDirectoryScoped({ | |
| 216 | prefix: "t3-test-codex-home-", | |
| 217 | }); | |
| 218 | const previousPath = process.env.PATH; | |
| 219 | process.env.PATH = binDir; | |
| 220 | ||
| 221 | try { | |
| 222 | const serverSettingsLayer = ServerSettingsService.layerTest({ | |
| 223 | providers: { | |
| 224 | codex: { | |
| 225 | homePath: customCodexHome, | |
| 226 | }, | |
| 227 | }, | |
| 228 | }); | |
| 229 | ||
| 230 | const status = yield* checkCodexProviderStatus().pipe( | |
| 231 | Effect.provide(serverSettingsLayer), | |
| 232 | ); | |
| 233 | assert.strictEqual(status.provider, "codex"); | |
| 234 | assert.strictEqual(status.installed, true); | |
| 235 | assert.strictEqual(status.status, "ready"); | |
| 236 | assert.strictEqual(status.authStatus, "authenticated"); | |
| 237 | } finally { | |
| 238 | process.env.PATH = previousPath; | |
| 239 | } | |
| 240 | }), | |
| 241 | ); | |
| 242 | ||
| 243 | it.effect("returns unavailable when codex is missing", () => | |
| 244 | Effect.gen(function* () { | |
| 245 | yield* withTempCodexHome(); | |
| 246 | const status = yield* checkCodexProviderStatus(); | |
| 247 | assert.strictEqual(status.provider, "codex"); | |
| 248 | assert.strictEqual(status.status, "error"); | |
| 249 | assert.strictEqual(status.installed, false); | |
| 250 | assert.strictEqual(status.authStatus, "unknown"); | |
| 251 | assert.strictEqual( | |
| 252 | status.message, | |
| 253 | "Codex CLI (`codex`) is not installed or not on PATH.", | |
| 254 | ); | |
| 255 | }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), | |
| 256 | ); | |
| 257 | ||
| 258 | it.effect("returns unavailable when codex is below the minimum supported version", () => | |
| 259 | Effect.gen(function* () { | |
| 260 | yield* withTempCodexHome(); | |
| 261 | const status = yield* checkCodexProviderStatus(); | |
| 262 | assert.strictEqual(status.provider, "codex"); | |
| 263 | assert.strictEqual(status.status, "error"); | |
| 264 | assert.strictEqual(status.installed, true); | |
| 265 | assert.strictEqual(status.authStatus, "unknown"); | |
| 266 | assert.strictEqual( | |
| 267 | status.message, | |
| 268 | "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", | |
| 269 | ); | |
| 270 | }).pipe( | |
| 271 | Effect.provide( | |
| 272 | mockSpawnerLayer((args) => { | |
| 273 | const joined = args.join(" "); | |
| 274 | if (joined === "--version") return { stdout: "codex 0.36.0\n", stderr: "", code: 0 }; | |
| 275 | throw new Error(`Unexpected args: ${joined}`); | |
| 276 | }), | |
| 277 | ), | |
| 278 | ), | |
| 279 | ); | |
| 280 | ||
| 281 | it.effect("returns unauthenticated when auth probe reports login required", () => | |
| 282 | Effect.gen(function* () { | |
| 283 | yield* withTempCodexHome(); | |
| 284 | const status = yield* checkCodexProviderStatus(); | |
| 285 | assert.strictEqual(status.provider, "codex"); | |
| 286 | assert.strictEqual(status.status, "error"); | |
| 287 | assert.strictEqual(status.installed, true); | |
| 288 | assert.strictEqual(status.authStatus, "unauthenticated"); | |
| 289 | assert.strictEqual( | |
| 290 | status.message, | |
| 291 | "Codex CLI is not authenticated. Run `codex login` and try again.", | |
| 292 | ); | |
| 293 | }).pipe( | |
| 294 | Effect.provide( | |
| 295 | mockSpawnerLayer((args) => { | |
| 296 | const joined = args.join(" "); | |
| 297 | if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; | |
| 298 | if (joined === "login status") { | |
| 299 | return { stdout: "", stderr: "Not logged in. Run codex login.", code: 1 }; | |
| 300 | } | |
| 301 | throw new Error(`Unexpected args: ${joined}`); | |
| 302 | }), | |
| 303 | ), | |
| 304 | ), | |
| 305 | ); | |
| 306 | ||
| 307 | it.effect("returns unauthenticated when login status output includes 'not logged in'", () => | |
| 308 | Effect.gen(function* () { | |
| 309 | yield* withTempCodexHome(); | |
| 310 | const status = yield* checkCodexProviderStatus(); | |
| 311 | assert.strictEqual(status.provider, "codex"); | |
| 312 | assert.strictEqual(status.status, "error"); | |
| 313 | assert.strictEqual(status.installed, true); | |
| 314 | assert.strictEqual(status.authStatus, "unauthenticated"); | |
| 315 | assert.strictEqual( | |
| 316 | status.message, | |
| 317 | "Codex CLI is not authenticated. Run `codex login` and try again.", | |
| 318 | ); | |
| 319 | }).pipe( | |
| 320 | Effect.provide( | |
| 321 | mockSpawnerLayer((args) => { | |
| 322 | const joined = args.join(" "); | |
| 323 | if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; | |
| 324 | if (joined === "login status") | |
| 325 | return { stdout: "Not logged in\n", stderr: "", code: 1 }; | |
| 326 | throw new Error(`Unexpected args: ${joined}`); | |
| 327 | }), | |
| 328 | ), | |
| 329 | ), | |
| 330 | ); | |
| 331 | ||
| 332 | it.effect("returns warning when login status command is unsupported", () => | |
| 333 | Effect.gen(function* () { | |
| 334 | yield* withTempCodexHome(); | |
| 335 | const status = yield* checkCodexProviderStatus(); | |
| 336 | assert.strictEqual(status.provider, "codex"); | |
| 337 | assert.strictEqual(status.status, "warning"); | |
| 338 | assert.strictEqual(status.installed, true); | |
| 339 | assert.strictEqual(status.authStatus, "unknown"); | |
| 340 | assert.strictEqual( | |
| 341 | status.message, | |
| 342 | "Codex CLI authentication status command is unavailable in this Codex version.", | |
| 343 | ); | |
| 344 | }).pipe( | |
| 345 | Effect.provide( | |
| 346 | mockSpawnerLayer((args) => { | |
| 347 | const joined = args.join(" "); | |
| 348 | if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; | |
| 349 | if (joined === "login status") { | |
| 350 | return { stdout: "", stderr: "error: unknown command 'login'", code: 2 }; | |
| 351 | } | |
| 352 | throw new Error(`Unexpected args: ${joined}`); | |
| 353 | }), | |
| 354 | ), | |
| 355 | ), | |
| 356 | ); | |
| 357 | }); | |
| 358 | ||
| 359 | describe("ProviderRegistryLive", () => { | |
| 360 | it("treats equal provider snapshots as unchanged", () => { | |
| 361 | const providers = [ | |
| 362 | { | |
| 363 | provider: "codex", | |
| 364 | status: "ready", | |
| 365 | enabled: true, | |
| 366 | installed: true, | |
| 367 | authStatus: "authenticated", | |
| 368 | checkedAt: "2026-03-25T00:00:00.000Z", | |
| 369 | version: "1.0.0", | |
| 370 | models: [], | |
| 371 | }, | |
| 372 | { | |
| 373 | provider: "claudeAgent", | |
| 374 | status: "warning", | |
| 375 | enabled: true, | |
| 376 | installed: true, | |
| 377 | authStatus: "unknown", | |
| 378 | checkedAt: "2026-03-25T00:00:00.000Z", | |
| 379 | version: "1.0.0", | |
| 380 | models: [], | |
| 381 | }, | |
| 382 | ] as const satisfies ReadonlyArray<ServerProvider>; | |
| 383 | ||
| 384 | assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); | |
| 385 | }); | |
| 386 | ||
| 387 | it.effect("reruns codex health when codex provider settings change", () => | |
| 388 | Effect.gen(function* () { | |
| 389 | const serverSettings = yield* makeMutableServerSettingsService(); | |
| 390 | const scope = yield* Scope.make(); | |
| 391 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | |
| 392 | const providerRegistryLayer = ProviderRegistryLive.pipe( | |
| 393 | Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), | |
| 394 | Layer.provideMerge( | |
| 395 | mockCommandSpawnerLayer((command, args) => { | |
| 396 | const joined = args.join(" "); | |
| 397 | if (joined === "--version") { | |
| 398 | if (command === "codex") { | |
| 399 | return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; | |
| 400 | } | |
| 401 | return { stdout: "", stderr: "spawn ENOENT", code: 1 }; | |
| 402 | } | |
| 403 | if (joined === "login status") { | |
| 404 | return { stdout: "Logged in\n", stderr: "", code: 0 }; | |
| 405 | } | |
| 406 | throw new Error(`Unexpected args: ${joined}`); | |
| 407 | }), | |
| 408 | ), | |
| 409 | ); | |
| 410 | const runtimeServices = yield* Layer.build( | |
| 411 | Layer.mergeAll( | |
| 412 | Layer.succeed(ServerSettingsService, serverSettings), | |
| 413 | providerRegistryLayer, | |
| 414 | ), | |
| 415 | ).pipe(Scope.provide(scope)); | |
| 416 | ||
| 417 | yield* Effect.gen(function* () { | |
| 418 | const registry = yield* ProviderRegistry; | |
| 419 | ||
| 420 | const initial = yield* registry.getProviders; | |
| 421 | assert.strictEqual( | |
| 422 | initial.find((status) => status.provider === "codex")?.status, | |
| 423 | "ready", | |
| 424 | ); | |
| 425 | ||
| 426 | yield* serverSettings.updateSettings({ | |
| 427 | providers: { | |
| 428 | codex: { | |
| 429 | binaryPath: "/custom/codex", | |
| 430 | }, | |
| 431 | }, | |
| 432 | }); | |
| 433 | ||
| 434 | for (let attempt = 0; attempt < 20; attempt += 1) { | |
| 435 | const updated = yield* registry.getProviders; | |
| 436 | if (updated.find((status) => status.provider === "codex")?.status === "error") { | |
| 437 | return; | |
| 438 | } | |
| 439 | yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); | |
| 440 | } | |
| 441 | ||
| 442 | const updated = yield* registry.getProviders; | |
| 443 | assert.strictEqual( | |
| 444 | updated.find((status) => status.provider === "codex")?.status, | |
| 445 | "error", | |
| 446 | ); | |
| 447 | }).pipe(Effect.provide(runtimeServices)); | |
| 448 | }), | |
| 449 | ); | |
| 450 | ||
| 451 | it.effect("skips codex probes entirely when the provider is disabled", () => | |
| 452 | Effect.gen(function* () { | |
| 453 | const serverSettingsLayer = ServerSettingsService.layerTest({ | |
| 454 | providers: { | |
| 455 | codex: { | |
| 456 | enabled: false, | |
| 457 | }, | |
| 458 | }, | |
| 459 | }); | |
| 460 | ||
| 461 | const status = yield* checkCodexProviderStatus().pipe( | |
| 462 | Effect.provide( | |
| 463 | Layer.mergeAll(serverSettingsLayer, failingSpawnerLayer("spawn codex ENOENT")), | |
| 464 | ), | |
| 465 | ); | |
| 466 | assert.strictEqual(status.provider, "codex"); | |
| 467 | assert.strictEqual(status.enabled, false); | |
| 468 | assert.strictEqual(status.status, "disabled"); | |
| 469 | assert.strictEqual(status.installed, false); | |
| 470 | assert.strictEqual(status.message, "Codex is disabled in T3 Code settings."); | |
| 471 | }), | |
| 472 | ); | |
| 473 | }); | |
| 474 | ||
| 475 | // ── Custom model provider: checkCodexProviderStatus integration ─── | |
| 476 | ||
| 477 | describe("checkCodexProviderStatus with custom model provider", () => { | |
| 478 | it.effect( | |
| 479 | "skips auth probe and returns ready when a custom model provider is configured", | |
| 480 | () => | |
| 481 | Effect.gen(function* () { | |
| 482 | yield* withTempCodexHome( | |
| 483 | [ | |
| 484 | 'model_provider = "portkey"', | |
| 485 | "", | |
| 486 | "[model_providers.portkey]", | |
| 487 | 'base_url = "https://api.portkey.ai/v1"', | |
| 488 | 'env_key = "PORTKEY_API_KEY"', | |
| 489 | ].join("\n"), | |
| 490 | ); | |
| 491 | const status = yield* checkCodexProviderStatus(); | |
| 492 | assert.strictEqual(status.provider, "codex"); | |
| 493 | assert.strictEqual(status.status, "ready"); | |
| 494 | assert.strictEqual(status.installed, true); | |
| 495 | assert.strictEqual(status.authStatus, "unknown"); | |
| 496 | assert.strictEqual( | |
| 497 | status.message, | |
| 498 | "Using a custom Codex model provider; OpenAI login check skipped.", | |
| 499 | ); | |
| 500 | }).pipe( | |
| 501 | Effect.provide( | |
| 502 | // The spawner only handles --version; if the test attempts | |
| 503 | // "login status" the throw proves the auth probe was NOT skipped. | |
| 504 | mockSpawnerLayer((args) => { | |
| 505 | const joined = args.join(" "); | |
| 506 | if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; | |
| 507 | throw new Error(`Auth probe should have been skipped but got args: ${joined}`); | |
| 508 | }), | |
| 509 | ), | |
| 510 | ), | |
| 511 | ); | |
| 512 | ||
| 513 | it.effect("still reports error when codex CLI is missing even with custom provider", () => | |
| 514 | Effect.gen(function* () { | |
| 515 | yield* withTempCodexHome( | |
| 516 | [ | |
| 517 | 'model_provider = "portkey"', | |
| 518 | "", | |
| 519 | "[model_providers.portkey]", | |
| 520 | 'base_url = "https://api.portkey.ai/v1"', | |
| 521 | 'env_key = "PORTKEY_API_KEY"', | |
| 522 | ].join("\n"), | |
| 523 | ); | |
| 524 | const status = yield* checkCodexProviderStatus(); | |
| 525 | assert.strictEqual(status.status, "error"); | |
| 526 | assert.strictEqual(status.installed, false); | |
| 527 | }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), | |
| 528 | ); | |
| 529 | }); | |
| 530 | ||
| 531 | describe("checkCodexProviderStatus with openai model provider", () => { | |
| 532 | it.effect("still runs auth probe when model_provider is openai", () => | |
| 533 | Effect.gen(function* () { | |
| 534 | yield* withTempCodexHome('model_provider = "openai"\n'); | |
| 535 | const status = yield* checkCodexProviderStatus(); | |
| 536 | // The auth probe runs and sees "not logged in" → error | |
| 537 | assert.strictEqual(status.status, "error"); | |
| 538 | assert.strictEqual(status.authStatus, "unauthenticated"); | |
| 539 | }).pipe( | |
| 540 | Effect.provide( | |
| 541 | mockSpawnerLayer((args) => { | |
| 542 | const joined = args.join(" "); | |
| 543 | if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; | |
| 544 | if (joined === "login status") | |
| 545 | return { stdout: "Not logged in\n", stderr: "", code: 1 }; | |
| 546 | throw new Error(`Unexpected args: ${joined}`); | |
| 547 | }), | |
| 548 | ), | |
| 549 | ), | |
| 550 | ); | |
| 551 | }); | |
| 552 | ||
| 553 | // ── parseAuthStatusFromOutput pure tests ────────────────────────── | |
| 554 | ||
| 555 | describe("parseAuthStatusFromOutput", () => { | |
| 556 | it("exit code 0 with no auth markers is ready", () => { | |
| 557 | const parsed = parseAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); | |
| 558 | assert.strictEqual(parsed.status, "ready"); | |
| 559 | assert.strictEqual(parsed.authStatus, "authenticated"); | |
| 560 | }); | |
| 561 | ||
| 562 | it("JSON with authenticated=false is unauthenticated", () => { | |
| 563 | const parsed = parseAuthStatusFromOutput({ | |
| 564 | stdout: '[{"authenticated":false}]\n', | |
| 565 | stderr: "", | |
| 566 | code: 0, | |
| 567 | }); | |
| 568 | assert.strictEqual(parsed.status, "error"); | |
| 569 | assert.strictEqual(parsed.authStatus, "unauthenticated"); | |
| 570 | }); | |
| 571 | ||
| 572 | it("JSON without auth marker is warning", () => { | |
| 573 | const parsed = parseAuthStatusFromOutput({ | |
| 574 | stdout: '[{"ok":true}]\n', | |
| 575 | stderr: "", | |
| 576 | code: 0, | |
| 577 | }); | |
| 578 | assert.strictEqual(parsed.status, "warning"); | |
| 579 | assert.strictEqual(parsed.authStatus, "unknown"); | |
| 580 | }); | |
| 581 | }); | |
| 582 | ||
| 583 | // ── readCodexConfigModelProvider tests ───────────────────────────── | |
| 584 | ||
| 585 | describe("readCodexConfigModelProvider", () => { | |
| 586 | it.effect("returns undefined when config file does not exist", () => | |
| 587 | Effect.gen(function* () { | |
| 588 | yield* withTempCodexHome(); | |
| 589 | assert.strictEqual(yield* readCodexConfigModelProvider(), undefined); | |
| 590 | }), | |
| 591 | ); | |
| 592 | ||
| 593 | it.effect("returns undefined when config has no model_provider key", () => | |
| 594 | Effect.gen(function* () { | |
| 595 | yield* withTempCodexHome('model = "gpt-5-codex"\n'); | |
| 596 | assert.strictEqual(yield* readCodexConfigModelProvider(), undefined); | |
| 597 | }), | |
| 598 | ); | |
| 599 | ||
| 600 | it.effect("returns the provider when model_provider is set at top level", () => | |
| 601 | Effect.gen(function* () { | |
| 602 | yield* withTempCodexHome('model = "gpt-5-codex"\nmodel_provider = "portkey"\n'); | |
| 603 | assert.strictEqual(yield* readCodexConfigModelProvider(), "portkey"); | |
| 604 | }), | |
| 605 | ); | |
| 606 | ||
| 607 | it.effect("returns openai when model_provider is openai", () => | |
| 608 | Effect.gen(function* () { | |
| 609 | yield* withTempCodexHome('model_provider = "openai"\n'); | |
| 610 | assert.strictEqual(yield* readCodexConfigModelProvider(), "openai"); | |
| 611 | }), | |
| 612 | ); | |
| 613 | ||
| 614 | it.effect("ignores model_provider inside section headers", () => | |
| 615 | Effect.gen(function* () { | |
| 616 | yield* withTempCodexHome( | |
| 617 | [ | |
| 618 | 'model = "gpt-5-codex"', | |
| 619 | "", | |
| 620 | "[model_providers.portkey]", | |
| 621 | 'base_url = "https://api.portkey.ai/v1"', | |
| 622 | 'model_provider = "should-be-ignored"', | |
| 623 | "", | |
| 624 | ].join("\n"), | |
| 625 | ); | |
| 626 | assert.strictEqual(yield* readCodexConfigModelProvider(), undefined); | |
| 627 | }), | |
| 628 | ); | |
| 629 | ||
| 630 | it.effect("handles comments and whitespace", () => | |
| 631 | Effect.gen(function* () { | |
| 632 | yield* withTempCodexHome( | |
| 633 | [ | |
| 634 | "# This is a comment", | |
| 635 | "", | |
| 636 | ' model_provider = "azure" ', | |
| 637 | "", | |
| 638 | "[profiles.deep-review]", | |
| 639 | 'model = "gpt-5-pro"', | |
| 640 | ].join("\n"), | |
| 641 | ); | |
| 642 | assert.strictEqual(yield* readCodexConfigModelProvider(), "azure"); | |
| 643 | }), | |
| 644 | ); | |
| 645 | ||
| 646 | it.effect("handles single-quoted values in TOML", () => | |
| 647 | Effect.gen(function* () { | |
| 648 | yield* withTempCodexHome("model_provider = 'mistral'\n"); | |
| 649 | assert.strictEqual(yield* readCodexConfigModelProvider(), "mistral"); | |
| 650 | }), | |
| 651 | ); | |
| 652 | }); | |
| 653 | ||
| 654 | // ── hasCustomModelProvider tests ─────────────────────────────────── | |
| 655 | ||
| 656 | describe("hasCustomModelProvider", () => { | |
| 657 | it.effect("returns false when no config file exists", () => | |
| 658 | Effect.gen(function* () { | |
| 659 | yield* withTempCodexHome(); | |
| 660 | assert.strictEqual(yield* hasCustomModelProvider, false); | |
| 661 | }), | |
| 662 | ); | |
| 663 | ||
| 664 | it.effect("returns false when model_provider is not set", () => | |
| 665 | Effect.gen(function* () { | |
| 666 | yield* withTempCodexHome('model = "gpt-5-codex"\n'); | |
| 667 | assert.strictEqual(yield* hasCustomModelProvider, false); | |
| 668 | }), | |
| 669 | ); | |
| 670 | ||
| 671 | it.effect("returns false when model_provider is openai", () => | |
| 672 | Effect.gen(function* () { | |
| 673 | yield* withTempCodexHome('model_provider = "openai"\n'); | |
| 674 | assert.strictEqual(yield* hasCustomModelProvider, false); | |
| 675 | }), | |
| 676 | ); | |
| 677 | ||
| 678 | it.effect("returns true when model_provider is portkey", () => | |
| 679 | Effect.gen(function* () { | |
| 680 | yield* withTempCodexHome('model_provider = "portkey"\n'); | |
| 681 | assert.strictEqual(yield* hasCustomModelProvider, true); | |
| 682 | }), | |
| 683 | ); | |
| 684 | ||
| 685 | it.effect("returns true when model_provider is azure", () => | |
| 686 | Effect.gen(function* () { | |
| 687 | yield* withTempCodexHome('model_provider = "azure"\n'); | |
| 688 | assert.strictEqual(yield* hasCustomModelProvider, true); | |
| 689 | }), | |
| 690 | ); | |
| 691 | ||
| 692 | it.effect("returns true when model_provider is ollama", () => | |
| 693 | Effect.gen(function* () { | |
| 694 | yield* withTempCodexHome('model_provider = "ollama"\n'); | |
| 695 | assert.strictEqual(yield* hasCustomModelProvider, true); | |
| 696 | }), | |
| 697 | ); | |
| 698 | ||
| 699 | it.effect("returns true when model_provider is a custom proxy", () => | |
| 700 | Effect.gen(function* () { | |
| 701 | yield* withTempCodexHome('model_provider = "my-company-proxy"\n'); | |
| 702 | assert.strictEqual(yield* hasCustomModelProvider, true); | |
| 703 | }), | |
| 704 | ); | |
| 705 | }); | |
| 706 | ||
| 707 | // ── checkClaudeProviderStatus tests ────────────────────────── | |
| 708 | ||
| 709 | describe("checkClaudeProviderStatus", () => { | |
| 710 | it.effect("returns ready when claude is installed and authenticated", () => | |
| 711 | Effect.gen(function* () { | |
| 712 | const status = yield* checkClaudeProviderStatus(); | |
| 713 | assert.strictEqual(status.provider, "claudeAgent"); | |
| 714 | assert.strictEqual(status.status, "ready"); | |
| 715 | assert.strictEqual(status.installed, true); | |
| 716 | assert.strictEqual(status.authStatus, "authenticated"); | |
| 717 | }).pipe( | |
| 718 | Effect.provide( | |
| 719 | mockSpawnerLayer((args) => { | |
| 720 | const joined = args.join(" "); | |
| 721 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | |
| 722 | if (joined === "auth status") | |
| 723 | return { | |
| 724 | stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', | |
| 725 | stderr: "", | |
| 726 | code: 0, | |
| 727 | }; | |
| 728 | throw new Error(`Unexpected args: ${joined}`); | |
| 729 | }), | |
| 730 | ), | |
| 731 | ), | |
| 732 | ); | |
| 733 | ||
| 734 | it.effect("returns unavailable when claude is missing", () => | |
| 735 | Effect.gen(function* () { | |
| 736 | const status = yield* checkClaudeProviderStatus(); | |
| 737 | assert.strictEqual(status.provider, "claudeAgent"); | |
| 738 | assert.strictEqual(status.status, "error"); | |
| 739 | assert.strictEqual(status.installed, false); | |
| 740 | assert.strictEqual(status.authStatus, "unknown"); | |
| 741 | assert.strictEqual( | |
| 742 | status.message, | |
| 743 | "Claude Agent CLI (`claude`) is not installed or not on PATH.", | |
| 744 | ); | |
| 745 | }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), | |
| 746 | ); | |
| 747 | ||
| 748 | it.effect("returns error when version check fails with non-zero exit code", () => | |
| 749 | Effect.gen(function* () { | |
| 750 | const status = yield* checkClaudeProviderStatus(); | |
| 751 | assert.strictEqual(status.provider, "claudeAgent"); | |
| 752 | assert.strictEqual(status.status, "error"); | |
| 753 | assert.strictEqual(status.installed, true); | |
| 754 | }).pipe( | |
| 755 | Effect.provide( | |
| 756 | mockSpawnerLayer((args) => { | |
| 757 | const joined = args.join(" "); | |
| 758 | if (joined === "--version") | |
| 759 | return { stdout: "", stderr: "Something went wrong", code: 1 }; | |
| 760 | throw new Error(`Unexpected args: ${joined}`); | |
| 761 | }), | |
| 762 | ), | |
| 763 | ), | |
| 764 | ); | |
| 765 | ||
| 766 | it.effect("returns unauthenticated when auth status reports not logged in", () => | |
| 767 | Effect.gen(function* () { | |
| 768 | const status = yield* checkClaudeProviderStatus(); | |
| 769 | assert.strictEqual(status.provider, "claudeAgent"); | |
| 770 | assert.strictEqual(status.status, "error"); | |
| 771 | assert.strictEqual(status.installed, true); | |
| 772 | assert.strictEqual(status.authStatus, "unauthenticated"); | |
| 773 | assert.strictEqual( | |
| 774 | status.message, | |
| 775 | "Claude is not authenticated. Run `claude auth login` and try again.", | |
| 776 | ); | |
| 777 | }).pipe( | |
| 778 | Effect.provide( | |
| 779 | mockSpawnerLayer((args) => { | |
| 780 | const joined = args.join(" "); | |
| 781 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | |
| 782 | if (joined === "auth status") | |
| 783 | return { | |
| 784 | stdout: '{"loggedIn":false}\n', | |
| 785 | stderr: "", | |
| 786 | code: 1, | |
| 787 | }; | |
| 788 | throw new Error(`Unexpected args: ${joined}`); | |
| 789 | }), | |
| 790 | ), | |
| 791 | ), | |
| 792 | ); | |
| 793 | ||
| 794 | it.effect("returns unauthenticated when output includes 'not logged in'", () => | |
| 795 | Effect.gen(function* () { | |
| 796 | const status = yield* checkClaudeProviderStatus(); | |
| 797 | assert.strictEqual(status.provider, "claudeAgent"); | |
| 798 | assert.strictEqual(status.status, "error"); | |
| 799 | assert.strictEqual(status.installed, true); | |
| 800 | assert.strictEqual(status.authStatus, "unauthenticated"); | |
| 801 | }).pipe( | |
| 802 | Effect.provide( | |
| 803 | mockSpawnerLayer((args) => { | |
| 804 | const joined = args.join(" "); | |
| 805 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | |
| 806 | if (joined === "auth status") | |
| 807 | return { stdout: "Not logged in\n", stderr: "", code: 1 }; | |
| 808 | throw new Error(`Unexpected args: ${joined}`); | |
| 809 | }), | |
| 810 | ), | |
| 811 | ), | |
| 812 | ); | |
| 813 | ||
| 814 | it.effect("returns warning when auth status command is unsupported", () => | |
| 815 | Effect.gen(function* () { | |
| 816 | const status = yield* checkClaudeProviderStatus(); | |
| 817 | assert.strictEqual(status.provider, "claudeAgent"); | |
| 818 | assert.strictEqual(status.status, "warning"); | |
| 819 | assert.strictEqual(status.installed, true); | |
| 820 | assert.strictEqual(status.authStatus, "unknown"); | |
| 821 | assert.strictEqual( | |
| 822 | status.message, | |
| 823 | "Claude Agent authentication status command is unavailable in this version of Claude.", | |
| 824 | ); | |
| 825 | }).pipe( | |
| 826 | Effect.provide( | |
| 827 | mockSpawnerLayer((args) => { | |
| 828 | const joined = args.join(" "); | |
| 829 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | |
| 830 | if (joined === "auth status") | |
| 831 | return { stdout: "", stderr: "error: unknown command 'auth'", code: 2 }; | |
| 832 | throw new Error(`Unexpected args: ${joined}`); | |
| 833 | }), | |
| 834 | ), | |
| 835 | ), | |
| 836 | ); | |
| 837 | }); | |
| 838 | ||
| 839 | // ── parseClaudeAuthStatusFromOutput pure tests ──────────────────── | |
| 840 | ||
| 841 | describe("parseClaudeAuthStatusFromOutput", () => { | |
| 842 | it("exit code 0 with no auth markers is ready", () => { | |
| 843 | const parsed = parseClaudeAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); | |
| 844 | assert.strictEqual(parsed.status, "ready"); | |
| 845 | assert.strictEqual(parsed.authStatus, "authenticated"); | |
| 846 | }); | |
| 847 | ||
| 848 | it("JSON with loggedIn=true is authenticated", () => { | |
| 849 | const parsed = parseClaudeAuthStatusFromOutput({ | |
| 850 | stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', | |
| 851 | stderr: "", | |
| 852 | code: 0, | |
| 853 | }); | |
| 854 | assert.strictEqual(parsed.status, "ready"); | |
| 855 | assert.strictEqual(parsed.authStatus, "authenticated"); | |
| 856 | }); | |
| 857 | ||
| 858 | it("JSON with loggedIn=false is unauthenticated", () => { | |
| 859 | const parsed = parseClaudeAuthStatusFromOutput({ | |
| 860 | stdout: '{"loggedIn":false}\n', | |
| 861 | stderr: "", | |
| 862 | code: 0, | |
| 863 | }); | |
| 864 | assert.strictEqual(parsed.status, "error"); | |
| 865 | assert.strictEqual(parsed.authStatus, "unauthenticated"); | |
| 866 | }); | |
| 867 | ||
| 868 | it("JSON without auth marker is warning", () => { | |
| 869 | const parsed = parseClaudeAuthStatusFromOutput({ | |
| 870 | stdout: '{"ok":true}\n', | |
| 871 | stderr: "", | |
| 872 | code: 0, | |
| 873 | }); | |
| 874 | assert.strictEqual(parsed.status, "warning"); | |
| 875 | assert.strictEqual(parsed.authStatus, "unknown"); | |
| 876 | }); | |
| 877 | }); | |
| 878 | }, | |
| 879 | ); |
apps/server/src/provider/Layers/ProviderRegistry.ts created+93| ... | ... | @@ -0,0 +1,93 @@ |
| 1 | /** | |
| 2 | * ProviderRegistryLive - Aggregates provider-specific snapshot services. | |
| 3 | * | |
| 4 | * @module ProviderRegistryLive | |
| 5 | */ | |
| 6 | import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; | |
| 7 | import { Effect, Equal, Layer, PubSub, Ref, Stream } from "effect"; | |
| 8 | ||
| 9 | import { ClaudeProviderLive } from "./ClaudeProvider"; | |
| 10 | import { CodexProviderLive } from "./CodexProvider"; | |
| 11 | import type { ClaudeProviderShape } from "../Services/ClaudeProvider"; | |
| 12 | import { ClaudeProvider } from "../Services/ClaudeProvider"; | |
| 13 | import type { CodexProviderShape } from "../Services/CodexProvider"; | |
| 14 | import { CodexProvider } from "../Services/CodexProvider"; | |
| 15 | import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry"; | |
| 16 | ||
| 17 | const loadProviders = ( | |
| 18 | codexProvider: CodexProviderShape, | |
| 19 | claudeProvider: ClaudeProviderShape, | |
| 20 | ): Effect.Effect<readonly [ServerProvider, ServerProvider]> => | |
| 21 | Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot], { | |
| 22 | concurrency: "unbounded", | |
| 23 | }); | |
| 24 | ||
| 25 | export const haveProvidersChanged = ( | |
| 26 | previousProviders: ReadonlyArray<ServerProvider>, | |
| 27 | nextProviders: ReadonlyArray<ServerProvider>, | |
| 28 | ): boolean => !Equal.equals(previousProviders, nextProviders); | |
| 29 | ||
| 30 | export const ProviderRegistryLive = Layer.effect( | |
| 31 | ProviderRegistry, | |
| 32 | Effect.gen(function* () { | |
| 33 | const codexProvider = yield* CodexProvider; | |
| 34 | const claudeProvider = yield* ClaudeProvider; | |
| 35 | const changesPubSub = yield* Effect.acquireRelease( | |
| 36 | PubSub.unbounded<ReadonlyArray<ServerProvider>>(), | |
| 37 | PubSub.shutdown, | |
| 38 | ); | |
| 39 | const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>( | |
| 40 | yield* loadProviders(codexProvider, claudeProvider), | |
| 41 | ); | |
| 42 | ||
| 43 | const syncProviders = (options?: { readonly publish?: boolean }) => | |
| 44 | Effect.gen(function* () { | |
| 45 | const previousProviders = yield* Ref.get(providersRef); | |
| 46 | const providers = yield* loadProviders(codexProvider, claudeProvider); | |
| 47 | yield* Ref.set(providersRef, providers); | |
| 48 | ||
| 49 | if (options?.publish !== false && haveProvidersChanged(previousProviders, providers)) { | |
| 50 | yield* PubSub.publish(changesPubSub, providers); | |
| 51 | } | |
| 52 | ||
| 53 | return providers; | |
| 54 | }); | |
| 55 | ||
| 56 | yield* Stream.runForEach(codexProvider.streamChanges, () => syncProviders()).pipe( | |
| 57 | Effect.forkScoped, | |
| 58 | ); | |
| 59 | yield* Stream.runForEach(claudeProvider.streamChanges, () => syncProviders()).pipe( | |
| 60 | Effect.forkScoped, | |
| 61 | ); | |
| 62 | ||
| 63 | return { | |
| 64 | getProviders: syncProviders({ publish: false }).pipe( | |
| 65 | Effect.tapError(Effect.logError), | |
| 66 | Effect.orElseSucceed(() => []), | |
| 67 | ), | |
| 68 | refresh: (provider?: ProviderKind) => | |
| 69 | Effect.gen(function* () { | |
| 70 | switch (provider) { | |
| 71 | case "codex": | |
| 72 | yield* codexProvider.refresh; | |
| 73 | break; | |
| 74 | case "claudeAgent": | |
| 75 | yield* claudeProvider.refresh; | |
| 76 | break; | |
| 77 | default: | |
| 78 | yield* Effect.all([codexProvider.refresh, claudeProvider.refresh], { | |
| 79 | concurrency: "unbounded", | |
| 80 | }); | |
| 81 | break; | |
| 82 | } | |
| 83 | return yield* syncProviders(); | |
| 84 | }).pipe( | |
| 85 | Effect.tapError(Effect.logError), | |
| 86 | Effect.orElseSucceed(() => []), | |
| 87 | ), | |
| 88 | get streamChanges() { | |
| 89 | return Stream.fromPubSub(changesPubSub); | |
| 90 | }, | |
| 91 | } satisfies ProviderRegistryShape; | |
| 92 | }), | |
| 93 | ).pipe(Layer.provideMerge(CodexProviderLive), Layer.provideMerge(ClaudeProviderLive)); |
apps/server/src/provider/Layers/ProviderService.test.ts+58| ... | ... | @@ -42,8 +42,11 @@ import { |
| 42 | 42 | makeSqlitePersistenceLive, |
| 43 | 43 | SqlitePersistenceMemory, |
| 44 | 44 | } from "../../persistence/Layers/Sqlite.ts"; |
| 45 | import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 45 | 46 | import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; |
| 46 | 47 | |
| 48 | const defaultServerSettingsLayer = ServerSettingsService.layerTest(); | |
| 49 | ||
| 47 | 50 | const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); |
| 48 | 51 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| 49 | 52 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); |
| ... | ... | @@ -251,6 +254,7 @@ function makeProviderServiceLayer() { |
| 251 | 254 | makeProviderServiceLive().pipe( |
| 252 | 255 | Layer.provide(providerAdapterLayer), |
| 253 | 256 | Layer.provide(directoryLayer), |
| 257 | Layer.provide(defaultServerSettingsLayer), | |
| 254 | 258 | Layer.provideMerge(AnalyticsService.layerTest), |
| 255 | 259 | ), |
| 256 | 260 | directoryLayer, |
| ... | ... | @@ -267,6 +271,55 @@ function makeProviderServiceLayer() { |
| 267 | 271 | }; |
| 268 | 272 | } |
| 269 | 273 | |
| 274 | it.effect("ProviderServiceLive rejects new sessions for disabled providers", () => | |
| 275 | Effect.gen(function* () { | |
| 276 | const codex = makeFakeCodexAdapter(); | |
| 277 | const claude = makeFakeCodexAdapter("claudeAgent"); | |
| 278 | const registry: typeof ProviderAdapterRegistry.Service = { | |
| 279 | getByProvider: (provider) => | |
| 280 | provider === "codex" | |
| 281 | ? Effect.succeed(codex.adapter) | |
| 282 | : provider === "claudeAgent" | |
| 283 | ? Effect.succeed(claude.adapter) | |
| 284 | : Effect.fail(new ProviderUnsupportedError({ provider })), | |
| 285 | listProviders: () => Effect.succeed(["codex", "claudeAgent"]), | |
| 286 | }; | |
| 287 | const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); | |
| 288 | const serverSettingsLayer = ServerSettingsService.layerTest({ | |
| 289 | providers: { | |
| 290 | claudeAgent: { | |
| 291 | enabled: false, | |
| 292 | }, | |
| 293 | }, | |
| 294 | }); | |
| 295 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( | |
| 296 | Layer.provide(SqlitePersistenceMemory), | |
| 297 | ); | |
| 298 | const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); | |
| 299 | const providerLayer = makeProviderServiceLive().pipe( | |
| 300 | Layer.provide(providerAdapterLayer), | |
| 301 | Layer.provide(directoryLayer), | |
| 302 | Layer.provide(serverSettingsLayer), | |
| 303 | Layer.provide(AnalyticsService.layerTest), | |
| 304 | ); | |
| 305 | ||
| 306 | const failure = yield* Effect.flip( | |
| 307 | Effect.gen(function* () { | |
| 308 | const provider = yield* ProviderService; | |
| 309 | return yield* provider.startSession(asThreadId("thread-disabled"), { | |
| 310 | provider: "claudeAgent", | |
| 311 | threadId: asThreadId("thread-disabled"), | |
| 312 | runtimeMode: "full-access", | |
| 313 | }); | |
| 314 | }).pipe(Effect.provide(providerLayer)), | |
| 315 | ); | |
| 316 | ||
| 317 | assert.instanceOf(failure, ProviderValidationError); | |
| 318 | assert.include(failure.issue, "Provider 'claudeAgent' is disabled in T3 Code settings."); | |
| 319 | assert.equal(claude.startSession.mock.calls.length, 0); | |
| 320 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 321 | ); | |
| 322 | ||
| 270 | 323 | const routing = makeProviderServiceLayer(); |
| 271 | 324 | it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", () => |
| 272 | 325 | Effect.gen(function* () { |
| ... | ... | @@ -299,6 +352,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( |
| 299 | 352 | const providerLayer = makeProviderServiceLive().pipe( |
| 300 | 353 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), |
| 301 | 354 | Layer.provide(directoryLayer), |
| 355 | Layer.provide(defaultServerSettingsLayer), | |
| 302 | 356 | Layer.provide(AnalyticsService.layerTest), |
| 303 | 357 | ); |
| 304 | 358 | |
| ... | ... | @@ -358,6 +412,7 @@ it.effect( |
| 358 | 412 | const firstProviderLayer = makeProviderServiceLive().pipe( |
| 359 | 413 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), |
| 360 | 414 | Layer.provide(firstDirectoryLayer), |
| 415 | Layer.provide(defaultServerSettingsLayer), | |
| 361 | 416 | Layer.provide(AnalyticsService.layerTest), |
| 362 | 417 | ); |
| 363 | 418 | const updatedResumeCursor = { |
| ... | ... | @@ -409,6 +464,7 @@ it.effect( |
| 409 | 464 | const secondProviderLayer = makeProviderServiceLive().pipe( |
| 410 | 465 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), |
| 411 | 466 | Layer.provide(secondDirectoryLayer), |
| 467 | Layer.provide(defaultServerSettingsLayer), | |
| 412 | 468 | Layer.provide(AnalyticsService.layerTest), |
| 413 | 469 | ); |
| 414 | 470 | |
| ... | ... | @@ -769,6 +825,7 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 769 | 825 | const firstProviderLayer = makeProviderServiceLive().pipe( |
| 770 | 826 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), |
| 771 | 827 | Layer.provide(firstDirectoryLayer), |
| 828 | Layer.provide(defaultServerSettingsLayer), | |
| 772 | 829 | Layer.provide(AnalyticsService.layerTest), |
| 773 | 830 | ); |
| 774 | 831 | |
| ... | ... | @@ -801,6 +858,7 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 801 | 858 | const secondProviderLayer = makeProviderServiceLive().pipe( |
| 802 | 859 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), |
| 803 | 860 | Layer.provide(secondDirectoryLayer), |
| 861 | Layer.provide(defaultServerSettingsLayer), | |
| 804 | 862 | Layer.provide(AnalyticsService.layerTest), |
| 805 | 863 | ); |
| 806 | 864 |
apps/server/src/provider/Layers/ProviderService.ts+17-17| ... | ... | @@ -33,6 +33,7 @@ import { |
| 33 | 33 | } from "../Services/ProviderSessionDirectory.ts"; |
| 34 | 34 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 35 | 35 | import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; |
| 36 | import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 36 | 37 | |
| 37 | 38 | export interface ProviderServiceLiveOptions { |
| 38 | 39 | readonly canonicalEventLogPath?: string; |
| ... | ... | @@ -91,7 +92,6 @@ function toRuntimePayloadFromSession( |
| 91 | 92 | session: ProviderSession, |
| 92 | 93 | extra?: { |
| 93 | 94 | readonly modelSelection?: unknown; |
| 94 | readonly providerOptions?: unknown; | |
| 95 | 95 | readonly lastRuntimeEvent?: string; |
| 96 | 96 | readonly lastRuntimeEventAt?: string; |
| 97 | 97 | }, |
| ... | ... | @@ -102,7 +102,6 @@ function toRuntimePayloadFromSession( |
| 102 | 102 | activeTurnId: session.activeTurnId ?? null, |
| 103 | 103 | lastError: session.lastError ?? null, |
| 104 | 104 | ...(extra?.modelSelection !== undefined ? { modelSelection: extra.modelSelection } : {}), |
| 105 | ...(extra?.providerOptions !== undefined ? { providerOptions: extra.providerOptions } : {}), | |
| 106 | 105 | ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}), |
| 107 | 106 | ...(extra?.lastRuntimeEventAt !== undefined |
| 108 | 107 | ? { lastRuntimeEventAt: extra.lastRuntimeEventAt } |
| ... | ... | @@ -120,17 +119,6 @@ function readPersistedModelSelection( |
| 120 | 119 | return Schema.is(ModelSelection)(raw) ? raw : undefined; |
| 121 | 120 | } |
| 122 | 121 | |
| 123 | function readPersistedProviderOptions( | |
| 124 | runtimePayload: ProviderRuntimeBinding["runtimePayload"], | |
| 125 | ): Record<string, unknown> | undefined { | |
| 126 | if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { | |
| 127 | return undefined; | |
| 128 | } | |
| 129 | const raw = "providerOptions" in runtimePayload ? runtimePayload.providerOptions : undefined; | |
| 130 | if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; | |
| 131 | return raw as Record<string, unknown>; | |
| 132 | } | |
| 133 | ||
| 134 | 122 | function readPersistedCwd( |
| 135 | 123 | runtimePayload: ProviderRuntimeBinding["runtimePayload"], |
| 136 | 124 | ): string | undefined { |
| ... | ... | @@ -146,6 +134,7 @@ function readPersistedCwd( |
| 146 | 134 | const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 147 | 135 | Effect.gen(function* () { |
| 148 | 136 | const analytics = yield* Effect.service(AnalyticsService); |
| 137 | const serverSettings = yield* ServerSettingsService; | |
| 149 | 138 | const canonicalEventLogger = |
| 150 | 139 | options?.canonicalEventLogger ?? |
| 151 | 140 | (options?.canonicalEventLogPath !== undefined |
| ... | ... | @@ -173,7 +162,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 173 | 162 | threadId: ThreadId, |
| 174 | 163 | extra?: { |
| 175 | 164 | readonly modelSelection?: unknown; |
| 176 | readonly providerOptions?: unknown; | |
| 177 | 165 | readonly lastRuntimeEvent?: string; |
| 178 | 166 | readonly lastRuntimeEventAt?: string; |
| 179 | 167 | }, |
| ... | ... | @@ -239,14 +227,12 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 239 | 227 | |
| 240 | 228 | const persistedCwd = readPersistedCwd(input.binding.runtimePayload); |
| 241 | 229 | const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); |
| 242 | const persistedProviderOptions = readPersistedProviderOptions(input.binding.runtimePayload); | |
| 243 | 230 | |
| 244 | 231 | const resumed = yield* adapter.startSession({ |
| 245 | 232 | threadId: input.binding.threadId, |
| 246 | 233 | provider: input.binding.provider, |
| 247 | 234 | ...(persistedCwd ? { cwd: persistedCwd } : {}), |
| 248 | 235 | ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), |
| 249 | ...(persistedProviderOptions ? { providerOptions: persistedProviderOptions } : {}), | |
| 250 | 236 | ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), |
| 251 | 237 | runtimeMode: input.binding.runtimeMode ?? "full-access", |
| 252 | 238 | }); |
| ... | ... | @@ -308,6 +294,21 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 308 | 294 | threadId, |
| 309 | 295 | provider: parsed.provider ?? "codex", |
| 310 | 296 | }; |
| 297 | const settings = yield* serverSettings.getSettings.pipe( | |
| 298 | Effect.mapError((error) => | |
| 299 | toValidationError( | |
| 300 | "ProviderService.startSession", | |
| 301 | `Failed to load provider settings: ${error.message}`, | |
| 302 | error, | |
| 303 | ), | |
| 304 | ), | |
| 305 | ); | |
| 306 | if (!settings.providers[input.provider].enabled) { | |
| 307 | return yield* toValidationError( | |
| 308 | "ProviderService.startSession", | |
| 309 | `Provider '${input.provider}' is disabled in T3 Code settings.`, | |
| 310 | ); | |
| 311 | } | |
| 311 | 312 | const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId)); |
| 312 | 313 | const effectiveResumeCursor = |
| 313 | 314 | input.resumeCursor ?? |
| ... | ... | @@ -329,7 +330,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 329 | 330 | |
| 330 | 331 | yield* upsertSessionBinding(session, threadId, { |
| 331 | 332 | modelSelection: input.modelSelection, |
| 332 | providerOptions: input.providerOptions, | |
| 333 | 333 | }); |
| 334 | 334 | yield* analytics.record("provider.session.started", { |
| 335 | 335 | provider: session.provider, |
apps/server/src/provider/Services/ClaudeProvider.ts created+9| ... | ... | @@ -0,0 +1,9 @@ |
| 1 | import { ServiceMap } from "effect"; | |
| 2 | ||
| 3 | import type { ServerProviderShape } from "./ServerProvider"; | |
| 4 | ||
| 5 | export interface ClaudeProviderShape extends ServerProviderShape {} | |
| 6 | ||
| 7 | export class ClaudeProvider extends ServiceMap.Service<ClaudeProvider, ClaudeProviderShape>()( | |
| 8 | "t3/provider/Services/ClaudeProvider", | |
| 9 | ) {} |
apps/server/src/provider/Services/CodexProvider.ts created+9| ... | ... | @@ -0,0 +1,9 @@ |
| 1 | import { ServiceMap } from "effect"; | |
| 2 | ||
| 3 | import type { ServerProviderShape } from "./ServerProvider"; | |
| 4 | ||
| 5 | export interface CodexProviderShape extends ServerProviderShape {} | |
| 6 | ||
| 7 | export class CodexProvider extends ServiceMap.Service<CodexProvider, CodexProviderShape>()( | |
| 8 | "t3/provider/Services/CodexProvider", | |
| 9 | ) {} |
apps/server/src/provider/Services/ProviderHealth.ts deleted-22| ... | ... | @@ -1,22 +0,0 @@ |
| 1 | /** | |
| 2 | * ProviderHealth - Provider readiness snapshot service. | |
| 3 | * | |
| 4 | * Owns provider health checks (install/auth reachability) and exposes the | |
| 5 | * latest results to transport layers. | |
| 6 | * | |
| 7 | * @module ProviderHealth | |
| 8 | */ | |
| 9 | import type { ServerProviderStatus } from "@t3tools/contracts"; | |
| 10 | import { ServiceMap } from "effect"; | |
| 11 | import type { Effect } from "effect"; | |
| 12 | ||
| 13 | export interface ProviderHealthShape { | |
| 14 | /** | |
| 15 | * Read the latest provider health statuses. | |
| 16 | */ | |
| 17 | readonly getStatuses: Effect.Effect<ReadonlyArray<ServerProviderStatus>>; | |
| 18 | } | |
| 19 | ||
| 20 | export class ProviderHealth extends ServiceMap.Service<ProviderHealth, ProviderHealthShape>()( | |
| 21 | "t3/provider/Services/ProviderHealth", | |
| 22 | ) {} |
apps/server/src/provider/Services/ProviderRegistry.ts created+32| ... | ... | @@ -0,0 +1,32 @@ |
| 1 | /** | |
| 2 | * ProviderRegistry - Provider snapshot service. | |
| 3 | * | |
| 4 | * Owns provider install/auth/version/model snapshots and exposes the latest | |
| 5 | * provider state to transport layers. | |
| 6 | * | |
| 7 | * @module ProviderRegistry | |
| 8 | */ | |
| 9 | import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; | |
| 10 | import { ServiceMap } from "effect"; | |
| 11 | import type { Effect, Stream } from "effect"; | |
| 12 | ||
| 13 | export interface ProviderRegistryShape { | |
| 14 | /** | |
| 15 | * Read the latest provider snapshots. | |
| 16 | */ | |
| 17 | readonly getProviders: Effect.Effect<ReadonlyArray<ServerProvider>>; | |
| 18 | ||
| 19 | /** | |
| 20 | * Refresh all providers, or a single provider when specified. | |
| 21 | */ | |
| 22 | readonly refresh: (provider?: ProviderKind) => Effect.Effect<ReadonlyArray<ServerProvider>>; | |
| 23 | ||
| 24 | /** | |
| 25 | * Stream of provider snapshot updates. | |
| 26 | */ | |
| 27 | readonly streamChanges: Stream.Stream<ReadonlyArray<ServerProvider>>; | |
| 28 | } | |
| 29 | ||
| 30 | export class ProviderRegistry extends ServiceMap.Service<ProviderRegistry, ProviderRegistryShape>()( | |
| 31 | "t3/provider/Services/ProviderRegistry", | |
| 32 | ) {} |
apps/server/src/provider/Services/ServerProvider.ts created+8| ... | ... | @@ -0,0 +1,8 @@ |
| 1 | import type { ServerProvider } from "@t3tools/contracts"; | |
| 2 | import type { Effect, Stream } from "effect"; | |
| 3 | ||
| 4 | export interface ServerProviderShape { | |
| 5 | readonly getSnapshot: Effect.Effect<ServerProvider>; | |
| 6 | readonly refresh: Effect.Effect<ServerProvider>; | |
| 7 | readonly streamChanges: Stream.Stream<ServerProvider>; | |
| 8 | } |
apps/server/src/provider/makeManagedServerProvider.ts created+72| ... | ... | @@ -0,0 +1,72 @@ |
| 1 | import type { ServerProvider } from "@t3tools/contracts"; | |
| 2 | import { Duration, Effect, PubSub, Ref, Scope, Stream } from "effect"; | |
| 3 | import * as Semaphore from "effect/Semaphore"; | |
| 4 | ||
| 5 | import type { ServerProviderShape } from "./Services/ServerProvider"; | |
| 6 | import { ServerSettingsError } from "../serverSettings"; | |
| 7 | ||
| 8 | export function makeManagedServerProvider<Settings>(input: { | |
| 9 | readonly getSettings: Effect.Effect<Settings>; | |
| 10 | readonly streamSettings: Stream.Stream<Settings>; | |
| 11 | readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; | |
| 12 | readonly checkProvider: Effect.Effect<ServerProvider, ServerSettingsError>; | |
| 13 | readonly refreshInterval?: Duration.Input; | |
| 14 | }): Effect.Effect<ServerProviderShape, ServerSettingsError, Scope.Scope> { | |
| 15 | return Effect.gen(function* () { | |
| 16 | const refreshSemaphore = yield* Semaphore.make(1); | |
| 17 | const changesPubSub = yield* Effect.acquireRelease( | |
| 18 | PubSub.unbounded<ServerProvider>(), | |
| 19 | PubSub.shutdown, | |
| 20 | ); | |
| 21 | const initialSettings = yield* input.getSettings; | |
| 22 | const initialSnapshot = yield* input.checkProvider; | |
| 23 | const snapshotRef = yield* Ref.make(initialSnapshot); | |
| 24 | const settingsRef = yield* Ref.make(initialSettings); | |
| 25 | ||
| 26 | const applySnapshot = (nextSettings: Settings, options?: { readonly forceRefresh?: boolean }) => | |
| 27 | refreshSemaphore.withPermits(1)( | |
| 28 | Effect.gen(function* () { | |
| 29 | const forceRefresh = options?.forceRefresh === true; | |
| 30 | const previousSettings = yield* Ref.get(settingsRef); | |
| 31 | if (!forceRefresh && !input.haveSettingsChanged(previousSettings, nextSettings)) { | |
| 32 | yield* Ref.set(settingsRef, nextSettings); | |
| 33 | return yield* Ref.get(snapshotRef); | |
| 34 | } | |
| 35 | ||
| 36 | const nextSnapshot = yield* input.checkProvider; | |
| 37 | yield* Ref.set(settingsRef, nextSettings); | |
| 38 | yield* Ref.set(snapshotRef, nextSnapshot); | |
| 39 | yield* PubSub.publish(changesPubSub, nextSnapshot); | |
| 40 | return nextSnapshot; | |
| 41 | }), | |
| 42 | ); | |
| 43 | ||
| 44 | const refreshSnapshot = Effect.gen(function* () { | |
| 45 | const nextSettings = yield* input.getSettings; | |
| 46 | return yield* applySnapshot(nextSettings, { forceRefresh: true }); | |
| 47 | }); | |
| 48 | ||
| 49 | yield* Stream.runForEach(input.streamSettings, (nextSettings) => | |
| 50 | Effect.asVoid(applySnapshot(nextSettings)), | |
| 51 | ).pipe(Effect.forkScoped); | |
| 52 | ||
| 53 | yield* Effect.forever( | |
| 54 | Effect.sleep(input.refreshInterval ?? "60 seconds").pipe( | |
| 55 | Effect.flatMap(() => refreshSnapshot), | |
| 56 | Effect.ignoreCause({ log: true }), | |
| 57 | ), | |
| 58 | ).pipe(Effect.forkScoped); | |
| 59 | ||
| 60 | return { | |
| 61 | getSnapshot: input.getSettings.pipe( | |
| 62 | Effect.flatMap(applySnapshot), | |
| 63 | Effect.tapError(Effect.logError), | |
| 64 | Effect.orDie, | |
| 65 | ), | |
| 66 | refresh: refreshSnapshot.pipe(Effect.tapError(Effect.logError), Effect.orDie), | |
| 67 | get streamChanges() { | |
| 68 | return Stream.fromPubSub(changesPubSub); | |
| 69 | }, | |
| 70 | } satisfies ServerProviderShape; | |
| 71 | }); | |
| 72 | } |
apps/server/src/provider/providerSnapshot.ts created+134| ... | ... | @@ -0,0 +1,134 @@ |
| 1 | import type { | |
| 2 | ServerProvider, | |
| 3 | ServerProviderAuthStatus, | |
| 4 | ServerProviderModel, | |
| 5 | ServerProviderState, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { Effect, Stream } from "effect"; | |
| 8 | import { normalizeModelSlug } from "@t3tools/shared/model"; | |
| 9 | ||
| 10 | export const DEFAULT_TIMEOUT_MS = 4_000; | |
| 11 | ||
| 12 | export interface CommandResult { | |
| 13 | readonly stdout: string; | |
| 14 | readonly stderr: string; | |
| 15 | readonly code: number; | |
| 16 | } | |
| 17 | ||
| 18 | export interface ProviderProbeResult { | |
| 19 | readonly installed: boolean; | |
| 20 | readonly version: string | null; | |
| 21 | readonly status: Exclude<ServerProviderState, "disabled">; | |
| 22 | readonly authStatus: ServerProviderAuthStatus; | |
| 23 | readonly message?: string; | |
| 24 | } | |
| 25 | ||
| 26 | export function nonEmptyTrimmed(value: string | undefined): string | undefined { | |
| 27 | if (!value) return undefined; | |
| 28 | const trimmed = value.trim(); | |
| 29 | return trimmed.length > 0 ? trimmed : undefined; | |
| 30 | } | |
| 31 | ||
| 32 | export function isCommandMissingCause(error: unknown): boolean { | |
| 33 | if (!(error instanceof Error)) return false; | |
| 34 | const lower = error.message.toLowerCase(); | |
| 35 | return lower.includes("enoent") || lower.includes("notfound"); | |
| 36 | } | |
| 37 | ||
| 38 | export function detailFromResult( | |
| 39 | result: CommandResult & { readonly timedOut?: boolean }, | |
| 40 | ): string | undefined { | |
| 41 | if (result.timedOut) return "Timed out while running command."; | |
| 42 | const stderr = nonEmptyTrimmed(result.stderr); | |
| 43 | if (stderr) return stderr; | |
| 44 | const stdout = nonEmptyTrimmed(result.stdout); | |
| 45 | if (stdout) return stdout; | |
| 46 | if (result.code !== 0) { | |
| 47 | return `Command exited with code ${result.code}.`; | |
| 48 | } | |
| 49 | return undefined; | |
| 50 | } | |
| 51 | ||
| 52 | export function extractAuthBoolean(value: unknown): boolean | undefined { | |
| 53 | if (globalThis.Array.isArray(value)) { | |
| 54 | for (const entry of value) { | |
| 55 | const nested = extractAuthBoolean(entry); | |
| 56 | if (nested !== undefined) return nested; | |
| 57 | } | |
| 58 | return undefined; | |
| 59 | } | |
| 60 | ||
| 61 | if (!value || typeof value !== "object") return undefined; | |
| 62 | ||
| 63 | const record = value as Record<string, unknown>; | |
| 64 | for (const key of ["authenticated", "isAuthenticated", "loggedIn", "isLoggedIn"] as const) { | |
| 65 | if (typeof record[key] === "boolean") return record[key]; | |
| 66 | } | |
| 67 | for (const key of ["auth", "status", "session", "account"] as const) { | |
| 68 | const nested = extractAuthBoolean(record[key]); | |
| 69 | if (nested !== undefined) return nested; | |
| 70 | } | |
| 71 | return undefined; | |
| 72 | } | |
| 73 | ||
| 74 | export function parseGenericCliVersion(output: string): string | null { | |
| 75 | const match = output.match(/\b(\d+\.\d+\.\d+)\b/); | |
| 76 | return match?.[1] ?? null; | |
| 77 | } | |
| 78 | ||
| 79 | export function providerModelsFromSettings( | |
| 80 | builtInModels: ReadonlyArray<ServerProviderModel>, | |
| 81 | provider: ServerProvider["provider"], | |
| 82 | customModels: ReadonlyArray<string>, | |
| 83 | ): ReadonlyArray<ServerProviderModel> { | |
| 84 | const resolvedBuiltInModels = [...builtInModels]; | |
| 85 | const seen = new Set(resolvedBuiltInModels.map((model) => model.slug)); | |
| 86 | const customEntries: ServerProviderModel[] = []; | |
| 87 | ||
| 88 | for (const candidate of customModels) { | |
| 89 | const normalized = normalizeModelSlug(candidate, provider); | |
| 90 | if (!normalized || seen.has(normalized)) { | |
| 91 | continue; | |
| 92 | } | |
| 93 | seen.add(normalized); | |
| 94 | customEntries.push({ | |
| 95 | slug: normalized, | |
| 96 | name: normalized, | |
| 97 | isCustom: true, | |
| 98 | capabilities: null, | |
| 99 | }); | |
| 100 | } | |
| 101 | ||
| 102 | return [...resolvedBuiltInModels, ...customEntries]; | |
| 103 | } | |
| 104 | ||
| 105 | export function buildServerProvider(input: { | |
| 106 | provider: ServerProvider["provider"]; | |
| 107 | enabled: boolean; | |
| 108 | checkedAt: string; | |
| 109 | models: ReadonlyArray<ServerProviderModel>; | |
| 110 | probe: ProviderProbeResult; | |
| 111 | }): ServerProvider { | |
| 112 | return { | |
| 113 | provider: input.provider, | |
| 114 | enabled: input.enabled, | |
| 115 | installed: input.probe.installed, | |
| 116 | version: input.probe.version, | |
| 117 | status: input.enabled ? input.probe.status : "disabled", | |
| 118 | authStatus: input.probe.authStatus, | |
| 119 | checkedAt: input.checkedAt, | |
| 120 | ...(input.probe.message ? { message: input.probe.message } : {}), | |
| 121 | models: input.models, | |
| 122 | }; | |
| 123 | } | |
| 124 | ||
| 125 | export const collectStreamAsString = <E>( | |
| 126 | stream: Stream.Stream<Uint8Array, E>, | |
| 127 | ): Effect.Effect<string, E> => | |
| 128 | stream.pipe( | |
| 129 | Stream.decodeText(), | |
| 130 | Stream.runFold( | |
| 131 | () => "", | |
| 132 | (acc, chunk) => acc + chunk, | |
| 133 | ), | |
| 134 | ); |
apps/server/src/serverLayers.ts+6-1| ... | ... | @@ -24,6 +24,7 @@ import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; |
| 24 | 24 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; |
| 25 | 25 | import { ProviderService } from "./provider/Services/ProviderService"; |
| 26 | 26 | import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; |
| 27 | import { ServerSettingsService } from "./serverSettings"; | |
| 27 | 28 | |
| 28 | 29 | import { TerminalManagerLive } from "./terminal/Layers/Manager"; |
| 29 | 30 | import { KeybindingsLive } from "./keybindings"; |
| ... | ... | @@ -54,7 +55,11 @@ const makeRuntimePtyAdapterLayer = () => |
| 54 | 55 | export function makeServerProviderLayer(): Layer.Layer< |
| 55 | 56 | ProviderService, |
| 56 | 57 | ProviderUnsupportedError, |
| 57 | SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem | AnalyticsService | |
| 58 | | SqlClient.SqlClient | |
| 59 | | ServerConfig | |
| 60 | | ServerSettingsService | |
| 61 | | FileSystem.FileSystem | |
| 62 | | AnalyticsService | |
| 58 | 63 | > { |
| 59 | 64 | return Effect.gen(function* () { |
| 60 | 65 | const { providerEventLogPath } = yield* ServerConfig; |
apps/server/src/serverSettings.test.ts created+182| ... | ... | @@ -0,0 +1,182 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { DEFAULT_SERVER_SETTINGS, ServerSettingsPatch } from "@t3tools/contracts"; | |
| 3 | import { assert, it } from "@effect/vitest"; | |
| 4 | import { Effect, FileSystem, Layer, Schema } from "effect"; | |
| 5 | import { ServerConfig } from "./config"; | |
| 6 | import { ServerSettingsLive, ServerSettingsService } from "./serverSettings"; | |
| 7 | ||
| 8 | const makeServerSettingsLayer = () => | |
| 9 | ServerSettingsLive.pipe( | |
| 10 | Layer.provideMerge( | |
| 11 | Layer.fresh( | |
| 12 | ServerConfig.layerTest(process.cwd(), { | |
| 13 | prefix: "t3code-server-settings-test-", | |
| 14 | }), | |
| 15 | ), | |
| 16 | ), | |
| 17 | ); | |
| 18 | ||
| 19 | it.layer(NodeServices.layer)("server settings", (it) => { | |
| 20 | it.effect("decodes nested settings patches", () => | |
| 21 | Effect.sync(() => { | |
| 22 | const decodePatch = Schema.decodeUnknownSync(ServerSettingsPatch); | |
| 23 | ||
| 24 | assert.deepEqual(decodePatch({ providers: { codex: { binaryPath: "/tmp/codex" } } }), { | |
| 25 | providers: { codex: { binaryPath: "/tmp/codex" } }, | |
| 26 | }); | |
| 27 | ||
| 28 | assert.deepEqual( | |
| 29 | decodePatch({ | |
| 30 | textGenerationModelSelection: { | |
| 31 | options: { | |
| 32 | fastMode: false, | |
| 33 | }, | |
| 34 | }, | |
| 35 | }), | |
| 36 | { | |
| 37 | textGenerationModelSelection: { | |
| 38 | options: { | |
| 39 | fastMode: false, | |
| 40 | }, | |
| 41 | }, | |
| 42 | }, | |
| 43 | ); | |
| 44 | }), | |
| 45 | ); | |
| 46 | ||
| 47 | it.effect("deep merges nested settings updates without dropping siblings", () => | |
| 48 | Effect.gen(function* () { | |
| 49 | const serverSettings = yield* ServerSettingsService; | |
| 50 | ||
| 51 | yield* serverSettings.updateSettings({ | |
| 52 | providers: { | |
| 53 | codex: { | |
| 54 | binaryPath: "/usr/local/bin/codex", | |
| 55 | homePath: "/Users/julius/.codex", | |
| 56 | }, | |
| 57 | claudeAgent: { | |
| 58 | binaryPath: "/usr/local/bin/claude", | |
| 59 | customModels: ["claude-custom"], | |
| 60 | }, | |
| 61 | }, | |
| 62 | textGenerationModelSelection: { | |
| 63 | provider: "codex", | |
| 64 | model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, | |
| 65 | options: { | |
| 66 | reasoningEffort: "high", | |
| 67 | fastMode: true, | |
| 68 | }, | |
| 69 | }, | |
| 70 | }); | |
| 71 | ||
| 72 | const next = yield* serverSettings.updateSettings({ | |
| 73 | providers: { | |
| 74 | codex: { | |
| 75 | binaryPath: "/opt/homebrew/bin/codex", | |
| 76 | }, | |
| 77 | }, | |
| 78 | textGenerationModelSelection: { | |
| 79 | options: { | |
| 80 | fastMode: false, | |
| 81 | }, | |
| 82 | }, | |
| 83 | }); | |
| 84 | ||
| 85 | assert.deepEqual(next.providers.codex, { | |
| 86 | enabled: true, | |
| 87 | binaryPath: "/opt/homebrew/bin/codex", | |
| 88 | homePath: "/Users/julius/.codex", | |
| 89 | customModels: [], | |
| 90 | }); | |
| 91 | assert.deepEqual(next.providers.claudeAgent, { | |
| 92 | enabled: true, | |
| 93 | binaryPath: "/usr/local/bin/claude", | |
| 94 | customModels: ["claude-custom"], | |
| 95 | }); | |
| 96 | assert.deepEqual(next.textGenerationModelSelection, { | |
| 97 | provider: "codex", | |
| 98 | model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, | |
| 99 | options: { | |
| 100 | reasoningEffort: "high", | |
| 101 | fastMode: false, | |
| 102 | }, | |
| 103 | }); | |
| 104 | }).pipe(Effect.provide(makeServerSettingsLayer())), | |
| 105 | ); | |
| 106 | ||
| 107 | it.effect("trims provider path settings when updates are applied", () => | |
| 108 | Effect.gen(function* () { | |
| 109 | const serverSettings = yield* ServerSettingsService; | |
| 110 | ||
| 111 | const next = yield* serverSettings.updateSettings({ | |
| 112 | providers: { | |
| 113 | codex: { | |
| 114 | binaryPath: " /opt/homebrew/bin/codex ", | |
| 115 | homePath: " ", | |
| 116 | }, | |
| 117 | claudeAgent: { | |
| 118 | binaryPath: " /opt/homebrew/bin/claude ", | |
| 119 | }, | |
| 120 | }, | |
| 121 | }); | |
| 122 | ||
| 123 | assert.deepEqual(next.providers.codex, { | |
| 124 | enabled: true, | |
| 125 | binaryPath: "/opt/homebrew/bin/codex", | |
| 126 | homePath: "", | |
| 127 | customModels: [], | |
| 128 | }); | |
| 129 | assert.deepEqual(next.providers.claudeAgent, { | |
| 130 | enabled: true, | |
| 131 | binaryPath: "/opt/homebrew/bin/claude", | |
| 132 | customModels: [], | |
| 133 | }); | |
| 134 | }).pipe(Effect.provide(makeServerSettingsLayer())), | |
| 135 | ); | |
| 136 | ||
| 137 | it.effect("defaults blank binary paths to provider executables", () => | |
| 138 | Effect.gen(function* () { | |
| 139 | const serverSettings = yield* ServerSettingsService; | |
| 140 | ||
| 141 | const next = yield* serverSettings.updateSettings({ | |
| 142 | providers: { | |
| 143 | codex: { | |
| 144 | binaryPath: " ", | |
| 145 | }, | |
| 146 | claudeAgent: { | |
| 147 | binaryPath: "", | |
| 148 | }, | |
| 149 | }, | |
| 150 | }); | |
| 151 | ||
| 152 | assert.equal(next.providers.codex.binaryPath, "codex"); | |
| 153 | assert.equal(next.providers.claudeAgent.binaryPath, "claude"); | |
| 154 | }).pipe(Effect.provide(makeServerSettingsLayer())), | |
| 155 | ); | |
| 156 | ||
| 157 | it.effect("writes only non-default server settings to disk", () => | |
| 158 | Effect.gen(function* () { | |
| 159 | const serverSettings = yield* ServerSettingsService; | |
| 160 | const serverConfig = yield* ServerConfig; | |
| 161 | const fileSystem = yield* FileSystem.FileSystem; | |
| 162 | const next = yield* serverSettings.updateSettings({ | |
| 163 | providers: { | |
| 164 | codex: { | |
| 165 | binaryPath: "/opt/homebrew/bin/codex", | |
| 166 | }, | |
| 167 | }, | |
| 168 | }); | |
| 169 | ||
| 170 | assert.equal(next.providers.codex.binaryPath, "/opt/homebrew/bin/codex"); | |
| 171 | ||
| 172 | const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); | |
| 173 | assert.deepEqual(JSON.parse(raw), { | |
| 174 | providers: { | |
| 175 | codex: { | |
| 176 | binaryPath: "/opt/homebrew/bin/codex", | |
| 177 | }, | |
| 178 | }, | |
| 179 | }); | |
| 180 | }).pipe(Effect.provide(makeServerSettingsLayer())), | |
| 181 | ); | |
| 182 | }); |
apps/server/src/serverSettings.ts created+339| ... | ... | @@ -0,0 +1,339 @@ |
| 1 | /** | |
| 2 | * ServerSettings - Server-authoritative settings service. | |
| 3 | * | |
| 4 | * Owns persistence, validation, and change notification of settings that affect | |
| 5 | * server-side behavior (binary paths, streaming mode, env mode, custom models, | |
| 6 | * text generation model selection). | |
| 7 | * | |
| 8 | * Follows the same pattern as `keybindings.ts`: JSON file + Cache + PubSub + | |
| 9 | * Semaphore + FileSystem.watch for concurrency and external edit detection. | |
| 10 | * | |
| 11 | * @module ServerSettings | |
| 12 | */ | |
| 13 | import { | |
| 14 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, | |
| 15 | DEFAULT_SERVER_SETTINGS, | |
| 16 | type ModelSelection, | |
| 17 | type ProviderKind, | |
| 18 | ServerSettings, | |
| 19 | type ServerSettingsPatch, | |
| 20 | } from "@t3tools/contracts"; | |
| 21 | import { | |
| 22 | Cache, | |
| 23 | Deferred, | |
| 24 | Duration, | |
| 25 | Effect, | |
| 26 | Exit, | |
| 27 | FileSystem, | |
| 28 | Layer, | |
| 29 | Path, | |
| 30 | PubSub, | |
| 31 | Ref, | |
| 32 | Schema, | |
| 33 | SchemaIssue, | |
| 34 | Scope, | |
| 35 | ServiceMap, | |
| 36 | Stream, | |
| 37 | } from "effect"; | |
| 38 | import * as Semaphore from "effect/Semaphore"; | |
| 39 | import { ServerConfig } from "./config"; | |
| 40 | import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; | |
| 41 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; | |
| 42 | ||
| 43 | export class ServerSettingsError extends Schema.TaggedErrorClass<ServerSettingsError>()( | |
| 44 | "ServerSettingsError", | |
| 45 | { | |
| 46 | settingsPath: Schema.String, | |
| 47 | detail: Schema.String, | |
| 48 | cause: Schema.optional(Schema.Defect), | |
| 49 | }, | |
| 50 | ) { | |
| 51 | override get message(): string { | |
| 52 | return `Server settings error at ${this.settingsPath}: ${this.detail}`; | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | export interface ServerSettingsShape { | |
| 57 | /** Start the settings runtime and attach file watching. */ | |
| 58 | readonly start: Effect.Effect<void, ServerSettingsError>; | |
| 59 | ||
| 60 | /** Await settings runtime readiness. */ | |
| 61 | readonly ready: Effect.Effect<void, ServerSettingsError>; | |
| 62 | ||
| 63 | /** Read the current settings. */ | |
| 64 | readonly getSettings: Effect.Effect<ServerSettings, ServerSettingsError>; | |
| 65 | ||
| 66 | /** Patch settings and persist. Returns the new full settings object. */ | |
| 67 | readonly updateSettings: ( | |
| 68 | patch: ServerSettingsPatch, | |
| 69 | ) => Effect.Effect<ServerSettings, ServerSettingsError>; | |
| 70 | ||
| 71 | /** Stream of settings change events. */ | |
| 72 | readonly streamChanges: Stream.Stream<ServerSettings>; | |
| 73 | } | |
| 74 | ||
| 75 | export class ServerSettingsService extends ServiceMap.Service< | |
| 76 | ServerSettingsService, | |
| 77 | ServerSettingsShape | |
| 78 | >()("t3/serverSettings/ServerSettingsService") { | |
| 79 | static readonly layerTest = (overrides: DeepPartial<ServerSettings> = {}) => | |
| 80 | Layer.effect( | |
| 81 | ServerSettingsService, | |
| 82 | Effect.gen(function* () { | |
| 83 | const currentSettingsRef = yield* Ref.make<ServerSettings>( | |
| 84 | deepMerge(DEFAULT_SERVER_SETTINGS, overrides), | |
| 85 | ); | |
| 86 | ||
| 87 | return { | |
| 88 | start: Effect.void, | |
| 89 | ready: Effect.void, | |
| 90 | getSettings: Ref.get(currentSettingsRef), | |
| 91 | updateSettings: (patch) => | |
| 92 | Ref.get(currentSettingsRef).pipe( | |
| 93 | Effect.map((currentSettings) => deepMerge(currentSettings, patch)), | |
| 94 | Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), | |
| 95 | ), | |
| 96 | streamChanges: Stream.empty, | |
| 97 | } satisfies ServerSettingsShape; | |
| 98 | }), | |
| 99 | ); | |
| 100 | } | |
| 101 | ||
| 102 | const ServerSettingsJson = fromLenientJson(ServerSettings); | |
| 103 | ||
| 104 | const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent"]; | |
| 105 | ||
| 106 | /** | |
| 107 | * Ensure the `textGenerationModelSelection` points to an enabled provider. | |
| 108 | * If the selected provider is disabled, fall back to the first enabled | |
| 109 | * provider with its default model. This is applied at read-time so the | |
| 110 | * persisted preference is preserved for when a provider is re-enabled. | |
| 111 | */ | |
| 112 | function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { | |
| 113 | const selection = settings.textGenerationModelSelection; | |
| 114 | if (settings.providers[selection.provider].enabled) { | |
| 115 | return settings; | |
| 116 | } | |
| 117 | ||
| 118 | const fallback = PROVIDER_ORDER.find((p) => settings.providers[p].enabled); | |
| 119 | if (!fallback) { | |
| 120 | // No providers enabled — return as-is; callers will report the error. | |
| 121 | return settings; | |
| 122 | } | |
| 123 | ||
| 124 | return { | |
| 125 | ...settings, | |
| 126 | textGenerationModelSelection: { | |
| 127 | provider: fallback, | |
| 128 | model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback], | |
| 129 | } as ModelSelection, | |
| 130 | }; | |
| 131 | } | |
| 132 | ||
| 133 | function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined { | |
| 134 | if (Array.isArray(current) || Array.isArray(defaults)) { | |
| 135 | return JSON.stringify(current) === JSON.stringify(defaults) ? undefined : current; | |
| 136 | } | |
| 137 | ||
| 138 | if ( | |
| 139 | current !== null && | |
| 140 | defaults !== null && | |
| 141 | typeof current === "object" && | |
| 142 | typeof defaults === "object" | |
| 143 | ) { | |
| 144 | const currentRecord = current as Record<string, unknown>; | |
| 145 | const defaultsRecord = defaults as Record<string, unknown>; | |
| 146 | const next: Record<string, unknown> = {}; | |
| 147 | ||
| 148 | for (const key of Object.keys(currentRecord)) { | |
| 149 | const stripped = stripDefaultServerSettings(currentRecord[key], defaultsRecord[key]); | |
| 150 | if (stripped !== undefined) { | |
| 151 | next[key] = stripped; | |
| 152 | } | |
| 153 | } | |
| 154 | ||
| 155 | return Object.keys(next).length > 0 ? next : undefined; | |
| 156 | } | |
| 157 | ||
| 158 | return Object.is(current, defaults) ? undefined : current; | |
| 159 | } | |
| 160 | ||
| 161 | const makeServerSettings = Effect.gen(function* () { | |
| 162 | const { settingsPath } = yield* ServerConfig; | |
| 163 | const fs = yield* FileSystem.FileSystem; | |
| 164 | const pathService = yield* Path.Path; | |
| 165 | const writeSemaphore = yield* Semaphore.make(1); | |
| 166 | const cacheKey = "settings" as const; | |
| 167 | const changesPubSub = yield* PubSub.unbounded<ServerSettings>(); | |
| 168 | const startedRef = yield* Ref.make(false); | |
| 169 | const startedDeferred = yield* Deferred.make<void, ServerSettingsError>(); | |
| 170 | const watcherScope = yield* Scope.make("sequential"); | |
| 171 | yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void)); | |
| 172 | ||
| 173 | const emitChange = (settings: ServerSettings) => | |
| 174 | PubSub.publish(changesPubSub, settings).pipe(Effect.asVoid); | |
| 175 | ||
| 176 | const readConfigExists = fs.exists(settingsPath).pipe( | |
| 177 | Effect.mapError( | |
| 178 | (cause) => | |
| 179 | new ServerSettingsError({ | |
| 180 | settingsPath, | |
| 181 | detail: "failed to check settings file existence", | |
| 182 | cause, | |
| 183 | }), | |
| 184 | ), | |
| 185 | ); | |
| 186 | ||
| 187 | const readRawConfig = fs.readFileString(settingsPath).pipe( | |
| 188 | Effect.mapError( | |
| 189 | (cause) => | |
| 190 | new ServerSettingsError({ | |
| 191 | settingsPath, | |
| 192 | detail: "failed to read settings file", | |
| 193 | cause, | |
| 194 | }), | |
| 195 | ), | |
| 196 | ); | |
| 197 | ||
| 198 | const loadSettingsFromDisk = Effect.gen(function* () { | |
| 199 | if (!(yield* readConfigExists)) { | |
| 200 | return DEFAULT_SERVER_SETTINGS; | |
| 201 | } | |
| 202 | ||
| 203 | const raw = yield* readRawConfig; | |
| 204 | const decoded = Schema.decodeUnknownExit(ServerSettingsJson)(raw); | |
| 205 | if (decoded._tag === "Failure") { | |
| 206 | yield* Effect.logWarning("failed to parse settings.json, using defaults", { | |
| 207 | path: settingsPath, | |
| 208 | }); | |
| 209 | return DEFAULT_SERVER_SETTINGS; | |
| 210 | } | |
| 211 | return decoded.value; | |
| 212 | }); | |
| 213 | ||
| 214 | const settingsCache = yield* Cache.make<typeof cacheKey, ServerSettings, ServerSettingsError>({ | |
| 215 | capacity: 1, | |
| 216 | lookup: () => loadSettingsFromDisk, | |
| 217 | }); | |
| 218 | ||
| 219 | const getSettingsFromCache = Cache.get(settingsCache, cacheKey); | |
| 220 | ||
| 221 | const writeSettingsAtomically = (settings: ServerSettings) => { | |
| 222 | const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`; | |
| 223 | const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}; | |
| 224 | ||
| 225 | return Effect.succeed(`${JSON.stringify(sparseSettings, null, 2)}\n`).pipe( | |
| 226 | Effect.tap(() => fs.makeDirectory(pathService.dirname(settingsPath), { recursive: true })), | |
| 227 | Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), | |
| 228 | Effect.flatMap(() => fs.rename(tempPath, settingsPath)), | |
| 229 | Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))), | |
| 230 | Effect.mapError( | |
| 231 | (cause) => | |
| 232 | new ServerSettingsError({ | |
| 233 | settingsPath, | |
| 234 | detail: "failed to write settings file", | |
| 235 | cause, | |
| 236 | }), | |
| 237 | ), | |
| 238 | ); | |
| 239 | }; | |
| 240 | ||
| 241 | const revalidateAndEmit = writeSemaphore.withPermits(1)( | |
| 242 | Effect.gen(function* () { | |
| 243 | yield* Cache.invalidate(settingsCache, cacheKey); | |
| 244 | const settings = yield* getSettingsFromCache; | |
| 245 | yield* emitChange(settings); | |
| 246 | }), | |
| 247 | ); | |
| 248 | ||
| 249 | const startWatcher = Effect.gen(function* () { | |
| 250 | const settingsDir = pathService.dirname(settingsPath); | |
| 251 | const settingsFile = pathService.basename(settingsPath); | |
| 252 | const settingsPathResolved = pathService.resolve(settingsPath); | |
| 253 | ||
| 254 | yield* fs.makeDirectory(settingsDir, { recursive: true }).pipe( | |
| 255 | Effect.mapError( | |
| 256 | (cause) => | |
| 257 | new ServerSettingsError({ | |
| 258 | settingsPath, | |
| 259 | detail: "failed to prepare settings directory", | |
| 260 | cause, | |
| 261 | }), | |
| 262 | ), | |
| 263 | ); | |
| 264 | ||
| 265 | const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); | |
| 266 | ||
| 267 | // Debounce watch events so the file is fully written before we read it. | |
| 268 | // Editors emit multiple events per save (truncate, write, rename) and | |
| 269 | // `fs.watch` can fire before the content has been flushed to disk. | |
| 270 | const debouncedSettingsEvents = fs.watch(settingsDir).pipe( | |
| 271 | Stream.filter((event) => { | |
| 272 | return ( | |
| 273 | event.path === settingsFile || | |
| 274 | event.path === settingsPath || | |
| 275 | pathService.resolve(settingsDir, event.path) === settingsPathResolved | |
| 276 | ); | |
| 277 | }), | |
| 278 | Stream.debounce(Duration.millis(100)), | |
| 279 | ); | |
| 280 | ||
| 281 | yield* Stream.runForEach(debouncedSettingsEvents, () => revalidateAndEmitSafely).pipe( | |
| 282 | Effect.ignoreCause({ log: true }), | |
| 283 | Effect.forkIn(watcherScope), | |
| 284 | Effect.asVoid, | |
| 285 | ); | |
| 286 | }); | |
| 287 | ||
| 288 | const start = Effect.gen(function* () { | |
| 289 | const shouldStart = yield* Ref.modify(startedRef, (started) => [!started, true]); | |
| 290 | if (!shouldStart) { | |
| 291 | return yield* Deferred.await(startedDeferred); | |
| 292 | } | |
| 293 | ||
| 294 | const startup = Effect.gen(function* () { | |
| 295 | yield* startWatcher; | |
| 296 | yield* Cache.invalidate(settingsCache, cacheKey); | |
| 297 | yield* getSettingsFromCache; | |
| 298 | }); | |
| 299 | ||
| 300 | const startupExit = yield* Effect.exit(startup); | |
| 301 | if (startupExit._tag === "Failure") { | |
| 302 | yield* Deferred.failCause(startedDeferred, startupExit.cause).pipe(Effect.orDie); | |
| 303 | return yield* Effect.failCause(startupExit.cause); | |
| 304 | } | |
| 305 | ||
| 306 | yield* Deferred.succeed(startedDeferred, undefined).pipe(Effect.orDie); | |
| 307 | }); | |
| 308 | ||
| 309 | return { | |
| 310 | start, | |
| 311 | ready: Deferred.await(startedDeferred), | |
| 312 | getSettings: getSettingsFromCache.pipe(Effect.map(resolveTextGenerationProvider)), | |
| 313 | updateSettings: (patch) => | |
| 314 | writeSemaphore.withPermits(1)( | |
| 315 | Effect.gen(function* () { | |
| 316 | const current = yield* getSettingsFromCache; | |
| 317 | const next = yield* Schema.decodeEffect(ServerSettings)(deepMerge(current, patch)).pipe( | |
| 318 | Effect.mapError( | |
| 319 | (cause) => | |
| 320 | new ServerSettingsError({ | |
| 321 | settingsPath: "<memory>", | |
| 322 | detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, | |
| 323 | cause, | |
| 324 | }), | |
| 325 | ), | |
| 326 | ); | |
| 327 | yield* writeSettingsAtomically(next); | |
| 328 | yield* Cache.set(settingsCache, cacheKey, next); | |
| 329 | yield* emitChange(next); | |
| 330 | return resolveTextGenerationProvider(next); | |
| 331 | }), | |
| 332 | ), | |
| 333 | get streamChanges() { | |
| 334 | return Stream.fromPubSub(changesPubSub).pipe(Stream.map(resolveTextGenerationProvider)); | |
| 335 | }, | |
| 336 | } satisfies ServerSettingsShape; | |
| 337 | }); | |
| 338 | ||
| 339 | export const ServerSettingsLive = Layer.effect(ServerSettingsService, makeServerSettings); |
apps/server/src/wsServer.test.ts+31-19| ... | ... | @@ -13,18 +13,20 @@ import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serve |
| 13 | 13 | |
| 14 | 14 | import { |
| 15 | 15 | DEFAULT_TERMINAL_ID, |
| 16 | DEFAULT_SERVER_SETTINGS, | |
| 16 | 17 | EDITORS, |
| 17 | 18 | EventId, |
| 18 | 19 | ORCHESTRATION_WS_CHANNELS, |
| 19 | 20 | ORCHESTRATION_WS_METHODS, |
| 20 | 21 | ProviderItemId, |
| 22 | type ServerSettings, | |
| 21 | 23 | ThreadId, |
| 22 | 24 | TurnId, |
| 23 | 25 | WS_CHANNELS, |
| 24 | 26 | WS_METHODS, |
| 25 | 27 | type WebSocketResponse, |
| 26 | 28 | type ProviderRuntimeEvent, |
| 27 | type ServerProviderStatus, | |
| 29 | type ServerProvider, | |
| 28 | 30 | type KeybindingsConfig, |
| 29 | 31 | type ResolvedKeybindingsConfig, |
| 30 | 32 | type WsPushChannel, |
| ... | ... | @@ -45,7 +47,7 @@ import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/ |
| 45 | 47 | import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; |
| 46 | 48 | import { SqlClient, SqlError } from "effect/unstable/sql"; |
| 47 | 49 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; |
| 48 | import { ProviderHealth, type ProviderHealthShape } from "./provider/Services/ProviderHealth"; | |
| 50 | import { ProviderRegistry, type ProviderRegistryShape } from "./provider/Services/ProviderRegistry"; | |
| 49 | 51 | import { Open, type OpenShape } from "./open"; |
| 50 | 52 | import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; |
| 51 | 53 | import type { GitCoreShape } from "./git/Services/GitCore.ts"; |
| ... | ... | @@ -53,6 +55,7 @@ import { GitCore } from "./git/Services/GitCore.ts"; |
| 53 | 55 | import { GitCommandError, GitManagerError } from "./git/Errors.ts"; |
| 54 | 56 | import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; |
| 55 | 57 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; |
| 58 | import { ServerSettingsService } from "./serverSettings.ts"; | |
| 56 | 59 | |
| 57 | 60 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| 58 | 61 | const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); |
| ... | ... | @@ -64,20 +67,27 @@ const defaultOpenService: OpenShape = { |
| 64 | 67 | openInEditor: () => Effect.void, |
| 65 | 68 | }; |
| 66 | 69 | |
| 67 | const defaultProviderStatuses: ReadonlyArray<ServerProviderStatus> = [ | |
| 70 | const defaultProviderStatuses: ReadonlyArray<ServerProvider> = [ | |
| 68 | 71 | { |
| 69 | 72 | provider: "codex", |
| 73 | enabled: true, | |
| 74 | installed: true, | |
| 75 | version: "0.116.0", | |
| 70 | 76 | status: "ready", |
| 71 | available: true, | |
| 72 | 77 | authStatus: "authenticated", |
| 73 | 78 | checkedAt: "2026-01-01T00:00:00.000Z", |
| 79 | models: [], | |
| 74 | 80 | }, |
| 75 | 81 | ]; |
| 76 | 82 | |
| 77 | const defaultProviderHealthService: ProviderHealthShape = { | |
| 78 | getStatuses: Effect.succeed(defaultProviderStatuses), | |
| 83 | const defaultProviderRegistryService: ProviderRegistryShape = { | |
| 84 | getProviders: Effect.succeed(defaultProviderStatuses), | |
| 85 | refresh: () => Effect.succeed(defaultProviderStatuses), | |
| 86 | streamChanges: Stream.empty, | |
| 79 | 87 | }; |
| 80 | 88 | |
| 89 | const defaultServerSettings = DEFAULT_SERVER_SETTINGS; | |
| 90 | ||
| 81 | 91 | class MockTerminalManager implements TerminalManagerShape { |
| 82 | 92 | private readonly sessions = new Map<string, TerminalSessionSnapshot>(); |
| 83 | 93 | private readonly listeners = new Set<(event: TerminalEvent) => void>(); |
| ... | ... | @@ -487,11 +497,12 @@ describe("WebSocket Server", () => { |
| 487 | 497 | baseDir?: string; |
| 488 | 498 | staticDir?: string; |
| 489 | 499 | providerLayer?: Layer.Layer<ProviderService, never>; |
| 490 | providerHealth?: ProviderHealthShape; | |
| 500 | providerRegistry?: ProviderRegistryShape; | |
| 491 | 501 | open?: OpenShape; |
| 492 | 502 | gitManager?: GitManagerShape; |
| 493 | 503 | gitCore?: Pick<GitCoreShape, "listBranches" | "initRepo" | "pullCurrentBranch">; |
| 494 | 504 | terminalManager?: TerminalManagerShape; |
| 505 | serverSettings?: Partial<ServerSettings>; | |
| 495 | 506 | } = {}, |
| 496 | 507 | ): Promise<Http.Server> { |
| 497 | 508 | if (serverScope) { |
| ... | ... | @@ -504,9 +515,9 @@ describe("WebSocket Server", () => { |
| 504 | 515 | const scope = await Effect.runPromise(Scope.make("sequential")); |
| 505 | 516 | const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory; |
| 506 | 517 | const providerLayer = options.providerLayer ?? makeServerProviderLayer(); |
| 507 | const providerHealthLayer = Layer.succeed( | |
| 508 | ProviderHealth, | |
| 509 | options.providerHealth ?? defaultProviderHealthService, | |
| 518 | const providerRegistryLayer = Layer.succeed( | |
| 519 | ProviderRegistry, | |
| 520 | options.providerRegistry ?? defaultProviderRegistryService, | |
| 510 | 521 | ); |
| 511 | 522 | const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService); |
| 512 | 523 | const serverConfigLayer = Layer.succeed(ServerConfig, { |
| ... | ... | @@ -543,8 +554,9 @@ describe("WebSocket Server", () => { |
| 543 | 554 | ); |
| 544 | 555 | const dependenciesLayer = Layer.empty.pipe( |
| 545 | 556 | Layer.provideMerge(runtimeLayer), |
| 546 | Layer.provideMerge(providerHealthLayer), | |
| 557 | Layer.provideMerge(providerRegistryLayer), | |
| 547 | 558 | Layer.provideMerge(openLayer), |
| 559 | Layer.provideMerge(ServerSettingsService.layerTest(options.serverSettings)), | |
| 548 | 560 | Layer.provideMerge(serverConfigLayer), |
| 549 | 561 | Layer.provideMerge(AnalyticsService.layerTest), |
| 550 | 562 | Layer.provideMerge(NodeServices.layer), |
| ... | ... | @@ -858,6 +870,7 @@ describe("WebSocket Server", () => { |
| 858 | 870 | issues: [], |
| 859 | 871 | providers: defaultProviderStatuses, |
| 860 | 872 | availableEditors: expect.any(Array), |
| 873 | settings: defaultServerSettings, | |
| 861 | 874 | }); |
| 862 | 875 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); |
| 863 | 876 | }); |
| ... | ... | @@ -883,6 +896,7 @@ describe("WebSocket Server", () => { |
| 883 | 896 | issues: [], |
| 884 | 897 | providers: defaultProviderStatuses, |
| 885 | 898 | availableEditors: expect.any(Array), |
| 899 | settings: defaultServerSettings, | |
| 886 | 900 | }); |
| 887 | 901 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); |
| 888 | 902 | |
| ... | ... | @@ -919,6 +933,7 @@ describe("WebSocket Server", () => { |
| 919 | 933 | ], |
| 920 | 934 | providers: defaultProviderStatuses, |
| 921 | 935 | availableEditors: expect.any(Array), |
| 936 | settings: defaultServerSettings, | |
| 922 | 937 | }); |
| 923 | 938 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); |
| 924 | 939 | expect(fs.readFileSync(keybindingsPath, "utf8")).toBe("{ not-json"); |
| ... | ... | @@ -952,7 +967,7 @@ describe("WebSocket Server", () => { |
| 952 | 967 | keybindingsConfigPath: string; |
| 953 | 968 | keybindings: ResolvedKeybindingsConfig; |
| 954 | 969 | issues: Array<{ kind: string; index?: number; message: string }>; |
| 955 | providers: ReadonlyArray<ServerProviderStatus>; | |
| 970 | providers: ReadonlyArray<ServerProvider>; | |
| 956 | 971 | availableEditors: unknown; |
| 957 | 972 | }; |
| 958 | 973 | expect(result.cwd).toBe("/my/workspace"); |
| ... | ... | @@ -1000,7 +1015,6 @@ describe("WebSocket Server", () => { |
| 1000 | 1015 | ); |
| 1001 | 1016 | expect(malformedPush.data).toEqual({ |
| 1002 | 1017 | issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }], |
| 1003 | providers: defaultProviderStatuses, | |
| 1004 | 1018 | }); |
| 1005 | 1019 | |
| 1006 | 1020 | const successPush = await rewriteKeybindingsAndWaitForPush( |
| ... | ... | @@ -1009,7 +1023,7 @@ describe("WebSocket Server", () => { |
| 1009 | 1023 | "[]", |
| 1010 | 1024 | (push) => Array.isArray(push.data.issues) && push.data.issues.length === 0, |
| 1011 | 1025 | ); |
| 1012 | expect(successPush.data).toEqual({ issues: [], providers: defaultProviderStatuses }); | |
| 1026 | expect(successPush.data).toEqual({ issues: [] }); | |
| 1013 | 1027 | }); |
| 1014 | 1028 | |
| 1015 | 1029 | it("routes shell.openInEditor through the injected open service", async () => { |
| ... | ... | @@ -1069,6 +1083,7 @@ describe("WebSocket Server", () => { |
| 1069 | 1083 | issues: [], |
| 1070 | 1084 | providers: defaultProviderStatuses, |
| 1071 | 1085 | availableEditors: expect.any(Array), |
| 1086 | settings: defaultServerSettings, | |
| 1072 | 1087 | }); |
| 1073 | 1088 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); |
| 1074 | 1089 | }); |
| ... | ... | @@ -1117,6 +1132,7 @@ describe("WebSocket Server", () => { |
| 1117 | 1132 | issues: [], |
| 1118 | 1133 | providers: defaultProviderStatuses, |
| 1119 | 1134 | availableEditors: expect.any(Array), |
| 1135 | settings: defaultServerSettings, | |
| 1120 | 1136 | }); |
| 1121 | 1137 | expectAvailableEditors( |
| 1122 | 1138 | (configResponse.result as { availableEditors: unknown }).availableEditors, |
| ... | ... | @@ -1273,6 +1289,7 @@ describe("WebSocket Server", () => { |
| 1273 | 1289 | server = await createTestServer({ |
| 1274 | 1290 | cwd: "/test", |
| 1275 | 1291 | providerLayer, |
| 1292 | serverSettings: { enableAssistantStreaming: true }, | |
| 1276 | 1293 | }); |
| 1277 | 1294 | const addr = server.address(); |
| 1278 | 1295 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| ... | ... | @@ -1323,7 +1340,6 @@ describe("WebSocket Server", () => { |
| 1323 | 1340 | text: "hello", |
| 1324 | 1341 | attachments: [], |
| 1325 | 1342 | }, |
| 1326 | assistantDeliveryMode: "streaming", | |
| 1327 | 1343 | runtimeMode: "approval-required", |
| 1328 | 1344 | interactionMode: "default", |
| 1329 | 1345 | createdAt, |
| ... | ... | @@ -1850,10 +1866,6 @@ describe("WebSocket Server", () => { |
| 1850 | 1866 | actionId: "client-action-1", |
| 1851 | 1867 | cwd: "/test", |
| 1852 | 1868 | action: "commit_push", |
| 1853 | modelSelection: { | |
| 1854 | provider: "codex", | |
| 1855 | model: "gpt-5.4-mini", | |
| 1856 | }, | |
| 1857 | 1869 | }, |
| 1858 | 1870 | expect.objectContaining({ |
| 1859 | 1871 | actionId: "client-action-1", |
apps/server/src/wsServer.ts+49-7| ... | ... | @@ -49,12 +49,13 @@ import { createLogger } from "./logger"; |
| 49 | 49 | import { GitManager } from "./git/Services/GitManager.ts"; |
| 50 | 50 | import { TerminalManager } from "./terminal/Services/Manager.ts"; |
| 51 | 51 | import { Keybindings } from "./keybindings"; |
| 52 | import { ServerSettingsService } from "./serverSettings"; | |
| 52 | 53 | import { searchWorkspaceEntries } from "./workspaceEntries"; |
| 53 | 54 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; |
| 54 | 55 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; |
| 55 | 56 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; |
| 56 | 57 | import { ProviderService } from "./provider/Services/ProviderService"; |
| 57 | import { ProviderHealth } from "./provider/Services/ProviderHealth"; | |
| 58 | import { ProviderRegistry } from "./provider/Services/ProviderRegistry"; | |
| 58 | 59 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; |
| 59 | 60 | import { clamp } from "effect/Number"; |
| 60 | 61 | import { Open, resolveAvailableEditors } from "./open"; |
| ... | ... | @@ -208,7 +209,7 @@ export type ServerCoreRuntimeServices = |
| 208 | 209 | | CheckpointDiffQuery |
| 209 | 210 | | OrchestrationReactor |
| 210 | 211 | | ProviderService |
| 211 | | ProviderHealth; | |
| 212 | | ProviderRegistry; | |
| 212 | 213 | |
| 213 | 214 | export type ServerRuntimeServices = |
| 214 | 215 | | ServerCoreRuntimeServices |
| ... | ... | @@ -216,6 +217,7 @@ export type ServerRuntimeServices = |
| 216 | 217 | | GitCore |
| 217 | 218 | | TerminalManager |
| 218 | 219 | | Keybindings |
| 220 | | ServerSettingsService | |
| 219 | 221 | | Open |
| 220 | 222 | | AnalyticsService; |
| 221 | 223 | |
| ... | ... | @@ -253,7 +255,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 253 | 255 | const gitManager = yield* GitManager; |
| 254 | 256 | const terminalManager = yield* TerminalManager; |
| 255 | 257 | const keybindingsManager = yield* Keybindings; |
| 256 | const providerHealth = yield* ProviderHealth; | |
| 258 | const serverSettingsManager = yield* ServerSettingsService; | |
| 259 | const providerRegistry = yield* ProviderRegistry; | |
| 257 | 260 | const git = yield* GitCore; |
| 258 | 261 | const fileSystem = yield* FileSystem.FileSystem; |
| 259 | 262 | const path = yield* Path.Path; |
| ... | ... | @@ -268,7 +271,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 268 | 271 | ), |
| 269 | 272 | ); |
| 270 | 273 | |
| 271 | const providerStatuses = yield* providerHealth.getStatuses; | |
| 274 | const providersRef = yield* Ref.make(yield* providerRegistry.getProviders); | |
| 272 | 275 | |
| 273 | 276 | const clients = yield* Ref.make(new Set<WebSocket>()); |
| 274 | 277 | const logger = createLogger("ws"); |
| ... | ... | @@ -295,6 +298,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 295 | 298 | ), |
| 296 | 299 | ); |
| 297 | 300 | yield* readiness.markKeybindingsReady; |
| 301 | yield* serverSettingsManager.start.pipe( | |
| 302 | Effect.mapError( | |
| 303 | (cause) => new ServerLifecycleError({ operation: "serverSettingsRuntimeStart", cause }), | |
| 304 | ), | |
| 305 | ); | |
| 298 | 306 | |
| 299 | 307 | const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { |
| 300 | 308 | readonly command: ClientOrchestrationCommand; |
| ... | ... | @@ -614,7 +622,22 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 614 | 622 | yield* Stream.runForEach(keybindingsManager.streamChanges, (event) => |
| 615 | 623 | pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { |
| 616 | 624 | issues: event.issues, |
| 617 | providers: providerStatuses, | |
| 625 | }), | |
| 626 | ).pipe(Effect.forkIn(subscriptionsScope)); | |
| 627 | ||
| 628 | yield* Stream.runForEach(serverSettingsManager.streamChanges, (settings) => | |
| 629 | pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { | |
| 630 | issues: [], | |
| 631 | settings, | |
| 632 | }), | |
| 633 | ).pipe(Effect.forkIn(subscriptionsScope)); | |
| 634 | ||
| 635 | yield* Stream.runForEach(providerRegistry.streamChanges, (providers) => | |
| 636 | Effect.gen(function* () { | |
| 637 | yield* Ref.set(providersRef, providers); | |
| 638 | yield* pushBus.publishAll(WS_CHANNELS.serverProvidersUpdated, { | |
| 639 | providers, | |
| 640 | }); | |
| 618 | 641 | }), |
| 619 | 642 | ).pipe(Effect.forkIn(subscriptionsScope)); |
| 620 | 643 | |
| ... | ... | @@ -878,16 +901,26 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 878 | 901 | return yield* terminalManager.close(body); |
| 879 | 902 | } |
| 880 | 903 | |
| 881 | case WS_METHODS.serverGetConfig: | |
| 904 | case WS_METHODS.serverGetConfig: { | |
| 882 | 905 | const keybindingsConfig = yield* keybindingsManager.loadConfigState; |
| 906 | const settings = yield* serverSettingsManager.getSettings; | |
| 907 | const providers = yield* Ref.get(providersRef); | |
| 883 | 908 | return { |
| 884 | 909 | cwd, |
| 885 | 910 | keybindingsConfigPath, |
| 886 | 911 | keybindings: keybindingsConfig.keybindings, |
| 887 | 912 | issues: keybindingsConfig.issues, |
| 888 | providers: providerStatuses, | |
| 913 | providers, | |
| 889 | 914 | availableEditors, |
| 915 | settings, | |
| 890 | 916 | }; |
| 917 | } | |
| 918 | ||
| 919 | case WS_METHODS.serverRefreshProviders: { | |
| 920 | const providers = yield* providerRegistry.refresh(); | |
| 921 | yield* Ref.set(providersRef, providers); | |
| 922 | return { providers }; | |
| 923 | } | |
| 891 | 924 | |
| 892 | 925 | case WS_METHODS.serverUpsertKeybinding: { |
| 893 | 926 | const body = stripRequestTag(request.body); |
| ... | ... | @@ -895,6 +928,15 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 895 | 928 | return { keybindings: keybindingsConfig, issues: [] }; |
| 896 | 929 | } |
| 897 | 930 | |
| 931 | case WS_METHODS.serverGetSettings: { | |
| 932 | return yield* serverSettingsManager.getSettings; | |
| 933 | } | |
| 934 | ||
| 935 | case WS_METHODS.serverUpdateSettings: { | |
| 936 | const body = stripRequestTag(request.body); | |
| 937 | return yield* serverSettingsManager.updateSettings(body.patch); | |
| 938 | } | |
| 939 | ||
| 898 | 940 | default: { |
| 899 | 941 | const _exhaustiveCheck: never = request.body; |
| 900 | 942 | return yield* new RouteRequestError({ |
apps/server/src/wsServer/pushBus.test.ts-3| ... | ... | @@ -53,7 +53,6 @@ describe("makeServerPushBus", () => { |
| 53 | 53 | |
| 54 | 54 | yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { |
| 55 | 55 | issues: [{ kind: "keybindings.malformed-config", message: "queued-before-connect" }], |
| 56 | providers: [], | |
| 57 | 56 | }); |
| 58 | 57 | |
| 59 | 58 | const delivered = yield* pushBus.publishClient( |
| ... | ... | @@ -70,7 +69,6 @@ describe("makeServerPushBus", () => { |
| 70 | 69 | |
| 71 | 70 | yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { |
| 72 | 71 | issues: [], |
| 73 | providers: [], | |
| 74 | 72 | }); |
| 75 | 73 | |
| 76 | 74 | yield* Effect.promise(() => client.waitForSentCount(2)); |
| ... | ... | @@ -95,7 +93,6 @@ describe("makeServerPushBus", () => { |
| 95 | 93 | channel: WS_CHANNELS.serverConfigUpdated, |
| 96 | 94 | data: { |
| 97 | 95 | issues: [], |
| 98 | providers: [], | |
| 99 | 96 | }, |
| 100 | 97 | }); |
| 101 | 98 | }), |
apps/web/src/appSettings.test.ts deleted-325| ... | ... | @@ -1,325 +0,0 @@ |
| 1 | import { Schema } from "effect"; | |
| 2 | import { describe, expect, it } from "vitest"; | |
| 3 | ||
| 4 | import { | |
| 5 | AppSettingsSchema, | |
| 6 | DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, | |
| 7 | DEFAULT_SIDEBAR_THREAD_SORT_ORDER, | |
| 8 | DEFAULT_TIMESTAMP_FORMAT, | |
| 9 | getProviderStartOptions, | |
| 10 | } from "./appSettings"; | |
| 11 | import { | |
| 12 | getAppModelOptions, | |
| 13 | getCustomModelOptionsByProvider, | |
| 14 | getCustomModelsByProvider, | |
| 15 | getCustomModelsForProvider, | |
| 16 | getDefaultCustomModelsForProvider, | |
| 17 | MODEL_PROVIDER_SETTINGS, | |
| 18 | normalizeCustomModelSlugs, | |
| 19 | patchCustomModels, | |
| 20 | resolveAppModelSelectionState, | |
| 21 | resolveAppModelSelection, | |
| 22 | } from "./modelSelection"; | |
| 23 | ||
| 24 | describe("normalizeCustomModelSlugs", () => { | |
| 25 | it("normalizes aliases, removes built-ins, and deduplicates values", () => { | |
| 26 | expect( | |
| 27 | normalizeCustomModelSlugs([ | |
| 28 | " custom/internal-model ", | |
| 29 | "gpt-5.3-codex", | |
| 30 | "5.3", | |
| 31 | "custom/internal-model", | |
| 32 | "", | |
| 33 | null, | |
| 34 | ]), | |
| 35 | ).toEqual(["custom/internal-model"]); | |
| 36 | }); | |
| 37 | ||
| 38 | it("normalizes provider-specific aliases for claude", () => { | |
| 39 | expect(normalizeCustomModelSlugs(["sonnet"], "claudeAgent")).toEqual([]); | |
| 40 | expect(normalizeCustomModelSlugs(["claude/custom-sonnet"], "claudeAgent")).toEqual([ | |
| 41 | "claude/custom-sonnet", | |
| 42 | ]); | |
| 43 | }); | |
| 44 | }); | |
| 45 | ||
| 46 | describe("getAppModelOptions", () => { | |
| 47 | it("appends saved custom models after the built-in options", () => { | |
| 48 | const options = getAppModelOptions("codex", ["custom/internal-model"]); | |
| 49 | ||
| 50 | expect(options.map((option) => option.slug)).toEqual([ | |
| 51 | "gpt-5.4", | |
| 52 | "gpt-5.4-mini", | |
| 53 | "gpt-5.3-codex", | |
| 54 | "gpt-5.3-codex-spark", | |
| 55 | "gpt-5.2-codex", | |
| 56 | "gpt-5.2", | |
| 57 | "custom/internal-model", | |
| 58 | ]); | |
| 59 | }); | |
| 60 | ||
| 61 | it("keeps the currently selected custom model available even if it is no longer saved", () => { | |
| 62 | const options = getAppModelOptions("codex", [], "custom/selected-model"); | |
| 63 | ||
| 64 | expect(options.at(-1)).toEqual({ | |
| 65 | slug: "custom/selected-model", | |
| 66 | name: "custom/selected-model", | |
| 67 | isCustom: true, | |
| 68 | }); | |
| 69 | }); | |
| 70 | it("keeps a saved custom provider model available as an exact slug option", () => { | |
| 71 | const options = getAppModelOptions("claudeAgent", ["claude/custom-opus"], "claude/custom-opus"); | |
| 72 | ||
| 73 | expect(options.some((option) => option.slug === "claude/custom-opus" && option.isCustom)).toBe( | |
| 74 | true, | |
| 75 | ); | |
| 76 | }); | |
| 77 | }); | |
| 78 | ||
| 79 | describe("resolveAppModelSelection", () => { | |
| 80 | it("preserves saved custom model slugs instead of falling back to the default", () => { | |
| 81 | expect( | |
| 82 | resolveAppModelSelection( | |
| 83 | "codex", | |
| 84 | { codex: ["galapagos-alpha"], claudeAgent: [] }, | |
| 85 | "galapagos-alpha", | |
| 86 | ), | |
| 87 | ).toBe("galapagos-alpha"); | |
| 88 | }); | |
| 89 | ||
| 90 | it("falls back to the provider default when no model is selected", () => { | |
| 91 | expect(resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "")).toBe("gpt-5.4"); | |
| 92 | }); | |
| 93 | ||
| 94 | it("resolves display names through the shared resolver", () => { | |
| 95 | expect(resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "GPT-5.3 Codex")).toBe( | |
| 96 | "gpt-5.3-codex", | |
| 97 | ); | |
| 98 | }); | |
| 99 | ||
| 100 | it("resolves aliases through the shared resolver", () => { | |
| 101 | expect(resolveAppModelSelection("claudeAgent", { codex: [], claudeAgent: [] }, "sonnet")).toBe( | |
| 102 | "claude-sonnet-4-6", | |
| 103 | ); | |
| 104 | }); | |
| 105 | ||
| 106 | it("resolves transient selected custom models included in app model options", () => { | |
| 107 | expect( | |
| 108 | resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "custom/selected-model"), | |
| 109 | ).toBe("custom/selected-model"); | |
| 110 | }); | |
| 111 | }); | |
| 112 | ||
| 113 | describe("timestamp format defaults", () => { | |
| 114 | it("defaults timestamp format to locale", () => { | |
| 115 | expect(DEFAULT_TIMESTAMP_FORMAT).toBe("locale"); | |
| 116 | }); | |
| 117 | }); | |
| 118 | ||
| 119 | describe("sidebar sort defaults", () => { | |
| 120 | it("defaults project sorting to updated_at", () => { | |
| 121 | expect(DEFAULT_SIDEBAR_PROJECT_SORT_ORDER).toBe("updated_at"); | |
| 122 | }); | |
| 123 | ||
| 124 | it("defaults thread sorting to updated_at", () => { | |
| 125 | expect(DEFAULT_SIDEBAR_THREAD_SORT_ORDER).toBe("updated_at"); | |
| 126 | }); | |
| 127 | }); | |
| 128 | ||
| 129 | describe("provider-specific custom models", () => { | |
| 130 | it("includes provider-specific custom slugs in non-codex model lists", () => { | |
| 131 | const claudeOptions = getAppModelOptions("claudeAgent", ["claude/custom-opus"]); | |
| 132 | ||
| 133 | expect(claudeOptions.some((option) => option.slug === "claude/custom-opus")).toBe(true); | |
| 134 | }); | |
| 135 | }); | |
| 136 | ||
| 137 | describe("getProviderStartOptions", () => { | |
| 138 | it("returns only populated provider overrides", () => { | |
| 139 | expect( | |
| 140 | getProviderStartOptions({ | |
| 141 | claudeBinaryPath: "/usr/local/bin/claude", | |
| 142 | codexBinaryPath: "", | |
| 143 | codexHomePath: "/Users/you/.codex", | |
| 144 | }), | |
| 145 | ).toEqual({ | |
| 146 | claudeAgent: { | |
| 147 | binaryPath: "/usr/local/bin/claude", | |
| 148 | }, | |
| 149 | codex: { | |
| 150 | homePath: "/Users/you/.codex", | |
| 151 | }, | |
| 152 | }); | |
| 153 | }); | |
| 154 | ||
| 155 | it("returns undefined when no provider overrides are configured", () => { | |
| 156 | expect( | |
| 157 | getProviderStartOptions({ | |
| 158 | claudeBinaryPath: "", | |
| 159 | codexBinaryPath: "", | |
| 160 | codexHomePath: "", | |
| 161 | }), | |
| 162 | ).toBeUndefined(); | |
| 163 | }); | |
| 164 | }); | |
| 165 | ||
| 166 | describe("provider-indexed custom model settings", () => { | |
| 167 | const settings = { | |
| 168 | customCodexModels: ["custom/codex-model"], | |
| 169 | customClaudeModels: ["claude/custom-opus"], | |
| 170 | } as const; | |
| 171 | ||
| 172 | it("exports one provider config per provider", () => { | |
| 173 | expect(MODEL_PROVIDER_SETTINGS.map((config) => config.provider)).toEqual([ | |
| 174 | "codex", | |
| 175 | "claudeAgent", | |
| 176 | ]); | |
| 177 | }); | |
| 178 | ||
| 179 | it("reads custom models for each provider", () => { | |
| 180 | expect(getCustomModelsForProvider(settings, "codex")).toEqual(["custom/codex-model"]); | |
| 181 | expect(getCustomModelsForProvider(settings, "claudeAgent")).toEqual(["claude/custom-opus"]); | |
| 182 | }); | |
| 183 | ||
| 184 | it("reads default custom models for each provider", () => { | |
| 185 | const defaults = { | |
| 186 | customCodexModels: ["default/codex-model"], | |
| 187 | customClaudeModels: ["claude/default-opus"], | |
| 188 | } as const; | |
| 189 | ||
| 190 | expect(getDefaultCustomModelsForProvider(defaults, "codex")).toEqual(["default/codex-model"]); | |
| 191 | expect(getDefaultCustomModelsForProvider(defaults, "claudeAgent")).toEqual([ | |
| 192 | "claude/default-opus", | |
| 193 | ]); | |
| 194 | }); | |
| 195 | ||
| 196 | it("patches custom models for codex", () => { | |
| 197 | expect(patchCustomModels("codex", ["custom/codex-model"])).toEqual({ | |
| 198 | customCodexModels: ["custom/codex-model"], | |
| 199 | }); | |
| 200 | }); | |
| 201 | ||
| 202 | it("patches custom models for claude", () => { | |
| 203 | expect(patchCustomModels("claudeAgent", ["claude/custom-opus"])).toEqual({ | |
| 204 | customClaudeModels: ["claude/custom-opus"], | |
| 205 | }); | |
| 206 | }); | |
| 207 | ||
| 208 | it("builds a complete provider-indexed custom model record", () => { | |
| 209 | expect(getCustomModelsByProvider(settings)).toEqual({ | |
| 210 | codex: ["custom/codex-model"], | |
| 211 | claudeAgent: ["claude/custom-opus"], | |
| 212 | }); | |
| 213 | }); | |
| 214 | ||
| 215 | it("builds provider-indexed model options including custom models", () => { | |
| 216 | const modelOptionsByProvider = getCustomModelOptionsByProvider(settings); | |
| 217 | ||
| 218 | expect( | |
| 219 | modelOptionsByProvider.codex.some((option) => option.slug === "custom/codex-model"), | |
| 220 | ).toBe(true); | |
| 221 | expect( | |
| 222 | modelOptionsByProvider.claudeAgent.some((option) => option.slug === "claude/custom-opus"), | |
| 223 | ).toBe(true); | |
| 224 | }); | |
| 225 | ||
| 226 | it("normalizes and deduplicates custom model options per provider", () => { | |
| 227 | const modelOptionsByProvider = getCustomModelOptionsByProvider({ | |
| 228 | customCodexModels: [" custom/codex-model ", "gpt-5.4", "custom/codex-model"], | |
| 229 | customClaudeModels: [" sonnet ", "claude/custom-opus", "claude/custom-opus"], | |
| 230 | }); | |
| 231 | ||
| 232 | expect( | |
| 233 | modelOptionsByProvider.codex.filter((option) => option.slug === "custom/codex-model"), | |
| 234 | ).toHaveLength(1); | |
| 235 | expect(modelOptionsByProvider.codex.some((option) => option.slug === "gpt-5.4")).toBe(true); | |
| 236 | expect( | |
| 237 | modelOptionsByProvider.claudeAgent.filter((option) => option.slug === "claude/custom-opus"), | |
| 238 | ).toHaveLength(1); | |
| 239 | expect( | |
| 240 | modelOptionsByProvider.claudeAgent.some((option) => option.slug === "claude-sonnet-4-6"), | |
| 241 | ).toBe(true); | |
| 242 | }); | |
| 243 | }); | |
| 244 | ||
| 245 | describe("AppSettingsSchema", () => { | |
| 246 | it("fills decoding defaults for persisted settings that predate newer keys", () => { | |
| 247 | const decode = Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema)); | |
| 248 | ||
| 249 | expect( | |
| 250 | decode( | |
| 251 | JSON.stringify({ | |
| 252 | codexBinaryPath: "/usr/local/bin/codex", | |
| 253 | confirmThreadDelete: false, | |
| 254 | }), | |
| 255 | ), | |
| 256 | ).toMatchObject({ | |
| 257 | claudeBinaryPath: "", | |
| 258 | codexBinaryPath: "/usr/local/bin/codex", | |
| 259 | codexHomePath: "", | |
| 260 | defaultThreadEnvMode: "local", | |
| 261 | confirmThreadDelete: false, | |
| 262 | enableAssistantStreaming: false, | |
| 263 | sidebarProjectSortOrder: DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, | |
| 264 | sidebarThreadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, | |
| 265 | timestampFormat: DEFAULT_TIMESTAMP_FORMAT, | |
| 266 | customCodexModels: [], | |
| 267 | customClaudeModels: [], | |
| 268 | }); | |
| 269 | }); | |
| 270 | }); | |
| 271 | ||
| 272 | describe("resolveAppModelSelectionState", () => { | |
| 273 | it("falls back to the default git-writing codex selection", () => { | |
| 274 | expect( | |
| 275 | resolveAppModelSelectionState({ | |
| 276 | customCodexModels: [], | |
| 277 | customClaudeModels: [], | |
| 278 | textGenerationModelSelection: undefined, | |
| 279 | }), | |
| 280 | ).toEqual({ | |
| 281 | provider: "codex", | |
| 282 | model: "gpt-5.4-mini", | |
| 283 | }); | |
| 284 | }); | |
| 285 | ||
| 286 | it("preserves the selected provider and resolves saved custom models", () => { | |
| 287 | expect( | |
| 288 | resolveAppModelSelectionState({ | |
| 289 | customCodexModels: [], | |
| 290 | customClaudeModels: ["claude/custom-haiku"], | |
| 291 | textGenerationModelSelection: { | |
| 292 | provider: "claudeAgent", | |
| 293 | model: "claude/custom-haiku", | |
| 294 | }, | |
| 295 | }), | |
| 296 | ).toEqual({ | |
| 297 | provider: "claudeAgent", | |
| 298 | model: "claude/custom-haiku", | |
| 299 | }); | |
| 300 | }); | |
| 301 | ||
| 302 | it("normalizes provider options against the resolved model capabilities", () => { | |
| 303 | expect( | |
| 304 | resolveAppModelSelectionState({ | |
| 305 | customCodexModels: [], | |
| 306 | customClaudeModels: [], | |
| 307 | textGenerationModelSelection: { | |
| 308 | provider: "claudeAgent", | |
| 309 | model: "claude-haiku-4-5", | |
| 310 | options: { | |
| 311 | effort: "max", | |
| 312 | thinking: false, | |
| 313 | fastMode: true, | |
| 314 | }, | |
| 315 | }, | |
| 316 | }), | |
| 317 | ).toEqual({ | |
| 318 | provider: "claudeAgent", | |
| 319 | model: "claude-haiku-4-5", | |
| 320 | options: { | |
| 321 | thinking: false, | |
| 322 | }, | |
| 323 | }); | |
| 324 | }); | |
| 325 | }); |
apps/web/src/appSettings.ts deleted-121| ... | ... | @@ -1,121 +0,0 @@ |
| 1 | import { useCallback } from "react"; | |
| 2 | import { Option, Schema } from "effect"; | |
| 3 | import { | |
| 4 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, | |
| 5 | ModelSelection, | |
| 6 | type ProviderStartOptions, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { useLocalStorage } from "./hooks/useLocalStorage"; | |
| 9 | import { EnvMode } from "./components/BranchToolbar.logic"; | |
| 10 | import { normalizeCustomModelSlugs } from "./modelSelection"; | |
| 11 | ||
| 12 | const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; | |
| 13 | ||
| 14 | export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); | |
| 15 | export type TimestampFormat = typeof TimestampFormat.Type; | |
| 16 | export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; | |
| 17 | export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); | |
| 18 | export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; | |
| 19 | export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; | |
| 20 | export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]); | |
| 21 | export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; | |
| 22 | export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; | |
| 23 | ||
| 24 | const withDefaults = | |
| 25 | < | |
| 26 | S extends Schema.Top & Schema.WithoutConstructorDefault, | |
| 27 | D extends S["~type.make.in"] & S["Encoded"], | |
| 28 | >( | |
| 29 | fallback: () => D, | |
| 30 | ) => | |
| 31 | (schema: S) => | |
| 32 | schema.pipe( | |
| 33 | Schema.withConstructorDefault(() => Option.some(fallback())), | |
| 34 | Schema.withDecodingDefault(() => fallback()), | |
| 35 | ); | |
| 36 | ||
| 37 | export const AppSettingsSchema = Schema.Struct({ | |
| 38 | claudeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), | |
| 39 | codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), | |
| 40 | codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), | |
| 41 | defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)), | |
| 42 | confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)), | |
| 43 | diffWordWrap: Schema.Boolean.pipe(withDefaults(() => false)), | |
| 44 | enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)), | |
| 45 | sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( | |
| 46 | withDefaults(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER), | |
| 47 | ), | |
| 48 | sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( | |
| 49 | withDefaults(() => DEFAULT_SIDEBAR_THREAD_SORT_ORDER), | |
| 50 | ), | |
| 51 | timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)), | |
| 52 | customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), | |
| 53 | customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), | |
| 54 | textGenerationModelSelection: ModelSelection.pipe( | |
| 55 | withDefaults(() => ({ | |
| 56 | provider: "codex" as const, | |
| 57 | model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, | |
| 58 | })), | |
| 59 | ), | |
| 60 | }); | |
| 61 | export type AppSettings = typeof AppSettingsSchema.Type; | |
| 62 | ||
| 63 | const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); | |
| 64 | ||
| 65 | function normalizeAppSettings(settings: AppSettings): AppSettings { | |
| 66 | return { | |
| 67 | ...settings, | |
| 68 | customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), | |
| 69 | customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"), | |
| 70 | }; | |
| 71 | } | |
| 72 | ||
| 73 | export function getProviderStartOptions( | |
| 74 | settings: Pick<AppSettings, "claudeBinaryPath" | "codexBinaryPath" | "codexHomePath">, | |
| 75 | ): ProviderStartOptions | undefined { | |
| 76 | const providerOptions: ProviderStartOptions = { | |
| 77 | ...(settings.codexBinaryPath || settings.codexHomePath | |
| 78 | ? { | |
| 79 | codex: { | |
| 80 | ...(settings.codexBinaryPath ? { binaryPath: settings.codexBinaryPath } : {}), | |
| 81 | ...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}), | |
| 82 | }, | |
| 83 | } | |
| 84 | : {}), | |
| 85 | ...(settings.claudeBinaryPath | |
| 86 | ? { | |
| 87 | claudeAgent: { | |
| 88 | binaryPath: settings.claudeBinaryPath, | |
| 89 | }, | |
| 90 | } | |
| 91 | : {}), | |
| 92 | }; | |
| 93 | ||
| 94 | return Object.keys(providerOptions).length > 0 ? providerOptions : undefined; | |
| 95 | } | |
| 96 | ||
| 97 | export function useAppSettings() { | |
| 98 | const [settings, setSettings] = useLocalStorage( | |
| 99 | APP_SETTINGS_STORAGE_KEY, | |
| 100 | DEFAULT_APP_SETTINGS, | |
| 101 | AppSettingsSchema, | |
| 102 | ); | |
| 103 | ||
| 104 | const updateSettings = useCallback( | |
| 105 | (patch: Partial<AppSettings>) => { | |
| 106 | setSettings((prev) => normalizeAppSettings({ ...prev, ...patch })); | |
| 107 | }, | |
| 108 | [setSettings], | |
| 109 | ); | |
| 110 | ||
| 111 | const resetSettings = useCallback(() => { | |
| 112 | setSettings(DEFAULT_APP_SETTINGS); | |
| 113 | }, [setSettings]); | |
| 114 | ||
| 115 | return { | |
| 116 | settings, | |
| 117 | updateSettings, | |
| 118 | resetSettings, | |
| 119 | defaults: DEFAULT_APP_SETTINGS, | |
| 120 | } as const; | |
| 121 | } |
apps/web/src/components/ChatView.browser.tsx+10-1| ... | ... | @@ -12,6 +12,7 @@ import { |
| 12 | 12 | WS_CHANNELS, |
| 13 | 13 | WS_METHODS, |
| 14 | 14 | OrchestrationSessionStatus, |
| 15 | DEFAULT_SERVER_SETTINGS, | |
| 15 | 16 | } from "@t3tools/contracts"; |
| 16 | 17 | import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; |
| 17 | 18 | import { HttpResponse, http, ws } from "msw"; |
| ... | ... | @@ -30,6 +31,7 @@ import { isMacPlatform } from "../lib/utils"; |
| 30 | 31 | import { getRouter } from "../router"; |
| 31 | 32 | import { useStore } from "../store"; |
| 32 | 33 | import { estimateTimelineMessageHeight } from "./timelineHeight"; |
| 34 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; | |
| 33 | 35 | |
| 34 | 36 | const THREAD_ID = "thread-browser-test" as ThreadId; |
| 35 | 37 | const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; |
| ... | ... | @@ -110,13 +112,20 @@ function createBaseServerConfig(): ServerConfig { |
| 110 | 112 | providers: [ |
| 111 | 113 | { |
| 112 | 114 | provider: "codex", |
| 115 | enabled: true, | |
| 116 | installed: true, | |
| 117 | version: "0.116.0", | |
| 113 | 118 | status: "ready", |
| 114 | available: true, | |
| 115 | 119 | authStatus: "authenticated", |
| 116 | 120 | checkedAt: NOW_ISO, |
| 121 | models: [], | |
| 117 | 122 | }, |
| 118 | 123 | ], |
| 119 | 124 | availableEditors: [], |
| 125 | settings: { | |
| 126 | ...DEFAULT_SERVER_SETTINGS, | |
| 127 | ...DEFAULT_CLIENT_SETTINGS, | |
| 128 | }, | |
| 120 | 129 | }; |
| 121 | 130 | } |
| 122 | 131 |
apps/web/src/components/ChatView.tsx+77-66| ... | ... | @@ -13,7 +13,7 @@ import { |
| 13 | 13 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 14 | 14 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 15 | 15 | type ResolvedKeybindingsConfig, |
| 16 | type ServerProviderStatus, | |
| 16 | type ServerProvider, | |
| 17 | 17 | type ThreadId, |
| 18 | 18 | type TurnId, |
| 19 | 19 | type EditorId, |
| ... | ... | @@ -22,11 +22,7 @@ import { |
| 22 | 22 | ProviderInteractionMode, |
| 23 | 23 | RuntimeMode, |
| 24 | 24 | } from "@t3tools/contracts"; |
| 25 | import { | |
| 26 | applyClaudePromptEffortPrefix, | |
| 27 | getModelCapabilities, | |
| 28 | normalizeModelSlug, | |
| 29 | } from "@t3tools/shared/model"; | |
| 25 | import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; | |
| 30 | 26 | import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 31 | 27 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 32 | 28 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| ... | ... | @@ -120,12 +116,13 @@ import { |
| 120 | 116 | import { SidebarTrigger } from "./ui/sidebar"; |
| 121 | 117 | import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; |
| 122 | 118 | import { readNativeApi } from "~/nativeApi"; |
| 123 | import { getProviderStartOptions, useAppSettings } from "../appSettings"; | |
| 124 | 119 | import { |
| 125 | getCustomModelOptionsByProvider, | |
| 126 | getCustomModelsByProvider, | |
| 127 | resolveAppModelSelection, | |
| 128 | } from "../modelSelection"; | |
| 120 | getProviderModelCapabilities, | |
| 121 | getProviderModels, | |
| 122 | resolveSelectableProvider, | |
| 123 | } from "../providerModels"; | |
| 124 | import { useSettings } from "../hooks/useSettings"; | |
| 125 | import { resolveAppModelSelection } from "../modelSelection"; | |
| 129 | 126 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 130 | 127 | import { |
| 131 | 128 | type ComposerImageAttachment, |
| ... | ... | @@ -164,7 +161,7 @@ import { |
| 164 | 161 | renderProviderTraitsMenuContent, |
| 165 | 162 | renderProviderTraitsPicker, |
| 166 | 163 | } from "./chat/composerProviderRegistry"; |
| 167 | import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; | |
| 164 | import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; | |
| 168 | 165 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; |
| 169 | 166 | import { |
| 170 | 167 | buildExpiredTerminalContextToastCopy, |
| ... | ... | @@ -191,16 +188,17 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; |
| 191 | 188 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 192 | 189 | const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; |
| 193 | 190 | const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; |
| 194 | const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; | |
| 191 | const EMPTY_PROVIDERS: ServerProvider[] = []; | |
| 195 | 192 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; |
| 196 | 193 | |
| 197 | 194 | function formatOutgoingPrompt(params: { |
| 198 | 195 | provider: ProviderKind; |
| 199 | 196 | model: string | null; |
| 197 | models: ReadonlyArray<ServerProvider["models"][number]>; | |
| 200 | 198 | effort: string | null; |
| 201 | 199 | text: string; |
| 202 | 200 | }): string { |
| 203 | const caps = getModelCapabilities(params.provider, params.model); | |
| 201 | const caps = getProviderModelCapabilities(params.models, params.model, params.provider); | |
| 204 | 202 | if (params.effort && caps.promptInjectedEffortLevels.includes(params.effort)) { |
| 205 | 203 | return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null); |
| 206 | 204 | } |
| ... | ... | @@ -249,7 +247,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 249 | 247 | const syncServerReadModel = useStore((store) => store.syncServerReadModel); |
| 250 | 248 | const setStoreThreadError = useStore((store) => store.setError); |
| 251 | 249 | const setStoreThreadBranch = useStore((store) => store.setThreadBranch); |
| 252 | const { settings } = useAppSettings(); | |
| 250 | const settings = useSettings(); | |
| 253 | 251 | const setStickyComposerModelSelection = useComposerDraftStore( |
| 254 | 252 | (store) => store.setStickyModelSelection, |
| 255 | 253 | ); |
| ... | ... | @@ -608,25 +606,32 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 608 | 606 | const lockedProvider: ProviderKind | null = hasThreadStarted |
| 609 | 607 | ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null) |
| 610 | 608 | : null; |
| 611 | const selectedProvider: ProviderKind = | |
| 612 | lockedProvider ?? selectedProviderByThreadId ?? threadProvider ?? "codex"; | |
| 613 | const customModelsByProvider = useMemo(() => getCustomModelsByProvider(settings), [settings]); | |
| 609 | const serverConfigQuery = useQuery(serverConfigQueryOptions()); | |
| 610 | const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDERS; | |
| 611 | const unlockedSelectedProvider = resolveSelectableProvider( | |
| 612 | providerStatuses, | |
| 613 | selectedProviderByThreadId ?? threadProvider ?? "codex", | |
| 614 | ); | |
| 615 | const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; | |
| 614 | 616 | const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ |
| 615 | 617 | threadId, |
| 618 | providers: providerStatuses, | |
| 616 | 619 | selectedProvider, |
| 617 | 620 | threadModelSelection: activeThread?.modelSelection, |
| 618 | 621 | projectModelSelection: activeProject?.defaultModelSelection, |
| 619 | customModelsByProvider, | |
| 622 | settings, | |
| 620 | 623 | }); |
| 624 | const selectedProviderModels = getProviderModels(providerStatuses, selectedProvider); | |
| 621 | 625 | const composerProviderState = useMemo( |
| 622 | 626 | () => |
| 623 | 627 | getComposerProviderState({ |
| 624 | 628 | provider: selectedProvider, |
| 625 | 629 | model: selectedModel, |
| 630 | models: selectedProviderModels, | |
| 626 | 631 | prompt, |
| 627 | 632 | modelOptions: composerModelOptions, |
| 628 | 633 | }), |
| 629 | [composerModelOptions, prompt, selectedModel, selectedProvider], | |
| 634 | [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels], | |
| 630 | 635 | ); |
| 631 | 636 | const selectedPromptEffort = composerProviderState.promptEffort; |
| 632 | 637 | const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; |
| ... | ... | @@ -638,35 +643,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 638 | 643 | }), |
| 639 | 644 | [selectedModel, selectedModelOptionsForDispatch, selectedProvider], |
| 640 | 645 | ); |
| 641 | const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]); | |
| 642 | 646 | const selectedModelForPicker = selectedModel; |
| 643 | const modelOptionsByProvider = useMemo( | |
| 644 | () => getCustomModelOptionsByProvider(settings, selectedProvider, selectedModel), | |
| 645 | [settings, selectedProvider, selectedModel], | |
| 646 | ); | |
| 647 | const selectedModelForPickerWithCustomFallback = useMemo(() => { | |
| 648 | const currentOptions = modelOptionsByProvider[selectedProvider]; | |
| 649 | return currentOptions.some((option) => option.slug === selectedModelForPicker) | |
| 650 | ? selectedModelForPicker | |
| 651 | : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); | |
| 652 | }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); | |
| 653 | const searchableModelOptions = useMemo( | |
| 654 | () => | |
| 655 | AVAILABLE_PROVIDER_OPTIONS.filter( | |
| 656 | (option) => lockedProvider === null || option.value === lockedProvider, | |
| 657 | ).flatMap((option) => | |
| 658 | modelOptionsByProvider[option.value].map(({ slug, name }) => ({ | |
| 659 | provider: option.value, | |
| 660 | providerLabel: option.label, | |
| 661 | slug, | |
| 662 | name, | |
| 663 | searchSlug: slug.toLowerCase(), | |
| 664 | searchName: name.toLowerCase(), | |
| 665 | searchProvider: option.label.toLowerCase(), | |
| 666 | })), | |
| 667 | ), | |
| 668 | [lockedProvider, modelOptionsByProvider], | |
| 669 | ); | |
| 670 | 647 | const phase = derivePhase(activeThread?.session ?? null); |
| 671 | 648 | const isSendBusy = sendPhase !== "idle"; |
| 672 | 649 | const isPreparingWorktree = sendPhase === "preparing-worktree"; |
| ... | ... | @@ -1029,7 +1006,39 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1029 | 1006 | ); |
| 1030 | 1007 | const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; |
| 1031 | 1008 | const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); |
| 1032 | const serverConfigQuery = useQuery(serverConfigQueryOptions()); | |
| 1009 | const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; | |
| 1010 | const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; | |
| 1011 | const modelOptionsByProvider = useMemo( | |
| 1012 | () => ({ | |
| 1013 | codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], | |
| 1014 | claudeAgent: | |
| 1015 | providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [], | |
| 1016 | }), | |
| 1017 | [providerStatuses], | |
| 1018 | ); | |
| 1019 | const selectedModelForPickerWithCustomFallback = useMemo(() => { | |
| 1020 | const currentOptions = modelOptionsByProvider[selectedProvider]; | |
| 1021 | return currentOptions.some((option) => option.slug === selectedModelForPicker) | |
| 1022 | ? selectedModelForPicker | |
| 1023 | : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); | |
| 1024 | }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); | |
| 1025 | const searchableModelOptions = useMemo( | |
| 1026 | () => | |
| 1027 | AVAILABLE_PROVIDER_OPTIONS.filter( | |
| 1028 | (option) => lockedProvider === null || option.value === lockedProvider, | |
| 1029 | ).flatMap((option) => | |
| 1030 | modelOptionsByProvider[option.value].map(({ slug, name }) => ({ | |
| 1031 | provider: option.value, | |
| 1032 | providerLabel: option.label, | |
| 1033 | slug, | |
| 1034 | name, | |
| 1035 | searchSlug: slug.toLowerCase(), | |
| 1036 | searchName: name.toLowerCase(), | |
| 1037 | searchProvider: option.label.toLowerCase(), | |
| 1038 | })), | |
| 1039 | ), | |
| 1040 | [lockedProvider, modelOptionsByProvider], | |
| 1041 | ); | |
| 1033 | 1042 | const workspaceEntriesQuery = useQuery( |
| 1034 | 1043 | projectSearchEntriesQueryOptions({ |
| 1035 | 1044 | cwd: gitCwd, |
| ... | ... | @@ -1117,9 +1126,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1117 | 1126 | () => new Set(nonPersistedComposerImageIds), |
| 1118 | 1127 | [nonPersistedComposerImageIds], |
| 1119 | 1128 | ); |
| 1120 | const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; | |
| 1121 | const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; | |
| 1122 | const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; | |
| 1123 | 1129 | const activeProviderStatus = useMemo( |
| 1124 | 1130 | () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, |
| 1125 | 1131 | [selectedProvider, providerStatuses], |
| ... | ... | @@ -2460,6 +2466,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2460 | 2466 | const outgoingMessageText = formatOutgoingPrompt({ |
| 2461 | 2467 | provider: selectedProvider, |
| 2462 | 2468 | model: selectedModel, |
| 2469 | models: selectedProviderModels, | |
| 2463 | 2470 | effort: selectedPromptEffort, |
| 2464 | 2471 | text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, |
| 2465 | 2472 | }); |
| ... | ... | @@ -2645,8 +2652,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2645 | 2652 | attachments: turnAttachments, |
| 2646 | 2653 | }, |
| 2647 | 2654 | modelSelection: selectedModelSelection, |
| 2648 | ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), | |
| 2649 | assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", | |
| 2650 | 2655 | runtimeMode, |
| 2651 | 2656 | interactionMode, |
| 2652 | 2657 | createdAt: messageCreatedAt, |
| ... | ... | @@ -2883,6 +2888,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2883 | 2888 | const outgoingMessageText = formatOutgoingPrompt({ |
| 2884 | 2889 | provider: selectedProvider, |
| 2885 | 2890 | model: selectedModel, |
| 2891 | models: selectedProviderModels, | |
| 2886 | 2892 | effort: selectedPromptEffort, |
| 2887 | 2893 | text: trimmed, |
| 2888 | 2894 | }); |
| ... | ... | @@ -2927,8 +2933,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2927 | 2933 | attachments: [], |
| 2928 | 2934 | }, |
| 2929 | 2935 | modelSelection: selectedModelSelection, |
| 2930 | ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), | |
| 2931 | assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", | |
| 2932 | 2936 | runtimeMode, |
| 2933 | 2937 | interactionMode: nextInteractionMode, |
| 2934 | 2938 | ...(nextInteractionMode === "default" && activeProposedPlan |
| ... | ... | @@ -2974,11 +2978,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2974 | 2978 | runtimeMode, |
| 2975 | 2979 | selectedPromptEffort, |
| 2976 | 2980 | selectedModelSelection, |
| 2977 | providerOptionsForDispatch, | |
| 2978 | 2981 | selectedProvider, |
| 2982 | selectedProviderModels, | |
| 2979 | 2983 | setComposerDraftInteractionMode, |
| 2980 | 2984 | setThreadError, |
| 2981 | settings.enableAssistantStreaming, | |
| 2982 | 2985 | selectedModel, |
| 2983 | 2986 | ], |
| 2984 | 2987 | ); |
| ... | ... | @@ -3005,6 +3008,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3005 | 3008 | const outgoingImplementationPrompt = formatOutgoingPrompt({ |
| 3006 | 3009 | provider: selectedProvider, |
| 3007 | 3010 | model: selectedModel, |
| 3011 | models: selectedProviderModels, | |
| 3008 | 3012 | effort: selectedPromptEffort, |
| 3009 | 3013 | text: implementationPrompt, |
| 3010 | 3014 | }); |
| ... | ... | @@ -3044,8 +3048,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3044 | 3048 | attachments: [], |
| 3045 | 3049 | }, |
| 3046 | 3050 | modelSelection: selectedModelSelection, |
| 3047 | ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), | |
| 3048 | assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", | |
| 3049 | 3051 | runtimeMode, |
| 3050 | 3052 | interactionMode: "default", |
| 3051 | 3053 | createdAt, |
| ... | ... | @@ -3096,9 +3098,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3096 | 3098 | runtimeMode, |
| 3097 | 3099 | selectedPromptEffort, |
| 3098 | 3100 | selectedModelSelection, |
| 3099 | providerOptionsForDispatch, | |
| 3100 | 3101 | selectedProvider, |
| 3101 | settings.enableAssistantStreaming, | |
| 3102 | selectedProviderModels, | |
| 3102 | 3103 | syncServerReadModel, |
| 3103 | 3104 | selectedModel, |
| 3104 | 3105 | ]); |
| ... | ... | @@ -3110,9 +3111,15 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3110 | 3111 | scheduleComposerFocus(); |
| 3111 | 3112 | return; |
| 3112 | 3113 | } |
| 3113 | const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model); | |
| 3114 | const resolvedProvider = resolveSelectableProvider(providerStatuses, provider); | |
| 3115 | const resolvedModel = resolveAppModelSelection( | |
| 3116 | resolvedProvider, | |
| 3117 | settings, | |
| 3118 | providerStatuses, | |
| 3119 | model, | |
| 3120 | ); | |
| 3114 | 3121 | const nextModelSelection: ModelSelection = { |
| 3115 | provider, | |
| 3122 | provider: resolvedProvider, | |
| 3116 | 3123 | model: resolvedModel, |
| 3117 | 3124 | }; |
| 3118 | 3125 | setComposerDraftModelSelection(activeThread.id, nextModelSelection); |
| ... | ... | @@ -3125,7 +3132,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3125 | 3132 | scheduleComposerFocus, |
| 3126 | 3133 | setComposerDraftModelSelection, |
| 3127 | 3134 | setStickyComposerModelSelection, |
| 3128 | customModelsByProvider, | |
| 3135 | providerStatuses, | |
| 3136 | settings, | |
| 3129 | 3137 | ], |
| 3130 | 3138 | ); |
| 3131 | 3139 | const setPromptFromTraits = useCallback( |
| ... | ... | @@ -3148,6 +3156,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3148 | 3156 | provider: selectedProvider, |
| 3149 | 3157 | threadId, |
| 3150 | 3158 | model: selectedModel, |
| 3159 | models: selectedProviderModels, | |
| 3151 | 3160 | modelOptions: composerModelOptions?.[selectedProvider], |
| 3152 | 3161 | prompt, |
| 3153 | 3162 | onPromptChange: setPromptFromTraits, |
| ... | ... | @@ -3156,6 +3165,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3156 | 3165 | provider: selectedProvider, |
| 3157 | 3166 | threadId, |
| 3158 | 3167 | model: selectedModel, |
| 3168 | models: selectedProviderModels, | |
| 3159 | 3169 | modelOptions: composerModelOptions?.[selectedProvider], |
| 3160 | 3170 | prompt, |
| 3161 | 3171 | onPromptChange: setPromptFromTraits, |
| ... | ... | @@ -3519,7 +3529,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3519 | 3529 | </header> |
| 3520 | 3530 | |
| 3521 | 3531 | {/* Error banner */} |
| 3522 | <ProviderHealthBanner status={activeProviderStatus} /> | |
| 3532 | <ProviderStatusBanner status={activeProviderStatus} /> | |
| 3523 | 3533 | <ThreadErrorBanner |
| 3524 | 3534 | error={activeThread.error} |
| 3525 | 3535 | onDismiss={() => setThreadError(activeThread.id, null)} |
| ... | ... | @@ -3796,6 +3806,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3796 | 3806 | provider={selectedProvider} |
| 3797 | 3807 | model={selectedModelForPickerWithCustomFallback} |
| 3798 | 3808 | lockedProvider={lockedProvider} |
| 3809 | providers={providerStatuses} | |
| 3799 | 3810 | modelOptionsByProvider={modelOptionsByProvider} |
| 3800 | 3811 | {...(composerProviderState.modelPickerIconClassName |
| 3801 | 3812 | ? { |
apps/web/src/components/DiffPanel.tsx+2-2| ... | ... | @@ -30,7 +30,7 @@ import { buildPatchCacheKey } from "../lib/diffRendering"; |
| 30 | 30 | import { resolveDiffThemeName } from "../lib/diffRendering"; |
| 31 | 31 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 32 | 32 | import { useStore } from "../store"; |
| 33 | import { useAppSettings } from "../appSettings"; | |
| 33 | import { useSettings } from "../hooks/useSettings"; | |
| 34 | 34 | import { formatShortTimestamp } from "../timestampFormat"; |
| 35 | 35 | import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; |
| 36 | 36 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; |
| ... | ... | @@ -166,7 +166,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; |
| 166 | 166 | export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 167 | 167 | const navigate = useNavigate(); |
| 168 | 168 | const { resolvedTheme } = useTheme(); |
| 169 | const { settings } = useAppSettings(); | |
| 169 | const settings = useSettings(); | |
| 170 | 170 | const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked"); |
| 171 | 171 | const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); |
| 172 | 172 | const patchViewportRef = useRef<HTMLDivElement>(null); |
apps/web/src/components/GitActionsControl.tsx-3| ... | ... | @@ -20,7 +20,6 @@ import { |
| 20 | 20 | resolveQuickAction, |
| 21 | 21 | summarizeGitResult, |
| 22 | 22 | } from "./GitActionsControl.logic"; |
| 23 | import { useAppSettings } from "~/appSettings"; | |
| 24 | 23 | import { Button } from "~/components/ui/button"; |
| 25 | 24 | import { Checkbox } from "~/components/ui/checkbox"; |
| 26 | 25 | import { |
| ... | ... | @@ -205,7 +204,6 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { |
| 205 | 204 | } |
| 206 | 205 | |
| 207 | 206 | export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { |
| 208 | const { settings } = useAppSettings(); | |
| 209 | 207 | const threadToastData = useMemo( |
| 210 | 208 | () => (activeThreadId ? { threadId: activeThreadId } : undefined), |
| 211 | 209 | [activeThreadId], |
| ... | ... | @@ -261,7 +259,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 261 | 259 | gitRunStackedActionMutationOptions({ |
| 262 | 260 | cwd: gitCwd, |
| 263 | 261 | queryClient, |
| 264 | modelSelection: settings.textGenerationModelSelection, | |
| 265 | 262 | }), |
| 266 | 263 | ); |
| 267 | 264 | const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); |
apps/web/src/components/KeybindingsToast.browser.tsx+13-1| ... | ... | @@ -46,13 +46,25 @@ function createBaseServerConfig(): ServerConfig { |
| 46 | 46 | providers: [ |
| 47 | 47 | { |
| 48 | 48 | provider: "codex", |
| 49 | enabled: true, | |
| 50 | installed: true, | |
| 51 | version: "0.116.0", | |
| 49 | 52 | status: "ready", |
| 50 | available: true, | |
| 51 | 53 | authStatus: "authenticated", |
| 52 | 54 | checkedAt: NOW_ISO, |
| 55 | models: [], | |
| 53 | 56 | }, |
| 54 | 57 | ], |
| 55 | 58 | availableEditors: [], |
| 59 | settings: { | |
| 60 | enableAssistantStreaming: false, | |
| 61 | defaultThreadEnvMode: "local" as const, | |
| 62 | textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" }, | |
| 63 | providers: { | |
| 64 | codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, | |
| 65 | claudeAgent: { enabled: true, binaryPath: "", customModels: [] }, | |
| 66 | }, | |
| 67 | }, | |
| 56 | 68 | }; |
| 57 | 69 | } |
| 58 | 70 |
apps/web/src/components/PlanSidebar.tsx+1-1| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { memo, useState, useCallback } from "react"; |
| 2 | import { type TimestampFormat } from "../appSettings"; | |
| 2 | import { type TimestampFormat } from "@t3tools/contracts/settings"; | |
| 3 | 3 | import { Badge } from "./ui/badge"; |
| 4 | 4 | import { Button } from "./ui/button"; |
| 5 | 5 | import { ScrollArea } from "./ui/scroll-area"; |
apps/web/src/components/Sidebar.logic.ts+1-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "../appSettings"; | |
| 1 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; | |
| 2 | 2 | import type { Thread } from "../types"; |
| 3 | 3 | import { cn } from "../lib/utils"; |
| 4 | 4 | import { |
apps/web/src/components/Sidebar.tsx+4-3| ... | ... | @@ -41,8 +41,7 @@ import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; |
| 41 | 41 | import { |
| 42 | 42 | type SidebarProjectSortOrder, |
| 43 | 43 | type SidebarThreadSortOrder, |
| 44 | useAppSettings, | |
| 45 | } from "../appSettings"; | |
| 44 | } from "@t3tools/contracts/settings"; | |
| 46 | 45 | import { isElectron } from "../env"; |
| 47 | 46 | import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; |
| 48 | 47 | import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; |
| ... | ... | @@ -102,6 +101,7 @@ import { |
| 102 | 101 | sortThreadsForSidebar, |
| 103 | 102 | } from "./Sidebar.logic"; |
| 104 | 103 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 104 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; | |
| 105 | 105 | |
| 106 | 106 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 107 | 107 | const THREAD_PREVIEW_LIMIT = 6; |
| ... | ... | @@ -381,7 +381,8 @@ export default function Sidebar() { |
| 381 | 381 | ); |
| 382 | 382 | const navigate = useNavigate(); |
| 383 | 383 | const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" }); |
| 384 | const { settings: appSettings, updateSettings } = useAppSettings(); | |
| 384 | const appSettings = useSettings(); | |
| 385 | const { updateSettings } = useUpdateSettings(); | |
| 385 | 386 | const { handleNewThread } = useHandleNewThread(); |
| 386 | 387 | const routeThreadId = useParams({ |
| 387 | 388 | strict: false, |
apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx+65| ... | ... | @@ -43,6 +43,70 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str |
| 43 | 43 | document.body.append(host); |
| 44 | 44 | const onPromptChange = vi.fn(); |
| 45 | 45 | const providerOptions = props?.modelSelection?.options; |
| 46 | const models = | |
| 47 | provider === "claudeAgent" | |
| 48 | ? [ | |
| 49 | { | |
| 50 | slug: "claude-opus-4-6", | |
| 51 | name: "Claude Opus 4.6", | |
| 52 | isCustom: false, | |
| 53 | capabilities: { | |
| 54 | reasoningEffortLevels: [ | |
| 55 | { value: "low", label: "Low" }, | |
| 56 | { value: "medium", label: "Medium" }, | |
| 57 | { value: "high", label: "High", isDefault: true }, | |
| 58 | { value: "max", label: "Max" }, | |
| 59 | { value: "ultrathink", label: "Ultrathink" }, | |
| 60 | ], | |
| 61 | supportsFastMode: true, | |
| 62 | supportsThinkingToggle: false, | |
| 63 | promptInjectedEffortLevels: ["ultrathink"], | |
| 64 | }, | |
| 65 | }, | |
| 66 | { | |
| 67 | slug: "claude-haiku-4-5", | |
| 68 | name: "Claude Haiku 4.5", | |
| 69 | isCustom: false, | |
| 70 | capabilities: { | |
| 71 | reasoningEffortLevels: [], | |
| 72 | supportsFastMode: false, | |
| 73 | supportsThinkingToggle: true, | |
| 74 | promptInjectedEffortLevels: [], | |
| 75 | }, | |
| 76 | }, | |
| 77 | { | |
| 78 | slug: "claude-sonnet-4-6", | |
| 79 | name: "Claude Sonnet 4.6", | |
| 80 | isCustom: false, | |
| 81 | capabilities: { | |
| 82 | reasoningEffortLevels: [ | |
| 83 | { value: "low", label: "Low" }, | |
| 84 | { value: "medium", label: "Medium" }, | |
| 85 | { value: "high", label: "High", isDefault: true }, | |
| 86 | { value: "ultrathink", label: "Ultrathink" }, | |
| 87 | ], | |
| 88 | supportsFastMode: false, | |
| 89 | supportsThinkingToggle: false, | |
| 90 | promptInjectedEffortLevels: ["ultrathink"], | |
| 91 | }, | |
| 92 | }, | |
| 93 | ] | |
| 94 | : [ | |
| 95 | { | |
| 96 | slug: "gpt-5.4", | |
| 97 | name: "GPT-5.4", | |
| 98 | isCustom: false, | |
| 99 | capabilities: { | |
| 100 | reasoningEffortLevels: [ | |
| 101 | { value: "xhigh", label: "Extra High" }, | |
| 102 | { value: "high", label: "High", isDefault: true }, | |
| 103 | ], | |
| 104 | supportsFastMode: true, | |
| 105 | supportsThinkingToggle: false, | |
| 106 | promptInjectedEffortLevels: [], | |
| 107 | }, | |
| 108 | }, | |
| 109 | ]; | |
| 46 | 110 | const screen = await render( |
| 47 | 111 | <CompactComposerControlsMenu |
| 48 | 112 | activePlan={false} |
| ... | ... | @@ -52,6 +116,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str |
| 52 | 116 | traitsMenuContent={ |
| 53 | 117 | <TraitsMenuContent |
| 54 | 118 | provider={provider} |
| 119 | models={models} | |
| 55 | 120 | threadId={threadId} |
| 56 | 121 | model={model} |
| 57 | 122 | prompt={props?.prompt ?? ""} |
apps/web/src/components/chat/MessagesTimeline.tsx+1-1| ... | ... | @@ -48,7 +48,7 @@ import { |
| 48 | 48 | type ParsedTerminalContextEntry, |
| 49 | 49 | } from "~/lib/terminalContext"; |
| 50 | 50 | import { cn } from "~/lib/utils"; |
| 51 | import { type TimestampFormat } from "../../appSettings"; | |
| 51 | import { type TimestampFormat } from "@t3tools/contracts/settings"; | |
| 52 | 52 | import { formatTimestamp } from "../../timestampFormat"; |
| 53 | 53 | import { |
| 54 | 54 | buildInlineTerminalContextText, |
apps/web/src/components/chat/ProviderHealthBanner.tsx deleted-33| ... | ... | @@ -1,33 +0,0 @@ |
| 1 | import { PROVIDER_DISPLAY_NAMES, type ServerProviderStatus } from "@t3tools/contracts"; | |
| 2 | import { memo } from "react"; | |
| 3 | import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; | |
| 4 | import { CircleAlertIcon } from "lucide-react"; | |
| 5 | ||
| 6 | export const ProviderHealthBanner = memo(function ProviderHealthBanner({ | |
| 7 | status, | |
| 8 | }: { | |
| 9 | status: ServerProviderStatus | null; | |
| 10 | }) { | |
| 11 | if (!status || status.status === "ready") { | |
| 12 | return null; | |
| 13 | } | |
| 14 | ||
| 15 | const providerLabel = PROVIDER_DISPLAY_NAMES[status.provider] ?? status.provider; | |
| 16 | const defaultMessage = | |
| 17 | status.status === "error" | |
| 18 | ? `${providerLabel} provider is unavailable.` | |
| 19 | : `${providerLabel} provider has limited availability.`; | |
| 20 | const title = `${providerLabel} provider status`; | |
| 21 | ||
| 22 | return ( | |
| 23 | <div className="pt-3 mx-auto max-w-3xl"> | |
| 24 | <Alert variant={status.status === "error" ? "error" : "warning"}> | |
| 25 | <CircleAlertIcon /> | |
| 26 | <AlertTitle>{title}</AlertTitle> | |
| 27 | <AlertDescription className="line-clamp-3" title={status.message ?? defaultMessage}> | |
| 28 | {status.message ?? defaultMessage} | |
| 29 | </AlertDescription> | |
| 30 | </Alert> | |
| 31 | </div> | |
| 32 | ); | |
| 33 | }); |
apps/web/src/components/chat/ProviderModelPicker.browser.tsx+144-13| ... | ... | @@ -1,37 +1,134 @@ |
| 1 | import { type ModelSlug, type ProviderKind } from "@t3tools/contracts"; | |
| 1 | import { type ModelSlug, type ProviderKind, type ServerProvider } from "@t3tools/contracts"; | |
| 2 | 2 | import { page } from "vitest/browser"; |
| 3 | 3 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 4 | 4 | import { render } from "vitest-browser-react"; |
| 5 | 5 | |
| 6 | 6 | import { ProviderModelPicker } from "./ProviderModelPicker"; |
| 7 | import { getCustomModelOptionsByProvider } from "../../modelSelection"; | |
| 8 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | |
| 7 | 9 | |
| 8 | const MODEL_OPTIONS_BY_PROVIDER = { | |
| 9 | claudeAgent: [ | |
| 10 | { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, | |
| 11 | { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, | |
| 12 | { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" }, | |
| 13 | ], | |
| 14 | codex: [ | |
| 15 | { slug: "gpt-5-codex", name: "GPT-5 Codex" }, | |
| 16 | { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, | |
| 17 | ], | |
| 18 | } as const satisfies Record<ProviderKind, ReadonlyArray<{ slug: ModelSlug; name: string }>>; | |
| 10 | function effort(value: string, isDefault = false) { | |
| 11 | return { | |
| 12 | value, | |
| 13 | label: value, | |
| 14 | ...(isDefault ? { isDefault: true } : {}), | |
| 15 | }; | |
| 16 | } | |
| 17 | ||
| 18 | const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ | |
| 19 | { | |
| 20 | provider: "codex", | |
| 21 | enabled: true, | |
| 22 | installed: true, | |
| 23 | version: "0.116.0", | |
| 24 | status: "ready", | |
| 25 | authStatus: "authenticated", | |
| 26 | checkedAt: new Date().toISOString(), | |
| 27 | models: [ | |
| 28 | { | |
| 29 | slug: "gpt-5-codex", | |
| 30 | name: "GPT-5 Codex", | |
| 31 | isCustom: false, | |
| 32 | capabilities: { | |
| 33 | reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], | |
| 34 | supportsFastMode: true, | |
| 35 | supportsThinkingToggle: false, | |
| 36 | promptInjectedEffortLevels: [], | |
| 37 | }, | |
| 38 | }, | |
| 39 | { | |
| 40 | slug: "gpt-5.3-codex", | |
| 41 | name: "GPT-5.3 Codex", | |
| 42 | isCustom: false, | |
| 43 | capabilities: { | |
| 44 | reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], | |
| 45 | supportsFastMode: true, | |
| 46 | supportsThinkingToggle: false, | |
| 47 | promptInjectedEffortLevels: [], | |
| 48 | }, | |
| 49 | }, | |
| 50 | ], | |
| 51 | }, | |
| 52 | { | |
| 53 | provider: "claudeAgent", | |
| 54 | enabled: true, | |
| 55 | installed: true, | |
| 56 | version: "1.0.0", | |
| 57 | status: "ready", | |
| 58 | authStatus: "authenticated", | |
| 59 | checkedAt: new Date().toISOString(), | |
| 60 | models: [ | |
| 61 | { | |
| 62 | slug: "claude-opus-4-6", | |
| 63 | name: "Claude Opus 4.6", | |
| 64 | isCustom: false, | |
| 65 | capabilities: { | |
| 66 | reasoningEffortLevels: [ | |
| 67 | effort("low"), | |
| 68 | effort("medium", true), | |
| 69 | effort("high"), | |
| 70 | effort("max"), | |
| 71 | ], | |
| 72 | supportsFastMode: false, | |
| 73 | supportsThinkingToggle: true, | |
| 74 | promptInjectedEffortLevels: [], | |
| 75 | }, | |
| 76 | }, | |
| 77 | { | |
| 78 | slug: "claude-sonnet-4-6", | |
| 79 | name: "Claude Sonnet 4.6", | |
| 80 | isCustom: false, | |
| 81 | capabilities: { | |
| 82 | reasoningEffortLevels: [ | |
| 83 | effort("low"), | |
| 84 | effort("medium", true), | |
| 85 | effort("high"), | |
| 86 | effort("max"), | |
| 87 | ], | |
| 88 | supportsFastMode: false, | |
| 89 | supportsThinkingToggle: true, | |
| 90 | promptInjectedEffortLevels: [], | |
| 91 | }, | |
| 92 | }, | |
| 93 | { | |
| 94 | slug: "claude-haiku-4-5", | |
| 95 | name: "Claude Haiku 4.5", | |
| 96 | isCustom: false, | |
| 97 | capabilities: { | |
| 98 | reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], | |
| 99 | supportsFastMode: false, | |
| 100 | supportsThinkingToggle: true, | |
| 101 | promptInjectedEffortLevels: [], | |
| 102 | }, | |
| 103 | }, | |
| 104 | ], | |
| 105 | }, | |
| 106 | ]; | |
| 19 | 107 | |
| 20 | 108 | async function mountPicker(props: { |
| 21 | 109 | provider: ProviderKind; |
| 22 | 110 | model: ModelSlug; |
| 23 | 111 | lockedProvider: ProviderKind | null; |
| 112 | providers?: ReadonlyArray<ServerProvider>; | |
| 24 | 113 | triggerVariant?: "ghost" | "outline"; |
| 25 | 114 | }) { |
| 26 | 115 | const host = document.createElement("div"); |
| 27 | 116 | document.body.append(host); |
| 28 | 117 | const onProviderModelChange = vi.fn(); |
| 118 | const providers = props.providers ?? TEST_PROVIDERS; | |
| 119 | const modelOptionsByProvider = getCustomModelOptionsByProvider( | |
| 120 | DEFAULT_UNIFIED_SETTINGS, | |
| 121 | providers, | |
| 122 | props.provider, | |
| 123 | props.model, | |
| 124 | ); | |
| 29 | 125 | const screen = await render( |
| 30 | 126 | <ProviderModelPicker |
| 31 | 127 | provider={props.provider} |
| 32 | 128 | model={props.model} |
| 33 | 129 | lockedProvider={props.lockedProvider} |
| 34 | modelOptionsByProvider={MODEL_OPTIONS_BY_PROVIDER} | |
| 130 | providers={providers} | |
| 131 | modelOptionsByProvider={modelOptionsByProvider} | |
| 35 | 132 | triggerVariant={props.triggerVariant} |
| 36 | 133 | onProviderModelChange={onProviderModelChange} |
| 37 | 134 | />, |
| ... | ... | @@ -159,6 +256,40 @@ describe("ProviderModelPicker", () => { |
| 159 | 256 | } |
| 160 | 257 | }); |
| 161 | 258 | |
| 259 | it("shows disabled providers as non-selectable entries", async () => { | |
| 260 | const disabledProviders = TEST_PROVIDERS.slice(); | |
| 261 | const claudeIndex = disabledProviders.findIndex( | |
| 262 | (provider) => provider.provider === "claudeAgent", | |
| 263 | ); | |
| 264 | if (claudeIndex >= 0) { | |
| 265 | const claudeProvider = disabledProviders[claudeIndex]!; | |
| 266 | disabledProviders[claudeIndex] = { | |
| 267 | ...claudeProvider, | |
| 268 | enabled: false, | |
| 269 | status: "disabled", | |
| 270 | }; | |
| 271 | } | |
| 272 | const mounted = await mountPicker({ | |
| 273 | provider: "codex", | |
| 274 | model: "gpt-5-codex", | |
| 275 | lockedProvider: null, | |
| 276 | providers: disabledProviders, | |
| 277 | }); | |
| 278 | ||
| 279 | try { | |
| 280 | await page.getByRole("button").click(); | |
| 281 | ||
| 282 | await vi.waitFor(() => { | |
| 283 | const text = document.body.textContent ?? ""; | |
| 284 | expect(text).toContain("Claude"); | |
| 285 | expect(text).toContain("Disabled"); | |
| 286 | expect(text).not.toContain("Claude Sonnet 4.6"); | |
| 287 | }); | |
| 288 | } finally { | |
| 289 | await mounted.cleanup(); | |
| 290 | } | |
| 291 | }); | |
| 292 | ||
| 162 | 293 | it("accepts outline trigger styling", async () => { |
| 163 | 294 | const mounted = await mountPicker({ |
| 164 | 295 | provider: "codex", |
apps/web/src/components/chat/ProviderModelPicker.tsx+28-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { type ModelSlug, type ProviderKind } from "@t3tools/contracts"; | |
| 1 | import { type ModelSlug, type ProviderKind, type ServerProvider } from "@t3tools/contracts"; | |
| 2 | 2 | import { resolveSelectableModel } from "@t3tools/shared/model"; |
| 3 | 3 | import { memo, useState } from "react"; |
| 4 | 4 | import type { VariantProps } from "class-variance-authority"; |
| ... | ... | @@ -20,6 +20,7 @@ import { |
| 20 | 20 | } from "../ui/menu"; |
| 21 | 21 | import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons"; |
| 22 | 22 | import { cn } from "~/lib/utils"; |
| 23 | import { getProviderSnapshot } from "../../providerModels"; | |
| 23 | 24 | |
| 24 | 25 | function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { |
| 25 | 26 | value: ProviderKind; |
| ... | ... | @@ -53,6 +54,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 53 | 54 | provider: ProviderKind; |
| 54 | 55 | model: ModelSlug; |
| 55 | 56 | lockedProvider: ProviderKind | null; |
| 57 | providers?: ReadonlyArray<ServerProvider>; | |
| 56 | 58 | modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>; |
| 57 | 59 | activeProviderIconClassName?: string; |
| 58 | 60 | compact?: boolean; |
| ... | ... | @@ -145,6 +147,31 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 145 | 147 | <> |
| 146 | 148 | {AVAILABLE_PROVIDER_OPTIONS.map((option) => { |
| 147 | 149 | const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; |
| 150 | const liveProvider = props.providers | |
| 151 | ? getProviderSnapshot(props.providers, option.value) | |
| 152 | : undefined; | |
| 153 | if (liveProvider && liveProvider.status !== "ready") { | |
| 154 | const unavailableLabel = !liveProvider.enabled | |
| 155 | ? "Disabled" | |
| 156 | : !liveProvider.installed | |
| 157 | ? "Not installed" | |
| 158 | : "Unavailable"; | |
| 159 | return ( | |
| 160 | <MenuItem key={option.value} disabled> | |
| 161 | <OptionIcon | |
| 162 | aria-hidden="true" | |
| 163 | className={cn( | |
| 164 | "size-4 shrink-0 opacity-80", | |
| 165 | providerIconClassName(option.value, "text-muted-foreground/85"), | |
| 166 | )} | |
| 167 | /> | |
| 168 | <span>{option.label}</span> | |
| 169 | <span className="ms-auto text-[11px] text-muted-foreground/80 uppercase tracking-[0.08em]"> | |
| 170 | {unavailableLabel} | |
| 171 | </span> | |
| 172 | </MenuItem> | |
| 173 | ); | |
| 174 | } | |
| 148 | 175 | return ( |
| 149 | 176 | <MenuSub key={option.value}> |
| 150 | 177 | <MenuSubTrigger> |
apps/web/src/components/chat/ProviderStatusBanner.tsx created+33| ... | ... | @@ -0,0 +1,33 @@ |
| 1 | import { PROVIDER_DISPLAY_NAMES, type ServerProvider } from "@t3tools/contracts"; | |
| 2 | import { memo } from "react"; | |
| 3 | import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; | |
| 4 | import { CircleAlertIcon } from "lucide-react"; | |
| 5 | ||
| 6 | export const ProviderStatusBanner = memo(function ProviderStatusBanner({ | |
| 7 | status, | |
| 8 | }: { | |
| 9 | status: ServerProvider | null; | |
| 10 | }) { | |
| 11 | if (!status || status.status === "ready" || status.status === "disabled") { | |
| 12 | return null; | |
| 13 | } | |
| 14 | ||
| 15 | const providerLabel = PROVIDER_DISPLAY_NAMES[status.provider] ?? status.provider; | |
| 16 | const defaultMessage = | |
| 17 | status.status === "error" | |
| 18 | ? `${providerLabel} provider is unavailable.` | |
| 19 | : `${providerLabel} provider has limited availability.`; | |
| 20 | const title = `${providerLabel} provider status`; | |
| 21 | ||
| 22 | return ( | |
| 23 | <div className="pt-3 mx-auto max-w-3xl"> | |
| 24 | <Alert variant={status.status === "error" ? "error" : "warning"}> | |
| 25 | <CircleAlertIcon /> | |
| 26 | <AlertTitle>{title}</AlertTitle> | |
| 27 | <AlertDescription className="line-clamp-3" title={status.message ?? defaultMessage}> | |
| 28 | {status.message ?? defaultMessage} | |
| 29 | </AlertDescription> | |
| 30 | </Alert> | |
| 31 | </div> | |
| 32 | ); | |
| 33 | }); |
apps/web/src/components/chat/TraitsPicker.browser.tsx+96-5| ... | ... | @@ -5,7 +5,9 @@ import { |
| 5 | 5 | ClaudeModelOptions, |
| 6 | 6 | CodexModelOptions, |
| 7 | 7 | DEFAULT_MODEL_BY_PROVIDER, |
| 8 | DEFAULT_SERVER_SETTINGS, | |
| 8 | 9 | ProjectId, |
| 10 | type ServerProvider, | |
| 9 | 11 | ThreadId, |
| 10 | 12 | } from "@t3tools/contracts"; |
| 11 | 13 | import { page } from "vitest/browser"; |
| ... | ... | @@ -21,10 +23,93 @@ import { |
| 21 | 23 | useComposerThreadDraft, |
| 22 | 24 | useEffectiveComposerModelState, |
| 23 | 25 | } from "../../composerDraftStore"; |
| 26 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; | |
| 24 | 27 | |
| 25 | 28 | // ── Claude TraitsPicker tests ───────────────────────────────────────── |
| 26 | 29 | |
| 27 | 30 | const CLAUDE_THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits"); |
| 31 | const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ | |
| 32 | { | |
| 33 | provider: "codex", | |
| 34 | enabled: true, | |
| 35 | installed: true, | |
| 36 | version: "0.1.0", | |
| 37 | status: "ready", | |
| 38 | authStatus: "authenticated", | |
| 39 | checkedAt: "2026-01-01T00:00:00.000Z", | |
| 40 | models: [ | |
| 41 | { | |
| 42 | slug: "gpt-5.4", | |
| 43 | name: "GPT-5.4", | |
| 44 | isCustom: false, | |
| 45 | capabilities: { | |
| 46 | reasoningEffortLevels: [ | |
| 47 | { value: "xhigh", label: "Extra High" }, | |
| 48 | { value: "high", label: "High", isDefault: true }, | |
| 49 | ], | |
| 50 | supportsFastMode: true, | |
| 51 | supportsThinkingToggle: false, | |
| 52 | promptInjectedEffortLevels: [], | |
| 53 | }, | |
| 54 | }, | |
| 55 | ], | |
| 56 | }, | |
| 57 | { | |
| 58 | provider: "claudeAgent", | |
| 59 | enabled: true, | |
| 60 | installed: true, | |
| 61 | version: "0.1.0", | |
| 62 | status: "ready", | |
| 63 | authStatus: "authenticated", | |
| 64 | checkedAt: "2026-01-01T00:00:00.000Z", | |
| 65 | models: [ | |
| 66 | { | |
| 67 | slug: "claude-opus-4-6", | |
| 68 | name: "Claude Opus 4.6", | |
| 69 | isCustom: false, | |
| 70 | capabilities: { | |
| 71 | reasoningEffortLevels: [ | |
| 72 | { value: "low", label: "Low" }, | |
| 73 | { value: "medium", label: "Medium" }, | |
| 74 | { value: "high", label: "High", isDefault: true }, | |
| 75 | { value: "max", label: "Max" }, | |
| 76 | { value: "ultrathink", label: "Ultrathink" }, | |
| 77 | ], | |
| 78 | supportsFastMode: true, | |
| 79 | supportsThinkingToggle: false, | |
| 80 | promptInjectedEffortLevels: ["ultrathink"], | |
| 81 | }, | |
| 82 | }, | |
| 83 | { | |
| 84 | slug: "claude-sonnet-4-6", | |
| 85 | name: "Claude Sonnet 4.6", | |
| 86 | isCustom: false, | |
| 87 | capabilities: { | |
| 88 | reasoningEffortLevels: [ | |
| 89 | { value: "low", label: "Low" }, | |
| 90 | { value: "medium", label: "Medium" }, | |
| 91 | { value: "high", label: "High", isDefault: true }, | |
| 92 | { value: "ultrathink", label: "Ultrathink" }, | |
| 93 | ], | |
| 94 | supportsFastMode: false, | |
| 95 | supportsThinkingToggle: false, | |
| 96 | promptInjectedEffortLevels: ["ultrathink"], | |
| 97 | }, | |
| 98 | }, | |
| 99 | { | |
| 100 | slug: "claude-haiku-4-5", | |
| 101 | name: "Claude Haiku 4.5", | |
| 102 | isCustom: false, | |
| 103 | capabilities: { | |
| 104 | reasoningEffortLevels: [], | |
| 105 | supportsFastMode: false, | |
| 106 | supportsThinkingToggle: true, | |
| 107 | promptInjectedEffortLevels: [], | |
| 108 | }, | |
| 109 | }, | |
| 110 | ], | |
| 111 | }, | |
| 112 | ]; | |
| 28 | 113 | |
| 29 | 114 | function ClaudeTraitsPickerHarness(props: { |
| 30 | 115 | model: string; |
| ... | ... | @@ -35,10 +120,14 @@ function ClaudeTraitsPickerHarness(props: { |
| 35 | 120 | const setPrompt = useComposerDraftStore((store) => store.setPrompt); |
| 36 | 121 | const { modelOptions, selectedModel } = useEffectiveComposerModelState({ |
| 37 | 122 | threadId: CLAUDE_THREAD_ID, |
| 123 | providers: TEST_PROVIDERS, | |
| 38 | 124 | selectedProvider: "claudeAgent", |
| 39 | 125 | threadModelSelection: props.fallbackModelSelection, |
| 40 | 126 | projectModelSelection: null, |
| 41 | customModelsByProvider: { codex: [], claudeAgent: [] }, | |
| 127 | settings: { | |
| 128 | ...DEFAULT_SERVER_SETTINGS, | |
| 129 | ...DEFAULT_CLIENT_SETTINGS, | |
| 130 | }, | |
| 42 | 131 | }); |
| 43 | 132 | const handlePromptChange = useCallback( |
| 44 | 133 | (nextPrompt: string) => { |
| ... | ... | @@ -50,6 +139,7 @@ function ClaudeTraitsPickerHarness(props: { |
| 50 | 139 | return ( |
| 51 | 140 | <TraitsPicker |
| 52 | 141 | provider="claudeAgent" |
| 142 | models={TEST_PROVIDERS[1]!.models} | |
| 53 | 143 | threadId={CLAUDE_THREAD_ID} |
| 54 | 144 | model={selectedModel ?? props.model} |
| 55 | 145 | prompt={prompt} |
| ... | ... | @@ -109,7 +199,7 @@ async function mountClaudePicker(props?: { |
| 109 | 199 | ? ({ |
| 110 | 200 | provider: "claudeAgent", |
| 111 | 201 | model, |
| 112 | options: props.fallbackModelOptions ?? undefined, | |
| 202 | ...(props.fallbackModelOptions ? { options: props.fallbackModelOptions } : {}), | |
| 113 | 203 | } satisfies ModelSelection) |
| 114 | 204 | : null; |
| 115 | 205 | const screen = await render( |
| ... | ... | @@ -293,6 +383,7 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt |
| 293 | 383 | const screen = await render( |
| 294 | 384 | <TraitsPicker |
| 295 | 385 | provider="codex" |
| 386 | models={TEST_PROVIDERS[0]!.models} | |
| 296 | 387 | threadId={threadId} |
| 297 | 388 | model={props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex} |
| 298 | 389 | prompt="" |
| ... | ... | @@ -359,10 +450,10 @@ describe("TraitsPicker (Codex)", () => { |
| 359 | 450 | |
| 360 | 451 | await vi.waitFor(() => { |
| 361 | 452 | const text = document.body.textContent ?? ""; |
| 362 | expect(text).toContain("Low"); | |
| 363 | expect(text).toContain("Medium"); | |
| 364 | expect(text).toContain("High"); | |
| 365 | 453 | expect(text).toContain("Extra High"); |
| 454 | expect(text).toContain("High"); | |
| 455 | expect(text).not.toContain("Low"); | |
| 456 | expect(text).not.toContain("Medium"); | |
| 366 | 457 | }); |
| 367 | 458 | }); |
| 368 | 459 |
apps/web/src/components/chat/TraitsPicker.tsx+10-4| ... | ... | @@ -3,11 +3,11 @@ import { |
| 3 | 3 | type CodexModelOptions, |
| 4 | 4 | type ProviderKind, |
| 5 | 5 | type ProviderModelOptions, |
| 6 | type ServerProviderModel, | |
| 6 | 7 | type ThreadId, |
| 7 | 8 | } from "@t3tools/contracts"; |
| 8 | 9 | import { |
| 9 | 10 | applyClaudePromptEffortPrefix, |
| 10 | getModelCapabilities, | |
| 11 | 11 | isClaudeUltrathinkPrompt, |
| 12 | 12 | trimOrNull, |
| 13 | 13 | getDefaultEffort, |
| ... | ... | @@ -27,6 +27,7 @@ import { |
| 27 | 27 | MenuTrigger, |
| 28 | 28 | } from "../ui/menu"; |
| 29 | 29 | import { useComposerDraftStore } from "../../composerDraftStore"; |
| 30 | import { getProviderModelCapabilities } from "../../providerModels"; | |
| 30 | 31 | import { cn } from "~/lib/utils"; |
| 31 | 32 | |
| 32 | 33 | type ProviderOptions = ProviderModelOptions[ProviderKind]; |
| ... | ... | @@ -65,12 +66,13 @@ function buildNextOptions( |
| 65 | 66 | |
| 66 | 67 | function getSelectedTraits( |
| 67 | 68 | provider: ProviderKind, |
| 69 | models: ReadonlyArray<ServerProviderModel>, | |
| 68 | 70 | model: string | null | undefined, |
| 69 | 71 | prompt: string, |
| 70 | 72 | modelOptions: ProviderOptions | null | undefined, |
| 71 | 73 | allowPromptInjectedEffort: boolean, |
| 72 | 74 | ) { |
| 73 | const caps = getModelCapabilities(provider, model); | |
| 75 | const caps = getProviderModelCapabilities(models, model, provider); | |
| 74 | 76 | const effortLevels = allowPromptInjectedEffort |
| 75 | 77 | ? caps.reasoningEffortLevels |
| 76 | 78 | : caps.reasoningEffortLevels.filter( |
| ... | ... | @@ -120,6 +122,7 @@ function getSelectedTraits( |
| 120 | 122 | |
| 121 | 123 | export interface TraitsMenuContentProps { |
| 122 | 124 | provider: ProviderKind; |
| 125 | models: ReadonlyArray<ServerProviderModel>; | |
| 123 | 126 | model: string | null | undefined; |
| 124 | 127 | prompt: string; |
| 125 | 128 | onPromptChange: (prompt: string) => void; |
| ... | ... | @@ -131,6 +134,7 @@ export interface TraitsMenuContentProps { |
| 131 | 134 | |
| 132 | 135 | export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 133 | 136 | provider, |
| 137 | models, | |
| 134 | 138 | model, |
| 135 | 139 | prompt, |
| 136 | 140 | onPromptChange, |
| ... | ... | @@ -156,7 +160,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 156 | 160 | thinkingEnabled, |
| 157 | 161 | fastModeEnabled, |
| 158 | 162 | ultrathinkPromptControlled, |
| 159 | } = getSelectedTraits(provider, model, prompt, modelOptions, allowPromptInjectedEffort); | |
| 163 | } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort); | |
| 160 | 164 | const defaultEffort = getDefaultEffort(caps); |
| 161 | 165 | |
| 162 | 166 | const handleEffortChange = useCallback( |
| ... | ... | @@ -260,6 +264,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 260 | 264 | |
| 261 | 265 | export const TraitsPicker = memo(function TraitsPicker({ |
| 262 | 266 | provider, |
| 267 | models, | |
| 263 | 268 | model, |
| 264 | 269 | prompt, |
| 265 | 270 | onPromptChange, |
| ... | ... | @@ -277,7 +282,7 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 277 | 282 | thinkingEnabled, |
| 278 | 283 | fastModeEnabled, |
| 279 | 284 | ultrathinkPromptControlled, |
| 280 | } = getSelectedTraits(provider, model, prompt, modelOptions, allowPromptInjectedEffort); | |
| 285 | } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort); | |
| 281 | 286 | |
| 282 | 287 | const effortLabel = effort |
| 283 | 288 | ? (effortLevels.find((l) => l.value === effort)?.label ?? effort) |
| ... | ... | @@ -333,6 +338,7 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 333 | 338 | <MenuPopup align="start"> |
| 334 | 339 | <TraitsMenuContent |
| 335 | 340 | provider={provider} |
| 341 | models={models} | |
| 336 | 342 | model={model} |
| 337 | 343 | prompt={prompt} |
| 338 | 344 | onPromptChange={onPromptChange} |
apps/web/src/components/chat/composerProviderRegistry.test.tsx+75| ... | ... | @@ -1,11 +1,78 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import type { ServerProviderModel } from "@t3tools/contracts"; | |
| 2 | 3 | import { getComposerProviderState } from "./composerProviderRegistry"; |
| 3 | 4 | |
| 5 | const CODEX_MODELS: ReadonlyArray<ServerProviderModel> = [ | |
| 6 | { | |
| 7 | slug: "gpt-5.4", | |
| 8 | name: "GPT-5.4", | |
| 9 | isCustom: false, | |
| 10 | capabilities: { | |
| 11 | reasoningEffortLevels: [ | |
| 12 | { value: "xhigh", label: "Extra High" }, | |
| 13 | { value: "high", label: "High", isDefault: true }, | |
| 14 | { value: "medium", label: "Medium" }, | |
| 15 | { value: "low", label: "Low" }, | |
| 16 | ], | |
| 17 | supportsFastMode: true, | |
| 18 | supportsThinkingToggle: false, | |
| 19 | promptInjectedEffortLevels: [], | |
| 20 | }, | |
| 21 | }, | |
| 22 | ]; | |
| 23 | ||
| 24 | const CLAUDE_MODELS: ReadonlyArray<ServerProviderModel> = [ | |
| 25 | { | |
| 26 | slug: "claude-opus-4-6", | |
| 27 | name: "Claude Opus 4.6", | |
| 28 | isCustom: false, | |
| 29 | capabilities: { | |
| 30 | reasoningEffortLevels: [ | |
| 31 | { value: "medium", label: "Medium" }, | |
| 32 | { value: "high", label: "High", isDefault: true }, | |
| 33 | { value: "max", label: "Max" }, | |
| 34 | { value: "ultrathink", label: "Ultrathink" }, | |
| 35 | ], | |
| 36 | supportsFastMode: true, | |
| 37 | supportsThinkingToggle: false, | |
| 38 | promptInjectedEffortLevels: ["ultrathink"], | |
| 39 | }, | |
| 40 | }, | |
| 41 | { | |
| 42 | slug: "claude-sonnet-4-6", | |
| 43 | name: "Claude Sonnet 4.6", | |
| 44 | isCustom: false, | |
| 45 | capabilities: { | |
| 46 | reasoningEffortLevels: [ | |
| 47 | { value: "low", label: "Low" }, | |
| 48 | { value: "medium", label: "Medium" }, | |
| 49 | { value: "high", label: "High", isDefault: true }, | |
| 50 | { value: "ultrathink", label: "Ultrathink" }, | |
| 51 | ], | |
| 52 | supportsFastMode: false, | |
| 53 | supportsThinkingToggle: false, | |
| 54 | promptInjectedEffortLevels: ["ultrathink"], | |
| 55 | }, | |
| 56 | }, | |
| 57 | { | |
| 58 | slug: "claude-haiku-4-5", | |
| 59 | name: "Claude Haiku 4.5", | |
| 60 | isCustom: false, | |
| 61 | capabilities: { | |
| 62 | reasoningEffortLevels: [], | |
| 63 | supportsFastMode: false, | |
| 64 | supportsThinkingToggle: true, | |
| 65 | promptInjectedEffortLevels: [], | |
| 66 | }, | |
| 67 | }, | |
| 68 | ]; | |
| 69 | ||
| 4 | 70 | describe("getComposerProviderState", () => { |
| 5 | 71 | it("returns codex defaults when no codex draft options exist", () => { |
| 6 | 72 | const state = getComposerProviderState({ |
| 7 | 73 | provider: "codex", |
| 8 | 74 | model: "gpt-5.4", |
| 75 | models: CODEX_MODELS, | |
| 9 | 76 | prompt: "", |
| 10 | 77 | modelOptions: undefined, |
| 11 | 78 | }); |
| ... | ... | @@ -21,6 +88,7 @@ describe("getComposerProviderState", () => { |
| 21 | 88 | const state = getComposerProviderState({ |
| 22 | 89 | provider: "codex", |
| 23 | 90 | model: "gpt-5.4", |
| 91 | models: CODEX_MODELS, | |
| 24 | 92 | prompt: "", |
| 25 | 93 | modelOptions: { |
| 26 | 94 | codex: { |
| ... | ... | @@ -44,6 +112,7 @@ describe("getComposerProviderState", () => { |
| 44 | 112 | const state = getComposerProviderState({ |
| 45 | 113 | provider: "codex", |
| 46 | 114 | model: "gpt-5.4", |
| 115 | models: CODEX_MODELS, | |
| 47 | 116 | prompt: "", |
| 48 | 117 | modelOptions: { |
| 49 | 118 | codex: { |
| ... | ... | @@ -65,6 +134,7 @@ describe("getComposerProviderState", () => { |
| 65 | 134 | const state = getComposerProviderState({ |
| 66 | 135 | provider: "codex", |
| 67 | 136 | model: "gpt-5.4", |
| 137 | models: CODEX_MODELS, | |
| 68 | 138 | prompt: "", |
| 69 | 139 | modelOptions: { |
| 70 | 140 | codex: { |
| ... | ... | @@ -85,6 +155,7 @@ describe("getComposerProviderState", () => { |
| 85 | 155 | const state = getComposerProviderState({ |
| 86 | 156 | provider: "claudeAgent", |
| 87 | 157 | model: "claude-sonnet-4-6", |
| 158 | models: CLAUDE_MODELS, | |
| 88 | 159 | prompt: "", |
| 89 | 160 | modelOptions: undefined, |
| 90 | 161 | }); |
| ... | ... | @@ -100,6 +171,7 @@ describe("getComposerProviderState", () => { |
| 100 | 171 | const state = getComposerProviderState({ |
| 101 | 172 | provider: "claudeAgent", |
| 102 | 173 | model: "claude-sonnet-4-6", |
| 174 | models: CLAUDE_MODELS, | |
| 103 | 175 | prompt: "Ultrathink:\nInvestigate this failure", |
| 104 | 176 | modelOptions: { |
| 105 | 177 | claudeAgent: { |
| ... | ... | @@ -124,6 +196,7 @@ describe("getComposerProviderState", () => { |
| 124 | 196 | const state = getComposerProviderState({ |
| 125 | 197 | provider: "claudeAgent", |
| 126 | 198 | model: "claude-haiku-4-5", |
| 199 | models: CLAUDE_MODELS, | |
| 127 | 200 | prompt: "", |
| 128 | 201 | modelOptions: { |
| 129 | 202 | claudeAgent: { |
| ... | ... | @@ -146,6 +219,7 @@ describe("getComposerProviderState", () => { |
| 146 | 219 | const state = getComposerProviderState({ |
| 147 | 220 | provider: "claudeAgent", |
| 148 | 221 | model: "claude-opus-4-6", |
| 222 | models: CLAUDE_MODELS, | |
| 149 | 223 | prompt: "", |
| 150 | 224 | modelOptions: { |
| 151 | 225 | claudeAgent: { |
| ... | ... | @@ -167,6 +241,7 @@ describe("getComposerProviderState", () => { |
| 167 | 241 | const state = getComposerProviderState({ |
| 168 | 242 | provider: "claudeAgent", |
| 169 | 243 | model: "claude-opus-4-6", |
| 244 | models: CLAUDE_MODELS, | |
| 170 | 245 | prompt: "", |
| 171 | 246 | modelOptions: { |
| 172 | 247 | claudeAgent: { |
apps/web/src/components/chat/composerProviderRegistry.tsx+39-11| ... | ... | @@ -2,23 +2,27 @@ import { |
| 2 | 2 | type ModelSlug, |
| 3 | 3 | type ProviderKind, |
| 4 | 4 | type ProviderModelOptions, |
| 5 | type ServerProviderModel, | |
| 5 | 6 | type ThreadId, |
| 6 | 7 | } from "@t3tools/contracts"; |
| 7 | 8 | import { |
| 8 | getModelCapabilities, | |
| 9 | 9 | isClaudeUltrathinkPrompt, |
| 10 | normalizeClaudeModelOptions, | |
| 11 | normalizeCodexModelOptions, | |
| 12 | 10 | trimOrNull, |
| 13 | 11 | getDefaultEffort, |
| 14 | 12 | hasEffortLevel, |
| 15 | 13 | } from "@t3tools/shared/model"; |
| 16 | 14 | import type { ReactNode } from "react"; |
| 15 | import { | |
| 16 | getProviderModelCapabilities, | |
| 17 | normalizeClaudeModelOptionsWithCapabilities, | |
| 18 | normalizeCodexModelOptionsWithCapabilities, | |
| 19 | } from "../../providerModels"; | |
| 17 | 20 | import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; |
| 18 | 21 | |
| 19 | 22 | export type ComposerProviderStateInput = { |
| 20 | 23 | provider: ProviderKind; |
| 21 | 24 | model: ModelSlug; |
| 25 | models: ReadonlyArray<ServerProviderModel>; | |
| 22 | 26 | prompt: string; |
| 23 | 27 | modelOptions: ProviderModelOptions | null | undefined; |
| 24 | 28 | }; |
| ... | ... | @@ -37,6 +41,7 @@ type ProviderRegistryEntry = { |
| 37 | 41 | renderTraitsMenuContent: (input: { |
| 38 | 42 | threadId: ThreadId; |
| 39 | 43 | model: ModelSlug; |
| 44 | models: ReadonlyArray<ServerProviderModel>; | |
| 40 | 45 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; |
| 41 | 46 | prompt: string; |
| 42 | 47 | onPromptChange: (prompt: string) => void; |
| ... | ... | @@ -44,6 +49,7 @@ type ProviderRegistryEntry = { |
| 44 | 49 | renderTraitsPicker: (input: { |
| 45 | 50 | threadId: ThreadId; |
| 46 | 51 | model: ModelSlug; |
| 52 | models: ReadonlyArray<ServerProviderModel>; | |
| 47 | 53 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; |
| 48 | 54 | prompt: string; |
| 49 | 55 | onPromptChange: (prompt: string) => void; |
| ... | ... | @@ -53,8 +59,8 @@ type ProviderRegistryEntry = { |
| 53 | 59 | function getProviderStateFromCapabilities( |
| 54 | 60 | input: ComposerProviderStateInput, |
| 55 | 61 | ): ComposerProviderState { |
| 56 | const { provider, model, prompt, modelOptions } = input; | |
| 57 | const caps = getModelCapabilities(provider, model); | |
| 62 | const { provider, model, models, prompt, modelOptions } = input; | |
| 63 | const caps = getProviderModelCapabilities(models, model, provider); | |
| 58 | 64 | const providerOptions = modelOptions?.[provider]; |
| 59 | 65 | |
| 60 | 66 | // Resolve effort |
| ... | ... | @@ -81,8 +87,8 @@ function getProviderStateFromCapabilities( |
| 81 | 87 | // Normalize options for dispatch |
| 82 | 88 | const normalizedOptions = |
| 83 | 89 | provider === "codex" |
| 84 | ? normalizeCodexModelOptions(model, providerOptions) | |
| 85 | : normalizeClaudeModelOptions(model, providerOptions); | |
| 90 | ? normalizeCodexModelOptionsWithCapabilities(caps, providerOptions) | |
| 91 | : normalizeClaudeModelOptionsWithCapabilities(caps, providerOptions); | |
| 86 | 92 | |
| 87 | 93 | // Ultrathink styling (driven by capabilities data, not provider identity) |
| 88 | 94 | const ultrathinkActive = |
| ... | ... | @@ -103,9 +109,17 @@ function getProviderStateFromCapabilities( |
| 103 | 109 | const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { |
| 104 | 110 | codex: { |
| 105 | 111 | getState: (input) => getProviderStateFromCapabilities(input), |
| 106 | renderTraitsMenuContent: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( | |
| 112 | renderTraitsMenuContent: ({ | |
| 113 | threadId, | |
| 114 | model, | |
| 115 | models, | |
| 116 | modelOptions, | |
| 117 | prompt, | |
| 118 | onPromptChange, | |
| 119 | }) => ( | |
| 107 | 120 | <TraitsMenuContent |
| 108 | 121 | provider="codex" |
| 122 | models={models} | |
| 109 | 123 | threadId={threadId} |
| 110 | 124 | model={model} |
| 111 | 125 | modelOptions={modelOptions} |
| ... | ... | @@ -113,9 +127,10 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { |
| 113 | 127 | onPromptChange={onPromptChange} |
| 114 | 128 | /> |
| 115 | 129 | ), |
| 116 | renderTraitsPicker: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( | |
| 130 | renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( | |
| 117 | 131 | <TraitsPicker |
| 118 | 132 | provider="codex" |
| 133 | models={models} | |
| 119 | 134 | threadId={threadId} |
| 120 | 135 | model={model} |
| 121 | 136 | modelOptions={modelOptions} |
| ... | ... | @@ -126,9 +141,17 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { |
| 126 | 141 | }, |
| 127 | 142 | claudeAgent: { |
| 128 | 143 | getState: (input) => getProviderStateFromCapabilities(input), |
| 129 | renderTraitsMenuContent: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( | |
| 144 | renderTraitsMenuContent: ({ | |
| 145 | threadId, | |
| 146 | model, | |
| 147 | models, | |
| 148 | modelOptions, | |
| 149 | prompt, | |
| 150 | onPromptChange, | |
| 151 | }) => ( | |
| 130 | 152 | <TraitsMenuContent |
| 131 | 153 | provider="claudeAgent" |
| 154 | models={models} | |
| 132 | 155 | threadId={threadId} |
| 133 | 156 | model={model} |
| 134 | 157 | modelOptions={modelOptions} |
| ... | ... | @@ -136,9 +159,10 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { |
| 136 | 159 | onPromptChange={onPromptChange} |
| 137 | 160 | /> |
| 138 | 161 | ), |
| 139 | renderTraitsPicker: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( | |
| 162 | renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( | |
| 140 | 163 | <TraitsPicker |
| 141 | 164 | provider="claudeAgent" |
| 165 | models={models} | |
| 142 | 166 | threadId={threadId} |
| 143 | 167 | model={model} |
| 144 | 168 | modelOptions={modelOptions} |
| ... | ... | @@ -157,6 +181,7 @@ export function renderProviderTraitsMenuContent(input: { |
| 157 | 181 | provider: ProviderKind; |
| 158 | 182 | threadId: ThreadId; |
| 159 | 183 | model: ModelSlug; |
| 184 | models: ReadonlyArray<ServerProviderModel>; | |
| 160 | 185 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; |
| 161 | 186 | prompt: string; |
| 162 | 187 | onPromptChange: (prompt: string) => void; |
| ... | ... | @@ -164,6 +189,7 @@ export function renderProviderTraitsMenuContent(input: { |
| 164 | 189 | return composerProviderRegistry[input.provider].renderTraitsMenuContent({ |
| 165 | 190 | threadId: input.threadId, |
| 166 | 191 | model: input.model, |
| 192 | models: input.models, | |
| 167 | 193 | modelOptions: input.modelOptions, |
| 168 | 194 | prompt: input.prompt, |
| 169 | 195 | onPromptChange: input.onPromptChange, |
| ... | ... | @@ -174,6 +200,7 @@ export function renderProviderTraitsPicker(input: { |
| 174 | 200 | provider: ProviderKind; |
| 175 | 201 | threadId: ThreadId; |
| 176 | 202 | model: ModelSlug; |
| 203 | models: ReadonlyArray<ServerProviderModel>; | |
| 177 | 204 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; |
| 178 | 205 | prompt: string; |
| 179 | 206 | onPromptChange: (prompt: string) => void; |
| ... | ... | @@ -181,6 +208,7 @@ export function renderProviderTraitsPicker(input: { |
| 181 | 208 | return composerProviderRegistry[input.provider].renderTraitsPicker({ |
| 182 | 209 | threadId: input.threadId, |
| 183 | 210 | model: input.model, |
| 211 | models: input.models, | |
| 184 | 212 | modelOptions: input.modelOptions, |
| 185 | 213 | prompt: input.prompt, |
| 186 | 214 | onPromptChange: input.onPromptChange, |
apps/web/src/composerDraftStore.ts+19-16| ... | ... | @@ -9,16 +9,13 @@ import { |
| 9 | 9 | ProviderKind, |
| 10 | 10 | ProviderModelOptions, |
| 11 | 11 | RuntimeMode, |
| 12 | type ServerProvider, | |
| 12 | 13 | ThreadId, |
| 13 | 14 | } from "@t3tools/contracts"; |
| 14 | 15 | import * as Schema from "effect/Schema"; |
| 15 | 16 | import * as Equal from "effect/Equal"; |
| 16 | 17 | import { DeepMutable } from "effect/Types"; |
| 17 | import { | |
| 18 | getDefaultModel, | |
| 19 | normalizeModelSlug, | |
| 20 | resolveModelSlugForProvider, | |
| 21 | } from "@t3tools/shared/model"; | |
| 18 | import { getDefaultModel, normalizeModelSlug } from "@t3tools/shared/model"; | |
| 22 | 19 | import { useMemo } from "react"; |
| 23 | 20 | import { getLocalStorageItem } from "./hooks/useLocalStorage"; |
| 24 | 21 | import { resolveAppModelSelection } from "./modelSelection"; |
| ... | ... | @@ -31,6 +28,8 @@ import { |
| 31 | 28 | import { create } from "zustand"; |
| 32 | 29 | import { createJSONStorage, persist } from "zustand/middleware"; |
| 33 | 30 | import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; |
| 31 | import { getDefaultServerModel } from "./providerModels"; | |
| 32 | import { UnifiedSettings } from "@t3tools/contracts/settings"; | |
| 34 | 33 | |
| 35 | 34 | export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; |
| 36 | 35 | const COMPOSER_DRAFT_STORAGE_VERSION = 3; |
| ... | ... | @@ -613,22 +612,23 @@ export function deriveEffectiveComposerModelState(input: { |
| 613 | 612 | | Pick<ComposerThreadDraftState, "modelSelectionByProvider" | "activeProvider"> |
| 614 | 613 | | null |
| 615 | 614 | | undefined; |
| 615 | providers: ReadonlyArray<ServerProvider>; | |
| 616 | 616 | selectedProvider: ProviderKind; |
| 617 | 617 | threadModelSelection: ModelSelection | null | undefined; |
| 618 | 618 | projectModelSelection: ModelSelection | null | undefined; |
| 619 | customModelsByProvider: Record<ProviderKind, readonly string[]>; | |
| 619 | settings: UnifiedSettings; | |
| 620 | 620 | }): EffectiveComposerModelState { |
| 621 | const baseModel = resolveModelSlugForProvider( | |
| 622 | input.selectedProvider, | |
| 623 | input.threadModelSelection?.model ?? | |
| 624 | input.projectModelSelection?.model ?? | |
| 625 | getDefaultModel(input.selectedProvider), | |
| 626 | ); | |
| 621 | const baseModel = | |
| 622 | normalizeModelSlug( | |
| 623 | input.threadModelSelection?.model ?? input.projectModelSelection?.model, | |
| 624 | input.selectedProvider, | |
| 625 | ) ?? getDefaultServerModel(input.providers, input.selectedProvider); | |
| 627 | 626 | const activeSelection = input.draft?.modelSelectionByProvider?.[input.selectedProvider]; |
| 628 | 627 | const selectedModel = activeSelection?.model |
| 629 | 628 | ? resolveAppModelSelection( |
| 630 | 629 | input.selectedProvider, |
| 631 | input.customModelsByProvider, | |
| 630 | input.settings, | |
| 631 | input.providers, | |
| 632 | 632 | activeSelection.model, |
| 633 | 633 | ) |
| 634 | 634 | : baseModel; |
| ... | ... | @@ -2157,10 +2157,11 @@ export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftS |
| 2157 | 2157 | |
| 2158 | 2158 | export function useEffectiveComposerModelState(input: { |
| 2159 | 2159 | threadId: ThreadId; |
| 2160 | providers: ReadonlyArray<ServerProvider>; | |
| 2160 | 2161 | selectedProvider: ProviderKind; |
| 2161 | 2162 | threadModelSelection: ModelSelection | null | undefined; |
| 2162 | 2163 | projectModelSelection: ModelSelection | null | undefined; |
| 2163 | customModelsByProvider: Record<ProviderKind, readonly string[]>; | |
| 2164 | settings: UnifiedSettings; | |
| 2164 | 2165 | }): EffectiveComposerModelState { |
| 2165 | 2166 | const draft = useComposerThreadDraft(input.threadId); |
| 2166 | 2167 | |
| ... | ... | @@ -2168,14 +2169,16 @@ export function useEffectiveComposerModelState(input: { |
| 2168 | 2169 | () => |
| 2169 | 2170 | deriveEffectiveComposerModelState({ |
| 2170 | 2171 | draft, |
| 2172 | providers: input.providers, | |
| 2171 | 2173 | selectedProvider: input.selectedProvider, |
| 2172 | 2174 | threadModelSelection: input.threadModelSelection, |
| 2173 | 2175 | projectModelSelection: input.projectModelSelection, |
| 2174 | customModelsByProvider: input.customModelsByProvider, | |
| 2176 | settings: input.settings, | |
| 2175 | 2177 | }), |
| 2176 | 2178 | [ |
| 2177 | 2179 | draft, |
| 2178 | input.customModelsByProvider, | |
| 2180 | input.providers, | |
| 2181 | input.settings, | |
| 2179 | 2182 | input.projectModelSelection, |
| 2180 | 2183 | input.selectedProvider, |
| 2181 | 2184 | input.threadModelSelection, |
apps/web/src/hooks/useSettings.ts created+266| ... | ... | @@ -0,0 +1,266 @@ |
| 1 | /** | |
| 2 | * Unified settings hook. | |
| 3 | * | |
| 4 | * Abstracts the split between server-authoritative settings (persisted in | |
| 5 | * `settings.json` on the server, fetched via `server.getConfig`) and | |
| 6 | * client-only settings (persisted in localStorage). | |
| 7 | * | |
| 8 | * Consumers use `useSettings(selector)` to read, and `useUpdateSettings()` to | |
| 9 | * write. The hook transparently routes reads/writes to the correct backing | |
| 10 | * store. | |
| 11 | */ | |
| 12 | import { useCallback, useMemo } from "react"; | |
| 13 | import { useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 14 | import { | |
| 15 | ServerSettings, | |
| 16 | ServerSettingsPatch, | |
| 17 | ServerConfig, | |
| 18 | ModelSelection, | |
| 19 | ThreadEnvMode, | |
| 20 | } from "@t3tools/contracts"; | |
| 21 | import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; | |
| 22 | import { | |
| 23 | type ClientSettings, | |
| 24 | ClientSettingsSchema, | |
| 25 | DEFAULT_CLIENT_SETTINGS, | |
| 26 | DEFAULT_UNIFIED_SETTINGS, | |
| 27 | SidebarProjectSortOrder, | |
| 28 | SidebarThreadSortOrder, | |
| 29 | TimestampFormat, | |
| 30 | UnifiedSettings, | |
| 31 | } from "@t3tools/contracts/settings"; | |
| 32 | import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; | |
| 33 | import { ensureNativeApi } from "~/nativeApi"; | |
| 34 | import { useLocalStorage } from "./useLocalStorage"; | |
| 35 | import { normalizeCustomModelSlugs } from "~/modelSelection"; | |
| 36 | import { Predicate, Schema, Struct } from "effect"; | |
| 37 | import { DeepMutable } from "effect/Types"; | |
| 38 | import { deepMerge } from "@t3tools/shared/Struct"; | |
| 39 | ||
| 40 | const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; | |
| 41 | const OLD_SETTINGS_KEY = "t3code:app-settings:v1"; | |
| 42 | ||
| 43 | // ── Key sets for routing patches ───────────────────────────────────── | |
| 44 | ||
| 45 | const SERVER_SETTINGS_KEYS = new Set<string>(Struct.keys(ServerSettings.fields)); | |
| 46 | ||
| 47 | function splitPatch(patch: Partial<UnifiedSettings>): { | |
| 48 | serverPatch: ServerSettingsPatch; | |
| 49 | clientPatch: Partial<ClientSettings>; | |
| 50 | } { | |
| 51 | const serverPatch: Record<string, unknown> = {}; | |
| 52 | const clientPatch: Record<string, unknown> = {}; | |
| 53 | for (const [key, value] of Object.entries(patch)) { | |
| 54 | if (SERVER_SETTINGS_KEYS.has(key)) { | |
| 55 | serverPatch[key] = value; | |
| 56 | } else { | |
| 57 | clientPatch[key] = value; | |
| 58 | } | |
| 59 | } | |
| 60 | return { | |
| 61 | serverPatch: serverPatch as ServerSettingsPatch, | |
| 62 | clientPatch: clientPatch as Partial<ClientSettings>, | |
| 63 | }; | |
| 64 | } | |
| 65 | ||
| 66 | // ── Hooks ──────────────────────────────────────────────────────────── | |
| 67 | ||
| 68 | /** | |
| 69 | * Read merged settings. Selector narrows the subscription so components | |
| 70 | * only re-render when the slice they care about changes. | |
| 71 | */ | |
| 72 | ||
| 73 | export function useSettings<T extends UnifiedSettings = UnifiedSettings>( | |
| 74 | selector?: (s: UnifiedSettings) => T, | |
| 75 | ): T { | |
| 76 | const { data: serverConfig } = useQuery(serverConfigQueryOptions()); | |
| 77 | const [clientSettings] = useLocalStorage( | |
| 78 | CLIENT_SETTINGS_STORAGE_KEY, | |
| 79 | DEFAULT_CLIENT_SETTINGS, | |
| 80 | ClientSettingsSchema, | |
| 81 | ); | |
| 82 | ||
| 83 | const merged = useMemo<UnifiedSettings>( | |
| 84 | () => ({ | |
| 85 | ...(serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS), | |
| 86 | ...clientSettings, | |
| 87 | }), | |
| 88 | [serverConfig?.settings, clientSettings], | |
| 89 | ); | |
| 90 | ||
| 91 | return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]); | |
| 92 | } | |
| 93 | ||
| 94 | /** | |
| 95 | * Returns an updater that routes each key to the correct backing store. | |
| 96 | * | |
| 97 | * Server keys are optimistically patched in the React Query cache, then | |
| 98 | * persisted via RPC. Client keys go straight to localStorage. | |
| 99 | */ | |
| 100 | export function useUpdateSettings() { | |
| 101 | const queryClient = useQueryClient(); | |
| 102 | const [, setClientSettings] = useLocalStorage( | |
| 103 | CLIENT_SETTINGS_STORAGE_KEY, | |
| 104 | DEFAULT_CLIENT_SETTINGS, | |
| 105 | ClientSettingsSchema, | |
| 106 | ); | |
| 107 | ||
| 108 | const updateSettings = useCallback( | |
| 109 | (patch: Partial<UnifiedSettings>) => { | |
| 110 | const { serverPatch, clientPatch } = splitPatch(patch); | |
| 111 | ||
| 112 | if (Object.keys(serverPatch).length > 0) { | |
| 113 | // Optimistic update of the React Query cache | |
| 114 | queryClient.setQueryData<ServerConfig>(serverQueryKeys.config(), (old) => { | |
| 115 | if (!old) return old; | |
| 116 | return { | |
| 117 | ...old, | |
| 118 | settings: deepMerge(old.settings, serverPatch), | |
| 119 | }; | |
| 120 | }); | |
| 121 | // Fire-and-forget RPC — push will reconcile on success | |
| 122 | void ensureNativeApi().server.updateSettings(serverPatch); | |
| 123 | } | |
| 124 | ||
| 125 | if (Object.keys(clientPatch).length > 0) { | |
| 126 | setClientSettings((prev) => ({ ...prev, ...clientPatch })); | |
| 127 | } | |
| 128 | }, | |
| 129 | [queryClient, setClientSettings], | |
| 130 | ); | |
| 131 | ||
| 132 | const resetSettings = useCallback(() => { | |
| 133 | updateSettings(DEFAULT_UNIFIED_SETTINGS); | |
| 134 | }, [updateSettings]); | |
| 135 | ||
| 136 | return { | |
| 137 | updateSettings, | |
| 138 | resetSettings, | |
| 139 | }; | |
| 140 | } | |
| 141 | ||
| 142 | // ── One-time migration from localStorage ───────────────────────────── | |
| 143 | ||
| 144 | export function buildLegacyServerSettingsMigrationPatch(legacySettings: Record<string, unknown>) { | |
| 145 | const patch: DeepMutable<ServerSettingsPatch> = {}; | |
| 146 | ||
| 147 | if (Predicate.isBoolean(legacySettings.enableAssistantStreaming)) { | |
| 148 | patch.enableAssistantStreaming = legacySettings.enableAssistantStreaming; | |
| 149 | } | |
| 150 | ||
| 151 | if (Schema.is(ThreadEnvMode)(legacySettings.defaultThreadEnvMode)) { | |
| 152 | patch.defaultThreadEnvMode = legacySettings.defaultThreadEnvMode; | |
| 153 | } | |
| 154 | ||
| 155 | if (Schema.is(ModelSelection)(legacySettings.textGenerationModelSelection)) { | |
| 156 | patch.textGenerationModelSelection = legacySettings.textGenerationModelSelection; | |
| 157 | } | |
| 158 | ||
| 159 | if (typeof legacySettings.codexBinaryPath === "string") { | |
| 160 | patch.providers ??= {}; | |
| 161 | patch.providers.codex ??= {}; | |
| 162 | patch.providers.codex.binaryPath = legacySettings.codexBinaryPath; | |
| 163 | } | |
| 164 | ||
| 165 | if (typeof legacySettings.codexHomePath === "string") { | |
| 166 | patch.providers ??= {}; | |
| 167 | patch.providers.codex ??= {}; | |
| 168 | patch.providers.codex.homePath = legacySettings.codexHomePath; | |
| 169 | } | |
| 170 | ||
| 171 | if (Array.isArray(legacySettings.customCodexModels)) { | |
| 172 | patch.providers ??= {}; | |
| 173 | patch.providers.codex ??= {}; | |
| 174 | patch.providers.codex.customModels = normalizeCustomModelSlugs( | |
| 175 | legacySettings.customCodexModels, | |
| 176 | new Set<string>(), | |
| 177 | "codex", | |
| 178 | ); | |
| 179 | } | |
| 180 | ||
| 181 | if (Predicate.isString(legacySettings.claudeBinaryPath)) { | |
| 182 | patch.providers ??= {}; | |
| 183 | patch.providers.claudeAgent ??= {}; | |
| 184 | patch.providers.claudeAgent.binaryPath = legacySettings.claudeBinaryPath; | |
| 185 | } | |
| 186 | ||
| 187 | if (Array.isArray(legacySettings.customClaudeModels)) { | |
| 188 | patch.providers ??= {}; | |
| 189 | patch.providers.claudeAgent ??= {}; | |
| 190 | patch.providers.claudeAgent.customModels = normalizeCustomModelSlugs( | |
| 191 | legacySettings.customClaudeModels, | |
| 192 | new Set<string>(), | |
| 193 | "claudeAgent", | |
| 194 | ); | |
| 195 | } | |
| 196 | ||
| 197 | return patch; | |
| 198 | } | |
| 199 | ||
| 200 | export function buildLegacyClientSettingsMigrationPatch( | |
| 201 | legacySettings: Record<string, unknown>, | |
| 202 | ): Partial<DeepMutable<ClientSettings>> { | |
| 203 | const patch: Partial<DeepMutable<ClientSettings>> = {}; | |
| 204 | ||
| 205 | if (Predicate.isBoolean(legacySettings.confirmThreadDelete)) { | |
| 206 | patch.confirmThreadDelete = legacySettings.confirmThreadDelete; | |
| 207 | } | |
| 208 | ||
| 209 | if (Predicate.isBoolean(legacySettings.diffWordWrap)) { | |
| 210 | patch.diffWordWrap = legacySettings.diffWordWrap; | |
| 211 | } | |
| 212 | ||
| 213 | if (Schema.is(SidebarProjectSortOrder)(legacySettings.sidebarProjectSortOrder)) { | |
| 214 | patch.sidebarProjectSortOrder = legacySettings.sidebarProjectSortOrder; | |
| 215 | } | |
| 216 | ||
| 217 | if (Schema.is(SidebarThreadSortOrder)(legacySettings.sidebarThreadSortOrder)) { | |
| 218 | patch.sidebarThreadSortOrder = legacySettings.sidebarThreadSortOrder; | |
| 219 | } | |
| 220 | ||
| 221 | if (Schema.is(TimestampFormat)(legacySettings.timestampFormat)) { | |
| 222 | patch.timestampFormat = legacySettings.timestampFormat; | |
| 223 | } | |
| 224 | ||
| 225 | return patch; | |
| 226 | } | |
| 227 | ||
| 228 | /** | |
| 229 | * Call once on app startup. | |
| 230 | * If the legacy localStorage key exists, migrate its values to the new server | |
| 231 | * and client storage formats, then remove the legacy key so this only runs once. | |
| 232 | */ | |
| 233 | export function migrateLocalSettingsToServer(): void { | |
| 234 | if (typeof window === "undefined") return; | |
| 235 | ||
| 236 | const raw = localStorage.getItem(OLD_SETTINGS_KEY); | |
| 237 | if (!raw) return; | |
| 238 | ||
| 239 | try { | |
| 240 | const old = JSON.parse(raw); | |
| 241 | if (!Predicate.isObject(old)) return; | |
| 242 | ||
| 243 | // Migrate server-relevant keys via RPC | |
| 244 | const serverPatch = buildLegacyServerSettingsMigrationPatch(old); | |
| 245 | if (Object.keys(serverPatch).length > 0) { | |
| 246 | const api = ensureNativeApi(); | |
| 247 | void api.server.updateSettings(serverPatch); | |
| 248 | } | |
| 249 | ||
| 250 | // Migrate client-only keys to the new localStorage key | |
| 251 | const clientPatch = buildLegacyClientSettingsMigrationPatch(old); | |
| 252 | if (Object.keys(clientPatch).length > 0) { | |
| 253 | const existing = localStorage.getItem(CLIENT_SETTINGS_STORAGE_KEY); | |
| 254 | const current = existing ? (JSON.parse(existing) as Record<string, unknown>) : {}; | |
| 255 | localStorage.setItem( | |
| 256 | CLIENT_SETTINGS_STORAGE_KEY, | |
| 257 | JSON.stringify({ ...current, ...clientPatch }), | |
| 258 | ); | |
| 259 | } | |
| 260 | } catch (error) { | |
| 261 | console.error("[MIGRATION] Error migrating local settings:", error); | |
| 262 | } finally { | |
| 263 | // Remove the legacy key regardless to keep migration one-shot behavior. | |
| 264 | localStorage.removeItem(OLD_SETTINGS_KEY); | |
| 265 | } | |
| 266 | } |
apps/web/src/lib/gitReactQuery.test.ts-4| ... | ... | @@ -32,10 +32,6 @@ describe("git mutation options", () => { |
| 32 | 32 | const options = gitRunStackedActionMutationOptions({ |
| 33 | 33 | cwd: "/repo/a", |
| 34 | 34 | queryClient, |
| 35 | modelSelection: { | |
| 36 | provider: "codex", | |
| 37 | model: "gpt-5.4", | |
| 38 | }, | |
| 39 | 35 | }); |
| 40 | 36 | expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction("/repo/a")); |
| 41 | 37 | }); |
apps/web/src/lib/gitReactQuery.ts+1-3| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { type GitStackedAction, type ModelSelection } from "@t3tools/contracts"; | |
| 1 | import { type GitStackedAction } from "@t3tools/contracts"; | |
| 2 | 2 | import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; |
| 3 | 3 | import { ensureNativeApi } from "../nativeApi"; |
| 4 | 4 | |
| ... | ... | @@ -112,7 +112,6 @@ export function gitCheckoutMutationOptions(input: { |
| 112 | 112 | export function gitRunStackedActionMutationOptions(input: { |
| 113 | 113 | cwd: string | null; |
| 114 | 114 | queryClient: QueryClient; |
| 115 | modelSelection: ModelSelection; | |
| 116 | 115 | }) { |
| 117 | 116 | return mutationOptions({ |
| 118 | 117 | mutationKey: gitMutationKeys.runStackedAction(input.cwd), |
| ... | ... | @@ -134,7 +133,6 @@ export function gitRunStackedActionMutationOptions(input: { |
| 134 | 133 | return api.git.runStackedAction({ |
| 135 | 134 | actionId, |
| 136 | 135 | cwd: input.cwd, |
| 137 | modelSelection: input.modelSelection, | |
| 138 | 136 | action, |
| 139 | 137 | ...(commitMessage ? { commitMessage } : {}), |
| 140 | 138 | ...(featureBranch ? { featureBranch } : {}), |
apps/web/src/lib/serverReactQuery.ts+7-1| ... | ... | @@ -6,6 +6,13 @@ export const serverQueryKeys = { |
| 6 | 6 | config: () => ["server", "config"] as const, |
| 7 | 7 | }; |
| 8 | 8 | |
| 9 | /** | |
| 10 | * Server config query options. | |
| 11 | * | |
| 12 | * `staleTime` is kept short so that push-driven `invalidateQueries` calls in | |
| 13 | * the EventRouter always trigger a refetch, and so the query re-fetches when | |
| 14 | * the component re-mounts (e.g. navigating away from settings and back). | |
| 15 | */ | |
| 9 | 16 | export function serverConfigQueryOptions() { |
| 10 | 17 | return queryOptions({ |
| 11 | 18 | queryKey: serverQueryKeys.config(), |
| ... | ... | @@ -13,6 +20,5 @@ export function serverConfigQueryOptions() { |
| 13 | 20 | const api = ensureNativeApi(); |
| 14 | 21 | return api.server.getConfig(); |
| 15 | 22 | }, |
| 16 | staleTime: Infinity, | |
| 17 | 23 | }); |
| 18 | 24 | } |
apps/web/src/modelSelection.ts+50-78| ... | ... | @@ -2,27 +2,22 @@ import { |
| 2 | 2 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, |
| 3 | 3 | type ModelSelection, |
| 4 | 4 | type ProviderKind, |
| 5 | type ServerProvider, | |
| 5 | 6 | } from "@t3tools/contracts"; |
| 6 | import { | |
| 7 | getDefaultModel, | |
| 8 | getModelOptions, | |
| 9 | normalizeModelSlug, | |
| 10 | resolveSelectableModel, | |
| 11 | } from "@t3tools/shared/model"; | |
| 7 | import { normalizeModelSlug, resolveSelectableModel } from "@t3tools/shared/model"; | |
| 12 | 8 | import { getComposerProviderState } from "./components/chat/composerProviderRegistry"; |
| 9 | import { UnifiedSettings } from "@t3tools/contracts/settings"; | |
| 10 | import { | |
| 11 | getDefaultServerModel, | |
| 12 | getProviderModels, | |
| 13 | resolveSelectableProvider, | |
| 14 | } from "./providerModels"; | |
| 13 | 15 | |
| 14 | 16 | const MAX_CUSTOM_MODEL_COUNT = 32; |
| 15 | 17 | export const MAX_CUSTOM_MODEL_LENGTH = 256; |
| 16 | 18 | |
| 17 | export type CustomModelSettings = { | |
| 18 | customCodexModels: readonly string[]; | |
| 19 | customClaudeModels: readonly string[]; | |
| 20 | }; | |
| 21 | ||
| 22 | 19 | export type ProviderCustomModelConfig = { |
| 23 | 20 | provider: ProviderKind; |
| 24 | settingsKey: keyof CustomModelSettings; | |
| 25 | defaultSettingsKey: keyof CustomModelSettings; | |
| 26 | 21 | title: string; |
| 27 | 22 | description: string; |
| 28 | 23 | placeholder: string; |
| ... | ... | @@ -38,8 +33,6 @@ export interface AppModelOption { |
| 38 | 33 | const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConfig> = { |
| 39 | 34 | codex: { |
| 40 | 35 | provider: "codex", |
| 41 | settingsKey: "customCodexModels", | |
| 42 | defaultSettingsKey: "customCodexModels", | |
| 43 | 36 | title: "Codex", |
| 44 | 37 | description: "Save additional Codex model slugs for the picker and `/model` command.", |
| 45 | 38 | placeholder: "your-codex-model-slug", |
| ... | ... | @@ -47,8 +40,6 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConf |
| 47 | 40 | }, |
| 48 | 41 | claudeAgent: { |
| 49 | 42 | provider: "claudeAgent", |
| 50 | settingsKey: "customClaudeModels", | |
| 51 | defaultSettingsKey: "customClaudeModels", | |
| 52 | 43 | title: "Claude", |
| 53 | 44 | description: "Save additional Claude model slugs for the picker and `/model` command.", |
| 54 | 45 | placeholder: "your-claude-model-slug", |
| ... | ... | @@ -56,20 +47,15 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConf |
| 56 | 47 | }, |
| 57 | 48 | }; |
| 58 | 49 | |
| 59 | const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = { | |
| 60 | codex: new Set(getModelOptions("codex").map((option) => option.slug)), | |
| 61 | claudeAgent: new Set(getModelOptions("claudeAgent").map((option) => option.slug)), | |
| 62 | }; | |
| 63 | ||
| 64 | 50 | export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG); |
| 65 | 51 | |
| 66 | 52 | export function normalizeCustomModelSlugs( |
| 67 | 53 | models: Iterable<string | null | undefined>, |
| 54 | builtInModelSlugs: ReadonlySet<string>, | |
| 68 | 55 | provider: ProviderKind = "codex", |
| 69 | 56 | ): string[] { |
| 70 | 57 | const normalizedModels: string[] = []; |
| 71 | 58 | const seen = new Set<string>(); |
| 72 | const builtInModelSlugs = BUILT_IN_MODEL_SLUGS_BY_PROVIDER[provider]; | |
| 73 | 59 | |
| 74 | 60 | for (const candidate of models) { |
| 75 | 61 | const normalized = normalizeModelSlug(candidate, provider); |
| ... | ... | @@ -92,52 +78,29 @@ export function normalizeCustomModelSlugs( |
| 92 | 78 | return normalizedModels; |
| 93 | 79 | } |
| 94 | 80 | |
| 95 | export function getCustomModelsForProvider( | |
| 96 | settings: CustomModelSettings, | |
| 97 | provider: ProviderKind, | |
| 98 | ): readonly string[] { | |
| 99 | return settings[PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]; | |
| 100 | } | |
| 101 | ||
| 102 | export function getDefaultCustomModelsForProvider( | |
| 103 | defaults: CustomModelSettings, | |
| 104 | provider: ProviderKind, | |
| 105 | ): readonly string[] { | |
| 106 | return defaults[PROVIDER_CUSTOM_MODEL_CONFIG[provider].defaultSettingsKey]; | |
| 107 | } | |
| 108 | ||
| 109 | export function patchCustomModels( | |
| 110 | provider: ProviderKind, | |
| 111 | models: string[], | |
| 112 | ): Partial<CustomModelSettings> { | |
| 113 | return { | |
| 114 | [PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]: models, | |
| 115 | }; | |
| 116 | } | |
| 117 | ||
| 118 | export function getCustomModelsByProvider( | |
| 119 | settings: CustomModelSettings, | |
| 120 | ): Record<ProviderKind, readonly string[]> { | |
| 121 | return { | |
| 122 | codex: getCustomModelsForProvider(settings, "codex"), | |
| 123 | claudeAgent: getCustomModelsForProvider(settings, "claudeAgent"), | |
| 124 | }; | |
| 125 | } | |
| 126 | ||
| 127 | 81 | export function getAppModelOptions( |
| 82 | settings: UnifiedSettings, | |
| 83 | providers: ReadonlyArray<ServerProvider>, | |
| 128 | 84 | provider: ProviderKind, |
| 129 | customModels: readonly string[], | |
| 130 | 85 | selectedModel?: string | null, |
| 131 | 86 | ): AppModelOption[] { |
| 132 | const options: AppModelOption[] = getModelOptions(provider).map(({ slug, name }) => ({ | |
| 133 | slug, | |
| 134 | name, | |
| 135 | isCustom: false, | |
| 136 | })); | |
| 87 | const options: AppModelOption[] = getProviderModels(providers, provider).map( | |
| 88 | ({ slug, name, isCustom }) => ({ | |
| 89 | slug, | |
| 90 | name, | |
| 91 | isCustom, | |
| 92 | }), | |
| 93 | ); | |
| 137 | 94 | const seen = new Set(options.map((option) => option.slug)); |
| 138 | 95 | const trimmedSelectedModel = selectedModel?.trim().toLowerCase(); |
| 139 | ||
| 140 | for (const slug of normalizeCustomModelSlugs(customModels, provider)) { | |
| 96 | const builtInModelSlugs = new Set( | |
| 97 | getProviderModels(providers, provider) | |
| 98 | .filter((model) => !model.isCustom) | |
| 99 | .map((model) => model.slug), | |
| 100 | ); | |
| 101 | ||
| 102 | const customModels = settings.providers[provider].customModels; | |
| 103 | for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs, provider)) { | |
| 141 | 104 | if (seen.has(slug)) { |
| 142 | 105 | continue; |
| 143 | 106 | } |
| ... | ... | @@ -171,52 +134,61 @@ export function getAppModelOptions( |
| 171 | 134 | |
| 172 | 135 | export function resolveAppModelSelection( |
| 173 | 136 | provider: ProviderKind, |
| 174 | customModels: Record<ProviderKind, readonly string[]>, | |
| 137 | settings: UnifiedSettings, | |
| 138 | providers: ReadonlyArray<ServerProvider>, | |
| 175 | 139 | selectedModel: string | null | undefined, |
| 176 | 140 | ): string { |
| 177 | const customModelsForProvider = customModels[provider]; | |
| 178 | const options = getAppModelOptions(provider, customModelsForProvider, selectedModel); | |
| 179 | return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider); | |
| 141 | const resolvedProvider = resolveSelectableProvider(providers, provider); | |
| 142 | const options = getAppModelOptions(settings, providers, resolvedProvider, selectedModel); | |
| 143 | return ( | |
| 144 | resolveSelectableModel(resolvedProvider, selectedModel, options) ?? | |
| 145 | getDefaultServerModel(providers, resolvedProvider) | |
| 146 | ); | |
| 180 | 147 | } |
| 181 | 148 | |
| 182 | 149 | export function getCustomModelOptionsByProvider( |
| 183 | settings: CustomModelSettings, | |
| 150 | settings: UnifiedSettings, | |
| 151 | providers: ReadonlyArray<ServerProvider>, | |
| 184 | 152 | selectedProvider?: ProviderKind | null, |
| 185 | 153 | selectedModel?: string | null, |
| 186 | 154 | ): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> { |
| 187 | const customModelsByProvider = getCustomModelsByProvider(settings); | |
| 188 | 155 | return { |
| 189 | 156 | codex: getAppModelOptions( |
| 157 | settings, | |
| 158 | providers, | |
| 190 | 159 | "codex", |
| 191 | customModelsByProvider.codex, | |
| 192 | 160 | selectedProvider === "codex" ? selectedModel : undefined, |
| 193 | 161 | ), |
| 194 | 162 | claudeAgent: getAppModelOptions( |
| 163 | settings, | |
| 164 | providers, | |
| 195 | 165 | "claudeAgent", |
| 196 | customModelsByProvider.claudeAgent, | |
| 197 | 166 | selectedProvider === "claudeAgent" ? selectedModel : undefined, |
| 198 | 167 | ), |
| 199 | 168 | }; |
| 200 | 169 | } |
| 201 | 170 | |
| 202 | 171 | export function resolveAppModelSelectionState( |
| 203 | settings: CustomModelSettings & { | |
| 204 | textGenerationModelSelection: ModelSelection | undefined; | |
| 205 | }, | |
| 172 | settings: UnifiedSettings, | |
| 173 | providers: ReadonlyArray<ServerProvider>, | |
| 206 | 174 | ): ModelSelection { |
| 207 | 175 | const selection = settings.textGenerationModelSelection ?? { |
| 208 | 176 | provider: "codex" as const, |
| 209 | 177 | model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, |
| 210 | 178 | }; |
| 211 | const provider = selection.provider; | |
| 212 | const customModelsByProvider = getCustomModelsByProvider(settings); | |
| 213 | const model = resolveAppModelSelection(provider, customModelsByProvider, selection.model); | |
| 179 | const provider = resolveSelectableProvider(providers, selection.provider); | |
| 180 | ||
| 181 | // When the provider changed due to fallback (e.g. selected provider was disabled), | |
| 182 | // don't carry over the old provider's model — use the fallback provider's default. | |
| 183 | const selectedModel = provider === selection.provider ? selection.model : null; | |
| 184 | const model = resolveAppModelSelection(provider, settings, providers, selectedModel); | |
| 214 | 185 | const { modelOptionsForDispatch } = getComposerProviderState({ |
| 215 | 186 | provider, |
| 216 | 187 | model, |
| 188 | models: getProviderModels(providers, provider), | |
| 217 | 189 | prompt: "", |
| 218 | 190 | modelOptions: { |
| 219 | [provider]: selection.options, | |
| 191 | [provider]: provider === selection.provider ? selection.options : undefined, | |
| 220 | 192 | }, |
| 221 | 193 | }); |
| 222 | 194 |
apps/web/src/providerModels.ts created+116| ... | ... | @@ -0,0 +1,116 @@ |
| 1 | import { | |
| 2 | DEFAULT_MODEL_BY_PROVIDER, | |
| 3 | type ClaudeModelOptions, | |
| 4 | type CodexModelOptions, | |
| 5 | type ModelCapabilities, | |
| 6 | type ProviderKind, | |
| 7 | type ServerProvider, | |
| 8 | type ServerProviderModel, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import { | |
| 11 | getDefaultEffort, | |
| 12 | hasEffortLevel, | |
| 13 | normalizeModelSlug, | |
| 14 | trimOrNull, | |
| 15 | } from "@t3tools/shared/model"; | |
| 16 | ||
| 17 | const EMPTY_CAPABILITIES: ModelCapabilities = { | |
| 18 | reasoningEffortLevels: [], | |
| 19 | supportsFastMode: false, | |
| 20 | supportsThinkingToggle: false, | |
| 21 | promptInjectedEffortLevels: [], | |
| 22 | }; | |
| 23 | ||
| 24 | export function getProviderModels( | |
| 25 | providers: ReadonlyArray<ServerProvider>, | |
| 26 | provider: ProviderKind, | |
| 27 | ): ReadonlyArray<ServerProviderModel> { | |
| 28 | return providers.find((candidate) => candidate.provider === provider)?.models ?? []; | |
| 29 | } | |
| 30 | ||
| 31 | export function getProviderSnapshot( | |
| 32 | providers: ReadonlyArray<ServerProvider>, | |
| 33 | provider: ProviderKind, | |
| 34 | ): ServerProvider | undefined { | |
| 35 | return providers.find((candidate) => candidate.provider === provider); | |
| 36 | } | |
| 37 | ||
| 38 | export function isProviderEnabled( | |
| 39 | providers: ReadonlyArray<ServerProvider>, | |
| 40 | provider: ProviderKind, | |
| 41 | ): boolean { | |
| 42 | return getProviderSnapshot(providers, provider)?.enabled ?? true; | |
| 43 | } | |
| 44 | ||
| 45 | export function resolveSelectableProvider( | |
| 46 | providers: ReadonlyArray<ServerProvider>, | |
| 47 | provider: ProviderKind | null | undefined, | |
| 48 | ): ProviderKind { | |
| 49 | const requested = provider ?? "codex"; | |
| 50 | if (isProviderEnabled(providers, requested)) { | |
| 51 | return requested; | |
| 52 | } | |
| 53 | return providers.find((candidate) => candidate.enabled)?.provider ?? requested; | |
| 54 | } | |
| 55 | ||
| 56 | export function getProviderModelCapabilities( | |
| 57 | models: ReadonlyArray<ServerProviderModel>, | |
| 58 | model: string | null | undefined, | |
| 59 | provider: ProviderKind, | |
| 60 | ): ModelCapabilities { | |
| 61 | const slug = normalizeModelSlug(model, provider); | |
| 62 | return models.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES; | |
| 63 | } | |
| 64 | ||
| 65 | export function getDefaultServerModel( | |
| 66 | providers: ReadonlyArray<ServerProvider>, | |
| 67 | provider: ProviderKind, | |
| 68 | ): string { | |
| 69 | const models = getProviderModels(providers, provider); | |
| 70 | return ( | |
| 71 | models.find((model) => !model.isCustom)?.slug ?? | |
| 72 | models[0]?.slug ?? | |
| 73 | DEFAULT_MODEL_BY_PROVIDER[provider] | |
| 74 | ); | |
| 75 | } | |
| 76 | ||
| 77 | export function normalizeCodexModelOptionsWithCapabilities( | |
| 78 | caps: ModelCapabilities, | |
| 79 | modelOptions: CodexModelOptions | null | undefined, | |
| 80 | ): CodexModelOptions | undefined { | |
| 81 | const defaultReasoningEffort = getDefaultEffort(caps); | |
| 82 | const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort; | |
| 83 | const fastModeEnabled = modelOptions?.fastMode === true; | |
| 84 | const nextOptions: CodexModelOptions = { | |
| 85 | ...(reasoningEffort && reasoningEffort !== defaultReasoningEffort | |
| 86 | ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] } | |
| 87 | : {}), | |
| 88 | ...(fastModeEnabled ? { fastMode: true } : {}), | |
| 89 | }; | |
| 90 | return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; | |
| 91 | } | |
| 92 | ||
| 93 | export function normalizeClaudeModelOptionsWithCapabilities( | |
| 94 | caps: ModelCapabilities, | |
| 95 | modelOptions: ClaudeModelOptions | null | undefined, | |
| 96 | ): ClaudeModelOptions | undefined { | |
| 97 | const defaultReasoningEffort = getDefaultEffort(caps); | |
| 98 | const resolvedEffort = trimOrNull(modelOptions?.effort); | |
| 99 | const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? ""); | |
| 100 | const effort = | |
| 101 | resolvedEffort && | |
| 102 | !isPromptInjected && | |
| 103 | hasEffortLevel(caps, resolvedEffort) && | |
| 104 | resolvedEffort !== defaultReasoningEffort | |
| 105 | ? resolvedEffort | |
| 106 | : undefined; | |
| 107 | const thinking = | |
| 108 | caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; | |
| 109 | const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; | |
| 110 | const nextOptions: ClaudeModelOptions = { | |
| 111 | ...(thinking === false ? { thinking: false } : {}), | |
| 112 | ...(effort ? { effort } : {}), | |
| 113 | ...(fastMode ? { fastMode: true } : {}), | |
| 114 | }; | |
| 115 | return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; | |
| 116 | } |
apps/web/src/routes/__root.tsx+14-1| ... | ... | @@ -20,7 +20,8 @@ import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDra |
| 20 | 20 | import { useStore } from "../store"; |
| 21 | 21 | import { useTerminalStateStore } from "../terminalStateStore"; |
| 22 | 22 | import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; |
| 23 | import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi"; | |
| 23 | import { onServerConfigUpdated, onServerProvidersUpdated, onServerWelcome } from "../wsNativeApi"; | |
| 24 | import { migrateLocalSettingsToServer } from "../hooks/useSettings"; | |
| 24 | 25 | import { providerQueryKeys } from "../lib/providerReactQuery"; |
| 25 | 26 | import { projectQueryKeys } from "../lib/projectReactQuery"; |
| 26 | 27 | import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; |
| ... | ... | @@ -230,6 +231,8 @@ function EventRouter() { |
| 230 | 231 | ); |
| 231 | 232 | }); |
| 232 | 233 | const unsubWelcome = onServerWelcome((payload) => { |
| 234 | // Migrate old localStorage settings to server on first connect | |
| 235 | migrateLocalSettingsToServer(); | |
| 233 | 236 | void (async () => { |
| 234 | 237 | await syncSnapshot(); |
| 235 | 238 | if (disposed) { |
| ... | ... | @@ -260,8 +263,14 @@ function EventRouter() { |
| 260 | 263 | // don't produce duplicate toasts. |
| 261 | 264 | let subscribed = false; |
| 262 | 265 | const unsubServerConfigUpdated = onServerConfigUpdated((payload) => { |
| 266 | // Invalidate the config query so active observers refetch fresh data. | |
| 263 | 267 | void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); |
| 268 | ||
| 264 | 269 | if (!subscribed) return; |
| 270 | ||
| 271 | // Only show keybindings toasts for keybindings changes (no settings in payload) | |
| 272 | if (payload.settings) return; | |
| 273 | ||
| 265 | 274 | const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); |
| 266 | 275 | if (!issue) { |
| 267 | 276 | toastManager.add({ |
| ... | ... | @@ -300,6 +309,9 @@ function EventRouter() { |
| 300 | 309 | }, |
| 301 | 310 | }); |
| 302 | 311 | }); |
| 312 | const unsubProvidersUpdated = onServerProvidersUpdated(() => { | |
| 313 | void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); | |
| 314 | }); | |
| 303 | 315 | subscribed = true; |
| 304 | 316 | return () => { |
| 305 | 317 | disposed = true; |
| ... | ... | @@ -309,6 +321,7 @@ function EventRouter() { |
| 309 | 321 | unsubTerminalEvent(); |
| 310 | 322 | unsubWelcome(); |
| 311 | 323 | unsubServerConfigUpdated(); |
| 324 | unsubProvidersUpdated(); | |
| 312 | 325 | }; |
| 313 | 326 | }, [ |
| 314 | 327 | navigate, |
apps/web/src/routes/_chat.settings.tsx+684-379| ... | ... | @@ -1,16 +1,27 @@ |
| 1 | 1 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | import { useQuery } from "@tanstack/react-query"; | |
| 3 | import { ChevronDownIcon, PlusIcon, RotateCcwIcon, Undo2Icon, XIcon } from "lucide-react"; | |
| 4 | import { type ReactNode, useCallback, useState } from "react"; | |
| 5 | import { type ProviderKind } from "@t3tools/contracts"; | |
| 6 | import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; | |
| 7 | import { useAppSettings } from "../appSettings"; | |
| 2 | import { useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 3 | import { | |
| 4 | ChevronDownIcon, | |
| 5 | InfoIcon, | |
| 6 | LoaderIcon, | |
| 7 | PlusIcon, | |
| 8 | RefreshCwIcon, | |
| 9 | RotateCcwIcon, | |
| 10 | Undo2Icon, | |
| 11 | XIcon, | |
| 12 | } from "lucide-react"; | |
| 13 | import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; | |
| 14 | import { | |
| 15 | PROVIDER_DISPLAY_NAMES, | |
| 16 | type ProviderKind, | |
| 17 | type ServerProvider, | |
| 18 | type ServerProviderModel, | |
| 19 | } from "@t3tools/contracts"; | |
| 20 | import { normalizeModelSlug } from "@t3tools/shared/model"; | |
| 21 | import { useSettings, useUpdateSettings } from "../hooks/useSettings"; | |
| 8 | 22 | import { |
| 9 | 23 | getCustomModelOptionsByProvider, |
| 10 | getCustomModelsForProvider, | |
| 11 | 24 | MAX_CUSTOM_MODEL_LENGTH, |
| 12 | MODEL_PROVIDER_SETTINGS, | |
| 13 | patchCustomModels, | |
| 14 | 25 | resolveAppModelSelectionState, |
| 15 | 26 | } from "../modelSelection"; |
| 16 | 27 | import { APP_VERSION } from "../branding"; |
| ... | ... | @@ -33,9 +44,12 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip" |
| 33 | 44 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; |
| 34 | 45 | import { isElectron } from "../env"; |
| 35 | 46 | import { useTheme } from "../hooks/useTheme"; |
| 36 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | |
| 47 | import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; | |
| 37 | 48 | import { cn } from "../lib/utils"; |
| 49 | import { formatRelativeTime } from "../timestampFormat"; | |
| 38 | 50 | import { ensureNativeApi, readNativeApi } from "../nativeApi"; |
| 51 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | |
| 52 | import { Equal } from "effect"; | |
| 39 | 53 | |
| 40 | 54 | const THEME_OPTIONS = [ |
| 41 | 55 | { |
| ... | ... | @@ -61,11 +75,11 @@ const TIMESTAMP_FORMAT_LABELS = { |
| 61 | 75 | "24-hour": "24-hour", |
| 62 | 76 | } as const; |
| 63 | 77 | |
| 64 | type InstallBinarySettingsKey = "claudeBinaryPath" | "codexBinaryPath"; | |
| 78 | const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = []; | |
| 79 | ||
| 65 | 80 | type InstallProviderSettings = { |
| 66 | 81 | provider: ProviderKind; |
| 67 | 82 | title: string; |
| 68 | binaryPathKey: InstallBinarySettingsKey; | |
| 69 | 83 | binaryPlaceholder: string; |
| 70 | 84 | binaryDescription: ReactNode; |
| 71 | 85 | homePathKey?: "codexHomePath"; |
| ... | ... | @@ -73,17 +87,12 @@ type InstallProviderSettings = { |
| 73 | 87 | homeDescription?: ReactNode; |
| 74 | 88 | }; |
| 75 | 89 | |
| 76 | const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ | |
| 90 | const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ | |
| 77 | 91 | { |
| 78 | 92 | provider: "codex", |
| 79 | 93 | title: "Codex", |
| 80 | binaryPathKey: "codexBinaryPath", | |
| 81 | 94 | binaryPlaceholder: "Codex binary path", |
| 82 | binaryDescription: ( | |
| 83 | <> | |
| 84 | Leave blank to use <code>codex</code> from your PATH. | |
| 85 | </> | |
| 86 | ), | |
| 95 | binaryDescription: "Path to the Codex binary", | |
| 87 | 96 | homePathKey: "codexHomePath", |
| 88 | 97 | homePlaceholder: "CODEX_HOME", |
| 89 | 98 | homeDescription: "Optional custom Codex home and config directory.", |
| ... | ... | @@ -91,22 +100,116 @@ const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ |
| 91 | 100 | { |
| 92 | 101 | provider: "claudeAgent", |
| 93 | 102 | title: "Claude", |
| 94 | binaryPathKey: "claudeBinaryPath", | |
| 95 | 103 | binaryPlaceholder: "Claude binary path", |
| 96 | binaryDescription: ( | |
| 97 | <> | |
| 98 | Leave blank to use <code>claude</code> from your PATH. | |
| 99 | </> | |
| 100 | ), | |
| 104 | binaryDescription: "Path to the Claude binary", | |
| 101 | 105 | }, |
| 102 | 106 | ]; |
| 103 | 107 | |
| 104 | function SettingsSection({ title, children }: { title: string; children: ReactNode }) { | |
| 108 | const PROVIDER_STATUS_STYLES = { | |
| 109 | disabled: { | |
| 110 | dot: "bg-amber-400", | |
| 111 | badge: "warning" as const, | |
| 112 | }, | |
| 113 | error: { | |
| 114 | dot: "bg-destructive", | |
| 115 | badge: "error" as const, | |
| 116 | }, | |
| 117 | ready: { | |
| 118 | dot: "bg-success", | |
| 119 | badge: "success" as const, | |
| 120 | }, | |
| 121 | warning: { | |
| 122 | dot: "bg-warning", | |
| 123 | badge: "warning" as const, | |
| 124 | }, | |
| 125 | } as const; | |
| 126 | ||
| 127 | function getProviderSummary(provider: ServerProvider | undefined): { | |
| 128 | readonly headline: string; | |
| 129 | readonly detail: string | null; | |
| 130 | } { | |
| 131 | if (!provider) { | |
| 132 | return { | |
| 133 | headline: "Checking provider status", | |
| 134 | detail: "Waiting for the server to report installation and authentication details.", | |
| 135 | }; | |
| 136 | } | |
| 137 | if (!provider.enabled) { | |
| 138 | return { | |
| 139 | headline: "Disabled", | |
| 140 | detail: | |
| 141 | provider.message ?? "This provider is installed but disabled for new sessions in T3 Code.", | |
| 142 | }; | |
| 143 | } | |
| 144 | if (!provider.installed) { | |
| 145 | return { | |
| 146 | headline: "Not found", | |
| 147 | detail: provider.message ?? "CLI not detected on PATH.", | |
| 148 | }; | |
| 149 | } | |
| 150 | if (provider.authStatus === "authenticated") { | |
| 151 | return { | |
| 152 | headline: "Authenticated", | |
| 153 | detail: provider.message ?? null, | |
| 154 | }; | |
| 155 | } | |
| 156 | if (provider.authStatus === "unauthenticated") { | |
| 157 | return { | |
| 158 | headline: "Not authenticated", | |
| 159 | detail: provider.message ?? null, | |
| 160 | }; | |
| 161 | } | |
| 162 | if (provider.status === "warning") { | |
| 163 | return { | |
| 164 | headline: "Needs attention", | |
| 165 | detail: | |
| 166 | provider.message ?? "The provider is installed, but the server could not fully verify it.", | |
| 167 | }; | |
| 168 | } | |
| 169 | if (provider.status === "error") { | |
| 170 | return { | |
| 171 | headline: "Unavailable", | |
| 172 | detail: provider.message ?? "The provider failed its startup checks.", | |
| 173 | }; | |
| 174 | } | |
| 175 | return { | |
| 176 | headline: "Available", | |
| 177 | detail: provider.message ?? "Installed and ready, but authentication could not be verified.", | |
| 178 | }; | |
| 179 | } | |
| 180 | ||
| 181 | function getProviderVersionLabel(version: string | null | undefined): string | null { | |
| 182 | if (!version) return null; | |
| 183 | return version.startsWith("v") ? version : `v${version}`; | |
| 184 | } | |
| 185 | ||
| 186 | /** Returns a timestamp that updates on an interval, forcing re-renders to keep relative times fresh. */ | |
| 187 | function useRelativeTimeTick(intervalMs = 1_000): number { | |
| 188 | const [tick, setTick] = useState(() => Date.now()); | |
| 189 | useEffect(() => { | |
| 190 | const id = setInterval(() => setTick(Date.now()), intervalMs); | |
| 191 | return () => clearInterval(id); | |
| 192 | }, [intervalMs]); | |
| 193 | return tick; | |
| 194 | } | |
| 195 | ||
| 196 | function SettingsSection({ | |
| 197 | title, | |
| 198 | headerAction, | |
| 199 | children, | |
| 200 | }: { | |
| 201 | title: string; | |
| 202 | headerAction?: ReactNode; | |
| 203 | children: ReactNode; | |
| 204 | }) { | |
| 105 | 205 | return ( |
| 106 | 206 | <section className="space-y-3"> |
| 107 | <h2 className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground"> | |
| 108 | {title} | |
| 109 | </h2> | |
| 207 | <div className="flex items-center justify-between"> | |
| 208 | <h2 className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground"> | |
| 209 | {title} | |
| 210 | </h2> | |
| 211 | {headerAction} | |
| 212 | </div> | |
| 110 | 213 | <div className="relative overflow-hidden rounded-2xl border bg-card not-dark:bg-clip-padding text-card-foreground shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]"> |
| 111 | 214 | {children} |
| 112 | 215 | </div> |
| ... | ... | @@ -121,7 +224,6 @@ function SettingsRow({ |
| 121 | 224 | resetAction, |
| 122 | 225 | control, |
| 123 | 226 | children, |
| 124 | onClick, | |
| 125 | 227 | }: { |
| 126 | 228 | title: string; |
| 127 | 229 | description: string; |
| ... | ... | @@ -129,20 +231,13 @@ function SettingsRow({ |
| 129 | 231 | resetAction?: ReactNode; |
| 130 | 232 | control?: ReactNode; |
| 131 | 233 | children?: ReactNode; |
| 132 | onClick?: () => void; | |
| 133 | 234 | }) { |
| 134 | 235 | return ( |
| 135 | 236 | <div |
| 136 | 237 | className="border-t border-border px-4 py-4 first:border-t-0 sm:px-5" |
| 137 | 238 | data-slot="settings-row" |
| 138 | 239 | > |
| 139 | <div | |
| 140 | className={cn( | |
| 141 | "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between", | |
| 142 | onClick && "cursor-pointer", | |
| 143 | )} | |
| 144 | onClick={onClick} | |
| 145 | > | |
| 240 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> | |
| 146 | 241 | <div className="min-w-0 flex-1 space-y-1"> |
| 147 | 242 | <div className="flex min-h-5 items-center gap-1.5"> |
| 148 | 243 | <h3 className="text-sm font-medium text-foreground">{title}</h3> |
| ... | ... | @@ -190,16 +285,23 @@ function SettingResetButton({ label, onClick }: { label: string; onClick: () => |
| 190 | 285 | |
| 191 | 286 | function SettingsRouteView() { |
| 192 | 287 | const { theme, setTheme } = useTheme(); |
| 193 | const { settings, defaults, updateSettings, resetSettings } = useAppSettings(); | |
| 288 | const settings = useSettings(); | |
| 289 | const { updateSettings, resetSettings } = useUpdateSettings(); | |
| 194 | 290 | const serverConfigQuery = useQuery(serverConfigQueryOptions()); |
| 195 | 291 | const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); |
| 196 | 292 | const [openKeybindingsError, setOpenKeybindingsError] = useState<string | null>(null); |
| 197 | const [openInstallProviders, setOpenInstallProviders] = useState<Record<ProviderKind, boolean>>({ | |
| 198 | codex: Boolean(settings.codexBinaryPath || settings.codexHomePath), | |
| 199 | claudeAgent: Boolean(settings.claudeBinaryPath), | |
| 293 | const [openProviderDetails, setOpenProviderDetails] = useState<Record<ProviderKind, boolean>>({ | |
| 294 | codex: Boolean( | |
| 295 | settings.providers.codex.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.binaryPath || | |
| 296 | settings.providers.codex.homePath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.homePath || | |
| 297 | settings.providers.codex.customModels.length > 0, | |
| 298 | ), | |
| 299 | claudeAgent: Boolean( | |
| 300 | settings.providers.claudeAgent.binaryPath !== | |
| 301 | DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath || | |
| 302 | settings.providers.claudeAgent.customModels.length > 0, | |
| 303 | ), | |
| 200 | 304 | }); |
| 201 | const [selectedCustomModelProvider, setSelectedCustomModelProvider] = | |
| 202 | useState<ProviderKind>("codex"); | |
| 203 | 305 | const [customModelInputByProvider, setCustomModelInputByProvider] = useState< |
| 204 | 306 | Record<ProviderKind, string> |
| 205 | 307 | >({ |
| ... | ... | @@ -209,63 +311,73 @@ function SettingsRouteView() { |
| 209 | 311 | const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< |
| 210 | 312 | Partial<Record<ProviderKind, string | null>> |
| 211 | 313 | >({}); |
| 212 | const [showAllCustomModels, setShowAllCustomModels] = useState(false); | |
| 314 | const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); | |
| 315 | const refreshingRef = useRef(false); | |
| 316 | const queryClient = useQueryClient(); | |
| 317 | useRelativeTimeTick(); | |
| 318 | ||
| 319 | const refreshProviders = useCallback(() => { | |
| 320 | if (refreshingRef.current) return; | |
| 321 | refreshingRef.current = true; | |
| 322 | setIsRefreshingProviders(true); | |
| 323 | const api = ensureNativeApi(); | |
| 324 | api.server | |
| 325 | .refreshProviders() | |
| 326 | .then(() => queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() })) | |
| 327 | .catch((error: unknown) => { | |
| 328 | console.warn("Failed to refresh providers", error); | |
| 329 | }) | |
| 330 | .finally(() => { | |
| 331 | refreshingRef.current = false; | |
| 332 | setIsRefreshingProviders(false); | |
| 333 | }); | |
| 334 | }, [queryClient]); | |
| 213 | 335 | |
| 214 | const codexBinaryPath = settings.codexBinaryPath; | |
| 215 | const codexHomePath = settings.codexHomePath; | |
| 216 | const claudeBinaryPath = settings.claudeBinaryPath; | |
| 336 | const modelListRefs = useRef<Partial<Record<ProviderKind, HTMLDivElement | null>>>({}); | |
| 337 | ||
| 338 | const codexHomePath = settings.providers.codex.homePath; | |
| 217 | 339 | const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; |
| 218 | 340 | const availableEditors = serverConfigQuery.data?.availableEditors; |
| 341 | const serverProviders = serverConfigQuery.data?.providers ?? EMPTY_SERVER_PROVIDERS; | |
| 219 | 342 | |
| 220 | const textGenerationModelSelection = resolveAppModelSelectionState(settings); | |
| 343 | const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); | |
| 221 | 344 | const textGenProvider = textGenerationModelSelection.provider; |
| 222 | 345 | const textGenModel = textGenerationModelSelection.model; |
| 223 | 346 | const textGenModelOptions = textGenerationModelSelection.options; |
| 224 | 347 | const gitModelOptionsByProvider = getCustomModelOptionsByProvider( |
| 225 | 348 | settings, |
| 349 | serverProviders, | |
| 226 | 350 | textGenProvider, |
| 227 | 351 | textGenModel, |
| 228 | 352 | ); |
| 229 | const selectedCustomModelProviderSettings = MODEL_PROVIDER_SETTINGS.find( | |
| 230 | (providerSettings) => providerSettings.provider === selectedCustomModelProvider, | |
| 231 | )!; | |
| 232 | const selectedCustomModelInput = customModelInputByProvider[selectedCustomModelProvider]; | |
| 233 | const selectedCustomModelError = customModelErrorByProvider[selectedCustomModelProvider] ?? null; | |
| 234 | const totalCustomModels = settings.customCodexModels.length + settings.customClaudeModels.length; | |
| 235 | const savedCustomModelRows = MODEL_PROVIDER_SETTINGS.flatMap((providerSettings) => | |
| 236 | getCustomModelsForProvider(settings, providerSettings.provider).map((slug) => ({ | |
| 237 | key: `${providerSettings.provider}:${slug}`, | |
| 238 | provider: providerSettings.provider, | |
| 239 | providerTitle: providerSettings.title, | |
| 240 | slug, | |
| 241 | })), | |
| 353 | const areProviderSettingsDirty = PROVIDER_SETTINGS.some((providerSettings) => { | |
| 354 | const currentSettings = settings.providers[providerSettings.provider]; | |
| 355 | const defaultSettings = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider]; | |
| 356 | return !Equal.equals(currentSettings, defaultSettings); | |
| 357 | }); | |
| 358 | const isGitWritingModelDirty = !Equal.equals( | |
| 359 | settings.textGenerationModelSelection ?? null, | |
| 360 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, | |
| 242 | 361 | ); |
| 243 | const visibleCustomModelRows = showAllCustomModels | |
| 244 | ? savedCustomModelRows | |
| 245 | : savedCustomModelRows.slice(0, 5); | |
| 246 | const isInstallSettingsDirty = | |
| 247 | settings.claudeBinaryPath !== defaults.claudeBinaryPath || | |
| 248 | settings.codexBinaryPath !== defaults.codexBinaryPath || | |
| 249 | settings.codexHomePath !== defaults.codexHomePath; | |
| 250 | 362 | const changedSettingLabels = [ |
| 251 | 363 | ...(theme !== "system" ? ["Theme"] : []), |
| 252 | ...(settings.timestampFormat !== defaults.timestampFormat ? ["Time format"] : []), | |
| 253 | ...(settings.diffWordWrap !== defaults.diffWordWrap ? ["Diff line wrapping"] : []), | |
| 254 | ...(settings.enableAssistantStreaming !== defaults.enableAssistantStreaming | |
| 255 | ? ["Assistant output"] | |
| 364 | ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat | |
| 365 | ? ["Time format"] | |
| 256 | 366 | : []), |
| 257 | ...(settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ["New thread mode"] : []), | |
| 258 | ...(settings.confirmThreadDelete !== defaults.confirmThreadDelete | |
| 259 | ? ["Delete confirmation"] | |
| 367 | ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap | |
| 368 | ? ["Diff line wrapping"] | |
| 369 | : []), | |
| 370 | ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming | |
| 371 | ? ["Assistant output"] | |
| 260 | 372 | : []), |
| 261 | ...(JSON.stringify(settings.textGenerationModelSelection ?? null) !== | |
| 262 | JSON.stringify(defaults.textGenerationModelSelection ?? null) | |
| 263 | ? ["Git writing model"] | |
| 373 | ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode | |
| 374 | ? ["New thread mode"] | |
| 264 | 375 | : []), |
| 265 | ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0 | |
| 266 | ? ["Custom models"] | |
| 376 | ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete | |
| 377 | ? ["Delete confirmation"] | |
| 267 | 378 | : []), |
| 268 | ...(isInstallSettingsDirty ? ["Provider installs"] : []), | |
| 379 | ...(isGitWritingModelDirty ? ["Git writing model"] : []), | |
| 380 | ...(areProviderSettingsDirty ? ["Providers"] : []), | |
| 269 | 381 | ]; |
| 270 | 382 | |
| 271 | 383 | const openKeybindingsFile = useCallback(() => { |
| ... | ... | @@ -294,7 +406,7 @@ function SettingsRouteView() { |
| 294 | 406 | const addCustomModel = useCallback( |
| 295 | 407 | (provider: ProviderKind) => { |
| 296 | 408 | const customModelInput = customModelInputByProvider[provider]; |
| 297 | const customModels = getCustomModelsForProvider(settings, provider); | |
| 409 | const customModels = settings.providers[provider].customModels; | |
| 298 | 410 | const normalized = normalizeModelSlug(customModelInput, provider); |
| 299 | 411 | if (!normalized) { |
| 300 | 412 | setCustomModelErrorByProvider((existing) => ({ |
| ... | ... | @@ -303,7 +415,11 @@ function SettingsRouteView() { |
| 303 | 415 | })); |
| 304 | 416 | return; |
| 305 | 417 | } |
| 306 | if (getModelOptions(provider).some((option) => option.slug === normalized)) { | |
| 418 | if ( | |
| 419 | serverProviders | |
| 420 | .find((candidate) => candidate.provider === provider) | |
| 421 | ?.models.some((option) => !option.isCustom && option.slug === normalized) | |
| 422 | ) { | |
| 307 | 423 | setCustomModelErrorByProvider((existing) => ({ |
| 308 | 424 | ...existing, |
| 309 | 425 | [provider]: "That model is already built in.", |
| ... | ... | @@ -325,7 +441,15 @@ function SettingsRouteView() { |
| 325 | 441 | return; |
| 326 | 442 | } |
| 327 | 443 | |
| 328 | updateSettings(patchCustomModels(provider, [...customModels, normalized])); | |
| 444 | updateSettings({ | |
| 445 | providers: { | |
| 446 | ...settings.providers, | |
| 447 | [provider]: { | |
| 448 | ...settings.providers[provider], | |
| 449 | customModels: [...customModels, normalized], | |
| 450 | }, | |
| 451 | }, | |
| 452 | }); | |
| 329 | 453 | setCustomModelInputByProvider((existing) => ({ |
| 330 | 454 | ...existing, |
| 331 | 455 | [provider]: "", |
| ... | ... | @@ -334,19 +458,37 @@ function SettingsRouteView() { |
| 334 | 458 | ...existing, |
| 335 | 459 | [provider]: null, |
| 336 | 460 | })); |
| 461 | // Watch for DOM changes (server may push updated model list) and scroll to bottom | |
| 462 | const el = modelListRefs.current[provider]; | |
| 463 | if (el) { | |
| 464 | const scrollToEnd = () => el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); | |
| 465 | // Immediate scroll for the optimistic update | |
| 466 | requestAnimationFrame(scrollToEnd); | |
| 467 | // Also observe mutations for when the server pushes an updated list | |
| 468 | const observer = new MutationObserver(() => { | |
| 469 | scrollToEnd(); | |
| 470 | observer.disconnect(); | |
| 471 | }); | |
| 472 | observer.observe(el, { childList: true, subtree: true }); | |
| 473 | // Clean up observer after a reasonable window | |
| 474 | setTimeout(() => observer.disconnect(), 2000); | |
| 475 | } | |
| 337 | 476 | }, |
| 338 | [customModelInputByProvider, settings, updateSettings], | |
| 477 | [customModelInputByProvider, serverProviders, settings, updateSettings], | |
| 339 | 478 | ); |
| 340 | 479 | |
| 341 | 480 | const removeCustomModel = useCallback( |
| 342 | 481 | (provider: ProviderKind, slug: string) => { |
| 343 | const customModels = getCustomModelsForProvider(settings, provider); | |
| 344 | updateSettings( | |
| 345 | patchCustomModels( | |
| 346 | provider, | |
| 347 | customModels.filter((model) => model !== slug), | |
| 348 | ), | |
| 349 | ); | |
| 482 | const customModels = settings.providers[provider].customModels; | |
| 483 | updateSettings({ | |
| 484 | providers: { | |
| 485 | ...settings.providers, | |
| 486 | [provider]: { | |
| 487 | ...settings.providers[provider], | |
| 488 | customModels: customModels.filter((model) => model !== slug), | |
| 489 | }, | |
| 490 | }, | |
| 491 | }); | |
| 350 | 492 | setCustomModelErrorByProvider((existing) => ({ |
| 351 | 493 | ...existing, |
| 352 | 494 | [provider]: null, |
| ... | ... | @@ -355,6 +497,46 @@ function SettingsRouteView() { |
| 355 | 497 | [settings, updateSettings], |
| 356 | 498 | ); |
| 357 | 499 | |
| 500 | const providerCards = PROVIDER_SETTINGS.map((providerSettings) => { | |
| 501 | const liveProvider = serverProviders.find( | |
| 502 | (candidate) => candidate.provider === providerSettings.provider, | |
| 503 | ); | |
| 504 | const providerConfig = settings.providers[providerSettings.provider]; | |
| 505 | const defaultProviderConfig = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider]; | |
| 506 | const statusKey = liveProvider?.status ?? (providerConfig.enabled ? "warning" : "disabled"); | |
| 507 | const statusStyle = PROVIDER_STATUS_STYLES[statusKey]; | |
| 508 | const summary = getProviderSummary(liveProvider); | |
| 509 | const models: ReadonlyArray<ServerProviderModel> = | |
| 510 | liveProvider?.models ?? | |
| 511 | providerConfig.customModels.map((slug) => ({ | |
| 512 | slug, | |
| 513 | name: slug, | |
| 514 | isCustom: true, | |
| 515 | capabilities: null, | |
| 516 | })); | |
| 517 | const binaryPathValue = providerConfig.binaryPath; | |
| 518 | const isDirty = !Equal.equals(providerConfig, defaultProviderConfig); | |
| 519 | ||
| 520 | return { | |
| 521 | provider: providerSettings.provider, | |
| 522 | title: providerSettings.title, | |
| 523 | binaryPlaceholder: providerSettings.binaryPlaceholder, | |
| 524 | binaryDescription: providerSettings.binaryDescription, | |
| 525 | homePathKey: providerSettings.homePathKey, | |
| 526 | homePlaceholder: providerSettings.homePlaceholder, | |
| 527 | homeDescription: providerSettings.homeDescription, | |
| 528 | binaryPathValue, | |
| 529 | isDirty, | |
| 530 | liveProvider, | |
| 531 | models, | |
| 532 | providerConfig, | |
| 533 | statusKey, | |
| 534 | statusStyle, | |
| 535 | summary, | |
| 536 | versionLabel: getProviderVersionLabel(liveProvider?.version), | |
| 537 | }; | |
| 538 | }); | |
| 539 | ||
| 358 | 540 | async function restoreDefaults() { |
| 359 | 541 | if (changedSettingLabels.length === 0) return; |
| 360 | 542 | |
| ... | ... | @@ -368,11 +550,10 @@ function SettingsRouteView() { |
| 368 | 550 | |
| 369 | 551 | setTheme("system"); |
| 370 | 552 | resetSettings(); |
| 371 | setOpenInstallProviders({ | |
| 553 | setOpenProviderDetails({ | |
| 372 | 554 | codex: false, |
| 373 | 555 | claudeAgent: false, |
| 374 | 556 | }); |
| 375 | setSelectedCustomModelProvider("codex"); | |
| 376 | 557 | setCustomModelInputByProvider({ |
| 377 | 558 | codex: "", |
| 378 | 559 | claudeAgent: "", |
| ... | ... | @@ -461,12 +642,12 @@ function SettingsRouteView() { |
| 461 | 642 | title="Time format" |
| 462 | 643 | description="System default follows your browser or OS clock preference." |
| 463 | 644 | resetAction={ |
| 464 | settings.timestampFormat !== defaults.timestampFormat ? ( | |
| 645 | settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ( | |
| 465 | 646 | <SettingResetButton |
| 466 | 647 | label="time format" |
| 467 | 648 | onClick={() => |
| 468 | 649 | updateSettings({ |
| 469 | timestampFormat: defaults.timestampFormat, | |
| 650 | timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, | |
| 470 | 651 | }) |
| 471 | 652 | } |
| 472 | 653 | /> |
| ... | ... | @@ -506,12 +687,12 @@ function SettingsRouteView() { |
| 506 | 687 | title="Diff line wrapping" |
| 507 | 688 | description="Set the default wrap state when the diff panel opens. The in-panel wrap toggle only affects the current diff session." |
| 508 | 689 | resetAction={ |
| 509 | settings.diffWordWrap !== defaults.diffWordWrap ? ( | |
| 690 | settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap ? ( | |
| 510 | 691 | <SettingResetButton |
| 511 | 692 | label="diff line wrapping" |
| 512 | 693 | onClick={() => |
| 513 | 694 | updateSettings({ |
| 514 | diffWordWrap: defaults.diffWordWrap, | |
| 695 | diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap, | |
| 515 | 696 | }) |
| 516 | 697 | } |
| 517 | 698 | /> |
| ... | ... | @@ -534,12 +715,14 @@ function SettingsRouteView() { |
| 534 | 715 | title="Assistant output" |
| 535 | 716 | description="Show token-by-token output while a response is in progress." |
| 536 | 717 | resetAction={ |
| 537 | settings.enableAssistantStreaming !== defaults.enableAssistantStreaming ? ( | |
| 718 | settings.enableAssistantStreaming !== | |
| 719 | DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? ( | |
| 538 | 720 | <SettingResetButton |
| 539 | 721 | label="assistant output" |
| 540 | 722 | onClick={() => |
| 541 | 723 | updateSettings({ |
| 542 | enableAssistantStreaming: defaults.enableAssistantStreaming, | |
| 724 | enableAssistantStreaming: | |
| 725 | DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, | |
| 543 | 726 | }) |
| 544 | 727 | } |
| 545 | 728 | /> |
| ... | ... | @@ -562,12 +745,13 @@ function SettingsRouteView() { |
| 562 | 745 | title="New threads" |
| 563 | 746 | description="Pick the default workspace mode for newly created draft threads." |
| 564 | 747 | resetAction={ |
| 565 | settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ( | |
| 748 | settings.defaultThreadEnvMode !== | |
| 749 | DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ( | |
| 566 | 750 | <SettingResetButton |
| 567 | 751 | label="new threads" |
| 568 | 752 | onClick={() => |
| 569 | 753 | updateSettings({ |
| 570 | defaultThreadEnvMode: defaults.defaultThreadEnvMode, | |
| 754 | defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, | |
| 571 | 755 | }) |
| 572 | 756 | } |
| 573 | 757 | /> |
| ... | ... | @@ -604,12 +788,12 @@ function SettingsRouteView() { |
| 604 | 788 | title="Delete confirmation" |
| 605 | 789 | description="Ask before deleting a thread and its chat history." |
| 606 | 790 | resetAction={ |
| 607 | settings.confirmThreadDelete !== defaults.confirmThreadDelete ? ( | |
| 791 | settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ( | |
| 608 | 792 | <SettingResetButton |
| 609 | 793 | label="delete confirmation" |
| 610 | 794 | onClick={() => |
| 611 | 795 | updateSettings({ |
| 612 | confirmThreadDelete: defaults.confirmThreadDelete, | |
| 796 | confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, | |
| 613 | 797 | }) |
| 614 | 798 | } |
| 615 | 799 | /> |
| ... | ... | @@ -627,20 +811,18 @@ function SettingsRouteView() { |
| 627 | 811 | /> |
| 628 | 812 | } |
| 629 | 813 | /> |
| 630 | </SettingsSection> | |
| 631 | ||
| 632 | <SettingsSection title="Models"> | |
| 633 | 814 | <SettingsRow |
| 634 | title="Git writing model" | |
| 635 | description="Provider and model used for auto-generated git content." | |
| 815 | title="Text generation model" | |
| 816 | description="Configure the model used for text generation (commit messages, PR content etc.)" | |
| 636 | 817 | resetAction={ |
| 637 | 818 | JSON.stringify(settings.textGenerationModelSelection ?? null) !== |
| 638 | JSON.stringify(defaults.textGenerationModelSelection ?? null) ? ( | |
| 819 | JSON.stringify(DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null) ? ( | |
| 639 | 820 | <SettingResetButton |
| 640 | label="git writing model" | |
| 821 | label="text generation model" | |
| 641 | 822 | onClick={() => { |
| 642 | 823 | updateSettings({ |
| 643 | textGenerationModelSelection: defaults.textGenerationModelSelection, | |
| 824 | textGenerationModelSelection: | |
| 825 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, | |
| 644 | 826 | }); |
| 645 | 827 | }} |
| 646 | 828 | /> |
| ... | ... | @@ -652,20 +834,28 @@ function SettingsRouteView() { |
| 652 | 834 | provider={textGenProvider} |
| 653 | 835 | model={textGenModel} |
| 654 | 836 | lockedProvider={null} |
| 837 | providers={serverProviders} | |
| 655 | 838 | modelOptionsByProvider={gitModelOptionsByProvider} |
| 656 | 839 | triggerVariant="outline" |
| 657 | 840 | triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" |
| 658 | 841 | onProviderModelChange={(provider, model) => { |
| 659 | 842 | updateSettings({ |
| 660 | textGenerationModelSelection: resolveAppModelSelectionState({ | |
| 661 | ...settings, | |
| 662 | textGenerationModelSelection: { provider, model }, | |
| 663 | }), | |
| 843 | textGenerationModelSelection: resolveAppModelSelectionState( | |
| 844 | { | |
| 845 | ...settings, | |
| 846 | textGenerationModelSelection: { provider, model }, | |
| 847 | }, | |
| 848 | serverProviders, | |
| 849 | ), | |
| 664 | 850 | }); |
| 665 | 851 | }} |
| 666 | 852 | /> |
| 667 | 853 | <TraitsPicker |
| 668 | 854 | provider={textGenProvider} |
| 855 | models={ | |
| 856 | serverProviders.find((provider) => provider.provider === textGenProvider) | |
| 857 | ?.models ?? [] | |
| 858 | } | |
| 669 | 859 | model={textGenModel} |
| 670 | 860 | prompt="" |
| 671 | 861 | onPromptChange={() => {}} |
| ... | ... | @@ -675,292 +865,407 @@ function SettingsRouteView() { |
| 675 | 865 | triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" |
| 676 | 866 | onModelOptionsChange={(nextOptions) => { |
| 677 | 867 | updateSettings({ |
| 678 | textGenerationModelSelection: resolveAppModelSelectionState({ | |
| 679 | ...settings, | |
| 680 | textGenerationModelSelection: { | |
| 681 | provider: textGenProvider, | |
| 682 | model: textGenModel, | |
| 683 | ...(nextOptions ? { options: nextOptions } : {}), | |
| 868 | textGenerationModelSelection: resolveAppModelSelectionState( | |
| 869 | { | |
| 870 | ...settings, | |
| 871 | textGenerationModelSelection: { | |
| 872 | provider: textGenProvider, | |
| 873 | model: textGenModel, | |
| 874 | ...(nextOptions ? { options: nextOptions } : {}), | |
| 875 | }, | |
| 684 | 876 | }, |
| 685 | }), | |
| 877 | serverProviders, | |
| 878 | ), | |
| 686 | 879 | }); |
| 687 | 880 | }} |
| 688 | 881 | /> |
| 689 | 882 | </div> |
| 690 | 883 | } |
| 691 | 884 | /> |
| 885 | </SettingsSection> | |
| 692 | 886 | |
| 693 | <SettingsRow | |
| 694 | title="Custom models" | |
| 695 | description="Add custom model slugs for supported providers." | |
| 696 | resetAction={ | |
| 697 | totalCustomModels > 0 ? ( | |
| 698 | <SettingResetButton | |
| 699 | label="custom models" | |
| 700 | onClick={() => { | |
| 701 | updateSettings({ | |
| 702 | customCodexModels: defaults.customCodexModels, | |
| 703 | customClaudeModels: defaults.customClaudeModels, | |
| 704 | }); | |
| 705 | setCustomModelErrorByProvider({}); | |
| 706 | setShowAllCustomModels(false); | |
| 707 | }} | |
| 708 | /> | |
| 709 | ) : null | |
| 710 | } | |
| 711 | > | |
| 712 | <div className="mt-4 border-t border-border pt-4"> | |
| 713 | <div className="flex flex-col gap-2 sm:flex-row sm:items-center"> | |
| 714 | <Select | |
| 715 | value={selectedCustomModelProvider} | |
| 716 | onValueChange={(value) => { | |
| 717 | if (value !== "codex" && value !== "claudeAgent") { | |
| 718 | return; | |
| 719 | } | |
| 720 | setSelectedCustomModelProvider(value); | |
| 721 | }} | |
| 722 | > | |
| 723 | <SelectTrigger | |
| 724 | size="sm" | |
| 725 | className="w-full sm:w-40" | |
| 726 | aria-label="Custom model provider" | |
| 727 | > | |
| 728 | <SelectValue>{selectedCustomModelProviderSettings.title}</SelectValue> | |
| 729 | </SelectTrigger> | |
| 730 | <SelectPopup align="start" alignItemWithTrigger={false}> | |
| 731 | {MODEL_PROVIDER_SETTINGS.map((providerSettings) => ( | |
| 732 | <SelectItem | |
| 733 | hideIndicator | |
| 734 | className="min-h-7 text-sm" | |
| 735 | key={providerSettings.provider} | |
| 736 | value={providerSettings.provider} | |
| 737 | > | |
| 738 | {providerSettings.title} | |
| 739 | </SelectItem> | |
| 740 | ))} | |
| 741 | </SelectPopup> | |
| 742 | </Select> | |
| 743 | <Input | |
| 744 | id="custom-model-slug" | |
| 745 | value={selectedCustomModelInput} | |
| 746 | onChange={(event) => { | |
| 747 | const value = event.target.value; | |
| 748 | setCustomModelInputByProvider((existing) => ({ | |
| 749 | ...existing, | |
| 750 | [selectedCustomModelProvider]: value, | |
| 751 | })); | |
| 752 | if (selectedCustomModelError) { | |
| 753 | setCustomModelErrorByProvider((existing) => ({ | |
| 754 | ...existing, | |
| 755 | [selectedCustomModelProvider]: null, | |
| 756 | })); | |
| 757 | } | |
| 758 | }} | |
| 759 | onKeyDown={(event) => { | |
| 760 | if (event.key !== "Enter") return; | |
| 761 | event.preventDefault(); | |
| 762 | addCustomModel(selectedCustomModelProvider); | |
| 763 | }} | |
| 764 | placeholder={selectedCustomModelProviderSettings.example} | |
| 765 | spellCheck={false} | |
| 766 | /> | |
| 767 | <Button | |
| 768 | className="shrink-0" | |
| 769 | variant="outline" | |
| 770 | onClick={() => addCustomModel(selectedCustomModelProvider)} | |
| 771 | > | |
| 772 | <PlusIcon className="size-3.5" /> | |
| 773 | Add | |
| 774 | </Button> | |
| 775 | </div> | |
| 776 | ||
| 777 | {selectedCustomModelError ? ( | |
| 778 | <p className="mt-2 text-xs text-destructive">{selectedCustomModelError}</p> | |
| 887 | <SettingsSection | |
| 888 | title="Providers" | |
| 889 | headerAction={ | |
| 890 | <div className="flex items-center gap-1.5"> | |
| 891 | {serverProviders.length > 0 ? ( | |
| 892 | <span className="text-[11px] text-muted-foreground/60"> | |
| 893 | {(() => { | |
| 894 | const rel = formatRelativeTime( | |
| 895 | serverProviders.reduce( | |
| 896 | (latest, provider) => | |
| 897 | provider.checkedAt > latest ? provider.checkedAt : latest, | |
| 898 | serverProviders[0]!.checkedAt, | |
| 899 | ), | |
| 900 | ); | |
| 901 | return rel.suffix ? ( | |
| 902 | <> | |
| 903 | Checked <span className="font-mono tabular-nums">{rel.value}</span>{" "} | |
| 904 | {rel.suffix} | |
| 905 | </> | |
| 906 | ) : ( | |
| 907 | <>Checked {rel.value}</> | |
| 908 | ); | |
| 909 | })()} | |
| 910 | </span> | |
| 779 | 911 | ) : null} |
| 912 | <Tooltip> | |
| 913 | <TooltipTrigger | |
| 914 | render={ | |
| 915 | <Button | |
| 916 | size="icon-xs" | |
| 917 | variant="ghost" | |
| 918 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" | |
| 919 | disabled={isRefreshingProviders} | |
| 920 | onClick={() => void refreshProviders()} | |
| 921 | aria-label="Refresh provider status" | |
| 922 | > | |
| 923 | {isRefreshingProviders ? ( | |
| 924 | <LoaderIcon className="size-3 animate-spin" /> | |
| 925 | ) : ( | |
| 926 | <RefreshCwIcon className="size-3" /> | |
| 927 | )} | |
| 928 | </Button> | |
| 929 | } | |
| 930 | /> | |
| 931 | <TooltipPopup side="top">Refresh provider status</TooltipPopup> | |
| 932 | </Tooltip> | |
| 933 | </div> | |
| 934 | } | |
| 935 | > | |
| 936 | {providerCards.map((providerCard) => { | |
| 937 | const customModelInput = customModelInputByProvider[providerCard.provider]; | |
| 938 | const customModelError = customModelErrorByProvider[providerCard.provider] ?? null; | |
| 939 | const providerDisplayName = | |
| 940 | PROVIDER_DISPLAY_NAMES[providerCard.provider] ?? providerCard.title; | |
| 780 | 941 | |
| 781 | {totalCustomModels > 0 ? ( | |
| 782 | <div className="mt-3"> | |
| 783 | <div> | |
| 784 | {visibleCustomModelRows.map((row) => ( | |
| 785 | <div | |
| 786 | key={row.key} | |
| 787 | className="group grid grid-cols-[minmax(5rem,6rem)_minmax(0,1fr)_auto] items-center gap-3 border-t border-border/60 px-4 py-2 first:border-t-0" | |
| 788 | > | |
| 789 | <span className="truncate text-xs text-muted-foreground"> | |
| 790 | {row.providerTitle} | |
| 942 | return ( | |
| 943 | <div | |
| 944 | key={providerCard.provider} | |
| 945 | className="border-t border-border first:border-t-0" | |
| 946 | data-slot="settings-row" | |
| 947 | > | |
| 948 | <div className="px-4 py-4 sm:px-5"> | |
| 949 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> | |
| 950 | <div className="min-w-0 flex-1 space-y-1"> | |
| 951 | <div className="flex min-h-5 items-center gap-1.5"> | |
| 952 | <span | |
| 953 | className={cn( | |
| 954 | "size-2 shrink-0 rounded-full", | |
| 955 | providerCard.statusStyle.dot, | |
| 956 | )} | |
| 957 | /> | |
| 958 | <h3 className="text-sm font-medium text-foreground"> | |
| 959 | {providerDisplayName} | |
| 960 | </h3> | |
| 961 | {providerCard.versionLabel ? ( | |
| 962 | <code className="text-xs text-muted-foreground"> | |
| 963 | {providerCard.versionLabel} | |
| 964 | </code> | |
| 965 | ) : null} | |
| 966 | <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center"> | |
| 967 | {providerCard.isDirty ? ( | |
| 968 | <SettingResetButton | |
| 969 | label={`${providerDisplayName} provider settings`} | |
| 970 | onClick={() => { | |
| 971 | updateSettings({ | |
| 972 | providers: { | |
| 973 | ...settings.providers, | |
| 974 | [providerCard.provider]: | |
| 975 | DEFAULT_UNIFIED_SETTINGS.providers[providerCard.provider], | |
| 976 | }, | |
| 977 | }); | |
| 978 | setCustomModelErrorByProvider((existing) => ({ | |
| 979 | ...existing, | |
| 980 | [providerCard.provider]: null, | |
| 981 | })); | |
| 982 | }} | |
| 983 | /> | |
| 984 | ) : null} | |
| 791 | 985 | </span> |
| 792 | <code className="min-w-0 truncate text-sm text-foreground"> | |
| 793 | {row.slug} | |
| 794 | </code> | |
| 795 | <button | |
| 796 | type="button" | |
| 797 | className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100 hover:opacity-100" | |
| 798 | aria-label={`Remove ${row.slug}`} | |
| 799 | onClick={() => removeCustomModel(row.provider, row.slug)} | |
| 800 | > | |
| 801 | <XIcon className="size-3.5 text-muted-foreground hover:text-foreground" /> | |
| 802 | </button> | |
| 803 | 986 | </div> |
| 804 | ))} | |
| 987 | <p className="text-xs text-muted-foreground"> | |
| 988 | {providerCard.summary.headline} | |
| 989 | {providerCard.summary.detail | |
| 990 | ? ` — ${providerCard.summary.detail}` | |
| 991 | : null} | |
| 992 | </p> | |
| 993 | </div> | |
| 994 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> | |
| 995 | <Button | |
| 996 | size="sm" | |
| 997 | variant="ghost" | |
| 998 | className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground" | |
| 999 | onClick={() => | |
| 1000 | setOpenProviderDetails((existing) => ({ | |
| 1001 | ...existing, | |
| 1002 | [providerCard.provider]: !existing[providerCard.provider], | |
| 1003 | })) | |
| 1004 | } | |
| 1005 | aria-label={`Toggle ${providerDisplayName} details`} | |
| 1006 | > | |
| 1007 | <ChevronDownIcon | |
| 1008 | className={cn( | |
| 1009 | "size-3.5 transition-transform", | |
| 1010 | openProviderDetails[providerCard.provider] && "rotate-180", | |
| 1011 | )} | |
| 1012 | /> | |
| 1013 | </Button> | |
| 1014 | <Switch | |
| 1015 | checked={providerCard.providerConfig.enabled} | |
| 1016 | onCheckedChange={(checked) => { | |
| 1017 | const isDisabling = !checked; | |
| 1018 | // The resolved provider accounts for both explicit | |
| 1019 | // selection and the implicit default (codex). | |
| 1020 | const resolvedProvider = textGenProvider; | |
| 1021 | // When disabling the provider that's currently used for | |
| 1022 | // text generation, clear the selection so it falls back to | |
| 1023 | // the next available provider's default model. | |
| 1024 | const shouldClearModelSelection = | |
| 1025 | isDisabling && resolvedProvider === providerCard.provider; | |
| 1026 | updateSettings({ | |
| 1027 | providers: { | |
| 1028 | ...settings.providers, | |
| 1029 | [providerCard.provider]: { | |
| 1030 | ...settings.providers[providerCard.provider], | |
| 1031 | enabled: Boolean(checked), | |
| 1032 | }, | |
| 1033 | }, | |
| 1034 | ...(shouldClearModelSelection | |
| 1035 | ? { | |
| 1036 | textGenerationModelSelection: | |
| 1037 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, | |
| 1038 | } | |
| 1039 | : {}), | |
| 1040 | }); | |
| 1041 | }} | |
| 1042 | aria-label={`Enable ${providerDisplayName}`} | |
| 1043 | /> | |
| 1044 | </div> | |
| 805 | 1045 | </div> |
| 806 | ||
| 807 | {savedCustomModelRows.length > 5 ? ( | |
| 808 | <button | |
| 809 | type="button" | |
| 810 | className="mt-2 text-xs text-muted-foreground transition-colors hover:text-foreground" | |
| 811 | onClick={() => setShowAllCustomModels((value) => !value)} | |
| 812 | > | |
| 813 | {showAllCustomModels | |
| 814 | ? "Show less" | |
| 815 | : `Show more (${savedCustomModelRows.length - 5})`} | |
| 816 | </button> | |
| 817 | ) : null} | |
| 818 | 1046 | </div> |
| 819 | ) : null} | |
| 820 | </div> | |
| 821 | </SettingsRow> | |
| 822 | </SettingsSection> | |
| 823 | 1047 | |
| 824 | <SettingsSection title="Advanced"> | |
| 825 | <SettingsRow | |
| 826 | title="Provider installs" | |
| 827 | description="Override the CLI used for new sessions." | |
| 828 | resetAction={ | |
| 829 | isInstallSettingsDirty ? ( | |
| 830 | <SettingResetButton | |
| 831 | label="provider installs" | |
| 832 | onClick={() => { | |
| 833 | updateSettings({ | |
| 834 | claudeBinaryPath: defaults.claudeBinaryPath, | |
| 835 | codexBinaryPath: defaults.codexBinaryPath, | |
| 836 | codexHomePath: defaults.codexHomePath, | |
| 837 | }); | |
| 838 | setOpenInstallProviders({ | |
| 839 | codex: false, | |
| 840 | claudeAgent: false, | |
| 841 | }); | |
| 842 | }} | |
| 843 | /> | |
| 844 | ) : null | |
| 845 | } | |
| 846 | > | |
| 847 | <div className="mt-4"> | |
| 848 | <div className="space-y-2"> | |
| 849 | {INSTALL_PROVIDER_SETTINGS.map((providerSettings) => { | |
| 850 | const isOpen = openInstallProviders[providerSettings.provider]; | |
| 851 | const isDirty = | |
| 852 | providerSettings.provider === "codex" | |
| 853 | ? settings.codexBinaryPath !== defaults.codexBinaryPath || | |
| 854 | settings.codexHomePath !== defaults.codexHomePath | |
| 855 | : settings.claudeBinaryPath !== defaults.claudeBinaryPath; | |
| 856 | const binaryPathValue = | |
| 857 | providerSettings.binaryPathKey === "claudeBinaryPath" | |
| 858 | ? claudeBinaryPath | |
| 859 | : codexBinaryPath; | |
| 860 | ||
| 861 | return ( | |
| 862 | <Collapsible | |
| 863 | key={providerSettings.provider} | |
| 864 | open={isOpen} | |
| 865 | onOpenChange={(open) => | |
| 866 | setOpenInstallProviders((existing) => ({ | |
| 867 | ...existing, | |
| 868 | [providerSettings.provider]: open, | |
| 869 | })) | |
| 870 | } | |
| 871 | > | |
| 872 | <div className="overflow-hidden rounded-xl border border-border/70"> | |
| 873 | <button | |
| 874 | type="button" | |
| 875 | className="flex w-full items-center gap-3 px-4 py-3 text-left" | |
| 876 | onClick={() => | |
| 877 | setOpenInstallProviders((existing) => ({ | |
| 878 | ...existing, | |
| 879 | [providerSettings.provider]: !existing[providerSettings.provider], | |
| 880 | })) | |
| 881 | } | |
| 1048 | <Collapsible | |
| 1049 | open={openProviderDetails[providerCard.provider]} | |
| 1050 | onOpenChange={(open) => | |
| 1051 | setOpenProviderDetails((existing) => ({ | |
| 1052 | ...existing, | |
| 1053 | [providerCard.provider]: open, | |
| 1054 | })) | |
| 1055 | } | |
| 1056 | > | |
| 1057 | <CollapsibleContent> | |
| 1058 | <div className="space-y-0"> | |
| 1059 | {/* Binary path */} | |
| 1060 | <div className="border-t border-border/60 px-4 py-3 sm:px-5"> | |
| 1061 | <label | |
| 1062 | htmlFor={`provider-install-${providerCard.provider}-binary-path`} | |
| 1063 | className="block" | |
| 882 | 1064 | > |
| 883 | <span className="min-w-0 flex-1 text-sm font-medium text-foreground"> | |
| 884 | {providerSettings.title} | |
| 1065 | <span className="text-xs font-medium text-foreground"> | |
| 1066 | {providerDisplayName} binary path | |
| 885 | 1067 | </span> |
| 886 | {isDirty ? ( | |
| 887 | <span className="text-[11px] text-muted-foreground">Custom</span> | |
| 888 | ) : null} | |
| 889 | <ChevronDownIcon | |
| 890 | className={cn( | |
| 891 | "size-4 shrink-0 text-muted-foreground transition-transform", | |
| 892 | isOpen && "rotate-180", | |
| 893 | )} | |
| 1068 | <Input | |
| 1069 | id={`provider-install-${providerCard.provider}-binary-path`} | |
| 1070 | className="mt-1.5" | |
| 1071 | value={providerCard.binaryPathValue} | |
| 1072 | onChange={(event) => | |
| 1073 | updateSettings({ | |
| 1074 | providers: { | |
| 1075 | ...settings.providers, | |
| 1076 | [providerCard.provider]: { | |
| 1077 | ...settings.providers[providerCard.provider], | |
| 1078 | binaryPath: event.target.value, | |
| 1079 | }, | |
| 1080 | }, | |
| 1081 | }) | |
| 1082 | } | |
| 1083 | placeholder={providerCard.binaryPlaceholder} | |
| 1084 | spellCheck={false} | |
| 894 | 1085 | /> |
| 895 | </button> | |
| 896 | ||
| 897 | <CollapsibleContent> | |
| 898 | <div className="border-t border-border/70 px-4 py-4"> | |
| 899 | <div className="space-y-3"> | |
| 900 | <label | |
| 901 | htmlFor={`provider-install-${providerSettings.binaryPathKey}`} | |
| 902 | className="block" | |
| 1086 | <span className="mt-1 block text-xs text-muted-foreground"> | |
| 1087 | {providerCard.binaryDescription} | |
| 1088 | </span> | |
| 1089 | </label> | |
| 1090 | </div> | |
| 1091 | ||
| 1092 | {/* Home path (Codex only) */} | |
| 1093 | {providerCard.homePathKey ? ( | |
| 1094 | <div className="border-t border-border/60 px-4 py-3 sm:px-5"> | |
| 1095 | <label | |
| 1096 | htmlFor={`provider-install-${providerCard.homePathKey}`} | |
| 1097 | className="block" | |
| 1098 | > | |
| 1099 | <span className="text-xs font-medium text-foreground"> | |
| 1100 | CODEX_HOME path | |
| 1101 | </span> | |
| 1102 | <Input | |
| 1103 | id={`provider-install-${providerCard.homePathKey}`} | |
| 1104 | className="mt-1.5" | |
| 1105 | value={codexHomePath} | |
| 1106 | onChange={(event) => | |
| 1107 | updateSettings({ | |
| 1108 | providers: { | |
| 1109 | ...settings.providers, | |
| 1110 | codex: { | |
| 1111 | ...settings.providers.codex, | |
| 1112 | homePath: event.target.value, | |
| 1113 | }, | |
| 1114 | }, | |
| 1115 | }) | |
| 1116 | } | |
| 1117 | placeholder={providerCard.homePlaceholder} | |
| 1118 | spellCheck={false} | |
| 1119 | /> | |
| 1120 | {providerCard.homeDescription ? ( | |
| 1121 | <span className="mt-1 block text-xs text-muted-foreground"> | |
| 1122 | {providerCard.homeDescription} | |
| 1123 | </span> | |
| 1124 | ) : null} | |
| 1125 | </label> | |
| 1126 | </div> | |
| 1127 | ) : null} | |
| 1128 | ||
| 1129 | {/* Models */} | |
| 1130 | <div className="border-t border-border/60 px-4 py-3 sm:px-5"> | |
| 1131 | <div className="text-xs font-medium text-foreground">Models</div> | |
| 1132 | <div className="mt-1 text-xs text-muted-foreground"> | |
| 1133 | {providerCard.models.length} model | |
| 1134 | {providerCard.models.length === 1 ? "" : "s"} available. | |
| 1135 | </div> | |
| 1136 | <div | |
| 1137 | ref={(el) => { | |
| 1138 | modelListRefs.current[providerCard.provider] = el; | |
| 1139 | }} | |
| 1140 | className="mt-2 max-h-40 overflow-y-auto pb-1" | |
| 1141 | > | |
| 1142 | {providerCard.models.map((model) => { | |
| 1143 | const caps = model.capabilities; | |
| 1144 | const capLabels: string[] = []; | |
| 1145 | if (caps?.supportsFastMode) capLabels.push("Fast mode"); | |
| 1146 | if (caps?.supportsThinkingToggle) capLabels.push("Thinking"); | |
| 1147 | if ( | |
| 1148 | caps?.reasoningEffortLevels && | |
| 1149 | caps.reasoningEffortLevels.length > 0 | |
| 1150 | ) | |
| 1151 | capLabels.push("Reasoning"); | |
| 1152 | const hasDetails = | |
| 1153 | capLabels.length > 0 || model.name !== model.slug; | |
| 1154 | ||
| 1155 | return ( | |
| 1156 | <div | |
| 1157 | key={`${providerCard.provider}:${model.slug}`} | |
| 1158 | className="flex items-center gap-2 py-1" | |
| 903 | 1159 | > |
| 904 | <span className="block text-xs font-medium text-foreground"> | |
| 905 | {providerSettings.title} binary path | |
| 1160 | <span className="min-w-0 truncate text-xs text-foreground/90"> | |
| 1161 | {model.name} | |
| 906 | 1162 | </span> |
| 907 | <Input | |
| 908 | id={`provider-install-${providerSettings.binaryPathKey}`} | |
| 909 | className="mt-1" | |
| 910 | value={binaryPathValue} | |
| 911 | onChange={(event) => | |
| 912 | updateSettings( | |
| 913 | providerSettings.binaryPathKey === "claudeBinaryPath" | |
| 914 | ? { claudeBinaryPath: event.target.value } | |
| 915 | : { codexBinaryPath: event.target.value }, | |
| 916 | ) | |
| 917 | } | |
| 918 | placeholder={providerSettings.binaryPlaceholder} | |
| 919 | spellCheck={false} | |
| 920 | /> | |
| 921 | <span className="mt-1 block text-xs text-muted-foreground"> | |
| 922 | {providerSettings.binaryDescription} | |
| 923 | </span> | |
| 924 | </label> | |
| 925 | ||
| 926 | {providerSettings.homePathKey ? ( | |
| 927 | <label | |
| 928 | htmlFor={`provider-install-${providerSettings.homePathKey}`} | |
| 929 | className="block" | |
| 930 | > | |
| 931 | <span className="block text-xs font-medium text-foreground"> | |
| 932 | CODEX_HOME path | |
| 933 | </span> | |
| 934 | <Input | |
| 935 | id={`provider-install-${providerSettings.homePathKey}`} | |
| 936 | className="mt-1" | |
| 937 | value={codexHomePath} | |
| 938 | onChange={(event) => | |
| 939 | updateSettings({ | |
| 940 | codexHomePath: event.target.value, | |
| 941 | }) | |
| 942 | } | |
| 943 | placeholder={providerSettings.homePlaceholder} | |
| 944 | spellCheck={false} | |
| 945 | /> | |
| 946 | {providerSettings.homeDescription ? ( | |
| 947 | <span className="mt-1 block text-xs text-muted-foreground"> | |
| 948 | {providerSettings.homeDescription} | |
| 1163 | {hasDetails ? ( | |
| 1164 | <Tooltip> | |
| 1165 | <TooltipTrigger | |
| 1166 | render={ | |
| 1167 | <button | |
| 1168 | type="button" | |
| 1169 | className="shrink-0 text-muted-foreground/40 transition-colors hover:text-muted-foreground" | |
| 1170 | aria-label={`Details for ${model.name}`} | |
| 1171 | /> | |
| 1172 | } | |
| 1173 | > | |
| 1174 | <InfoIcon className="size-3" /> | |
| 1175 | </TooltipTrigger> | |
| 1176 | <TooltipPopup side="top" className="max-w-56"> | |
| 1177 | <div className="space-y-1"> | |
| 1178 | <code className="block text-[11px] text-foreground"> | |
| 1179 | {model.slug} | |
| 1180 | </code> | |
| 1181 | {capLabels.length > 0 ? ( | |
| 1182 | <div className="flex flex-wrap gap-x-2 gap-y-0.5"> | |
| 1183 | {capLabels.map((label) => ( | |
| 1184 | <span | |
| 1185 | key={label} | |
| 1186 | className="text-[10px] text-muted-foreground" | |
| 1187 | > | |
| 1188 | {label} | |
| 1189 | </span> | |
| 1190 | ))} | |
| 1191 | </div> | |
| 1192 | ) : null} | |
| 1193 | </div> | |
| 1194 | </TooltipPopup> | |
| 1195 | </Tooltip> | |
| 1196 | ) : null} | |
| 1197 | {model.isCustom ? ( | |
| 1198 | <div className="ml-auto flex shrink-0 items-center gap-1.5"> | |
| 1199 | <span className="text-[10px] text-muted-foreground"> | |
| 1200 | custom | |
| 949 | 1201 | </span> |
| 950 | ) : null} | |
| 951 | </label> | |
| 952 | ) : null} | |
| 953 | </div> | |
| 954 | </div> | |
| 955 | </CollapsibleContent> | |
| 1202 | <button | |
| 1203 | type="button" | |
| 1204 | className="text-muted-foreground transition-colors hover:text-foreground" | |
| 1205 | aria-label={`Remove ${model.slug}`} | |
| 1206 | onClick={() => | |
| 1207 | removeCustomModel(providerCard.provider, model.slug) | |
| 1208 | } | |
| 1209 | > | |
| 1210 | <XIcon className="size-3" /> | |
| 1211 | </button> | |
| 1212 | </div> | |
| 1213 | ) : null} | |
| 1214 | </div> | |
| 1215 | ); | |
| 1216 | })} | |
| 1217 | </div> | |
| 1218 | <div className="mt-3 flex flex-col gap-2 sm:flex-row"> | |
| 1219 | <Input | |
| 1220 | id={`custom-model-${providerCard.provider}`} | |
| 1221 | value={customModelInput} | |
| 1222 | onChange={(event) => { | |
| 1223 | const value = event.target.value; | |
| 1224 | setCustomModelInputByProvider((existing) => ({ | |
| 1225 | ...existing, | |
| 1226 | [providerCard.provider]: value, | |
| 1227 | })); | |
| 1228 | if (customModelError) { | |
| 1229 | setCustomModelErrorByProvider((existing) => ({ | |
| 1230 | ...existing, | |
| 1231 | [providerCard.provider]: null, | |
| 1232 | })); | |
| 1233 | } | |
| 1234 | }} | |
| 1235 | onKeyDown={(event) => { | |
| 1236 | if (event.key !== "Enter") return; | |
| 1237 | event.preventDefault(); | |
| 1238 | addCustomModel(providerCard.provider); | |
| 1239 | }} | |
| 1240 | placeholder={ | |
| 1241 | providerCard.provider === "codex" | |
| 1242 | ? "gpt-6.7-codex-ultra-preview" | |
| 1243 | : "claude-sonnet-5-0" | |
| 1244 | } | |
| 1245 | spellCheck={false} | |
| 1246 | /> | |
| 1247 | <Button | |
| 1248 | className="shrink-0" | |
| 1249 | variant="outline" | |
| 1250 | onClick={() => addCustomModel(providerCard.provider)} | |
| 1251 | > | |
| 1252 | <PlusIcon className="size-3.5" /> | |
| 1253 | Add | |
| 1254 | </Button> | |
| 1255 | </div> | |
| 1256 | {customModelError ? ( | |
| 1257 | <p className="mt-2 text-xs text-destructive">{customModelError}</p> | |
| 1258 | ) : null} | |
| 956 | 1259 | </div> |
| 957 | </Collapsible> | |
| 958 | ); | |
| 959 | })} | |
| 1260 | </div> | |
| 1261 | </CollapsibleContent> | |
| 1262 | </Collapsible> | |
| 960 | 1263 | </div> |
| 961 | </div> | |
| 962 | </SettingsRow> | |
| 1264 | ); | |
| 1265 | })} | |
| 1266 | </SettingsSection> | |
| 963 | 1267 | |
| 1268 | <SettingsSection title="Advanced"> | |
| 964 | 1269 | <SettingsRow |
| 965 | 1270 | title="Keybindings" |
| 966 | 1271 | description="Open the persisted `keybindings.json` file to edit advanced bindings directly." |
apps/web/src/routes/_chat.tsx+2-2| ... | ... | @@ -11,7 +11,7 @@ import { resolveShortcutCommand } from "../keybindings"; |
| 11 | 11 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 12 | 12 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 13 | 13 | import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; |
| 14 | import { useAppSettings } from "~/appSettings"; | |
| 14 | import { useSettings } from "~/hooks/useSettings"; | |
| 15 | 15 | import { Sidebar, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; |
| 16 | 16 | |
| 17 | 17 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| ... | ... | @@ -31,7 +31,7 @@ function ChatRouteGlobalShortcuts() { |
| 31 | 31 | ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen |
| 32 | 32 | : false, |
| 33 | 33 | ); |
| 34 | const { settings: appSettings } = useAppSettings(); | |
| 34 | const appSettings = useSettings(); | |
| 35 | 35 | |
| 36 | 36 | useEffect(() => { |
| 37 | 37 | const onWindowKeyDown = (event: KeyboardEvent) => { |
apps/web/src/timestampFormat.ts+20-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { type TimestampFormat } from "./appSettings"; | |
| 1 | import { type TimestampFormat } from "@t3tools/contracts/settings"; | |
| 2 | 2 | |
| 3 | 3 | export function getTimestampFormatOptions( |
| 4 | 4 | timestampFormat: TimestampFormat, |
| ... | ... | @@ -47,3 +47,22 @@ export function formatTimestamp(isoDate: string, timestampFormat: TimestampForma |
| 47 | 47 | export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { |
| 48 | 48 | return getTimestampFormatter(timestampFormat, false).format(new Date(isoDate)); |
| 49 | 49 | } |
| 50 | ||
| 51 | /** | |
| 52 | * Format a relative time string from an ISO date. | |
| 53 | * Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }` | |
| 54 | * so callers can style the numeric portion independently. | |
| 55 | */ | |
| 56 | export function formatRelativeTime(isoDate: string): { value: string; suffix: string | null } { | |
| 57 | const diffMs = Date.now() - new Date(isoDate).getTime(); | |
| 58 | if (diffMs < 0) return { value: "just now", suffix: null }; | |
| 59 | const seconds = Math.floor(diffMs / 1000); | |
| 60 | if (seconds < 5) return { value: "just now", suffix: null }; | |
| 61 | if (seconds < 60) return { value: `${seconds}s`, suffix: "ago" }; | |
| 62 | const minutes = Math.floor(seconds / 60); | |
| 63 | if (minutes < 60) return { value: `${minutes}m`, suffix: "ago" }; | |
| 64 | const hours = Math.floor(minutes / 60); | |
| 65 | if (hours < 24) return { value: `${hours}h`, suffix: "ago" }; | |
| 66 | const days = Math.floor(hours / 24); | |
| 67 | return { value: `${days}d`, suffix: "ago" }; | |
| 68 | } |
apps/web/src/wsNativeApi.test.ts+27-15| ... | ... | @@ -13,7 +13,7 @@ import { |
| 13 | 13 | WS_CHANNELS, |
| 14 | 14 | WS_METHODS, |
| 15 | 15 | type WsPush, |
| 16 | type ServerProviderStatus, | |
| 16 | type ServerProvider, | |
| 17 | 17 | } from "@t3tools/contracts"; |
| 18 | 18 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 19 | 19 | |
| ... | ... | @@ -92,13 +92,16 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn |
| 92 | 92 | return testGlobal.window; |
| 93 | 93 | } |
| 94 | 94 | |
| 95 | const defaultProviders: ReadonlyArray<ServerProviderStatus> = [ | |
| 95 | const defaultProviders: ReadonlyArray<ServerProvider> = [ | |
| 96 | 96 | { |
| 97 | 97 | provider: "codex", |
| 98 | enabled: true, | |
| 99 | installed: true, | |
| 100 | version: "0.116.0", | |
| 98 | 101 | status: "ready", |
| 99 | available: true, | |
| 100 | 102 | authStatus: "authenticated", |
| 101 | 103 | checkedAt: "2026-01-01T00:00:00.000Z", |
| 104 | models: [], | |
| 102 | 105 | }, |
| 103 | 106 | ]; |
| 104 | 107 | |
| ... | ... | @@ -197,7 +200,6 @@ describe("wsNativeApi", () => { |
| 197 | 200 | message: "Entry at index 1 is invalid.", |
| 198 | 201 | }, |
| 199 | 202 | ], |
| 200 | providers: defaultProviders, | |
| 201 | 203 | } as const; |
| 202 | 204 | emitPush(WS_CHANNELS.serverConfigUpdated, payload); |
| 203 | 205 | |
| ... | ... | @@ -219,20 +221,38 @@ describe("wsNativeApi", () => { |
| 219 | 221 | |
| 220 | 222 | emitPush(WS_CHANNELS.serverConfigUpdated, { |
| 221 | 223 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], |
| 222 | providers: defaultProviders, | |
| 223 | 224 | }); |
| 224 | 225 | emitPush(WS_CHANNELS.serverConfigUpdated, { |
| 225 | 226 | issues: [], |
| 226 | providers: defaultProviders, | |
| 227 | 227 | }); |
| 228 | 228 | |
| 229 | 229 | expect(listener).toHaveBeenCalledTimes(2); |
| 230 | 230 | expect(listener).toHaveBeenLastCalledWith({ |
| 231 | 231 | issues: [], |
| 232 | providers: defaultProviders, | |
| 233 | 232 | }); |
| 234 | 233 | }); |
| 235 | 234 | |
| 235 | it("delivers and caches valid server.providersUpdated payloads", async () => { | |
| 236 | const { createWsNativeApi, onServerProvidersUpdated } = await import("./wsNativeApi"); | |
| 237 | ||
| 238 | createWsNativeApi(); | |
| 239 | const listener = vi.fn(); | |
| 240 | onServerProvidersUpdated(listener); | |
| 241 | ||
| 242 | const payload = { | |
| 243 | providers: defaultProviders, | |
| 244 | } as const; | |
| 245 | emitPush(WS_CHANNELS.serverProvidersUpdated, payload); | |
| 246 | ||
| 247 | expect(listener).toHaveBeenCalledTimes(1); | |
| 248 | expect(listener).toHaveBeenCalledWith(payload); | |
| 249 | ||
| 250 | const lateListener = vi.fn(); | |
| 251 | onServerProvidersUpdated(lateListener); | |
| 252 | expect(lateListener).toHaveBeenCalledTimes(1); | |
| 253 | expect(lateListener).toHaveBeenCalledWith(payload); | |
| 254 | }); | |
| 255 | ||
| 236 | 256 | it("forwards valid terminal and orchestration events", async () => { |
| 237 | 257 | const { createWsNativeApi } = await import("./wsNativeApi"); |
| 238 | 258 | |
| ... | ... | @@ -357,10 +377,6 @@ describe("wsNativeApi", () => { |
| 357 | 377 | actionId: "action-1", |
| 358 | 378 | cwd: "/repo", |
| 359 | 379 | action: "commit", |
| 360 | modelSelection: { | |
| 361 | provider: "codex", | |
| 362 | model: "gpt-5.4-mini", | |
| 363 | }, | |
| 364 | 380 | }); |
| 365 | 381 | |
| 366 | 382 | expect(requestMock).toHaveBeenCalledWith( |
| ... | ... | @@ -369,10 +385,6 @@ describe("wsNativeApi", () => { |
| 369 | 385 | actionId: "action-1", |
| 370 | 386 | cwd: "/repo", |
| 371 | 387 | action: "commit", |
| 372 | modelSelection: { | |
| 373 | provider: "codex", | |
| 374 | model: "gpt-5.4-mini", | |
| 375 | }, | |
| 376 | 388 | }, |
| 377 | 389 | { timeoutMs: null }, |
| 378 | 390 | ); |
apps/web/src/wsNativeApi.ts+35| ... | ... | @@ -5,6 +5,7 @@ import { |
| 5 | 5 | type ContextMenuItem, |
| 6 | 6 | type NativeApi, |
| 7 | 7 | ServerConfigUpdatedPayload, |
| 8 | ServerProviderUpdatedPayload, | |
| 8 | 9 | WS_CHANNELS, |
| 9 | 10 | WS_METHODS, |
| 10 | 11 | type WsWelcomePayload, |
| ... | ... | @@ -16,6 +17,7 @@ import { WsTransport } from "./wsTransport"; |
| 16 | 17 | let instance: { api: NativeApi; transport: WsTransport } | null = null; |
| 17 | 18 | const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>(); |
| 18 | 19 | const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>(); |
| 20 | const providersUpdatedListeners = new Set<(payload: ServerProviderUpdatedPayload) => void>(); | |
| 19 | 21 | const gitActionProgressListeners = new Set<(payload: GitActionProgressEvent) => void>(); |
| 20 | 22 | |
| 21 | 23 | /** |
| ... | ... | @@ -64,6 +66,26 @@ export function onServerConfigUpdated( |
| 64 | 66 | }; |
| 65 | 67 | } |
| 66 | 68 | |
| 69 | export function onServerProvidersUpdated( | |
| 70 | listener: (payload: ServerProviderUpdatedPayload) => void, | |
| 71 | ): () => void { | |
| 72 | providersUpdatedListeners.add(listener); | |
| 73 | ||
| 74 | const latestProviders = | |
| 75 | instance?.transport.getLatestPush(WS_CHANNELS.serverProvidersUpdated)?.data ?? null; | |
| 76 | if (latestProviders) { | |
| 77 | try { | |
| 78 | listener(latestProviders); | |
| 79 | } catch { | |
| 80 | // Swallow listener errors | |
| 81 | } | |
| 82 | } | |
| 83 | ||
| 84 | return () => { | |
| 85 | providersUpdatedListeners.delete(listener); | |
| 86 | }; | |
| 87 | } | |
| 88 | ||
| 67 | 89 | export function createWsNativeApi(): NativeApi { |
| 68 | 90 | if (instance) return instance.api; |
| 69 | 91 | |
| ... | ... | @@ -89,6 +111,16 @@ export function createWsNativeApi(): NativeApi { |
| 89 | 111 | } |
| 90 | 112 | } |
| 91 | 113 | }); |
| 114 | transport.subscribe(WS_CHANNELS.serverProvidersUpdated, (message) => { | |
| 115 | const payload = message.data; | |
| 116 | for (const listener of providersUpdatedListeners) { | |
| 117 | try { | |
| 118 | listener(payload); | |
| 119 | } catch { | |
| 120 | // Swallow listener errors | |
| 121 | } | |
| 122 | } | |
| 123 | }); | |
| 92 | 124 | transport.subscribe(WS_CHANNELS.gitActionProgress, (message) => { |
| 93 | 125 | const payload = message.data; |
| 94 | 126 | for (const listener of gitActionProgressListeners) { |
| ... | ... | @@ -178,7 +210,10 @@ export function createWsNativeApi(): NativeApi { |
| 178 | 210 | }, |
| 179 | 211 | server: { |
| 180 | 212 | getConfig: () => transport.request(WS_METHODS.serverGetConfig), |
| 213 | refreshProviders: () => transport.request(WS_METHODS.serverRefreshProviders), | |
| 181 | 214 | upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), |
| 215 | getSettings: () => transport.request(WS_METHODS.serverGetSettings), | |
| 216 | updateSettings: (patch) => transport.request(WS_METHODS.serverUpdateSettings, { patch }), | |
| 182 | 217 | }, |
| 183 | 218 | orchestration: { |
| 184 | 219 | getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot), |
apps/web/src/wsTransport.test.ts+4-4| ... | ... | @@ -98,7 +98,7 @@ describe("WsTransport", () => { |
| 98 | 98 | type: "push", |
| 99 | 99 | sequence: 1, |
| 100 | 100 | channel: WS_CHANNELS.serverConfigUpdated, |
| 101 | data: { issues: [], providers: [] }, | |
| 101 | data: { issues: [] }, | |
| 102 | 102 | }), |
| 103 | 103 | ); |
| 104 | 104 | |
| ... | ... | @@ -107,7 +107,7 @@ describe("WsTransport", () => { |
| 107 | 107 | type: "push", |
| 108 | 108 | sequence: 1, |
| 109 | 109 | channel: WS_CHANNELS.serverConfigUpdated, |
| 110 | data: { issues: [], providers: [] }, | |
| 110 | data: { issues: [] }, | |
| 111 | 111 | }); |
| 112 | 112 | |
| 113 | 113 | transport.dispose(); |
| ... | ... | @@ -160,7 +160,7 @@ describe("WsTransport", () => { |
| 160 | 160 | type: "push", |
| 161 | 161 | sequence: 3, |
| 162 | 162 | channel: WS_CHANNELS.serverConfigUpdated, |
| 163 | data: { issues: [], providers: [] }, | |
| 163 | data: { issues: [] }, | |
| 164 | 164 | }), |
| 165 | 165 | ); |
| 166 | 166 | |
| ... | ... | @@ -169,7 +169,7 @@ describe("WsTransport", () => { |
| 169 | 169 | type: "push", |
| 170 | 170 | sequence: 3, |
| 171 | 171 | channel: WS_CHANNELS.serverConfigUpdated, |
| 172 | data: { issues: [], providers: [] }, | |
| 172 | data: { issues: [] }, | |
| 173 | 173 | }); |
| 174 | 174 | expect(warnSpy).toHaveBeenCalledTimes(2); |
| 175 | 175 | expect(warnSpy).toHaveBeenNthCalledWith( |
packages/contracts/package.json+5| ... | ... | @@ -10,6 +10,11 @@ |
| 10 | 10 | "module": "./dist/index.mjs", |
| 11 | 11 | "types": "./src/index.ts", |
| 12 | 12 | "exports": { |
| 13 | "./settings": { | |
| 14 | "types": "./src/settings.ts", | |
| 15 | "import": "./src/settings.ts", | |
| 16 | "require": "./src/settings.ts" | |
| 17 | }, | |
| 13 | 18 | ".": { |
| 14 | 19 | "types": "./src/index.ts", |
| 15 | 20 | "import": "./src/index.ts", |
packages/contracts/src/git.test.ts-15| ... | ... | @@ -74,19 +74,4 @@ describe("GitRunStackedActionInput", () => { |
| 74 | 74 | expect(parsed.actionId).toBe("action-1"); |
| 75 | 75 | expect(parsed.action).toBe("commit"); |
| 76 | 76 | }); |
| 77 | ||
| 78 | it("accepts git text generation as a modelSelection", () => { | |
| 79 | const parsed = decodeRunStackedActionInput({ | |
| 80 | actionId: "action-1", | |
| 81 | cwd: "/repo", | |
| 82 | action: "commit_push_pr", | |
| 83 | modelSelection: { | |
| 84 | provider: "claudeAgent", | |
| 85 | model: "claude-haiku-4-5", | |
| 86 | }, | |
| 87 | }); | |
| 88 | ||
| 89 | expect(parsed.modelSelection?.provider).toBe("claudeAgent"); | |
| 90 | expect(parsed.modelSelection?.model).toBe("claude-haiku-4-5"); | |
| 91 | }); | |
| 92 | 77 | }); |
packages/contracts/src/git.ts-2| ... | ... | @@ -1,6 +1,5 @@ |
| 1 | 1 | import { Schema } from "effect"; |
| 2 | 2 | import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; |
| 3 | import { ModelSelection } from "./orchestration"; | |
| 4 | 3 | |
| 5 | 4 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; |
| 6 | 5 | |
| ... | ... | @@ -80,7 +79,6 @@ export const GitRunStackedActionInput = Schema.Struct({ |
| 80 | 79 | filePaths: Schema.optional( |
| 81 | 80 | Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)), |
| 82 | 81 | ), |
| 83 | modelSelection: ModelSelection, | |
| 84 | 82 | }); |
| 85 | 83 | export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; |
| 86 | 84 |
packages/contracts/src/index.ts+1| ... | ... | @@ -7,6 +7,7 @@ export * from "./model"; |
| 7 | 7 | export * from "./ws"; |
| 8 | 8 | export * from "./keybindings"; |
| 9 | 9 | export * from "./server"; |
| 10 | export * from "./settings"; | |
| 10 | 11 | export * from "./git"; |
| 11 | 12 | export * from "./orchestration"; |
| 12 | 13 | export * from "./editor"; |
packages/contracts/src/ipc.ts+10-2| ... | ... | @@ -25,7 +25,11 @@ import type { |
| 25 | 25 | ProjectWriteFileInput, |
| 26 | 26 | ProjectWriteFileResult, |
| 27 | 27 | } from "./project"; |
| 28 | import type { ServerConfig } from "./server"; | |
| 28 | import type { | |
| 29 | ServerConfig, | |
| 30 | ServerProviderUpdatedPayload, | |
| 31 | ServerUpsertKeybindingResult, | |
| 32 | } from "./server"; | |
| 29 | 33 | import type { |
| 30 | 34 | TerminalClearInput, |
| 31 | 35 | TerminalCloseInput, |
| ... | ... | @@ -36,7 +40,7 @@ import type { |
| 36 | 40 | TerminalSessionSnapshot, |
| 37 | 41 | TerminalWriteInput, |
| 38 | 42 | } from "./terminal"; |
| 39 | import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; | |
| 43 | import type { ServerUpsertKeybindingInput } from "./server"; | |
| 40 | 44 | import type { |
| 41 | 45 | ClientOrchestrationCommand, |
| 42 | 46 | OrchestrationGetFullThreadDiffInput, |
| ... | ... | @@ -47,6 +51,7 @@ import type { |
| 47 | 51 | OrchestrationReadModel, |
| 48 | 52 | } from "./orchestration"; |
| 49 | 53 | import { EditorId } from "./editor"; |
| 54 | import { ServerSettings, ServerSettingsPatch } from "./settings"; | |
| 50 | 55 | |
| 51 | 56 | export interface ContextMenuItem<T extends string = string> { |
| 52 | 57 | id: T; |
| ... | ... | @@ -160,7 +165,10 @@ export interface NativeApi { |
| 160 | 165 | }; |
| 161 | 166 | server: { |
| 162 | 167 | getConfig: () => Promise<ServerConfig>; |
| 168 | refreshProviders: () => Promise<ServerProviderUpdatedPayload>; | |
| 163 | 169 | upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>; |
| 170 | getSettings: () => Promise<ServerSettings>; | |
| 171 | updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>; | |
| 164 | 172 | }; |
| 165 | 173 | orchestration: { |
| 166 | 174 | getSnapshot: () => Promise<OrchestrationReadModel>; |
packages/contracts/src/model.ts+15-173| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { Schema } from "effect"; |
| 2 | import { TrimmedNonEmptyString } from "./baseSchemas"; | |
| 2 | 3 | import type { ProviderKind } from "./orchestration"; |
| 3 | 4 | |
| 4 | 5 | export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const; |
| ... | ... | @@ -26,178 +27,28 @@ export const ProviderModelOptions = Schema.Struct({ |
| 26 | 27 | }); |
| 27 | 28 | export type ProviderModelOptions = typeof ProviderModelOptions.Type; |
| 28 | 29 | |
| 29 | export type EffortOption = { | |
| 30 | readonly value: string; | |
| 31 | readonly label: string; | |
| 32 | readonly isDefault?: true; | |
| 33 | }; | |
| 34 | ||
| 35 | export type ModelCapabilities = { | |
| 36 | readonly reasoningEffortLevels: readonly EffortOption[]; | |
| 37 | readonly supportsFastMode: boolean; | |
| 38 | readonly supportsThinkingToggle: boolean; | |
| 39 | readonly promptInjectedEffortLevels: readonly string[]; | |
| 40 | }; | |
| 41 | ||
| 42 | type ModelDefinition = { | |
| 43 | readonly slug: string; | |
| 44 | readonly name: string; | |
| 45 | readonly capabilities: ModelCapabilities; | |
| 46 | }; | |
| 30 | export const EffortOption = Schema.Struct({ | |
| 31 | value: TrimmedNonEmptyString, | |
| 32 | label: TrimmedNonEmptyString, | |
| 33 | isDefault: Schema.optional(Schema.Boolean), | |
| 34 | }); | |
| 35 | export type EffortOption = typeof EffortOption.Type; | |
| 47 | 36 | |
| 48 | /** | |
| 49 | * TODO: This should not be a static array, each provider | |
| 50 | * should return its own model list over the WS API. | |
| 51 | */ | |
| 52 | export const MODEL_OPTIONS_BY_PROVIDER = { | |
| 53 | codex: [ | |
| 54 | { | |
| 55 | slug: "gpt-5.4", | |
| 56 | name: "GPT-5.4", | |
| 57 | capabilities: { | |
| 58 | reasoningEffortLevels: [ | |
| 59 | { value: "xhigh", label: "Extra High" }, | |
| 60 | { value: "high", label: "High", isDefault: true }, | |
| 61 | { value: "medium", label: "Medium" }, | |
| 62 | { value: "low", label: "Low" }, | |
| 63 | ], | |
| 64 | supportsFastMode: true, | |
| 65 | supportsThinkingToggle: false, | |
| 66 | promptInjectedEffortLevels: [], | |
| 67 | }, | |
| 68 | }, | |
| 69 | { | |
| 70 | slug: "gpt-5.4-mini", | |
| 71 | name: "GPT-5.4 Mini", | |
| 72 | capabilities: { | |
| 73 | reasoningEffortLevels: [ | |
| 74 | { value: "xhigh", label: "Extra High" }, | |
| 75 | { value: "high", label: "High", isDefault: true }, | |
| 76 | { value: "medium", label: "Medium" }, | |
| 77 | { value: "low", label: "Low" }, | |
| 78 | ], | |
| 79 | supportsFastMode: true, | |
| 80 | supportsThinkingToggle: false, | |
| 81 | promptInjectedEffortLevels: [], | |
| 82 | }, | |
| 83 | }, | |
| 84 | { | |
| 85 | slug: "gpt-5.3-codex", | |
| 86 | name: "GPT-5.3 Codex", | |
| 87 | capabilities: { | |
| 88 | reasoningEffortLevels: [ | |
| 89 | { value: "xhigh", label: "Extra High" }, | |
| 90 | { value: "high", label: "High", isDefault: true }, | |
| 91 | { value: "medium", label: "Medium" }, | |
| 92 | { value: "low", label: "Low" }, | |
| 93 | ], | |
| 94 | supportsFastMode: true, | |
| 95 | supportsThinkingToggle: false, | |
| 96 | promptInjectedEffortLevels: [], | |
| 97 | }, | |
| 98 | }, | |
| 99 | { | |
| 100 | slug: "gpt-5.3-codex-spark", | |
| 101 | name: "GPT-5.3 Codex Spark", | |
| 102 | capabilities: { | |
| 103 | reasoningEffortLevels: [ | |
| 104 | { value: "xhigh", label: "Extra High" }, | |
| 105 | { value: "high", label: "High", isDefault: true }, | |
| 106 | { value: "medium", label: "Medium" }, | |
| 107 | { value: "low", label: "Low" }, | |
| 108 | ], | |
| 109 | supportsFastMode: true, | |
| 110 | supportsThinkingToggle: false, | |
| 111 | promptInjectedEffortLevels: [], | |
| 112 | }, | |
| 113 | }, | |
| 114 | { | |
| 115 | slug: "gpt-5.2-codex", | |
| 116 | name: "GPT-5.2 Codex", | |
| 117 | capabilities: { | |
| 118 | reasoningEffortLevels: [ | |
| 119 | { value: "xhigh", label: "Extra High" }, | |
| 120 | { value: "high", label: "High", isDefault: true }, | |
| 121 | { value: "medium", label: "Medium" }, | |
| 122 | { value: "low", label: "Low" }, | |
| 123 | ], | |
| 124 | supportsFastMode: true, | |
| 125 | supportsThinkingToggle: false, | |
| 126 | promptInjectedEffortLevels: [], | |
| 127 | }, | |
| 128 | }, | |
| 129 | { | |
| 130 | slug: "gpt-5.2", | |
| 131 | name: "GPT-5.2", | |
| 132 | capabilities: { | |
| 133 | reasoningEffortLevels: [ | |
| 134 | { value: "xhigh", label: "Extra High" }, | |
| 135 | { value: "high", label: "High", isDefault: true }, | |
| 136 | { value: "medium", label: "Medium" }, | |
| 137 | { value: "low", label: "Low" }, | |
| 138 | ], | |
| 139 | supportsFastMode: true, | |
| 140 | supportsThinkingToggle: false, | |
| 141 | promptInjectedEffortLevels: [], | |
| 142 | }, | |
| 143 | }, | |
| 144 | ], | |
| 145 | claudeAgent: [ | |
| 146 | { | |
| 147 | slug: "claude-opus-4-6", | |
| 148 | name: "Claude Opus 4.6", | |
| 149 | capabilities: { | |
| 150 | reasoningEffortLevels: [ | |
| 151 | { value: "low", label: "Low" }, | |
| 152 | { value: "medium", label: "Medium" }, | |
| 153 | { value: "high", label: "High", isDefault: true }, | |
| 154 | { value: "max", label: "Max" }, | |
| 155 | { value: "ultrathink", label: "Ultrathink" }, | |
| 156 | ], | |
| 157 | supportsFastMode: true, | |
| 158 | supportsThinkingToggle: false, | |
| 159 | promptInjectedEffortLevels: ["ultrathink"], | |
| 160 | }, | |
| 161 | }, | |
| 162 | { | |
| 163 | slug: "claude-sonnet-4-6", | |
| 164 | name: "Claude Sonnet 4.6", | |
| 165 | capabilities: { | |
| 166 | reasoningEffortLevels: [ | |
| 167 | { value: "low", label: "Low" }, | |
| 168 | { value: "medium", label: "Medium" }, | |
| 169 | { value: "high", label: "High", isDefault: true }, | |
| 170 | { value: "ultrathink", label: "Ultrathink" }, | |
| 171 | ], | |
| 172 | supportsFastMode: false, | |
| 173 | supportsThinkingToggle: false, | |
| 174 | promptInjectedEffortLevels: ["ultrathink"], | |
| 175 | }, | |
| 176 | }, | |
| 177 | { | |
| 178 | slug: "claude-haiku-4-5", | |
| 179 | name: "Claude Haiku 4.5", | |
| 180 | capabilities: { | |
| 181 | reasoningEffortLevels: [], | |
| 182 | supportsFastMode: false, | |
| 183 | supportsThinkingToggle: true, | |
| 184 | promptInjectedEffortLevels: [], | |
| 185 | }, | |
| 186 | }, | |
| 187 | ], | |
| 188 | } as const satisfies Record<ProviderKind, readonly ModelDefinition[]>; | |
| 189 | export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; | |
| 37 | export const ModelCapabilities = Schema.Struct({ | |
| 38 | reasoningEffortLevels: Schema.Array(EffortOption), | |
| 39 | supportsFastMode: Schema.Boolean, | |
| 40 | supportsThinkingToggle: Schema.Boolean, | |
| 41 | promptInjectedEffortLevels: Schema.Array(TrimmedNonEmptyString), | |
| 42 | }); | |
| 43 | export type ModelCapabilities = typeof ModelCapabilities.Type; | |
| 190 | 44 | |
| 191 | type BuiltInModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"]; | |
| 192 | export type ModelSlug = BuiltInModelSlug | (string & {}); | |
| 45 | export type ModelSlug = string & {}; | |
| 193 | 46 | |
| 194 | 47 | export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, ModelSlug> = { |
| 195 | 48 | codex: "gpt-5.4", |
| 196 | 49 | claudeAgent: "claude-sonnet-4-6", |
| 197 | 50 | }; |
| 198 | 51 | |
| 199 | // Backward compatibility for existing Codex-only call sites. | |
| 200 | export const MODEL_OPTIONS = MODEL_OPTIONS_BY_PROVIDER.codex; | |
| 201 | 52 | export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; |
| 202 | 53 | |
| 203 | 54 | /** Per-provider text generation model defaults. */ |
| ... | ... | @@ -230,15 +81,6 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, |
| 230 | 81 | }, |
| 231 | 82 | }; |
| 232 | 83 | |
| 233 | // ── Model capabilities index ────────────────────────────────────────── | |
| 234 | ||
| 235 | export const MODEL_CAPABILITIES_INDEX = Object.fromEntries( | |
| 236 | Object.entries(MODEL_OPTIONS_BY_PROVIDER).map(([provider, models]) => [ | |
| 237 | provider, | |
| 238 | Object.fromEntries(models.map((m) => [m.slug, m.capabilities])), | |
| 239 | ]), | |
| 240 | ) as unknown as Record<ProviderKind, Record<string, ModelCapabilities>>; | |
| 241 | ||
| 242 | 84 | // ── Provider display names ──────────────────────────────────────────── |
| 243 | 85 | |
| 244 | 86 | export const PROVIDER_DISPLAY_NAMES: Record<ProviderKind, string> = { |
packages/contracts/src/orchestration.ts+2-24| ... | ... | @@ -47,37 +47,20 @@ export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; |
| 47 | 47 | export const CodexModelSelection = Schema.Struct({ |
| 48 | 48 | provider: Schema.Literal("codex"), |
| 49 | 49 | model: TrimmedNonEmptyString, |
| 50 | options: Schema.optional(CodexModelOptions), | |
| 50 | options: Schema.optionalKey(CodexModelOptions), | |
| 51 | 51 | }); |
| 52 | 52 | export type CodexModelSelection = typeof CodexModelSelection.Type; |
| 53 | 53 | |
| 54 | 54 | export const ClaudeModelSelection = Schema.Struct({ |
| 55 | 55 | provider: Schema.Literal("claudeAgent"), |
| 56 | 56 | model: TrimmedNonEmptyString, |
| 57 | options: Schema.optional(ClaudeModelOptions), | |
| 57 | options: Schema.optionalKey(ClaudeModelOptions), | |
| 58 | 58 | }); |
| 59 | 59 | export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; |
| 60 | 60 | |
| 61 | 61 | export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]); |
| 62 | 62 | export type ModelSelection = typeof ModelSelection.Type; |
| 63 | 63 | |
| 64 | export const CodexProviderStartOptions = Schema.Struct({ | |
| 65 | binaryPath: Schema.optional(TrimmedNonEmptyString), | |
| 66 | homePath: Schema.optional(TrimmedNonEmptyString), | |
| 67 | }); | |
| 68 | ||
| 69 | export const ClaudeProviderStartOptions = Schema.Struct({ | |
| 70 | binaryPath: Schema.optional(TrimmedNonEmptyString), | |
| 71 | permissionMode: Schema.optional(TrimmedNonEmptyString), | |
| 72 | maxThinkingTokens: Schema.optional(NonNegativeInt), | |
| 73 | }); | |
| 74 | ||
| 75 | export const ProviderStartOptions = Schema.Struct({ | |
| 76 | codex: Schema.optional(CodexProviderStartOptions), | |
| 77 | claudeAgent: Schema.optional(ClaudeProviderStartOptions), | |
| 78 | }); | |
| 79 | export type ProviderStartOptions = typeof ProviderStartOptions.Type; | |
| 80 | ||
| 81 | 64 | export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); |
| 82 | 65 | export type RuntimeMode = typeof RuntimeMode.Type; |
| 83 | 66 | export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| ... | ... | @@ -402,8 +385,6 @@ export const ThreadTurnStartCommand = Schema.Struct({ |
| 402 | 385 | attachments: Schema.Array(ChatAttachment), |
| 403 | 386 | }), |
| 404 | 387 | modelSelection: Schema.optional(ModelSelection), |
| 405 | providerOptions: Schema.optional(ProviderStartOptions), | |
| 406 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), | |
| 407 | 388 | runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), |
| 408 | 389 | interactionMode: ProviderInteractionMode.pipe( |
| 409 | 390 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), |
| ... | ... | @@ -423,8 +404,6 @@ const ClientThreadTurnStartCommand = Schema.Struct({ |
| 423 | 404 | attachments: Schema.Array(UploadChatAttachment), |
| 424 | 405 | }), |
| 425 | 406 | modelSelection: Schema.optional(ModelSelection), |
| 426 | providerOptions: Schema.optional(ProviderStartOptions), | |
| 427 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), | |
| 428 | 407 | runtimeMode: RuntimeMode, |
| 429 | 408 | interactionMode: ProviderInteractionMode, |
| 430 | 409 | sourceProposedPlan: Schema.optional(SourceProposedPlanReference), |
| ... | ... | @@ -702,7 +681,6 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ |
| 702 | 681 | threadId: ThreadId, |
| 703 | 682 | messageId: MessageId, |
| 704 | 683 | modelSelection: Schema.optional(ModelSelection), |
| 705 | providerOptions: Schema.optional(ProviderStartOptions), | |
| 706 | 684 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), |
| 707 | 685 | runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), |
| 708 | 686 | interactionMode: ProviderInteractionMode.pipe( |
packages/contracts/src/provider.test.ts-18| ... | ... | @@ -21,12 +21,6 @@ describe("ProviderSessionStartInput", () => { |
| 21 | 21 | }, |
| 22 | 22 | }, |
| 23 | 23 | runtimeMode: "full-access", |
| 24 | providerOptions: { | |
| 25 | codex: { | |
| 26 | binaryPath: "/usr/local/bin/codex", | |
| 27 | homePath: "/tmp/.codex", | |
| 28 | }, | |
| 29 | }, | |
| 30 | 24 | }); |
| 31 | 25 | expect(parsed.runtimeMode).toBe("full-access"); |
| 32 | 26 | expect(parsed.modelSelection?.provider).toBe("codex"); |
| ... | ... | @@ -36,8 +30,6 @@ describe("ProviderSessionStartInput", () => { |
| 36 | 30 | } |
| 37 | 31 | expect(parsed.modelSelection.options?.reasoningEffort).toBe("high"); |
| 38 | 32 | expect(parsed.modelSelection.options?.fastMode).toBe(true); |
| 39 | expect(parsed.providerOptions?.codex?.binaryPath).toBe("/usr/local/bin/codex"); | |
| 40 | expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex"); | |
| 41 | 33 | }); |
| 42 | 34 | |
| 43 | 35 | it("rejects payloads without runtime mode", () => { |
| ... | ... | @@ -63,13 +55,6 @@ describe("ProviderSessionStartInput", () => { |
| 63 | 55 | fastMode: true, |
| 64 | 56 | }, |
| 65 | 57 | }, |
| 66 | providerOptions: { | |
| 67 | claudeAgent: { | |
| 68 | binaryPath: "/usr/local/bin/claude", | |
| 69 | permissionMode: "plan", | |
| 70 | maxThinkingTokens: 12_000, | |
| 71 | }, | |
| 72 | }, | |
| 73 | 58 | runtimeMode: "full-access", |
| 74 | 59 | }); |
| 75 | 60 | expect(parsed.provider).toBe("claudeAgent"); |
| ... | ... | @@ -81,9 +66,6 @@ describe("ProviderSessionStartInput", () => { |
| 81 | 66 | expect(parsed.modelSelection.options?.thinking).toBe(true); |
| 82 | 67 | expect(parsed.modelSelection.options?.effort).toBe("max"); |
| 83 | 68 | expect(parsed.modelSelection.options?.fastMode).toBe(true); |
| 84 | expect(parsed.providerOptions?.claudeAgent?.binaryPath).toBe("/usr/local/bin/claude"); | |
| 85 | expect(parsed.providerOptions?.claudeAgent?.permissionMode).toBe("plan"); | |
| 86 | expect(parsed.providerOptions?.claudeAgent?.maxThinkingTokens).toBe(12_000); | |
| 87 | 69 | expect(parsed.runtimeMode).toBe("full-access"); |
| 88 | 70 | }); |
| 89 | 71 | }); |
packages/contracts/src/provider.ts-2| ... | ... | @@ -19,7 +19,6 @@ import { |
| 19 | 19 | ProviderKind, |
| 20 | 20 | ProviderRequestKind, |
| 21 | 21 | ProviderSandboxMode, |
| 22 | ProviderStartOptions, | |
| 23 | 22 | ProviderUserInputAnswers, |
| 24 | 23 | RuntimeMode, |
| 25 | 24 | } from "./orchestration"; |
| ... | ... | @@ -55,7 +54,6 @@ export const ProviderSessionStartInput = Schema.Struct({ |
| 55 | 54 | resumeCursor: Schema.optional(Schema.Unknown), |
| 56 | 55 | approvalPolicy: Schema.optional(ProviderApprovalPolicy), |
| 57 | 56 | sandboxMode: Schema.optional(ProviderSandboxMode), |
| 58 | providerOptions: Schema.optional(ProviderStartOptions), | |
| 59 | 57 | runtimeMode: RuntimeMode, |
| 60 | 58 | }); |
| 61 | 59 | export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type; |
packages/contracts/src/server.ts+28-9| ... | ... | @@ -2,7 +2,9 @@ import { Schema } from "effect"; |
| 2 | 2 | import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas"; |
| 3 | 3 | import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; |
| 4 | 4 | import { EditorId } from "./editor"; |
| 5 | import { ModelCapabilities } from "./model"; | |
| 5 | 6 | import { ProviderKind } from "./orchestration"; |
| 7 | import { ServerSettings } from "./settings"; | |
| 6 | 8 | |
| 7 | 9 | const KeybindingsMalformedConfigIssue = Schema.Struct({ |
| 8 | 10 | kind: Schema.Literal("keybindings.malformed-config"), |
| ... | ... | @@ -23,8 +25,8 @@ export type ServerConfigIssue = typeof ServerConfigIssue.Type; |
| 23 | 25 | |
| 24 | 26 | const ServerConfigIssues = Schema.Array(ServerConfigIssue); |
| 25 | 27 | |
| 26 | export const ServerProviderStatusState = Schema.Literals(["ready", "warning", "error"]); | |
| 27 | export type ServerProviderStatusState = typeof ServerProviderStatusState.Type; | |
| 28 | export const ServerProviderState = Schema.Literals(["ready", "warning", "error", "disabled"]); | |
| 29 | export type ServerProviderState = typeof ServerProviderState.Type; | |
| 28 | 30 | |
| 29 | 31 | export const ServerProviderAuthStatus = Schema.Literals([ |
| 30 | 32 | "authenticated", |
| ... | ... | @@ -33,25 +35,37 @@ export const ServerProviderAuthStatus = Schema.Literals([ |
| 33 | 35 | ]); |
| 34 | 36 | export type ServerProviderAuthStatus = typeof ServerProviderAuthStatus.Type; |
| 35 | 37 | |
| 36 | export const ServerProviderStatus = Schema.Struct({ | |
| 38 | export const ServerProviderModel = Schema.Struct({ | |
| 39 | slug: TrimmedNonEmptyString, | |
| 40 | name: TrimmedNonEmptyString, | |
| 41 | isCustom: Schema.Boolean, | |
| 42 | capabilities: Schema.NullOr(ModelCapabilities), | |
| 43 | }); | |
| 44 | export type ServerProviderModel = typeof ServerProviderModel.Type; | |
| 45 | ||
| 46 | export const ServerProvider = Schema.Struct({ | |
| 37 | 47 | provider: ProviderKind, |
| 38 | status: ServerProviderStatusState, | |
| 39 | available: Schema.Boolean, | |
| 48 | enabled: Schema.Boolean, | |
| 49 | installed: Schema.Boolean, | |
| 50 | version: Schema.NullOr(TrimmedNonEmptyString), | |
| 51 | status: ServerProviderState, | |
| 40 | 52 | authStatus: ServerProviderAuthStatus, |
| 41 | 53 | checkedAt: IsoDateTime, |
| 42 | 54 | message: Schema.optional(TrimmedNonEmptyString), |
| 55 | models: Schema.Array(ServerProviderModel), | |
| 43 | 56 | }); |
| 44 | export type ServerProviderStatus = typeof ServerProviderStatus.Type; | |
| 57 | export type ServerProvider = typeof ServerProvider.Type; | |
| 45 | 58 | |
| 46 | const ServerProviderStatuses = Schema.Array(ServerProviderStatus); | |
| 59 | const ServerProviders = Schema.Array(ServerProvider); | |
| 47 | 60 | |
| 48 | 61 | export const ServerConfig = Schema.Struct({ |
| 49 | 62 | cwd: TrimmedNonEmptyString, |
| 50 | 63 | keybindingsConfigPath: TrimmedNonEmptyString, |
| 51 | 64 | keybindings: ResolvedKeybindingsConfig, |
| 52 | 65 | issues: ServerConfigIssues, |
| 53 | providers: ServerProviderStatuses, | |
| 66 | providers: ServerProviders, | |
| 54 | 67 | availableEditors: Schema.Array(EditorId), |
| 68 | settings: ServerSettings, | |
| 55 | 69 | }); |
| 56 | 70 | export type ServerConfig = typeof ServerConfig.Type; |
| 57 | 71 | |
| ... | ... | @@ -66,6 +80,11 @@ export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.T |
| 66 | 80 | |
| 67 | 81 | export const ServerConfigUpdatedPayload = Schema.Struct({ |
| 68 | 82 | issues: ServerConfigIssues, |
| 69 | providers: ServerProviderStatuses, | |
| 83 | settings: Schema.optional(ServerSettings), | |
| 70 | 84 | }); |
| 71 | 85 | export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type; |
| 86 | ||
| 87 | export const ServerProviderUpdatedPayload = Schema.Struct({ | |
| 88 | providers: ServerProviders, | |
| 89 | }); | |
| 90 | export type ServerProviderUpdatedPayload = typeof ServerProviderUpdatedPayload.Type; |
packages/contracts/src/settings.ts created+153| ... | ... | @@ -0,0 +1,153 @@ |
| 1 | import { Effect } from "effect"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 3 | import * as SchemaTransformation from "effect/SchemaTransformation"; | |
| 4 | import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas"; | |
| 5 | import { | |
| 6 | ClaudeModelOptions, | |
| 7 | CodexModelOptions, | |
| 8 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, | |
| 9 | } from "./model"; | |
| 10 | import { ModelSelection } from "./orchestration"; | |
| 11 | ||
| 12 | // ── Client Settings (local-only) ─────────────────────────────── | |
| 13 | ||
| 14 | export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); | |
| 15 | export type TimestampFormat = typeof TimestampFormat.Type; | |
| 16 | export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; | |
| 17 | ||
| 18 | export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); | |
| 19 | export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; | |
| 20 | export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; | |
| 21 | ||
| 22 | export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]); | |
| 23 | export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; | |
| 24 | export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; | |
| 25 | ||
| 26 | export const ClientSettingsSchema = Schema.Struct({ | |
| 27 | confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), | |
| 28 | diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), | |
| 29 | sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( | |
| 30 | Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER), | |
| 31 | ), | |
| 32 | sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( | |
| 33 | Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_THREAD_SORT_ORDER), | |
| 34 | ), | |
| 35 | timestampFormat: TimestampFormat.pipe(Schema.withDecodingDefault(() => DEFAULT_TIMESTAMP_FORMAT)), | |
| 36 | }); | |
| 37 | export type ClientSettings = typeof ClientSettingsSchema.Type; | |
| 38 | ||
| 39 | export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientSettingsSchema)({}); | |
| 40 | ||
| 41 | // ── Server Settings (server-authoritative) ──────────────────── | |
| 42 | ||
| 43 | export const ThreadEnvMode = Schema.Literals(["local", "worktree"]); | |
| 44 | export type ThreadEnvMode = typeof ThreadEnvMode.Type; | |
| 45 | ||
| 46 | const makeBinaryPathSetting = (fallback: string) => | |
| 47 | TrimmedString.pipe( | |
| 48 | Schema.decodeTo( | |
| 49 | Schema.String, | |
| 50 | SchemaTransformation.transformOrFail({ | |
| 51 | decode: (value) => Effect.succeed(value || fallback), | |
| 52 | encode: (value) => Effect.succeed(value), | |
| 53 | }), | |
| 54 | ), | |
| 55 | Schema.withDecodingDefault(() => fallback), | |
| 56 | ); | |
| 57 | ||
| 58 | export const CodexSettings = Schema.Struct({ | |
| 59 | enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), | |
| 60 | binaryPath: makeBinaryPathSetting("codex"), | |
| 61 | homePath: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), | |
| 62 | customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(() => [])), | |
| 63 | }); | |
| 64 | export type CodexSettings = typeof CodexSettings.Type; | |
| 65 | ||
| 66 | export const ClaudeSettings = Schema.Struct({ | |
| 67 | enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), | |
| 68 | binaryPath: makeBinaryPathSetting("claude"), | |
| 69 | customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(() => [])), | |
| 70 | }); | |
| 71 | export type ClaudeSettings = typeof ClaudeSettings.Type; | |
| 72 | ||
| 73 | export const ServerSettings = Schema.Struct({ | |
| 74 | enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), | |
| 75 | defaultThreadEnvMode: ThreadEnvMode.pipe( | |
| 76 | Schema.withDecodingDefault(() => "local" as const satisfies ThreadEnvMode), | |
| 77 | ), | |
| 78 | textGenerationModelSelection: ModelSelection.pipe( | |
| 79 | Schema.withDecodingDefault(() => ({ | |
| 80 | provider: "codex" as const, | |
| 81 | model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, | |
| 82 | })), | |
| 83 | ), | |
| 84 | ||
| 85 | // Provider specific settings | |
| 86 | providers: Schema.Struct({ | |
| 87 | codex: CodexSettings.pipe(Schema.withDecodingDefault(() => ({}))), | |
| 88 | claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(() => ({}))), | |
| 89 | }).pipe(Schema.withDecodingDefault(() => ({}))), | |
| 90 | }); | |
| 91 | export type ServerSettings = typeof ServerSettings.Type; | |
| 92 | ||
| 93 | export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerSettings)({}); | |
| 94 | ||
| 95 | // ── Unified type ───────────────────────────────────────────────────── | |
| 96 | ||
| 97 | export type UnifiedSettings = ServerSettings & ClientSettings; | |
| 98 | export const DEFAULT_UNIFIED_SETTINGS: UnifiedSettings = { | |
| 99 | ...DEFAULT_SERVER_SETTINGS, | |
| 100 | ...DEFAULT_CLIENT_SETTINGS, | |
| 101 | }; | |
| 102 | ||
| 103 | // ── Server Settings Patch (replace with a Schema.deepPartial if available) ────────────────────────────────────────── | |
| 104 | ||
| 105 | const CodexModelOptionsPatch = Schema.Struct({ | |
| 106 | reasoningEffort: Schema.optionalKey(CodexModelOptions.fields.reasoningEffort), | |
| 107 | fastMode: Schema.optionalKey(CodexModelOptions.fields.fastMode), | |
| 108 | }); | |
| 109 | ||
| 110 | const ClaudeModelOptionsPatch = Schema.Struct({ | |
| 111 | thinking: Schema.optionalKey(ClaudeModelOptions.fields.thinking), | |
| 112 | effort: Schema.optionalKey(ClaudeModelOptions.fields.effort), | |
| 113 | fastMode: Schema.optionalKey(ClaudeModelOptions.fields.fastMode), | |
| 114 | }); | |
| 115 | ||
| 116 | const ModelSelectionPatch = Schema.Union([ | |
| 117 | Schema.Struct({ | |
| 118 | provider: Schema.optionalKey(Schema.Literal("codex")), | |
| 119 | model: Schema.optionalKey(TrimmedNonEmptyString), | |
| 120 | options: Schema.optionalKey(CodexModelOptionsPatch), | |
| 121 | }), | |
| 122 | Schema.Struct({ | |
| 123 | provider: Schema.optionalKey(Schema.Literal("claudeAgent")), | |
| 124 | model: Schema.optionalKey(TrimmedNonEmptyString), | |
| 125 | options: Schema.optionalKey(ClaudeModelOptionsPatch), | |
| 126 | }), | |
| 127 | ]); | |
| 128 | ||
| 129 | const CodexSettingsPatch = Schema.Struct({ | |
| 130 | enabled: Schema.optionalKey(Schema.Boolean), | |
| 131 | binaryPath: Schema.optionalKey(Schema.String), | |
| 132 | homePath: Schema.optionalKey(Schema.String), | |
| 133 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), | |
| 134 | }); | |
| 135 | ||
| 136 | const ClaudeSettingsPatch = Schema.Struct({ | |
| 137 | enabled: Schema.optionalKey(Schema.Boolean), | |
| 138 | binaryPath: Schema.optionalKey(Schema.String), | |
| 139 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), | |
| 140 | }); | |
| 141 | ||
| 142 | export const ServerSettingsPatch = Schema.Struct({ | |
| 143 | enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), | |
| 144 | defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), | |
| 145 | textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), | |
| 146 | providers: Schema.optionalKey( | |
| 147 | Schema.Struct({ | |
| 148 | codex: Schema.optionalKey(CodexSettingsPatch), | |
| 149 | claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), | |
| 150 | }), | |
| 151 | ), | |
| 152 | }); | |
| 153 | export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; |
packages/contracts/src/ws.test.ts+19| ... | ... | @@ -119,6 +119,25 @@ it.effect("accepts git.actionProgress push envelopes", () => |
| 119 | 119 | }), |
| 120 | 120 | ); |
| 121 | 121 | |
| 122 | it.effect("accepts server.providersUpdated push envelopes", () => | |
| 123 | Effect.gen(function* () { | |
| 124 | const parsed = yield* decodeWsResponse({ | |
| 125 | type: "push", | |
| 126 | sequence: 4, | |
| 127 | channel: WS_CHANNELS.serverProvidersUpdated, | |
| 128 | data: { | |
| 129 | providers: [], | |
| 130 | }, | |
| 131 | }); | |
| 132 | ||
| 133 | if (!("type" in parsed) || parsed.type !== "push") { | |
| 134 | assert.fail("expected websocket response to decode as a push envelope"); | |
| 135 | } | |
| 136 | ||
| 137 | assert.strictEqual(parsed.channel, WS_CHANNELS.serverProvidersUpdated); | |
| 138 | }), | |
| 139 | ); | |
| 140 | ||
| 122 | 141 | it.effect("rejects push envelopes when channel payload does not match the channel schema", () => |
| 123 | 142 | Effect.gen(function* () { |
| 124 | 143 | const result = yield* Effect.exit( |
packages/contracts/src/ws.ts+16-1| ... | ... | @@ -37,7 +37,8 @@ import { |
| 37 | 37 | import { KeybindingRule } from "./keybindings"; |
| 38 | 38 | import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; |
| 39 | 39 | import { OpenInEditorInput } from "./editor"; |
| 40 | import { ServerConfigUpdatedPayload } from "./server"; | |
| 40 | import { ServerConfigUpdatedPayload, ServerProviderUpdatedPayload } from "./server"; | |
| 41 | import { ServerSettingsPatch } from "./settings"; | |
| 41 | 42 | |
| 42 | 43 | // ── WebSocket RPC Method Names ─────────────────────────────────────── |
| 43 | 44 | |
| ... | ... | @@ -75,7 +76,10 @@ export const WS_METHODS = { |
| 75 | 76 | |
| 76 | 77 | // Server meta |
| 77 | 78 | serverGetConfig: "server.getConfig", |
| 79 | serverRefreshProviders: "server.refreshProviders", | |
| 78 | 80 | serverUpsertKeybinding: "server.upsertKeybinding", |
| 81 | serverGetSettings: "server.getSettings", | |
| 82 | serverUpdateSettings: "server.updateSettings", | |
| 79 | 83 | } as const; |
| 80 | 84 | |
| 81 | 85 | // ── Push Event Channels ────────────────────────────────────────────── |
| ... | ... | @@ -85,6 +89,7 @@ export const WS_CHANNELS = { |
| 85 | 89 | terminalEvent: "terminal.event", |
| 86 | 90 | serverWelcome: "server.welcome", |
| 87 | 91 | serverConfigUpdated: "server.configUpdated", |
| 92 | serverProvidersUpdated: "server.providersUpdated", | |
| 88 | 93 | } as const; |
| 89 | 94 | |
| 90 | 95 | // -- Tagged Union of all request body schemas ───────────────────────── |
| ... | ... | @@ -140,7 +145,10 @@ const WebSocketRequestBody = Schema.Union([ |
| 140 | 145 | |
| 141 | 146 | // Server meta |
| 142 | 147 | tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), |
| 148 | tagRequestBody(WS_METHODS.serverRefreshProviders, Schema.Struct({})), | |
| 143 | 149 | tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), |
| 150 | tagRequestBody(WS_METHODS.serverGetSettings, Schema.Struct({})), | |
| 151 | tagRequestBody(WS_METHODS.serverUpdateSettings, Schema.Struct({ patch: ServerSettingsPatch })), | |
| 144 | 152 | ]); |
| 145 | 153 | |
| 146 | 154 | export const WebSocketRequest = Schema.Struct({ |
| ... | ... | @@ -174,6 +182,7 @@ export type WsWelcomePayload = typeof WsWelcomePayload.Type; |
| 174 | 182 | export interface WsPushPayloadByChannel { |
| 175 | 183 | readonly [WS_CHANNELS.serverWelcome]: WsWelcomePayload; |
| 176 | 184 | readonly [WS_CHANNELS.serverConfigUpdated]: typeof ServerConfigUpdatedPayload.Type; |
| 185 | readonly [WS_CHANNELS.serverProvidersUpdated]: typeof ServerProviderUpdatedPayload.Type; | |
| 177 | 186 | readonly [WS_CHANNELS.gitActionProgress]: typeof GitActionProgressEvent.Type; |
| 178 | 187 | readonly [WS_CHANNELS.terminalEvent]: typeof TerminalEvent.Type; |
| 179 | 188 | readonly [ORCHESTRATION_WS_CHANNELS.domainEvent]: OrchestrationEvent; |
| ... | ... | @@ -198,6 +207,10 @@ export const WsPushServerConfigUpdated = makeWsPushSchema( |
| 198 | 207 | WS_CHANNELS.serverConfigUpdated, |
| 199 | 208 | ServerConfigUpdatedPayload, |
| 200 | 209 | ); |
| 210 | export const WsPushServerProvidersUpdated = makeWsPushSchema( | |
| 211 | WS_CHANNELS.serverProvidersUpdated, | |
| 212 | ServerProviderUpdatedPayload, | |
| 213 | ); | |
| 201 | 214 | export const WsPushGitActionProgress = makeWsPushSchema( |
| 202 | 215 | WS_CHANNELS.gitActionProgress, |
| 203 | 216 | GitActionProgressEvent, |
| ... | ... | @@ -212,6 +225,7 @@ export const WsPushChannelSchema = Schema.Literals([ |
| 212 | 225 | WS_CHANNELS.gitActionProgress, |
| 213 | 226 | WS_CHANNELS.serverWelcome, |
| 214 | 227 | WS_CHANNELS.serverConfigUpdated, |
| 228 | WS_CHANNELS.serverProvidersUpdated, | |
| 215 | 229 | WS_CHANNELS.terminalEvent, |
| 216 | 230 | ORCHESTRATION_WS_CHANNELS.domainEvent, |
| 217 | 231 | ]); |
| ... | ... | @@ -220,6 +234,7 @@ export type WsPushChannelSchema = typeof WsPushChannelSchema.Type; |
| 220 | 234 | export const WsPush = Schema.Union([ |
| 221 | 235 | WsPushServerWelcome, |
| 222 | 236 | WsPushServerConfigUpdated, |
| 237 | WsPushServerProvidersUpdated, | |
| 223 | 238 | WsPushGitActionProgress, |
| 224 | 239 | WsPushTerminalEvent, |
| 225 | 240 | WsPushOrchestrationDomainEvent, |
packages/shared/package.json+4| ... | ... | @@ -31,6 +31,10 @@ |
| 31 | 31 | "./schemaJson": { |
| 32 | 32 | "types": "./src/schemaJson.ts", |
| 33 | 33 | "import": "./src/schemaJson.ts" |
| 34 | }, | |
| 35 | "./Struct": { | |
| 36 | "types": "./src/Struct.ts", | |
| 37 | "import": "./src/Struct.ts" | |
| 34 | 38 | } |
| 35 | 39 | }, |
| 36 | 40 | "scripts": { |
packages/shared/src/Struct.ts created+23| ... | ... | @@ -0,0 +1,23 @@ |
| 1 | import * as P from "effect/Predicate"; | |
| 2 | ||
| 3 | export type DeepPartial<T> = T extends readonly (infer U)[] | |
| 4 | ? readonly DeepPartial<U>[] | |
| 5 | : T extends object | |
| 6 | ? { [K in keyof T]?: DeepPartial<T[K]> } | |
| 7 | : T; | |
| 8 | ||
| 9 | export function deepMerge<T extends Record<string, unknown>>(current: T, patch: DeepPartial<T>): T { | |
| 10 | if (!P.isObject(current) || !P.isObject(patch)) { | |
| 11 | return patch as T; | |
| 12 | } | |
| 13 | ||
| 14 | const next = { ...current } as Record<string, unknown>; | |
| 15 | for (const [key, value] of Object.entries(patch)) { | |
| 16 | if (value === undefined) continue; | |
| 17 | ||
| 18 | const existing = next[key]; | |
| 19 | next[key] = P.isObject(existing) && P.isObject(value) ? deepMerge(existing, value) : value; | |
| 20 | } | |
| 21 | ||
| 22 | return next as T; | |
| 23 | } |
packages/shared/src/model.test.ts+57-270| ... | ... | @@ -2,31 +2,46 @@ import { describe, expect, it } from "vitest"; |
| 2 | 2 | import { |
| 3 | 3 | DEFAULT_MODEL, |
| 4 | 4 | DEFAULT_MODEL_BY_PROVIDER, |
| 5 | MODEL_OPTIONS, | |
| 6 | MODEL_OPTIONS_BY_PROVIDER, | |
| 7 | CODEX_REASONING_EFFORT_OPTIONS, | |
| 5 | type ModelCapabilities, | |
| 8 | 6 | } from "@t3tools/contracts"; |
| 9 | 7 | |
| 10 | 8 | import { |
| 11 | 9 | applyClaudePromptEffortPrefix, |
| 12 | getDefaultModel, | |
| 13 | getModelCapabilities, | |
| 14 | getModelOptions, | |
| 10 | getDefaultEffort, | |
| 11 | hasEffortLevel, | |
| 15 | 12 | isClaudeUltrathinkPrompt, |
| 16 | normalizeClaudeModelOptions, | |
| 17 | normalizeCodexModelOptions, | |
| 18 | 13 | normalizeModelSlug, |
| 19 | resolveSelectableModel, | |
| 20 | 14 | resolveModelSlug, |
| 21 | 15 | resolveModelSlugForProvider, |
| 22 | getDefaultEffort, | |
| 23 | hasEffortLevel, | |
| 16 | resolveSelectableModel, | |
| 17 | trimOrNull, | |
| 24 | 18 | } from "./model"; |
| 25 | 19 | |
| 20 | const codexCaps: ModelCapabilities = { | |
| 21 | reasoningEffortLevels: [ | |
| 22 | { value: "xhigh", label: "Extra High" }, | |
| 23 | { value: "high", label: "High", isDefault: true }, | |
| 24 | ], | |
| 25 | supportsFastMode: true, | |
| 26 | supportsThinkingToggle: false, | |
| 27 | promptInjectedEffortLevels: [], | |
| 28 | }; | |
| 29 | ||
| 30 | const claudeCaps: ModelCapabilities = { | |
| 31 | reasoningEffortLevels: [ | |
| 32 | { value: "medium", label: "Medium" }, | |
| 33 | { value: "high", label: "High", isDefault: true }, | |
| 34 | { value: "ultrathink", label: "Ultrathink" }, | |
| 35 | ], | |
| 36 | supportsFastMode: false, | |
| 37 | supportsThinkingToggle: false, | |
| 38 | promptInjectedEffortLevels: ["ultrathink"], | |
| 39 | }; | |
| 40 | ||
| 26 | 41 | describe("normalizeModelSlug", () => { |
| 27 | 42 | it("maps known aliases to canonical slugs", () => { |
| 28 | 43 | expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); |
| 29 | expect(normalizeModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); | |
| 44 | expect(normalizeModelSlug("sonnet", "claudeAgent")).toBe("claude-sonnet-4-6"); | |
| 30 | 45 | }); |
| 31 | 46 | |
| 32 | 47 | it("returns null for empty or missing values", () => { |
| ... | ... | @@ -35,290 +50,62 @@ describe("normalizeModelSlug", () => { |
| 35 | 50 | expect(normalizeModelSlug(null)).toBeNull(); |
| 36 | 51 | expect(normalizeModelSlug(undefined)).toBeNull(); |
| 37 | 52 | }); |
| 38 | ||
| 39 | it("preserves non-aliased model slugs", () => { | |
| 40 | expect(normalizeModelSlug("gpt-5.2")).toBe("gpt-5.2"); | |
| 41 | expect(normalizeModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); | |
| 42 | }); | |
| 43 | ||
| 44 | it("does not leak prototype properties as aliases", () => { | |
| 45 | expect(normalizeModelSlug("toString")).toBe("toString"); | |
| 46 | expect(normalizeModelSlug("constructor")).toBe("constructor"); | |
| 47 | }); | |
| 48 | ||
| 49 | it("uses provider-specific aliases", () => { | |
| 50 | expect(normalizeModelSlug("sonnet", "claudeAgent")).toBe("claude-sonnet-4-6"); | |
| 51 | expect(normalizeModelSlug("opus-4.6", "claudeAgent")).toBe("claude-opus-4-6"); | |
| 52 | expect(normalizeModelSlug("claude-haiku-4-5-20251001", "claudeAgent")).toBe("claude-haiku-4-5"); | |
| 53 | }); | |
| 54 | 53 | }); |
| 55 | 54 | |
| 56 | 55 | describe("resolveModelSlug", () => { |
| 57 | it("returns default only when the model is missing", () => { | |
| 56 | it("returns defaults when the model is missing", () => { | |
| 58 | 57 | expect(resolveModelSlug(undefined)).toBe(DEFAULT_MODEL); |
| 59 | expect(resolveModelSlug(null)).toBe(DEFAULT_MODEL); | |
| 60 | }); | |
| 61 | ||
| 62 | it("preserves unknown custom models", () => { | |
| 63 | expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL); | |
| 64 | expect(resolveModelSlug("custom/internal-model")).toBe(DEFAULT_MODEL); | |
| 65 | }); | |
| 66 | ||
| 67 | it("resolves only supported model options", () => { | |
| 68 | for (const model of MODEL_OPTIONS) { | |
| 69 | expect(resolveModelSlug(model.slug)).toBe(model.slug); | |
| 70 | } | |
| 71 | }); | |
| 72 | ||
| 73 | it("supports provider-aware resolution", () => { | |
| 74 | 58 | expect(resolveModelSlugForProvider("claudeAgent", undefined)).toBe( |
| 75 | 59 | DEFAULT_MODEL_BY_PROVIDER.claudeAgent, |
| 76 | 60 | ); |
| 77 | expect(resolveModelSlugForProvider("claudeAgent", "sonnet")).toBe("claude-sonnet-4-6"); | |
| 78 | expect(resolveModelSlugForProvider("claudeAgent", "gpt-5.3-codex")).toBe( | |
| 79 | DEFAULT_MODEL_BY_PROVIDER.claudeAgent, | |
| 80 | ); | |
| 81 | 61 | }); |
| 82 | 62 | |
| 83 | it("keeps codex defaults for backward compatibility", () => { | |
| 84 | expect(getDefaultModel()).toBe(DEFAULT_MODEL); | |
| 85 | expect(getModelOptions()).toEqual(MODEL_OPTIONS); | |
| 86 | expect(getModelOptions("claudeAgent")).toEqual(MODEL_OPTIONS_BY_PROVIDER.claudeAgent); | |
| 63 | it("preserves normalized unknown models", () => { | |
| 64 | expect(resolveModelSlug("custom/internal-model")).toBe("custom/internal-model"); | |
| 87 | 65 | }); |
| 88 | 66 | }); |
| 89 | 67 | |
| 90 | 68 | describe("resolveSelectableModel", () => { |
| 91 | it("resolves exact slug matches", () => { | |
| 92 | expect( | |
| 93 | resolveSelectableModel("codex", "gpt-5.3-codex", [ | |
| 94 | { slug: "gpt-5.4", name: "GPT-5.4" }, | |
| 95 | { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, | |
| 96 | ]), | |
| 97 | ).toBe("gpt-5.3-codex"); | |
| 98 | }); | |
| 99 | ||
| 100 | it("resolves case-insensitive display-name matches", () => { | |
| 101 | expect( | |
| 102 | resolveSelectableModel("codex", "gpt-5.3 codex", [ | |
| 103 | { slug: "gpt-5.4", name: "GPT-5.4" }, | |
| 104 | { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, | |
| 105 | ]), | |
| 106 | ).toBe("gpt-5.3-codex"); | |
| 107 | }); | |
| 108 | ||
| 109 | it("resolves provider-specific aliases after normalization", () => { | |
| 110 | expect( | |
| 111 | resolveSelectableModel("claudeAgent", "sonnet", [ | |
| 112 | { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, | |
| 113 | { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, | |
| 114 | ]), | |
| 115 | ).toBe("claude-sonnet-4-6"); | |
| 116 | }); | |
| 117 | ||
| 118 | it("returns null for empty input", () => { | |
| 119 | expect(resolveSelectableModel("codex", "", [{ slug: "gpt-5.4", name: "GPT-5.4" }])).toBeNull(); | |
| 120 | expect( | |
| 121 | resolveSelectableModel("codex", " ", [{ slug: "gpt-5.4", name: "GPT-5.4" }]), | |
| 122 | ).toBeNull(); | |
| 123 | expect( | |
| 124 | resolveSelectableModel("codex", null, [{ slug: "gpt-5.4", name: "GPT-5.4" }]), | |
| 125 | ).toBeNull(); | |
| 126 | }); | |
| 127 | ||
| 128 | it("returns null for unknown values that are not present in options", () => { | |
| 129 | expect( | |
| 130 | resolveSelectableModel("codex", "gpt-4.1", [{ slug: "gpt-5.4", name: "GPT-5.4" }]), | |
| 131 | ).toBeNull(); | |
| 132 | }); | |
| 133 | ||
| 134 | it("does not accept normalized custom-looking slugs unless they exist in options", () => { | |
| 135 | expect( | |
| 136 | resolveSelectableModel("codex", "custom/internal-model", [ | |
| 137 | { slug: "gpt-5.4", name: "GPT-5.4" }, | |
| 138 | ]), | |
| 139 | ).toBeNull(); | |
| 140 | }); | |
| 141 | ||
| 142 | it("respects provider boundaries", () => { | |
| 143 | expect( | |
| 144 | resolveSelectableModel("codex", "sonnet", [{ slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }]), | |
| 145 | ).toBeNull(); | |
| 146 | expect( | |
| 147 | resolveSelectableModel("claudeAgent", "5.3", [ | |
| 148 | { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, | |
| 149 | ]), | |
| 150 | ).toBeNull(); | |
| 151 | }); | |
| 152 | }); | |
| 153 | ||
| 154 | describe("getModelCapabilities reasoningEffortLevels", () => { | |
| 155 | const values = (provider: "codex" | "claudeAgent", model: string | null) => | |
| 156 | getModelCapabilities(provider, model).reasoningEffortLevels.map((l) => l.value); | |
| 157 | ||
| 158 | it("returns codex reasoning options for codex", () => { | |
| 159 | expect(values("codex", "gpt-5.4")).toEqual([...CODEX_REASONING_EFFORT_OPTIONS]); | |
| 160 | }); | |
| 161 | ||
| 162 | it("returns claude effort options for Opus 4.6", () => { | |
| 163 | expect(values("claudeAgent", "claude-opus-4-6")).toEqual([ | |
| 164 | "low", | |
| 165 | "medium", | |
| 166 | "high", | |
| 167 | "max", | |
| 168 | "ultrathink", | |
| 169 | ]); | |
| 170 | }); | |
| 171 | ||
| 172 | it("returns claude effort options for Sonnet 4.6", () => { | |
| 173 | expect(values("claudeAgent", "claude-sonnet-4-6")).toEqual([ | |
| 174 | "low", | |
| 175 | "medium", | |
| 176 | "high", | |
| 177 | "ultrathink", | |
| 178 | ]); | |
| 179 | }); | |
| 180 | ||
| 181 | it("returns no claude effort options for Haiku 4.5", () => { | |
| 182 | expect(values("claudeAgent", "claude-haiku-4-5")).toEqual([]); | |
| 183 | }); | |
| 184 | ||
| 185 | it("co-locates labels with effort values", () => { | |
| 186 | const levels = getModelCapabilities("claudeAgent", "claude-opus-4-6").reasoningEffortLevels; | |
| 187 | const high = levels.find((l) => l.value === "high"); | |
| 188 | expect(high).toEqual({ value: "high", label: "High", isDefault: true }); | |
| 189 | const xhigh = getModelCapabilities("codex", "gpt-5.4").reasoningEffortLevels.find( | |
| 190 | (l) => l.value === "xhigh", | |
| 191 | ); | |
| 192 | expect(xhigh).toEqual({ value: "xhigh", label: "Extra High" }); | |
| 69 | it("resolves exact slugs, labels, and aliases", () => { | |
| 70 | const options = [ | |
| 71 | { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, | |
| 72 | { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, | |
| 73 | ]; | |
| 74 | expect(resolveSelectableModel("codex", "gpt-5.3-codex", options)).toBe("gpt-5.3-codex"); | |
| 75 | expect(resolveSelectableModel("codex", "gpt-5.3 codex", options)).toBe("gpt-5.3-codex"); | |
| 76 | expect(resolveSelectableModel("claudeAgent", "sonnet", options)).toBe("claude-sonnet-4-6"); | |
| 193 | 77 | }); |
| 194 | 78 | }); |
| 195 | 79 | |
| 196 | describe("getDefaultEffort", () => { | |
| 197 | it("returns the default effort from capabilities", () => { | |
| 198 | expect(getDefaultEffort(getModelCapabilities("codex", "gpt-5.4"))).toBe("high"); | |
| 199 | expect(getDefaultEffort(getModelCapabilities("claudeAgent", "claude-opus-4-6"))).toBe("high"); | |
| 200 | expect(getDefaultEffort(getModelCapabilities("claudeAgent", "claude-haiku-4-5"))).toBeNull(); | |
| 80 | describe("capability helpers", () => { | |
| 81 | it("reads default efforts", () => { | |
| 82 | expect(getDefaultEffort(codexCaps)).toBe("high"); | |
| 83 | expect(getDefaultEffort(claudeCaps)).toBe("high"); | |
| 201 | 84 | }); |
| 202 | }); | |
| 203 | 85 | |
| 204 | describe("hasEffortLevel", () => { | |
| 205 | it("validates effort against model capabilities", () => { | |
| 206 | const opusCaps = getModelCapabilities("claudeAgent", "claude-opus-4-6"); | |
| 207 | expect(hasEffortLevel(opusCaps, "max")).toBe(true); | |
| 208 | expect(hasEffortLevel(opusCaps, "xhigh")).toBe(false); | |
| 209 | ||
| 210 | const codexCaps = getModelCapabilities("codex", "gpt-5.4"); | |
| 86 | it("checks effort support", () => { | |
| 211 | 87 | expect(hasEffortLevel(codexCaps, "xhigh")).toBe(true); |
| 212 | 88 | expect(hasEffortLevel(codexCaps, "max")).toBe(false); |
| 213 | 89 | }); |
| 214 | 90 | }); |
| 215 | 91 | |
| 216 | describe("applyClaudePromptEffortPrefix", () => { | |
| 217 | it("prefixes ultrathink prompts exactly once", () => { | |
| 218 | expect(applyClaudePromptEffortPrefix("Investigate this", "ultrathink")).toBe( | |
| 219 | "Ultrathink:\nInvestigate this", | |
| 220 | ); | |
| 221 | expect(applyClaudePromptEffortPrefix("Ultrathink:\nInvestigate this", "ultrathink")).toBe( | |
| 222 | "Ultrathink:\nInvestigate this", | |
| 223 | ); | |
| 224 | }); | |
| 225 | ||
| 226 | it("leaves non-ultrathink prompts unchanged", () => { | |
| 227 | expect(applyClaudePromptEffortPrefix("Investigate this", "high")).toBe("Investigate this"); | |
| 228 | }); | |
| 229 | }); | |
| 230 | ||
| 231 | describe("normalizeCodexModelOptions", () => { | |
| 232 | it("drops default-only codex options", () => { | |
| 233 | expect( | |
| 234 | normalizeCodexModelOptions("gpt-5.4", { reasoningEffort: "high", fastMode: false }), | |
| 235 | ).toBeUndefined(); | |
| 236 | }); | |
| 237 | ||
| 238 | it("preserves non-default codex options", () => { | |
| 239 | expect( | |
| 240 | normalizeCodexModelOptions("gpt-5.4", { reasoningEffort: "xhigh", fastMode: true }), | |
| 241 | ).toEqual({ | |
| 242 | reasoningEffort: "xhigh", | |
| 243 | fastMode: true, | |
| 244 | }); | |
| 245 | }); | |
| 246 | }); | |
| 247 | ||
| 248 | describe("normalizeClaudeModelOptions", () => { | |
| 249 | it("drops unsupported fast mode and max effort for Sonnet", () => { | |
| 250 | expect( | |
| 251 | normalizeClaudeModelOptions("claude-sonnet-4-6", { | |
| 252 | effort: "max", | |
| 253 | fastMode: true, | |
| 254 | }), | |
| 255 | ).toBeUndefined(); | |
| 256 | }); | |
| 257 | ||
| 258 | it("keeps the Haiku thinking toggle and removes unsupported effort", () => { | |
| 259 | expect( | |
| 260 | normalizeClaudeModelOptions("claude-haiku-4-5", { | |
| 261 | thinking: false, | |
| 262 | effort: "high", | |
| 263 | }), | |
| 264 | ).toEqual({ | |
| 265 | thinking: false, | |
| 266 | }); | |
| 267 | }); | |
| 268 | }); | |
| 269 | ||
| 270 | describe("getModelCapabilities Claude capability flags", () => { | |
| 271 | it("only enables adaptive reasoning for Opus 4.6 and Sonnet 4.6", () => { | |
| 272 | const has = (m: string | undefined) => | |
| 273 | getModelCapabilities("claudeAgent", m).reasoningEffortLevels.length > 0; | |
| 274 | expect(has("claude-opus-4-6")).toBe(true); | |
| 275 | expect(has("claude-sonnet-4-6")).toBe(true); | |
| 276 | expect(has("claude-haiku-4-5")).toBe(false); | |
| 277 | expect(has(undefined)).toBe(false); | |
| 278 | }); | |
| 279 | ||
| 280 | it("only enables max effort for Opus 4.6", () => { | |
| 281 | const has = (m: string | undefined) => | |
| 282 | getModelCapabilities("claudeAgent", m).reasoningEffortLevels.some((l) => l.value === "max"); | |
| 283 | expect(has("claude-opus-4-6")).toBe(true); | |
| 284 | expect(has("claude-sonnet-4-6")).toBe(false); | |
| 285 | expect(has("claude-haiku-4-5")).toBe(false); | |
| 286 | expect(has(undefined)).toBe(false); | |
| 287 | }); | |
| 288 | ||
| 289 | it("only enables Claude fast mode for Opus 4.6", () => { | |
| 290 | const has = (m: string | undefined) => getModelCapabilities("claudeAgent", m).supportsFastMode; | |
| 291 | expect(has("claude-opus-4-6")).toBe(true); | |
| 292 | expect(has("opus")).toBe(true); | |
| 293 | expect(has("claude-sonnet-4-6")).toBe(false); | |
| 294 | expect(has("claude-haiku-4-5")).toBe(false); | |
| 295 | expect(has(undefined)).toBe(false); | |
| 296 | }); | |
| 297 | ||
| 298 | it("only enables ultrathink keyword handling for Opus 4.6 and Sonnet 4.6", () => { | |
| 299 | const has = (m: string | undefined) => | |
| 300 | getModelCapabilities("claudeAgent", m).reasoningEffortLevels.length > 0; | |
| 301 | expect(has("claude-opus-4-6")).toBe(true); | |
| 302 | expect(has("claude-sonnet-4-6")).toBe(true); | |
| 303 | expect(has("claude-haiku-4-5")).toBe(false); | |
| 92 | describe("misc helpers", () => { | |
| 93 | it("detects ultrathink prompts", () => { | |
| 94 | expect(isClaudeUltrathinkPrompt("Ultrathink:\nInvestigate")).toBe(true); | |
| 95 | expect(isClaudeUltrathinkPrompt("Investigate")).toBe(false); | |
| 304 | 96 | }); |
| 305 | 97 | |
| 306 | it("only enables the Claude thinking toggle for Haiku 4.5", () => { | |
| 307 | const has = (m: string | undefined) => | |
| 308 | getModelCapabilities("claudeAgent", m).supportsThinkingToggle; | |
| 309 | expect(has("claude-opus-4-6")).toBe(false); | |
| 310 | expect(has("claude-sonnet-4-6")).toBe(false); | |
| 311 | expect(has("claude-haiku-4-5")).toBe(true); | |
| 312 | expect(has("haiku")).toBe(true); | |
| 313 | expect(has(undefined)).toBe(false); | |
| 98 | it("prefixes ultrathink prompts once", () => { | |
| 99 | expect(applyClaudePromptEffortPrefix("Investigate", "ultrathink")).toBe( | |
| 100 | "Ultrathink:\nInvestigate", | |
| 101 | ); | |
| 102 | expect(applyClaudePromptEffortPrefix("Ultrathink:\nInvestigate", "ultrathink")).toBe( | |
| 103 | "Ultrathink:\nInvestigate", | |
| 104 | ); | |
| 314 | 105 | }); |
| 315 | }); | |
| 316 | 106 | |
| 317 | describe("isClaudeUltrathinkPrompt", () => { | |
| 318 | it("detects ultrathink prompts case-insensitively", () => { | |
| 319 | expect(isClaudeUltrathinkPrompt("Please ultrathink about this")).toBe(true); | |
| 320 | expect(isClaudeUltrathinkPrompt("Ultrathink:\nInvestigate")).toBe(true); | |
| 321 | expect(isClaudeUltrathinkPrompt("Think hard about this")).toBe(false); | |
| 322 | expect(isClaudeUltrathinkPrompt(undefined)).toBe(false); | |
| 107 | it("trims strings to null", () => { | |
| 108 | expect(trimOrNull(" hi ")).toBe("hi"); | |
| 109 | expect(trimOrNull(" ")).toBeNull(); | |
| 323 | 110 | }); |
| 324 | 111 | }); |
packages/shared/src/model.ts+1-77| ... | ... | @@ -1,31 +1,17 @@ |
| 1 | 1 | import { |
| 2 | 2 | DEFAULT_MODEL_BY_PROVIDER, |
| 3 | MODEL_CAPABILITIES_INDEX, | |
| 4 | MODEL_OPTIONS_BY_PROVIDER, | |
| 5 | 3 | MODEL_SLUG_ALIASES_BY_PROVIDER, |
| 6 | type ClaudeModelOptions, | |
| 7 | 4 | type ClaudeCodeEffort, |
| 8 | type CodexModelOptions, | |
| 9 | 5 | type ModelCapabilities, |
| 10 | 6 | type ModelSlug, |
| 11 | 7 | type ProviderKind, |
| 12 | CodexReasoningEffort, | |
| 13 | 8 | } from "@t3tools/contracts"; |
| 14 | 9 | |
| 15 | const MODEL_SLUG_SET_BY_PROVIDER: Record<ProviderKind, ReadonlySet<ModelSlug>> = { | |
| 16 | claudeAgent: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeAgent.map((option) => option.slug)), | |
| 17 | codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), | |
| 18 | }; | |
| 19 | ||
| 20 | 10 | export interface SelectableModelOption { |
| 21 | 11 | slug: string; |
| 22 | 12 | name: string; |
| 23 | 13 | } |
| 24 | 14 | |
| 25 | export function getModelOptions(provider: ProviderKind = "codex") { | |
| 26 | return MODEL_OPTIONS_BY_PROVIDER[provider]; | |
| 27 | } | |
| 28 | ||
| 29 | 15 | export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug { |
| 30 | 16 | return DEFAULT_MODEL_BY_PROVIDER[provider]; |
| 31 | 17 | } |
| ... | ... | @@ -42,24 +28,6 @@ export function getDefaultEffort(caps: ModelCapabilities): string | null { |
| 42 | 28 | return caps.reasoningEffortLevels.find((l) => l.isDefault)?.value ?? null; |
| 43 | 29 | } |
| 44 | 30 | |
| 45 | // ── Data-driven capability resolver ─────────────────────────────────── | |
| 46 | ||
| 47 | export function getModelCapabilities( | |
| 48 | provider: ProviderKind, | |
| 49 | model: string | null | undefined, | |
| 50 | ): ModelCapabilities { | |
| 51 | const slug = normalizeModelSlug(model, provider); | |
| 52 | if (slug && MODEL_CAPABILITIES_INDEX[provider]?.[slug]) { | |
| 53 | return MODEL_CAPABILITIES_INDEX[provider][slug]; | |
| 54 | } | |
| 55 | return { | |
| 56 | reasoningEffortLevels: [], | |
| 57 | supportsFastMode: false, | |
| 58 | supportsThinkingToggle: false, | |
| 59 | promptInjectedEffortLevels: [], | |
| 60 | }; | |
| 61 | } | |
| 62 | ||
| 63 | 31 | export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { |
| 64 | 32 | return typeof text === "string" && /\bultrathink\b/i.test(text); |
| 65 | 33 | } |
| ... | ... | @@ -125,10 +93,7 @@ export function resolveModelSlug( |
| 125 | 93 | if (!normalized) { |
| 126 | 94 | return DEFAULT_MODEL_BY_PROVIDER[provider]; |
| 127 | 95 | } |
| 128 | ||
| 129 | return MODEL_SLUG_SET_BY_PROVIDER[provider].has(normalized) | |
| 130 | ? normalized | |
| 131 | : DEFAULT_MODEL_BY_PROVIDER[provider]; | |
| 96 | return normalized; | |
| 132 | 97 | } |
| 133 | 98 | |
| 134 | 99 | export function resolveModelSlugForProvider( |
| ... | ... | @@ -145,47 +110,6 @@ export function trimOrNull<T extends string>(value: T | null | undefined): T | n |
| 145 | 110 | return trimmed || null; |
| 146 | 111 | } |
| 147 | 112 | |
| 148 | export function normalizeCodexModelOptions( | |
| 149 | model: string | null | undefined, | |
| 150 | modelOptions: CodexModelOptions | null | undefined, | |
| 151 | ): CodexModelOptions | undefined { | |
| 152 | const caps = getModelCapabilities("codex", model); | |
| 153 | const defaultReasoningEffort = getDefaultEffort(caps) as CodexReasoningEffort; | |
| 154 | const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort; | |
| 155 | const fastModeEnabled = modelOptions?.fastMode === true; | |
| 156 | const nextOptions: CodexModelOptions = { | |
| 157 | ...(reasoningEffort !== defaultReasoningEffort ? { reasoningEffort } : {}), | |
| 158 | ...(fastModeEnabled ? { fastMode: true } : {}), | |
| 159 | }; | |
| 160 | return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; | |
| 161 | } | |
| 162 | ||
| 163 | export function normalizeClaudeModelOptions( | |
| 164 | model: string | null | undefined, | |
| 165 | modelOptions: ClaudeModelOptions | null | undefined, | |
| 166 | ): ClaudeModelOptions | undefined { | |
| 167 | const caps = getModelCapabilities("claudeAgent", model); | |
| 168 | const defaultReasoningEffort = getDefaultEffort(caps); | |
| 169 | const resolvedEffort = trimOrNull(modelOptions?.effort); | |
| 170 | const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? ""); | |
| 171 | const effort = | |
| 172 | resolvedEffort && | |
| 173 | !isPromptInjected && | |
| 174 | hasEffortLevel(caps, resolvedEffort) && | |
| 175 | resolvedEffort !== defaultReasoningEffort | |
| 176 | ? resolvedEffort | |
| 177 | : undefined; | |
| 178 | const thinking = | |
| 179 | caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; | |
| 180 | const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; | |
| 181 | const nextOptions: ClaudeModelOptions = { | |
| 182 | ...(thinking === false ? { thinking: false } : {}), | |
| 183 | ...(effort ? { effort } : {}), | |
| 184 | ...(fastMode ? { fastMode: true } : {}), | |
| 185 | }; | |
| 186 | return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; | |
| 187 | } | |
| 188 | ||
| 189 | 113 | export function applyClaudePromptEffortPrefix( |
| 190 | 114 | text: string, |
| 191 | 115 | effort: ClaudeCodeEffort | null | undefined, |
packages/shared/src/schemaJson.ts+63-1| ... | ... | @@ -1,4 +1,14 @@ |
| 1 | import { Cause, Exit, Result, Schema, SchemaIssue } from "effect"; | |
| 1 | import { | |
| 2 | Cause, | |
| 3 | Effect, | |
| 4 | Exit, | |
| 5 | Option, | |
| 6 | Result, | |
| 7 | Schema, | |
| 8 | SchemaGetter, | |
| 9 | SchemaIssue, | |
| 10 | SchemaTransformation, | |
| 11 | } from "effect"; | |
| 2 | 12 | |
| 3 | 13 | export const decodeJsonResult = <S extends Schema.Codec<unknown, unknown, never, never>>( |
| 4 | 14 | schema: S, |
| ... | ... | @@ -32,3 +42,55 @@ export const formatSchemaError = (cause: Cause.Cause<Schema.SchemaError>) => { |
| 32 | 42 | ? SchemaIssue.makeFormatterDefault()(squashed.issue) |
| 33 | 43 | : Cause.pretty(cause); |
| 34 | 44 | }; |
| 45 | ||
| 46 | /** | |
| 47 | * A `Getter` that parses a lenient JSON string (tolerating trailing commas | |
| 48 | * and JS-style comments) into an unknown value. | |
| 49 | * | |
| 50 | * Mirrors `SchemaGetter.parseJson()` but uses `parseLenientJson` instead | |
| 51 | * of `JSON.parse`. | |
| 52 | */ | |
| 53 | const parseLenientJsonGetter = SchemaGetter.onSome((input: string) => | |
| 54 | Effect.try({ | |
| 55 | try: () => { | |
| 56 | // Strip single-line comments — alternation preserves quoted strings. | |
| 57 | let stripped = input.replace( | |
| 58 | /("(?:[^"\\]|\\.)*")|\/\/[^\n]*/g, | |
| 59 | (match, stringLiteral: string | undefined) => (stringLiteral ? match : ""), | |
| 60 | ); | |
| 61 | ||
| 62 | // Strip multi-line comments. | |
| 63 | stripped = stripped.replace( | |
| 64 | /("(?:[^"\\]|\\.)*")|\/\*[\s\S]*?\*\//g, | |
| 65 | (match, stringLiteral: string | undefined) => (stringLiteral ? match : ""), | |
| 66 | ); | |
| 67 | ||
| 68 | // Strip trailing commas before `}` or `]`. | |
| 69 | stripped = stripped.replace(/,(\s*[}\]])/g, "$1"); | |
| 70 | ||
| 71 | return Option.some(JSON.parse(stripped)); | |
| 72 | }, | |
| 73 | catch: (e) => new SchemaIssue.InvalidValue(Option.some(input), { message: String(e) }), | |
| 74 | }), | |
| 75 | ); | |
| 76 | ||
| 77 | /** | |
| 78 | * Schema transformation: lenient JSONC string ↔ unknown. | |
| 79 | * | |
| 80 | * Same API as `SchemaTransformation.fromJsonString`, but the decode side | |
| 81 | * strips trailing commas and JS-style comments before parsing. | |
| 82 | * Encoding produces strict JSON via `JSON.stringify`. | |
| 83 | */ | |
| 84 | export const fromLenientJsonString = new SchemaTransformation.Transformation( | |
| 85 | parseLenientJsonGetter, | |
| 86 | SchemaGetter.stringifyJson(), | |
| 87 | ); | |
| 88 | ||
| 89 | /** | |
| 90 | * Build a schema that decodes a lenient JSON string into `A`. | |
| 91 | * | |
| 92 | * Drop-in replacement for `Schema.fromJsonString(schema)` that tolerates | |
| 93 | * trailing commas and comments in the input. | |
| 94 | */ | |
| 95 | export const fromLenientJson = <S extends Schema.Top>(schema: S) => | |
| 96 | Schema.String.pipe(Schema.decodeTo(schema, fromLenientJsonString)); |