| 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,6 +38,7 @@ import { ProjectionPendingApprovalRepository } from "../src/persistence/Services |
| 38 | import { ProviderUnsupportedError } from "../src/provider/Errors.ts"; | 38 | import { ProviderUnsupportedError } from "../src/provider/Errors.ts"; |
| 39 | import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; | 39 | import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; |
| 40 | import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; | 40 | import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; |
| 41 | import { ServerSettingsService } from "../src/serverSettings.ts"; | ||
| 41 | import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; | 42 | import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; |
| 42 | import { makeCodexAdapterLive } from "../src/provider/Layers/CodexAdapter.ts"; | 43 | import { makeCodexAdapterLive } from "../src/provider/Layers/CodexAdapter.ts"; |
| 43 | import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts"; | 44 | import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts"; |
| ... | @@ -295,8 +296,10 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -295,8 +296,10 @@ export const makeOrchestrationIntegrationHarness = ( |
| 295 | providerLayer, | 296 | providerLayer, |
| 296 | RuntimeReceiptBusLive, | 297 | RuntimeReceiptBusLive, |
| 297 | ); | 298 | ); |
| 299 | const serverSettingsLayer = ServerSettingsService.layerTest(); | ||
| 298 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( | 300 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( |
| 299 | Layer.provideMerge(runtimeServicesLayer), | 301 | Layer.provideMerge(runtimeServicesLayer), |
| 302 | Layer.provideMerge(serverSettingsLayer), | ||
| 300 | ); | 303 | ); |
| 301 | const gitCoreLayer = Layer.succeed(GitCore, { | 304 | const gitCoreLayer = Layer.succeed(GitCore, { |
| 302 | renameBranch: (input: Parameters<GitCoreShape["renameBranch"]>[0]) => | 305 | renameBranch: (input: Parameters<GitCoreShape["renameBranch"]>[0]) => |
| ... | @@ -309,6 +312,7 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -309,6 +312,7 @@ export const makeOrchestrationIntegrationHarness = ( |
| 309 | Layer.provideMerge(runtimeServicesLayer), | 312 | Layer.provideMerge(runtimeServicesLayer), |
| 310 | Layer.provideMerge(gitCoreLayer), | 313 | Layer.provideMerge(gitCoreLayer), |
| 311 | Layer.provideMerge(textGenerationLayer), | 314 | Layer.provideMerge(textGenerationLayer), |
| 315 | Layer.provideMerge(serverSettingsLayer), | ||
| 312 | ); | 316 | ); |
| 313 | const checkpointReactorLayer = CheckpointReactorLive.pipe( | 317 | const checkpointReactorLayer = CheckpointReactorLive.pipe( |
| 314 | Layer.provideMerge(runtimeServicesLayer), | 318 | Layer.provideMerge(runtimeServicesLayer), |
| ... | @@ -320,6 +324,7 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -320,6 +324,7 @@ export const makeOrchestrationIntegrationHarness = ( |
| 320 | ); | 324 | ); |
| 321 | const layer = orchestrationReactorLayer.pipe( | 325 | const layer = orchestrationReactorLayer.pipe( |
| 322 | Layer.provide(persistenceLayer), | 326 | Layer.provide(persistenceLayer), |
| 327 | Layer.provideMerge(ServerSettingsService.layerTest()), | ||
| 323 | Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), | 328 | Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), |
| 324 | Layer.provideMerge(NodeServices.layer), | 329 | Layer.provideMerge(NodeServices.layer), |
| 325 | ); | 330 | ); |
apps/server/integration/providerService.integration.test.ts+3| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import type { ProviderRuntimeEvent } from "@t3tools/contracts"; | 1 | import type { ProviderRuntimeEvent } from "@t3tools/contracts"; |
| 2 | import { ThreadId } from "@t3tools/contracts"; | 2 | import { ThreadId } from "@t3tools/contracts"; |
| 3 | import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings"; | ||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import { it, assert } from "@effect/vitest"; | 5 | import { it, assert } from "@effect/vitest"; |
| 5 | import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect"; | 6 | import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect"; |
| ... | @@ -12,6 +13,7 @@ import { | ... | @@ -12,6 +13,7 @@ import { |
| 12 | ProviderService, | 13 | ProviderService, |
| 13 | type ProviderServiceShape, | 14 | type ProviderServiceShape, |
| 14 | } from "../src/provider/Services/ProviderService.ts"; | 15 | } from "../src/provider/Services/ProviderService.ts"; |
| 16 | import { ServerSettingsService } from "../src/serverSettings.ts"; | ||
| 15 | import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; | 17 | import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; |
| 16 | import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; | 18 | import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; |
| 17 | import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; | 19 | import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; |
| ... | @@ -60,6 +62,7 @@ const makeIntegrationFixture = Effect.gen(function* () { | ... | @@ -60,6 +62,7 @@ const makeIntegrationFixture = Effect.gen(function* () { |
| 60 | const shared = Layer.mergeAll( | 62 | const shared = Layer.mergeAll( |
| 61 | directoryLayer, | 63 | directoryLayer, |
| 62 | Layer.succeed(ProviderAdapterRegistry, registry), | 64 | Layer.succeed(ProviderAdapterRegistry, registry), |
| 65 | ServerSettingsService.layerTest(DEFAULT_SERVER_SETTINGS), | ||
| 63 | AnalyticsService.layerTest, | 66 | AnalyticsService.layerTest, |
| 64 | ).pipe(Layer.provide(SqlitePersistenceMemory)); | 67 | ).pipe(Layer.provide(SqlitePersistenceMemory)); |
| 65 | 68 |
apps/server/src/codexAppServerManager.test.ts+6-12| ... | @@ -342,6 +342,7 @@ describe("startSession", () => { | ... | @@ -342,6 +342,7 @@ describe("startSession", () => { |
| 342 | manager.startSession({ | 342 | manager.startSession({ |
| 343 | threadId: asThreadId("thread-1"), | 343 | threadId: asThreadId("thread-1"), |
| 344 | provider: "codex", | 344 | provider: "codex", |
| 345 | binaryPath: "codex", | ||
| 345 | runtimeMode: "full-access", | 346 | runtimeMode: "full-access", |
| 346 | }), | 347 | }), |
| 347 | ).rejects.toThrow("cwd missing"); | 348 | ).rejects.toThrow("cwd missing"); |
| ... | @@ -390,6 +391,7 @@ describe("startSession", () => { | ... | @@ -390,6 +391,7 @@ describe("startSession", () => { |
| 390 | manager.startSession({ | 391 | manager.startSession({ |
| 391 | threadId: asThreadId("thread-1"), | 392 | threadId: asThreadId("thread-1"), |
| 392 | provider: "codex", | 393 | provider: "codex", |
| 394 | binaryPath: "codex", | ||
| 393 | runtimeMode: "full-access", | 395 | runtimeMode: "full-access", |
| 394 | }), | 396 | }), |
| 395 | ).rejects.toThrow( | 397 | ).rejects.toThrow( |
| ... | @@ -948,12 +950,8 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume" | ... | @@ -948,12 +950,8 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume" |
| 948 | provider: "codex", | 950 | provider: "codex", |
| 949 | cwd: workspaceDir, | 951 | cwd: workspaceDir, |
| 950 | runtimeMode: "full-access", | 952 | runtimeMode: "full-access", |
| 951 | providerOptions: { | 953 | binaryPath: process.env.CODEX_BINARY_PATH!, |
| 952 | codex: { | 954 | ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}), |
| 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 | }, | ||
| 957 | }); | 955 | }); |
| 958 | 956 | ||
| 959 | const firstTurn = await manager.sendTurn({ | 957 | const firstTurn = await manager.sendTurn({ |
| ... | @@ -983,12 +981,8 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume" | ... | @@ -983,12 +981,8 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume" |
| 983 | cwd: workspaceDir, | 981 | cwd: workspaceDir, |
| 984 | runtimeMode: "approval-required", | 982 | runtimeMode: "approval-required", |
| 985 | resumeCursor: firstSession.resumeCursor, | 983 | resumeCursor: firstSession.resumeCursor, |
| 986 | providerOptions: { | 984 | binaryPath: process.env.CODEX_BINARY_PATH!, |
| 987 | codex: { | 985 | ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}), |
| 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 | }, | ||
| 992 | }); | 986 | }); |
| 993 | 987 | ||
| 994 | expect(resumedSession.threadId).toBe(originalThreadId); | 988 | expect(resumedSession.threadId).toBe(originalThreadId); |
apps/server/src/codexAppServerManager.ts+9-20| ... | @@ -14,7 +14,6 @@ import { | ... | @@ -14,7 +14,6 @@ import { |
| 14 | type ProviderApprovalDecision, | 14 | type ProviderApprovalDecision, |
| 15 | type ProviderEvent, | 15 | type ProviderEvent, |
| 16 | type ProviderSession, | 16 | type ProviderSession, |
| 17 | type ProviderSessionStartInput, | ||
| 18 | type ProviderTurnStartResult, | 17 | type ProviderTurnStartResult, |
| 19 | RuntimeMode, | 18 | RuntimeMode, |
| 20 | ProviderInteractionMode, | 19 | ProviderInteractionMode, |
| ... | @@ -131,7 +130,8 @@ export interface CodexAppServerStartSessionInput { | ... | @@ -131,7 +130,8 @@ export interface CodexAppServerStartSessionInput { |
| 131 | readonly model?: string; | 130 | readonly model?: string; |
| 132 | readonly serviceTier?: string; | 131 | readonly serviceTier?: string; |
| 133 | readonly resumeCursor?: unknown; | 132 | readonly resumeCursor?: unknown; |
| 134 | readonly providerOptions?: ProviderSessionStartInput["providerOptions"]; | 133 | readonly binaryPath: string; |
| 134 | readonly homePath?: string; | ||
| 135 | readonly runtimeMode: RuntimeMode; | 135 | readonly runtimeMode: RuntimeMode; |
| 136 | } | 136 | } |
| 137 | 137 | ||
| ... | @@ -541,9 +541,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -541,9 +541,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 541 | updatedAt: now, | 541 | updatedAt: now, |
| 542 | }; | 542 | }; |
| 543 | 543 | ||
| 544 | const codexOptions = readCodexProviderOptions(input); | 544 | const codexBinaryPath = input.binaryPath; |
| 545 | const codexBinaryPath = codexOptions.binaryPath ?? "codex"; | 545 | const codexHomePath = input.homePath; |
| 546 | const codexHomePath = codexOptions.homePath; | ||
| 547 | this.assertSupportedCodexCliVersion({ | 546 | this.assertSupportedCodexCliVersion({ |
| 548 | binaryPath: codexBinaryPath, | 547 | binaryPath: codexBinaryPath, |
| 549 | cwd: resolvedCwd, | 548 | cwd: resolvedCwd, |
| ... | @@ -1591,20 +1590,6 @@ function normalizeProviderThreadId(value: string | undefined): string | undefine | ... | @@ -1591,20 +1590,6 @@ function normalizeProviderThreadId(value: string | undefined): string | undefine |
| 1591 | return brandIfNonEmpty(value, (normalized) => normalized); | 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 | function assertSupportedCodexCliVersion(input: { | 1593 | function assertSupportedCodexCliVersion(input: { |
| 1609 | readonly binaryPath: string; | 1594 | readonly binaryPath: string; |
| 1610 | readonly cwd: string; | 1595 | readonly cwd: string; |
| ... | @@ -1658,7 +1643,11 @@ function readResumeCursorThreadId(resumeCursor: unknown): string | undefined { | ... | @@ -1658,7 +1643,11 @@ function readResumeCursorThreadId(resumeCursor: unknown): string | undefined { |
| 1658 | return typeof rawThreadId === "string" ? normalizeProviderThreadId(rawThreadId) : undefined; | 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 | return readResumeCursorThreadId(input.resumeCursor); | 1651 | return readResumeCursorThreadId(input.resumeCursor); |
| 1663 | } | 1652 | } |
| 1664 | 1653 |
apps/server/src/config.ts+2| ... | @@ -19,6 +19,7 @@ export interface ServerDerivedPaths { | ... | @@ -19,6 +19,7 @@ export interface ServerDerivedPaths { |
| 19 | readonly stateDir: string; | 19 | readonly stateDir: string; |
| 20 | readonly dbPath: string; | 20 | readonly dbPath: string; |
| 21 | readonly keybindingsConfigPath: string; | 21 | readonly keybindingsConfigPath: string; |
| 22 | readonly settingsPath: string; | ||
| 22 | readonly worktreesDir: string; | 23 | readonly worktreesDir: string; |
| 23 | readonly attachmentsDir: string; | 24 | readonly attachmentsDir: string; |
| 24 | readonly logsDir: string; | 25 | readonly logsDir: string; |
| ... | @@ -60,6 +61,7 @@ export const deriveServerPaths = Effect.fn(function* ( | ... | @@ -60,6 +61,7 @@ export const deriveServerPaths = Effect.fn(function* ( |
| 60 | stateDir, | 61 | stateDir, |
| 61 | dbPath, | 62 | dbPath, |
| 62 | keybindingsConfigPath: join(stateDir, "keybindings.json"), | 63 | keybindingsConfigPath: join(stateDir, "keybindings.json"), |
| 64 | settingsPath: join(stateDir, "settings.json"), | ||
| 63 | worktreesDir: join(baseDir, "worktrees"), | 65 | worktreesDir: join(baseDir, "worktrees"), |
| 64 | attachmentsDir, | 66 | attachmentsDir, |
| 65 | logsDir, | 67 | logsDir, |
apps/server/src/git/Layers/ClaudeTextGeneration.test.ts+2| ... | @@ -6,8 +6,10 @@ import { expect } from "vitest"; | ... | @@ -6,8 +6,10 @@ import { expect } from "vitest"; |
| 6 | import { ServerConfig } from "../../config.ts"; | 6 | import { ServerConfig } from "../../config.ts"; |
| 7 | import { TextGeneration } from "../Services/TextGeneration.ts"; | 7 | import { TextGeneration } from "../Services/TextGeneration.ts"; |
| 8 | import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; | 8 | import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; |
| 9 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 9 | 10 | ||
| 10 | const ClaudeTextGenerationTestLayer = ClaudeTextGenerationLive.pipe( | 11 | const ClaudeTextGenerationTestLayer = ClaudeTextGenerationLive.pipe( |
| 12 | Layer.provideMerge(ServerSettingsService.layerTest()), | ||
| 11 | Layer.provideMerge( | 13 | Layer.provideMerge( |
| 12 | ServerConfig.layerTest(process.cwd(), { | 14 | ServerConfig.layerTest(process.cwd(), { |
| 13 | prefix: "t3code-claude-text-generation-test-", | 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,7 +11,6 @@ import { Effect, Layer, Option, Schema, Stream } from "effect"; |
| 11 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 11 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 12 | 12 | ||
| 13 | import { ClaudeModelSelection } from "@t3tools/contracts"; | 13 | import { ClaudeModelSelection } from "@t3tools/contracts"; |
| 14 | import { normalizeClaudeModelOptions } from "@t3tools/shared/model"; | ||
| 15 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | 14 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 16 | 15 | ||
| 17 | import { TextGenerationError } from "../Errors.ts"; | 16 | import { TextGenerationError } from "../Errors.ts"; |
| ... | @@ -27,6 +26,8 @@ import { | ... | @@ -27,6 +26,8 @@ import { |
| 27 | sanitizePrTitle, | 26 | sanitizePrTitle, |
| 28 | toJsonSchemaObject, | 27 | toJsonSchemaObject, |
| 29 | } from "../Utils.ts"; | 28 | } from "../Utils.ts"; |
| 29 | import { normalizeClaudeModelOptions } from "../../provider/Layers/ClaudeProvider.ts"; | ||
| 30 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 30 | 31 | ||
| 31 | const CLAUDE_TIMEOUT_MS = 180_000; | 32 | const CLAUDE_TIMEOUT_MS = 180_000; |
| 32 | 33 | ||
| ... | @@ -40,6 +41,7 @@ const ClaudeOutputEnvelope = Schema.Struct({ | ... | @@ -40,6 +41,7 @@ const ClaudeOutputEnvelope = Schema.Struct({ |
| 40 | 41 | ||
| 41 | const makeClaudeTextGeneration = Effect.gen(function* () { | 42 | const makeClaudeTextGeneration = Effect.gen(function* () { |
| 42 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 43 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 44 | const serverSettingsService = yield* Effect.service(ServerSettingsService); | ||
| 43 | 45 | ||
| 44 | const readStreamAsString = <E>( | 46 | const readStreamAsString = <E>( |
| 45 | operation: string, | 47 | operation: string, |
| ... | @@ -86,9 +88,14 @@ const makeClaudeTextGeneration = Effect.gen(function* () { | ... | @@ -86,9 +88,14 @@ const makeClaudeTextGeneration = Effect.gen(function* () { |
| 86 | ...(normalizedOptions?.fastMode ? { fastMode: true } : {}), | 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 | const runClaudeCommand = Effect.gen(function* () { | 96 | const runClaudeCommand = Effect.gen(function* () { |
| 90 | const command = ChildProcess.make( | 97 | const command = ChildProcess.make( |
| 91 | "claude", | 98 | claudeSettings?.binaryPath || "claude", |
| 92 | [ | 99 | [ |
| 93 | "-p", | 100 | "-p", |
| 94 | "--output-format", | 101 | "--output-format", |
apps/server/src/git/Layers/CodexTextGeneration.test.ts+99-172| ... | @@ -7,6 +7,7 @@ import { ServerConfig } from "../../config.ts"; | ... | @@ -7,6 +7,7 @@ import { ServerConfig } from "../../config.ts"; |
| 7 | import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; | 7 | import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; |
| 8 | import { TextGenerationError } from "../Errors.ts"; | 8 | import { TextGenerationError } from "../Errors.ts"; |
| 9 | import { TextGeneration } from "../Services/TextGeneration.ts"; | 9 | import { TextGeneration } from "../Services/TextGeneration.ts"; |
| 10 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 10 | 11 | ||
| 11 | const DEFAULT_TEST_MODEL_SELECTION = { | 12 | const DEFAULT_TEST_MODEL_SELECTION = { |
| 12 | provider: "codex" as const, | 13 | provider: "codex" as const, |
| ... | @@ -14,6 +15,7 @@ const DEFAULT_TEST_MODEL_SELECTION = { | ... | @@ -14,6 +15,7 @@ const DEFAULT_TEST_MODEL_SELECTION = { |
| 14 | }; | 15 | }; |
| 15 | 16 | ||
| 16 | const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe( | 17 | const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe( |
| 18 | Layer.provideMerge(ServerSettingsService.layerTest()), | ||
| 17 | Layer.provideMerge( | 19 | Layer.provideMerge( |
| 18 | ServerConfig.layerTest(process.cwd(), { | 20 | ServerConfig.layerTest(process.cwd(), { |
| 19 | prefix: "t3code-codex-text-generation-test-", | 21 | prefix: "t3code-codex-text-generation-test-", |
| ... | @@ -22,7 +24,20 @@ const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe( | ... | @@ -22,7 +24,20 @@ const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe( |
| 22 | Layer.provideMerge(NodeServices.layer), | 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 | return Effect.gen(function* () { | 41 | return Effect.gen(function* () { |
| 27 | const fs = yield* FileSystem.FileSystem; | 42 | const fs = yield* FileSystem.FileSystem; |
| 28 | const path = yield* Path.Path; | 43 | const path = yield* Path.Path; |
| ... | @@ -35,12 +50,16 @@ function makeFakeCodexBinary(dir: string) { | ... | @@ -35,12 +50,16 @@ function makeFakeCodexBinary(dir: string) { |
| 35 | [ | 50 | [ |
| 36 | "#!/bin/sh", | 51 | "#!/bin/sh", |
| 37 | 'output_path=""', | 52 | 'output_path=""', |
| 53 | 'seen_image="0"', | ||
| 54 | 'seen_fast_service_tier="0"', | ||
| 55 | 'seen_reasoning_effort=""', | ||
| 38 | "while [ $# -gt 0 ]; do", | 56 | "while [ $# -gt 0 ]; do", |
| 39 | ' if [ "$1" = "--image" ]; then', | 57 | ' if [ "$1" = "--image" ]; then', |
| 40 | " shift", | 58 | " shift", |
| 41 | ' if [ -n "$1" ]; then', | 59 | ' if [ -n "$1" ]; then', |
| 42 | ' seen_image="1"', | 60 | ' seen_image="1"', |
| 43 | " fi", | 61 | " fi", |
| 62 | " shift", | ||
| 44 | " continue", | 63 | " continue", |
| 45 | " fi", | 64 | " fi", |
| 46 | ' if [ "$1" = "--config" ]; then', | 65 | ' if [ "$1" = "--config" ]; then', |
| ... | @@ -53,55 +72,80 @@ function makeFakeCodexBinary(dir: string) { | ... | @@ -53,55 +72,80 @@ function makeFakeCodexBinary(dir: string) { |
| 53 | ' seen_reasoning_effort="$1"', | 72 | ' seen_reasoning_effort="$1"', |
| 54 | " ;;", | 73 | " ;;", |
| 55 | " esac", | 74 | " esac", |
| 75 | " shift", | ||
| 56 | " continue", | 76 | " continue", |
| 57 | " fi", | 77 | " fi", |
| 58 | ' if [ "$1" = "--output-last-message" ]; then', | 78 | ' if [ "$1" = "--output-last-message" ]; then', |
| 59 | " shift", | 79 | " shift", |
| 60 | ' output_path="$1"', | 80 | ' output_path="$1"', |
| 81 | " shift", | ||
| 82 | " continue", | ||
| 61 | " fi", | 83 | " fi", |
| 62 | " shift", | 84 | " shift", |
| 63 | "done", | 85 | "done", |
| 64 | 'stdin_content="$(cat)"', | 86 | 'stdin_content="$(cat)"', |
| 65 | 'if [ "$T3_FAKE_CODEX_REQUIRE_IMAGE" = "1" ] && [ "$seen_image" != "1" ]; then', | 87 | ...(input.requireImage |
| 66 | ' printf "%s\\n" "missing --image input" >&2', | 88 | ? [ |
| 67 | " exit 2", | 89 | 'if [ "$seen_image" != "1" ]; then', |
| 68 | "fi", | 90 | ' printf "%s\\n" "missing --image input" >&2', |
| 69 | 'if [ "$T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER" = "1" ] && [ "$seen_fast_service_tier" != "1" ]; then', | 91 | ` exit 2`, |
| 70 | ' printf "%s\\n" "missing fast service tier config" >&2', | 92 | "fi", |
| 71 | " exit 5", | 93 | ] |
| 72 | "fi", | 94 | : []), |
| 73 | 'if [ -n "$T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT" ] && [ "$seen_reasoning_effort" != "model_reasoning_effort=\\"$T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT\\"" ]; then', | 95 | ...(input.requireFastServiceTier |
| 74 | ' printf "%s\\n" "unexpected reasoning effort config: $seen_reasoning_effort" >&2', | 96 | ? [ |
| 75 | " exit 6", | 97 | 'if [ "$seen_fast_service_tier" != "1" ]; then', |
| 76 | "fi", | 98 | ' printf "%s\\n" "missing fast service tier config" >&2', |
| 77 | 'if [ "$T3_FAKE_CODEX_FORBID_REASONING_EFFORT" = "1" ] && [ -n "$seen_reasoning_effort" ]; then', | 99 | ` exit 5`, |
| 78 | ' printf "%s\\n" "reasoning effort config should be omitted: $seen_reasoning_effort" >&2', | 100 | "fi", |
| 79 | " exit 7", | 101 | ] |
| 80 | "fi", | 102 | : []), |
| 81 | 'if [ -n "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" ]; then', | 103 | ...(input.requireReasoningEffort !== undefined |
| 82 | ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" >/dev/null || {', | 104 | ? [ |
| 83 | ' printf "%s\\n" "stdin missing expected content" >&2', | 105 | `if [ "$seen_reasoning_effort" != "model_reasoning_effort=\\"${input.requireReasoningEffort}\\"" ]; then`, |
| 84 | " exit 3", | 106 | ' printf "%s\\n" "unexpected reasoning effort config: $seen_reasoning_effort" >&2', |
| 85 | " }", | 107 | ` exit 6`, |
| 86 | "fi", | 108 | "fi", |
| 87 | 'if [ -n "$T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN" ]; then', | 109 | ] |
| 88 | ' if printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN" >/dev/null; then', | 110 | : []), |
| 89 | ' printf "%s\\n" "stdin contained forbidden content" >&2', | 111 | ...(input.forbidReasoningEffort |
| 90 | " exit 4", | 112 | ? [ |
| 91 | " fi", | 113 | 'if [ -n "$seen_reasoning_effort" ]; then', |
| 92 | "fi", | 114 | ' printf "%s\\n" "reasoning effort config should be omitted: $seen_reasoning_effort" >&2', |
| 93 | 'if [ -n "$T3_FAKE_CODEX_STDERR" ]; then', | 115 | ` exit 7`, |
| 94 | ' printf "%s\\n" "$T3_FAKE_CODEX_STDERR" >&2', | 116 | "fi", |
| 95 | "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 | 'if [ -n "$output_path" ]; then', | 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 | "fi", | 142 | "fi", |
| 99 | 'exit "${T3_FAKE_CODEX_EXIT_CODE:-0}"', | 143 | `exit ${input.exitCode ?? 0}`, |
| 100 | "", | 144 | "", |
| 101 | ].join("\n"), | 145 | ].join("\n"), |
| 102 | ); | 146 | ); |
| 103 | yield* fs.chmod(codexPath, 0o755); | 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,146 +167,29 @@ function withFakeCodexEnv<A, E, R>( |
| 123 | Effect.gen(function* () { | 167 | Effect.gen(function* () { |
| 124 | const fs = yield* FileSystem.FileSystem; | 168 | const fs = yield* FileSystem.FileSystem; |
| 125 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); | 169 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); |
| 126 | const binDir = yield* makeFakeCodexBinary(tempDir); | 170 | const codexPath = yield* makeFakeCodexBinary(tempDir, input); |
| 127 | const previousPath = process.env.PATH; | 171 | const serverSettings = yield* ServerSettingsService; |
| 128 | const previousOutput = process.env.T3_FAKE_CODEX_OUTPUT_B64; | 172 | const previousSettings = yield* serverSettings.getSettings; |
| 129 | const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE; | 173 | yield* serverSettings.updateSettings({ |
| 130 | const previousStderr = process.env.T3_FAKE_CODEX_STDERR; | 174 | providers: { |
| 131 | const previousRequireImage = process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; | 175 | codex: { |
| 132 | const previousRequireFastServiceTier = process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER; | 176 | binaryPath: codexPath, |
| 133 | const previousRequireReasoningEffort = process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT; | 177 | }, |
| 134 | const previousForbidReasoningEffort = process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT; | 178 | }, |
| 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 | } | ||
| 189 | }); | 179 | }); |
| 190 | 180 | return { serverSettings, previousBinaryPath: previousSettings.providers.codex.binaryPath }; | |
| 191 | return { | ||
| 192 | previousPath, | ||
| 193 | previousOutput, | ||
| 194 | previousExitCode, | ||
| 195 | previousStderr, | ||
| 196 | previousRequireImage, | ||
| 197 | previousRequireFastServiceTier, | ||
| 198 | previousRequireReasoningEffort, | ||
| 199 | previousForbidReasoningEffort, | ||
| 200 | previousStdinMustContain, | ||
| 201 | previousStdinMustNotContain, | ||
| 202 | }; | ||
| 203 | }), | 181 | }), |
| 204 | () => effect, | 182 | () => effect, |
| 205 | (previous) => | 183 | ({ serverSettings, previousBinaryPath }) => |
| 206 | Effect.sync(() => { | 184 | serverSettings |
| 207 | process.env.PATH = previous.previousPath; | 185 | .updateSettings({ |
| 208 | 186 | providers: { | |
| 209 | if (previous.previousOutput === undefined) { | 187 | codex: { |
| 210 | delete process.env.T3_FAKE_CODEX_OUTPUT_B64; | 188 | binaryPath: previousBinaryPath, |
| 211 | } else { | 189 | }, |
| 212 | process.env.T3_FAKE_CODEX_OUTPUT_B64 = previous.previousOutput; | 190 | }, |
| 213 | } | 191 | }) |
| 214 | 192 | .pipe(Effect.asVoid), | |
| 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 | }), | ||
| 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,7 +4,6 @@ import { Effect, FileSystem, Layer, Option, Path, Schema, Scope, Stream } from " |
| 4 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 4 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 5 | 5 | ||
| 6 | import { CodexModelSelection } from "@t3tools/contracts"; | 6 | import { CodexModelSelection } from "@t3tools/contracts"; |
| 7 | import { normalizeCodexModelOptions } from "@t3tools/shared/model"; | ||
| 8 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | 7 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 9 | 8 | ||
| 10 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | 9 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| ... | @@ -26,6 +25,8 @@ import { | ... | @@ -26,6 +25,8 @@ import { |
| 26 | sanitizePrTitle, | 25 | sanitizePrTitle, |
| 27 | toJsonSchemaObject, | 26 | toJsonSchemaObject, |
| 28 | } from "../Utils.ts"; | 27 | } from "../Utils.ts"; |
| 28 | import { normalizeCodexModelOptions } from "../../provider/Layers/CodexProvider.ts"; | ||
| 29 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 29 | 30 | ||
| 30 | const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; | 31 | const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; |
| 31 | const CODEX_TIMEOUT_MS = 180_000; | 32 | const CODEX_TIMEOUT_MS = 180_000; |
| ... | @@ -35,6 +36,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -35,6 +36,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 35 | const path = yield* Path.Path; | 36 | const path = yield* Path.Path; |
| 36 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 37 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 37 | const serverConfig = yield* Effect.service(ServerConfig); | 38 | const serverConfig = yield* Effect.service(ServerConfig); |
| 39 | const serverSettingsService = yield* Effect.service(ServerSettingsService); | ||
| 38 | 40 | ||
| 39 | type MaterializedImageAttachments = { | 41 | type MaterializedImageAttachments = { |
| 40 | readonly imagePaths: ReadonlyArray<string>; | 42 | readonly imagePaths: ReadonlyArray<string>; |
| ... | @@ -138,6 +140,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -138,6 +140,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 138 | ); | 140 | ); |
| 139 | const outputPath = yield* writeTempFile(operation, "codex-output", ""); | 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 | const runCodexCommand = Effect.gen(function* () { | 148 | const runCodexCommand = Effect.gen(function* () { |
| 142 | const normalizedOptions = normalizeCodexModelOptions( | 149 | const normalizedOptions = normalizeCodexModelOptions( |
| 143 | modelSelection.model, | 150 | modelSelection.model, |
| ... | @@ -146,7 +153,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -146,7 +153,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 146 | const reasoningEffort = | 153 | const reasoningEffort = |
| 147 | modelSelection.options?.reasoningEffort ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; | 154 | modelSelection.options?.reasoningEffort ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; |
| 148 | const command = ChildProcess.make( | 155 | const command = ChildProcess.make( |
| 149 | "codex", | 156 | codexSettings?.binaryPath || "codex", |
| 150 | [ | 157 | [ |
| 151 | "exec", | 158 | "exec", |
| 152 | "--ephemeral", | 159 | "--ephemeral", |
| ... | @@ -165,6 +172,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -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 | cwd, | 179 | cwd, |
| 169 | shell: process.platform === "win32", | 180 | shell: process.platform === "win32", |
| 170 | stdin: { | 181 | stdin: { |
apps/server/src/git/Layers/GitManager.test.ts+4-6| ... | @@ -20,11 +20,7 @@ import { GitCoreLive } from "./GitCore.ts"; | ... | @@ -20,11 +20,7 @@ import { GitCoreLive } from "./GitCore.ts"; |
| 20 | import { GitCore } from "../Services/GitCore.ts"; | 20 | import { GitCore } from "../Services/GitCore.ts"; |
| 21 | import { makeGitManager } from "./GitManager.ts"; | 21 | import { makeGitManager } from "./GitManager.ts"; |
| 22 | import { ServerConfig } from "../../config.ts"; | 22 | import { ServerConfig } from "../../config.ts"; |
| 23 | 23 | import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 24 | const DEFAULT_TEST_MODEL_SELECTION = { | ||
| 25 | provider: "codex", | ||
| 26 | model: "gpt-5.4-mini", | ||
| 27 | } as const; | ||
| 28 | 24 | ||
| 29 | interface FakeGhScenario { | 25 | interface FakeGhScenario { |
| 30 | prListSequence?: string[]; | 26 | prListSequence?: string[]; |
| ... | @@ -466,7 +462,6 @@ function runStackedAction( | ... | @@ -466,7 +462,6 @@ function runStackedAction( |
| 466 | { | 462 | { |
| 467 | ...input, | 463 | ...input, |
| 468 | actionId: input.actionId ?? "test-action-id", | 464 | actionId: input.actionId ?? "test-action-id", |
| 469 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 470 | }, | 465 | }, |
| 471 | options, | 466 | options, |
| 472 | ); | 467 | ); |
| ... | @@ -493,6 +488,8 @@ function makeManager(input?: { | ... | @@ -493,6 +488,8 @@ function makeManager(input?: { |
| 493 | prefix: "t3-git-manager-test-", | 488 | prefix: "t3-git-manager-test-", |
| 494 | }); | 489 | }); |
| 495 | 490 | ||
| 491 | const serverSettingsLayer = ServerSettingsService.layerTest(); | ||
| 492 | |||
| 496 | const gitCoreLayer = GitCoreLive.pipe( | 493 | const gitCoreLayer = GitCoreLive.pipe( |
| 497 | Layer.provideMerge(NodeServices.layer), | 494 | Layer.provideMerge(NodeServices.layer), |
| 498 | Layer.provideMerge(ServerConfigLayer), | 495 | Layer.provideMerge(ServerConfigLayer), |
| ... | @@ -502,6 +499,7 @@ function makeManager(input?: { | ... | @@ -502,6 +499,7 @@ function makeManager(input?: { |
| 502 | Layer.succeed(GitHubCli, gitHubCli), | 499 | Layer.succeed(GitHubCli, gitHubCli), |
| 503 | Layer.succeed(TextGeneration, textGeneration), | 500 | Layer.succeed(TextGeneration, textGeneration), |
| 504 | gitCoreLayer, | 501 | gitCoreLayer, |
| 502 | serverSettingsLayer, | ||
| 505 | ).pipe(Layer.provideMerge(NodeServices.layer)); | 503 | ).pipe(Layer.provideMerge(NodeServices.layer)); |
| 506 | 504 | ||
| 507 | return makeGitManager.pipe( | 505 | return makeGitManager.pipe( |
apps/server/src/git/Layers/GitManager.ts+12-3| ... | @@ -19,6 +19,7 @@ import { | ... | @@ -19,6 +19,7 @@ import { |
| 19 | import { GitCore } from "../Services/GitCore.ts"; | 19 | import { GitCore } from "../Services/GitCore.ts"; |
| 20 | import { GitHubCli } from "../Services/GitHubCli.ts"; | 20 | import { GitHubCli } from "../Services/GitHubCli.ts"; |
| 21 | import { TextGeneration } from "../Services/TextGeneration.ts"; | 21 | import { TextGeneration } from "../Services/TextGeneration.ts"; |
| 22 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 22 | 23 | ||
| 23 | const COMMIT_TIMEOUT_MS = 10 * 60_000; | 24 | const COMMIT_TIMEOUT_MS = 10 * 60_000; |
| 24 | const MAX_PROGRESS_TEXT_LENGTH = 500; | 25 | const MAX_PROGRESS_TEXT_LENGTH = 500; |
| ... | @@ -358,6 +359,7 @@ export const makeGitManager = Effect.gen(function* () { | ... | @@ -358,6 +359,7 @@ export const makeGitManager = Effect.gen(function* () { |
| 358 | const gitCore = yield* GitCore; | 359 | const gitCore = yield* GitCore; |
| 359 | const gitHubCli = yield* GitHubCli; | 360 | const gitHubCli = yield* GitHubCli; |
| 360 | const textGeneration = yield* TextGeneration; | 361 | const textGeneration = yield* TextGeneration; |
| 362 | const serverSettingsService = yield* ServerSettingsService; | ||
| 361 | 363 | ||
| 362 | const createProgressEmitter = ( | 364 | const createProgressEmitter = ( |
| 363 | input: { cwd: string; action: "commit" | "commit_push" | "commit_push_pr" }, | 365 | input: { cwd: string; action: "commit" | "commit_push" | "commit_push_pr" }, |
| ... | @@ -1173,6 +1175,13 @@ export const makeGitManager = Effect.gen(function* () { | ... | @@ -1173,6 +1175,13 @@ export const makeGitManager = Effect.gen(function* () { |
| 1173 | let commitMessageForStep = input.commitMessage; | 1175 | let commitMessageForStep = input.commitMessage; |
| 1174 | let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; | 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 | if (input.featureBranch) { | 1185 | if (input.featureBranch) { |
| 1177 | currentPhase = "branch"; | 1186 | currentPhase = "branch"; |
| 1178 | yield* progress.emit({ | 1187 | yield* progress.emit({ |
| ... | @@ -1181,7 +1190,7 @@ export const makeGitManager = Effect.gen(function* () { | ... | @@ -1181,7 +1190,7 @@ export const makeGitManager = Effect.gen(function* () { |
| 1181 | label: "Preparing feature branch...", | 1190 | label: "Preparing feature branch...", |
| 1182 | }); | 1191 | }); |
| 1183 | const result = yield* runFeatureBranchStep( | 1192 | const result = yield* runFeatureBranchStep( |
| 1184 | input.modelSelection, | 1193 | modelSelection, |
| 1185 | input.cwd, | 1194 | input.cwd, |
| 1186 | initialStatus.branch, | 1195 | initialStatus.branch, |
| 1187 | input.commitMessage, | 1196 | input.commitMessage, |
| ... | @@ -1198,7 +1207,7 @@ export const makeGitManager = Effect.gen(function* () { | ... | @@ -1198,7 +1207,7 @@ export const makeGitManager = Effect.gen(function* () { |
| 1198 | 1207 | ||
| 1199 | currentPhase = "commit"; | 1208 | currentPhase = "commit"; |
| 1200 | const commit = yield* runCommitStep( | 1209 | const commit = yield* runCommitStep( |
| 1201 | input.modelSelection, | 1210 | modelSelection, |
| 1202 | input.cwd, | 1211 | input.cwd, |
| 1203 | input.action, | 1212 | input.action, |
| 1204 | currentBranch, | 1213 | currentBranch, |
| ... | @@ -1237,7 +1246,7 @@ export const makeGitManager = Effect.gen(function* () { | ... | @@ -1237,7 +1246,7 @@ export const makeGitManager = Effect.gen(function* () { |
| 1237 | Effect.flatMap(() => | 1246 | Effect.flatMap(() => |
| 1238 | Effect.gen(function* () { | 1247 | Effect.gen(function* () { |
| 1239 | currentPhase = "pr"; | 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,6 +23,7 @@ import { |
| 23 | Cache, | 23 | Cache, |
| 24 | Cause, | 24 | Cause, |
| 25 | Deferred, | 25 | Deferred, |
| 26 | Duration, | ||
| 26 | Effect, | 27 | Effect, |
| 27 | Exit, | 28 | Exit, |
| 28 | FileSystem, | 29 | FileSystem, |
| ... | @@ -42,6 +43,7 @@ import { | ... | @@ -42,6 +43,7 @@ import { |
| 42 | } from "effect"; | 43 | } from "effect"; |
| 43 | import * as Semaphore from "effect/Semaphore"; | 44 | import * as Semaphore from "effect/Semaphore"; |
| 44 | import { ServerConfig } from "./config"; | 45 | import { ServerConfig } from "./config"; |
| 46 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; | ||
| 45 | 47 | ||
| 46 | export class KeybindingsConfigError extends Schema.TaggedErrorClass<KeybindingsConfigError>()( | 48 | export class KeybindingsConfigError extends Schema.TaggedErrorClass<KeybindingsConfigError>()( |
| 47 | "KeybindingsConfigParseError", | 49 | "KeybindingsConfigParseError", |
| ... | @@ -408,7 +410,7 @@ function encodeWhenAst(node: KeybindingWhenNode): string { | ... | @@ -408,7 +410,7 @@ function encodeWhenAst(node: KeybindingWhenNode): string { |
| 408 | 410 | ||
| 409 | const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS); | 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 | const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); | 414 | const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); |
| 413 | const PrettyJsonString = SchemaGetter.parseJson<string>().compose( | 415 | const PrettyJsonString = SchemaGetter.parseJson<string>().compose( |
| 414 | SchemaGetter.stringifyJson({ space: 2 }), | 416 | SchemaGetter.stringifyJson({ space: 2 }), |
| ... | @@ -672,6 +674,7 @@ const makeKeybindings = Effect.gen(function* () { | ... | @@ -672,6 +674,7 @@ const makeKeybindings = Effect.gen(function* () { |
| 672 | Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })), | 674 | Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })), |
| 673 | Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), | 675 | Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), |
| 674 | Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)), | 676 | Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)), |
| 677 | Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))), | ||
| 675 | Effect.mapError( | 678 | Effect.mapError( |
| 676 | (cause) => | 679 | (cause) => |
| 677 | new KeybindingsConfigError({ | 680 | new KeybindingsConfigError({ |
| ... | @@ -817,16 +820,25 @@ const makeKeybindings = Effect.gen(function* () { | ... | @@ -817,16 +820,25 @@ const makeKeybindings = Effect.gen(function* () { |
| 817 | 820 | ||
| 818 | const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); | 821 | const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); |
| 819 | 822 | ||
| 820 | yield* Stream.runForEach(fs.watch(keybindingsConfigDir), (event) => { | 823 | // Debounce watch events so the file is fully written before we read it. |
| 821 | const isTargetConfigEvent = | 824 | // Editors emit multiple events per save (truncate, write, rename) and |
| 822 | event.path === keybindingsConfigFile || | 825 | // `fs.watch` can fire before the content has been flushed to disk. |
| 823 | event.path === keybindingsConfigPath || | 826 | const debouncedKeybindingsEvents = fs.watch(keybindingsConfigDir).pipe( |
| 824 | path.resolve(keybindingsConfigDir, event.path) === keybindingsConfigPathResolved; | 827 | Stream.filter((event) => { |
| 825 | if (!isTargetConfigEvent) { | 828 | return ( |
| 826 | return Effect.void; | 829 | event.path === keybindingsConfigFile || |
| 827 | } | 830 | event.path === keybindingsConfigPath || |
| 828 | return revalidateAndEmitSafely; | 831 | path.resolve(keybindingsConfigDir, event.path) === keybindingsConfigPathResolved |
| 829 | }).pipe(Effect.ignoreCause({ log: true }), Effect.forkIn(watcherScope), Effect.asVoid); | 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 | const start = Effect.gen(function* () { | 844 | const start = Effect.gen(function* () { |
apps/server/src/main.test.ts+2| ... | @@ -17,6 +17,7 @@ import { Open, type OpenShape } from "./open"; | ... | @@ -17,6 +17,7 @@ import { Open, type OpenShape } from "./open"; |
| 17 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; | 17 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; |
| 18 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; | 18 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; |
| 19 | import { Server, type ServerShape } from "./wsServer"; | 19 | import { Server, type ServerShape } from "./wsServer"; |
| 20 | import { ServerSettingsService } from "./serverSettings"; | ||
| 20 | 21 | ||
| 21 | const start = vi.fn(() => undefined); | 22 | const start = vi.fn(() => undefined); |
| 22 | const stop = vi.fn(() => undefined); | 23 | const stop = vi.fn(() => undefined); |
| ... | @@ -52,6 +53,7 @@ const testLayer = Layer.mergeAll( | ... | @@ -52,6 +53,7 @@ const testLayer = Layer.mergeAll( |
| 52 | openBrowser: (_target: string) => Effect.void, | 53 | openBrowser: (_target: string) => Effect.void, |
| 53 | openInEditor: () => Effect.void, | 54 | openInEditor: () => Effect.void, |
| 54 | } satisfies OpenShape), | 55 | } satisfies OpenShape), |
| 56 | ServerSettingsService.layerTest(), | ||
| 55 | AnalyticsService.layerTest, | 57 | AnalyticsService.layerTest, |
| 56 | FetchHttpClient.layer, | 58 | FetchHttpClient.layer, |
| 57 | NodeServices.layer, | 59 | NodeServices.layer, |
apps/server/src/main.ts+12-5| ... | @@ -22,12 +22,13 @@ import { Open } from "./open"; | ... | @@ -22,12 +22,13 @@ import { Open } from "./open"; |
| 22 | import * as SqlitePersistence from "./persistence/Layers/Sqlite"; | 22 | import * as SqlitePersistence from "./persistence/Layers/Sqlite"; |
| 23 | import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; | 23 | import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; |
| 24 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; | 24 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; |
| 25 | import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; | 25 | import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry"; |
| 26 | import { Server } from "./wsServer"; | 26 | import { Server } from "./wsServer"; |
| 27 | import { ServerLoggerLive } from "./serverLogger"; | 27 | import { ServerLoggerLive } from "./serverLogger"; |
| 28 | import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; | 28 | import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; |
| 29 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; | 29 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; |
| 30 | import { readBootstrapEnvelope } from "./bootstrap"; | 30 | import { readBootstrapEnvelope } from "./bootstrap"; |
| 31 | import { ServerSettingsLive } from "./serverSettings"; | ||
| 31 | 32 | ||
| 32 | export class StartupError extends Data.TaggedError("StartupError")<{ | 33 | export class StartupError extends Data.TaggedError("StartupError")<{ |
| 33 | readonly message: string; | 34 | readonly message: string; |
| ... | @@ -293,10 +294,11 @@ const LayerLive = (input: CliInput) => | ... | @@ -293,10 +294,11 @@ const LayerLive = (input: CliInput) => |
| 293 | Layer.empty.pipe( | 294 | Layer.empty.pipe( |
| 294 | Layer.provideMerge(makeServerRuntimeServicesLayer()), | 295 | Layer.provideMerge(makeServerRuntimeServicesLayer()), |
| 295 | Layer.provideMerge(makeServerProviderLayer()), | 296 | Layer.provideMerge(makeServerProviderLayer()), |
| 296 | Layer.provideMerge(ProviderHealthLive), | 297 | Layer.provideMerge(ProviderRegistryLive), |
| 297 | Layer.provideMerge(SqlitePersistence.layerConfig), | 298 | Layer.provideMerge(SqlitePersistence.layerConfig), |
| 298 | Layer.provideMerge(ServerLoggerLive), | 299 | Layer.provideMerge(ServerLoggerLive), |
| 299 | Layer.provideMerge(AnalyticsServiceLayerLive), | 300 | Layer.provideMerge(AnalyticsServiceLayerLive), |
| 301 | Layer.provideMerge(ServerSettingsLive), | ||
| 300 | Layer.provideMerge(ServerConfigLive(input)), | 302 | Layer.provideMerge(ServerConfigLive(input)), |
| 301 | ); | 303 | ); |
| 302 | 304 | ||
| ... | @@ -331,12 +333,10 @@ export const recordStartupHeartbeat = Effect.gen(function* () { | ... | @@ -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 | Effect.gen(function* () { | 337 | Effect.gen(function* () { |
| 336 | const cliConfig = yield* CliConfig; | ||
| 337 | const { start, stopSignal } = yield* Server; | 338 | const { start, stopSignal } = yield* Server; |
| 338 | const openDeps = yield* Open; | 339 | const openDeps = yield* Open; |
| 339 | yield* cliConfig.fixPath; | ||
| 340 | 340 | ||
| 341 | const config = yield* ServerConfig; | 341 | const config = yield* ServerConfig; |
| 342 | 342 | ||
| ... | @@ -378,6 +378,13 @@ const makeServerProgram = (input: CliInput) => | ... | @@ -378,6 +378,13 @@ const makeServerProgram = (input: CliInput) => |
| 378 | return yield* stopSignal; | 378 | return yield* stopSignal; |
| 379 | }).pipe(Effect.provide(LayerLive(input))); | 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 | * These flags mirrors the environment variables and the config shape. | 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,6 +34,7 @@ import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts"; |
| 34 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | 34 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 35 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; | 35 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; |
| 36 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 36 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 37 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 37 | 38 | ||
| 38 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | 39 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 39 | const asApprovalRequestId = (value: string): ApprovalRequestId => | 40 | const asApprovalRequestId = (value: string): ApprovalRequestId => |
| ... | @@ -214,6 +215,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -214,6 +215,7 @@ describe("ProviderCommandReactor", () => { |
| 214 | Layer.provideMerge( | 215 | Layer.provideMerge( |
| 215 | Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape), | 216 | Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape), |
| 216 | ), | 217 | ), |
| 218 | Layer.provideMerge(ServerSettingsService.layerTest()), | ||
| 217 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), | 219 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), |
| 218 | Layer.provideMerge(NodeServices.layer), | 220 | Layer.provideMerge(NodeServices.layer), |
| 219 | ); | 221 | ); |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+41-63| ... | @@ -1,12 +1,10 @@ | ... | @@ -1,12 +1,10 @@ |
| 1 | import { | 1 | import { |
| 2 | type ChatAttachment, | 2 | type ChatAttachment, |
| 3 | CommandId, | 3 | CommandId, |
| 4 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, | ||
| 5 | EventId, | 4 | EventId, |
| 6 | type ModelSelection, | 5 | type ModelSelection, |
| 7 | type OrchestrationEvent, | 6 | type OrchestrationEvent, |
| 8 | ProviderKind, | 7 | ProviderKind, |
| 9 | type ProviderStartOptions, | ||
| 10 | type OrchestrationSession, | 8 | type OrchestrationSession, |
| 11 | ThreadId, | 9 | ThreadId, |
| 12 | type ProviderSession, | 10 | type ProviderSession, |
| ... | @@ -26,6 +24,7 @@ import { | ... | @@ -26,6 +24,7 @@ import { |
| 26 | ProviderCommandReactor, | 24 | ProviderCommandReactor, |
| 27 | type ProviderCommandReactorShape, | 25 | type ProviderCommandReactorShape, |
| 28 | } from "../Services/ProviderCommandReactor.ts"; | 26 | } from "../Services/ProviderCommandReactor.ts"; |
| 27 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 29 | 28 | ||
| 30 | type ProviderIntentEvent = Extract< | 29 | type ProviderIntentEvent = Extract< |
| 31 | OrchestrationEvent, | 30 | OrchestrationEvent, |
| ... | @@ -138,6 +137,7 @@ const make = Effect.gen(function* () { | ... | @@ -138,6 +137,7 @@ const make = Effect.gen(function* () { |
| 138 | const providerService = yield* ProviderService; | 137 | const providerService = yield* ProviderService; |
| 139 | const git = yield* GitCore; | 138 | const git = yield* GitCore; |
| 140 | const textGeneration = yield* TextGeneration; | 139 | const textGeneration = yield* TextGeneration; |
| 140 | const serverSettingsService = yield* ServerSettingsService; | ||
| 141 | const handledTurnStartKeys = yield* Cache.make<string, true>({ | 141 | const handledTurnStartKeys = yield* Cache.make<string, true>({ |
| 142 | capacity: HANDLED_TURN_START_KEY_MAX, | 142 | capacity: HANDLED_TURN_START_KEY_MAX, |
| 143 | timeToLive: HANDLED_TURN_START_KEY_TTL, | 143 | timeToLive: HANDLED_TURN_START_KEY_TTL, |
| ... | @@ -151,7 +151,6 @@ const make = Effect.gen(function* () { | ... | @@ -151,7 +151,6 @@ const make = Effect.gen(function* () { |
| 151 | ), | 151 | ), |
| 152 | ); | 152 | ); |
| 153 | 153 | ||
| 154 | const threadProviderOptions = new Map<string, ProviderStartOptions>(); | ||
| 155 | const threadModelSelections = new Map<string, ModelSelection>(); | 154 | const threadModelSelections = new Map<string, ModelSelection>(); |
| 156 | 155 | ||
| 157 | const appendProviderFailureActivity = (input: { | 156 | const appendProviderFailureActivity = (input: { |
| ... | @@ -210,7 +209,6 @@ const make = Effect.gen(function* () { | ... | @@ -210,7 +209,6 @@ const make = Effect.gen(function* () { |
| 210 | createdAt: string, | 209 | createdAt: string, |
| 211 | options?: { | 210 | options?: { |
| 212 | readonly modelSelection?: ModelSelection; | 211 | readonly modelSelection?: ModelSelection; |
| 213 | readonly providerOptions?: ProviderStartOptions; | ||
| 214 | }, | 212 | }, |
| 215 | ) { | 213 | ) { |
| 216 | const readModel = yield* orchestrationEngine.getReadModel(); | 214 | const readModel = yield* orchestrationEngine.getReadModel(); |
| ... | @@ -258,9 +256,6 @@ const make = Effect.gen(function* () { | ... | @@ -258,9 +256,6 @@ const make = Effect.gen(function* () { |
| 258 | ...(preferredProvider ? { provider: preferredProvider } : {}), | 256 | ...(preferredProvider ? { provider: preferredProvider } : {}), |
| 259 | ...(effectiveCwd ? { cwd: effectiveCwd } : {}), | 257 | ...(effectiveCwd ? { cwd: effectiveCwd } : {}), |
| 260 | modelSelection: desiredModelSelection, | 258 | modelSelection: desiredModelSelection, |
| 261 | ...(options?.providerOptions !== undefined | ||
| 262 | ? { providerOptions: options.providerOptions } | ||
| 263 | : {}), | ||
| 264 | ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), | 259 | ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), |
| 265 | runtimeMode: desiredRuntimeMode, | 260 | runtimeMode: desiredRuntimeMode, |
| 266 | }); | 261 | }); |
| ... | @@ -354,7 +349,6 @@ const make = Effect.gen(function* () { | ... | @@ -354,7 +349,6 @@ const make = Effect.gen(function* () { |
| 354 | readonly messageText: string; | 349 | readonly messageText: string; |
| 355 | readonly attachments?: ReadonlyArray<ChatAttachment>; | 350 | readonly attachments?: ReadonlyArray<ChatAttachment>; |
| 356 | readonly modelSelection?: ModelSelection; | 351 | readonly modelSelection?: ModelSelection; |
| 357 | readonly providerOptions?: ProviderStartOptions; | ||
| 358 | readonly interactionMode?: "default" | "plan"; | 352 | readonly interactionMode?: "default" | "plan"; |
| 359 | readonly createdAt: string; | 353 | readonly createdAt: string; |
| 360 | }) { | 354 | }) { |
| ... | @@ -362,13 +356,11 @@ const make = Effect.gen(function* () { | ... | @@ -362,13 +356,11 @@ const make = Effect.gen(function* () { |
| 362 | if (!thread) { | 356 | if (!thread) { |
| 363 | return; | 357 | return; |
| 364 | } | 358 | } |
| 365 | yield* ensureSessionForThread(input.threadId, input.createdAt, { | 359 | yield* ensureSessionForThread( |
| 366 | ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), | 360 | input.threadId, |
| 367 | ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), | 361 | input.createdAt, |
| 368 | }); | 362 | input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}, |
| 369 | if (input.providerOptions !== undefined) { | 363 | ); |
| 370 | threadProviderOptions.set(input.threadId, input.providerOptions); | ||
| 371 | } | ||
| 372 | if (input.modelSelection !== undefined) { | 364 | if (input.modelSelection !== undefined) { |
| 373 | threadModelSelections.set(input.threadId, input.modelSelection); | 365 | threadModelSelections.set(input.threadId, input.modelSelection); |
| 374 | } | 366 | } |
| ... | @@ -432,48 +424,39 @@ const make = Effect.gen(function* () { | ... | @@ -432,48 +424,39 @@ const make = Effect.gen(function* () { |
| 432 | const oldBranch = input.branch; | 424 | const oldBranch = input.branch; |
| 433 | const cwd = input.worktreePath; | 425 | const cwd = input.worktreePath; |
| 434 | const attachments = input.attachments ?? []; | 426 | const attachments = input.attachments ?? []; |
| 435 | yield* textGeneration | 427 | yield* Effect.gen(function* () { |
| 436 | .generateBranchName({ | 428 | const { textGenerationModelSelection: modelSelection } = |
| 429 | yield* serverSettingsService.getSettings; | ||
| 430 | |||
| 431 | const generated = yield* textGeneration.generateBranchName({ | ||
| 437 | cwd, | 432 | cwd, |
| 438 | message: input.messageText, | 433 | message: input.messageText, |
| 439 | ...(attachments.length > 0 ? { attachments } : {}), | 434 | ...(attachments.length > 0 ? { attachments } : {}), |
| 440 | modelSelection: { | 435 | modelSelection, |
| 441 | provider: "codex", | 436 | }); |
| 442 | model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, | 437 | if (!generated) return; |
| 443 | }, | 438 | |
| 444 | }) | 439 | const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); |
| 445 | .pipe( | 440 | if (targetBranch === oldBranch) return; |
| 446 | Effect.catch((error) => | 441 | |
| 447 | Effect.logWarning( | 442 | const renamed = yield* git.renameBranch({ cwd, oldBranch, newBranch: targetBranch }); |
| 448 | "provider command reactor failed to generate worktree branch name; skipping rename", | 443 | yield* orchestrationEngine.dispatch({ |
| 449 | { threadId: input.threadId, cwd, oldBranch, reason: error.message }, | 444 | type: "thread.meta.update", |
| 450 | ), | 445 | commandId: serverCommandId("worktree-branch-rename"), |
| 451 | ), | 446 | threadId: input.threadId, |
| 452 | Effect.flatMap((generated) => { | 447 | branch: renamed.branch, |
| 453 | if (!generated) return Effect.void; | 448 | worktreePath: cwd, |
| 454 | 449 | }); | |
| 455 | const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); | 450 | }).pipe( |
| 456 | if (targetBranch === oldBranch) return Effect.void; | 451 | Effect.catchCause((cause) => |
| 457 | 452 | Effect.logWarning("provider command reactor failed to generate or rename worktree branch", { | |
| 458 | return Effect.flatMap( | 453 | threadId: input.threadId, |
| 459 | git.renameBranch({ cwd, oldBranch, newBranch: targetBranch }), | 454 | cwd, |
| 460 | (renamed) => | 455 | oldBranch, |
| 461 | orchestrationEngine.dispatch({ | 456 | cause: Cause.pretty(cause), |
| 462 | type: "thread.meta.update", | ||
| 463 | commandId: serverCommandId("worktree-branch-rename"), | ||
| 464 | threadId: input.threadId, | ||
| 465 | branch: renamed.branch, | ||
| 466 | worktreePath: cwd, | ||
| 467 | }), | ||
| 468 | ); | ||
| 469 | }), | 457 | }), |
| 470 | Effect.catchCause((cause) => | 458 | ), |
| 471 | Effect.logWarning( | 459 | ); |
| 472 | "provider command reactor failed to generate or rename worktree branch", | ||
| 473 | { threadId: input.threadId, cwd, oldBranch, cause: Cause.pretty(cause) }, | ||
| 474 | ), | ||
| 475 | ), | ||
| 476 | ); | ||
| 477 | }); | 460 | }); |
| 478 | 461 | ||
| 479 | const processTurnStartRequested = Effect.fnUntraced(function* ( | 462 | const processTurnStartRequested = Effect.fnUntraced(function* ( |
| ... | @@ -518,9 +501,6 @@ const make = Effect.gen(function* () { | ... | @@ -518,9 +501,6 @@ const make = Effect.gen(function* () { |
| 518 | ...(event.payload.modelSelection !== undefined | 501 | ...(event.payload.modelSelection !== undefined |
| 519 | ? { modelSelection: event.payload.modelSelection } | 502 | ? { modelSelection: event.payload.modelSelection } |
| 520 | : {}), | 503 | : {}), |
| 521 | ...(event.payload.providerOptions !== undefined | ||
| 522 | ? { providerOptions: event.payload.providerOptions } | ||
| 523 | : {}), | ||
| 524 | interactionMode: event.payload.interactionMode, | 504 | interactionMode: event.payload.interactionMode, |
| 525 | createdAt: event.payload.createdAt, | 505 | createdAt: event.payload.createdAt, |
| 526 | }).pipe( | 506 | }).pipe( |
| ... | @@ -686,14 +666,12 @@ const make = Effect.gen(function* () { | ... | @@ -686,14 +666,12 @@ const make = Effect.gen(function* () { |
| 686 | if (!thread?.session || thread.session.status === "stopped") { | 666 | if (!thread?.session || thread.session.status === "stopped") { |
| 687 | return; | 667 | return; |
| 688 | } | 668 | } |
| 689 | const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId); | ||
| 690 | const cachedModelSelection = threadModelSelections.get(event.payload.threadId); | 669 | const cachedModelSelection = threadModelSelections.get(event.payload.threadId); |
| 691 | yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, { | 670 | yield* ensureSessionForThread( |
| 692 | ...(cachedProviderOptions !== undefined | 671 | event.payload.threadId, |
| 693 | ? { providerOptions: cachedProviderOptions } | 672 | event.occurredAt, |
| 694 | : {}), | 673 | cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {}, |
| 695 | ...(cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {}), | 674 | ); |
| 696 | }); | ||
| 697 | return; | 675 | return; |
| 698 | } | 676 | } |
| 699 | case "thread.turn-start-requested": | 677 | case "thread.turn-start-requested": |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+9-3| ... | @@ -16,6 +16,7 @@ import { | ... | @@ -16,6 +16,7 @@ import { |
| 16 | MessageId, | 16 | MessageId, |
| 17 | ProjectId, | 17 | ProjectId, |
| 18 | ProviderItemId, | 18 | ProviderItemId, |
| 19 | type ServerSettings, | ||
| 19 | ThreadId, | 20 | ThreadId, |
| 20 | TurnId, | 21 | TurnId, |
| 21 | } from "@t3tools/contracts"; | 22 | } from "@t3tools/contracts"; |
| ... | @@ -38,8 +39,13 @@ import { | ... | @@ -38,8 +39,13 @@ import { |
| 38 | } from "../Services/OrchestrationEngine.ts"; | 39 | } from "../Services/OrchestrationEngine.ts"; |
| 39 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; | 40 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; |
| 40 | import { ServerConfig } from "../../config.ts"; | 41 | import { ServerConfig } from "../../config.ts"; |
| 42 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 41 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 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 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | 49 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 44 | const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); | 50 | const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); |
| 45 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | 51 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| ... | @@ -155,7 +161,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -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 | const workspaceRoot = makeTempDir("t3-provider-project-"); | 165 | const workspaceRoot = makeTempDir("t3-provider-project-"); |
| 160 | fs.mkdirSync(path.join(workspaceRoot, ".git")); | 166 | fs.mkdirSync(path.join(workspaceRoot, ".git")); |
| 161 | const provider = createProviderServiceHarness(); | 167 | const provider = createProviderServiceHarness(); |
| ... | @@ -169,6 +175,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -169,6 +175,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 169 | Layer.provideMerge(orchestrationLayer), | 175 | Layer.provideMerge(orchestrationLayer), |
| 170 | Layer.provideMerge(SqlitePersistenceMemory), | 176 | Layer.provideMerge(SqlitePersistenceMemory), |
| 171 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), | 177 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), |
| 178 | Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), | ||
| 172 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | 179 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| 173 | Layer.provideMerge(NodeServices.layer), | 180 | Layer.provideMerge(NodeServices.layer), |
| 174 | ); | 181 | ); |
| ... | @@ -1357,7 +1364,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1357,7 +1364,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1357 | }); | 1364 | }); |
| 1358 | 1365 | ||
| 1359 | it("streams assistant deltas when thread.turn.start requests streaming mode", async () => { | 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 | const now = new Date().toISOString(); | 1368 | const now = new Date().toISOString(); |
| 1362 | 1369 | ||
| 1363 | await Effect.runPromise( | 1370 | await Effect.runPromise( |
| ... | @@ -1371,7 +1378,6 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1371,7 +1378,6 @@ describe("ProviderRuntimeIngestion", () => { |
| 1371 | text: "stream please", | 1378 | text: "stream please", |
| 1372 | attachments: [], | 1379 | attachments: [], |
| 1373 | }, | 1380 | }, |
| 1374 | assistantDeliveryMode: "streaming", | ||
| 1375 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 1381 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 1376 | runtimeMode: "approval-required", | 1382 | runtimeMode: "approval-required", |
| 1377 | createdAt: now, | 1383 | createdAt: now, |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+8-12| ... | @@ -13,7 +13,7 @@ import { | ... | @@ -13,7 +13,7 @@ import { |
| 13 | type OrchestrationThreadActivity, | 13 | type OrchestrationThreadActivity, |
| 14 | type ProviderRuntimeEvent, | 14 | type ProviderRuntimeEvent, |
| 15 | } from "@t3tools/contracts"; | 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 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; | 17 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; |
| 18 | 18 | ||
| 19 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; | 19 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| ... | @@ -26,12 +26,12 @@ import { | ... | @@ -26,12 +26,12 @@ import { |
| 26 | ProviderRuntimeIngestionService, | 26 | ProviderRuntimeIngestionService, |
| 27 | type ProviderRuntimeIngestionShape, | 27 | type ProviderRuntimeIngestionShape, |
| 28 | } from "../Services/ProviderRuntimeIngestion.ts"; | 28 | } from "../Services/ProviderRuntimeIngestion.ts"; |
| 29 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 29 | 30 | ||
| 30 | const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; | 31 | const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; |
| 31 | const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId => | 32 | const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId => |
| 32 | CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`); | 33 | CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`); |
| 33 | 34 | ||
| 34 | const DEFAULT_ASSISTANT_DELIVERY_MODE: AssistantDeliveryMode = "buffered"; | ||
| 35 | const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000; | 35 | const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000; |
| 36 | const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120); | 36 | const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120); |
| 37 | const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; | 37 | const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; |
| ... | @@ -537,10 +537,7 @@ const make = Effect.gen(function* () { | ... | @@ -537,10 +537,7 @@ const make = Effect.gen(function* () { |
| 537 | const orchestrationEngine = yield* OrchestrationEngineService; | 537 | const orchestrationEngine = yield* OrchestrationEngineService; |
| 538 | const providerService = yield* ProviderService; | 538 | const providerService = yield* ProviderService; |
| 539 | const projectionTurnRepository = yield* ProjectionTurnRepository; | 539 | const projectionTurnRepository = yield* ProjectionTurnRepository; |
| 540 | 540 | const serverSettingsService = yield* ServerSettingsService; | |
| 541 | const assistantDeliveryModeRef = yield* Ref.make<AssistantDeliveryMode>( | ||
| 542 | DEFAULT_ASSISTANT_DELIVERY_MODE, | ||
| 543 | ); | ||
| 544 | 541 | ||
| 545 | const turnMessageIdsByTurnKey = yield* Cache.make<string, Set<MessageId>>({ | 542 | const turnMessageIdsByTurnKey = yield* Cache.make<string, Set<MessageId>>({ |
| 546 | capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY, | 543 | capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY, |
| ... | @@ -1048,7 +1045,10 @@ const make = Effect.gen(function* () { | ... | @@ -1048,7 +1045,10 @@ const make = Effect.gen(function* () { |
| 1048 | yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); | 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 | if (assistantDeliveryMode === "buffered") { | 1052 | if (assistantDeliveryMode === "buffered") { |
| 1053 | const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); | 1053 | const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); |
| 1054 | if (spillChunk.length > 0) { | 1054 | if (spillChunk.length > 0) { |
| ... | @@ -1256,11 +1256,7 @@ const make = Effect.gen(function* () { | ... | @@ -1256,11 +1256,7 @@ const make = Effect.gen(function* () { |
| 1256 | ).pipe(Effect.asVoid); | 1256 | ).pipe(Effect.asVoid); |
| 1257 | }); | 1257 | }); |
| 1258 | 1258 | ||
| 1259 | const processDomainEvent = (event: TurnStartRequestedDomainEvent) => | 1259 | const processDomainEvent = (_event: TurnStartRequestedDomainEvent) => Effect.void; |
| 1260 | Ref.set( | ||
| 1261 | assistantDeliveryModeRef, | ||
| 1262 | event.payload.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, | ||
| 1263 | ); | ||
| 1264 | 1260 | ||
| 1265 | const processInput = (input: RuntimeIngestionInput) => | 1261 | const processInput = (input: RuntimeIngestionInput) => |
| 1266 | input.source === "runtime" ? processRuntimeEvent(input.event) : processDomainEvent(input.event); | 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,7 +188,6 @@ describe("decider project scripts", () => { |
| 188 | if (turnStartEvent?.type !== "thread.turn-start-requested") { | 188 | if (turnStartEvent?.type !== "thread.turn-start-requested") { |
| 189 | return; | 189 | return; |
| 190 | } | 190 | } |
| 191 | expect(turnStartEvent.payload.assistantDeliveryMode).toBe("buffered"); | ||
| 192 | expect(turnStartEvent.payload).toMatchObject({ | 191 | expect(turnStartEvent.payload).toMatchObject({ |
| 193 | threadId: ThreadId.makeUnsafe("thread-1"), | 192 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 194 | messageId: asMessageId("message-user-1"), | 193 | messageId: asMessageId("message-user-1"), |
apps/server/src/orchestration/decider.ts-5| ... | @@ -14,7 +14,6 @@ import { | ... | @@ -14,7 +14,6 @@ import { |
| 14 | } from "./commandInvariants.ts"; | 14 | } from "./commandInvariants.ts"; |
| 15 | 15 | ||
| 16 | const nowIso = () => new Date().toISOString(); | 16 | const nowIso = () => new Date().toISOString(); |
| 17 | const DEFAULT_ASSISTANT_DELIVERY_MODE = "buffered" as const; | ||
| 18 | 17 | ||
| 19 | const defaultMetadata: Omit<OrchestrationEvent, "sequence" | "type" | "payload"> = { | 18 | const defaultMetadata: Omit<OrchestrationEvent, "sequence" | "type" | "payload"> = { |
| 20 | eventId: crypto.randomUUID() as OrchestrationEvent["eventId"], | 19 | eventId: crypto.randomUUID() as OrchestrationEvent["eventId"], |
| ... | @@ -330,10 +329,6 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -330,10 +329,6 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 330 | ...(command.modelSelection !== undefined | 329 | ...(command.modelSelection !== undefined |
| 331 | ? { modelSelection: command.modelSelection } | 330 | ? { modelSelection: command.modelSelection } |
| 332 | : {}), | 331 | : {}), |
| 333 | ...(command.providerOptions !== undefined | ||
| 334 | ? { providerOptions: command.providerOptions } | ||
| 335 | : {}), | ||
| 336 | assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, | ||
| 337 | runtimeMode: targetThread.runtimeMode, | 332 | runtimeMode: targetThread.runtimeMode, |
| 338 | interactionMode: targetThread.interactionMode, | 333 | interactionMode: targetThread.interactionMode, |
| 339 | ...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}), | 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,6 +21,7 @@ import { Effect, Fiber, Layer, Random, Stream } from "effect"; |
| 21 | 21 | ||
| 22 | import { attachmentRelativePath } from "../../attachmentStore.ts"; | 22 | import { attachmentRelativePath } from "../../attachmentStore.ts"; |
| 23 | import { ServerConfig } from "../../config.ts"; | 23 | import { ServerConfig } from "../../config.ts"; |
| 24 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 24 | import { ProviderAdapterValidationError } from "../Errors.ts"; | 25 | import { ProviderAdapterValidationError } from "../Errors.ts"; |
| 25 | import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; | 26 | import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; |
| 26 | import { makeClaudeAdapterLive, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; | 27 | import { makeClaudeAdapterLive, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; |
| ... | @@ -168,6 +169,7 @@ function makeHarness(config?: { | ... | @@ -168,6 +169,7 @@ function makeHarness(config?: { |
| 168 | config?.baseDir ?? "/tmp", | 169 | config?.baseDir ?? "/tmp", |
| 169 | ), | 170 | ), |
| 170 | ), | 171 | ), |
| 172 | Layer.provideMerge(ServerSettingsService.layerTest()), | ||
| 171 | Layer.provideMerge(NodeServices.layer), | 173 | Layer.provideMerge(NodeServices.layer), |
| 172 | ), | 174 | ), |
| 173 | query, | 175 | query, |
| ... | @@ -301,7 +303,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -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 | const harness = makeHarness(); | 307 | const harness = makeHarness(); |
| 306 | return Effect.gen(function* () { | 308 | return Effect.gen(function* () { |
| 307 | const adapter = yield* ClaudeAdapter; | 309 | const adapter = yield* ClaudeAdapter; |
| ... | @@ -309,16 +311,11 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -309,16 +311,11 @@ describe("ClaudeAdapterLive", () => { |
| 309 | threadId: THREAD_ID, | 311 | threadId: THREAD_ID, |
| 310 | provider: "claudeAgent", | 312 | provider: "claudeAgent", |
| 311 | runtimeMode: "full-access", | 313 | runtimeMode: "full-access", |
| 312 | providerOptions: { | ||
| 313 | claudeAgent: { | ||
| 314 | permissionMode: "plan", | ||
| 315 | }, | ||
| 316 | }, | ||
| 317 | }); | 314 | }); |
| 318 | 315 | ||
| 319 | const createInput = harness.getLastCreateQueryInput(); | 316 | const createInput = harness.getLastCreateQueryInput(); |
| 320 | assert.equal(createInput?.options.permissionMode, "plan"); | 317 | assert.equal(createInput?.options.permissionMode, "bypassPermissions"); |
| 321 | assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); | 318 | assert.equal(createInput?.options.allowDangerouslySkipPermissions, true); |
| 322 | }).pipe( | 319 | }).pipe( |
| 323 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | 320 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| 324 | Effect.provide(harness.layer), | 321 | Effect.provide(harness.layer), |
| ... | @@ -1195,6 +1192,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -1195,6 +1192,7 @@ describe("ClaudeAdapterLive", () => { |
| 1195 | }, | 1192 | }, |
| 1196 | }).pipe( | 1193 | }).pipe( |
| 1197 | Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), | 1194 | Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), |
| 1195 | Layer.provideMerge(ServerSettingsService.layerTest()), | ||
| 1198 | Layer.provideMerge(NodeServices.layer), | 1196 | Layer.provideMerge(NodeServices.layer), |
| 1199 | ); | 1197 | ); |
| 1200 | 1198 |
apps/server/src/provider/Layers/ClaudeAdapter.ts+21-31| ... | @@ -40,12 +40,7 @@ import { | ... | @@ -40,12 +40,7 @@ import { |
| 40 | type UserInputQuestion, | 40 | type UserInputQuestion, |
| 41 | ClaudeCodeEffort, | 41 | ClaudeCodeEffort, |
| 42 | } from "@t3tools/contracts"; | 42 | } from "@t3tools/contracts"; |
| 43 | import { | 43 | import { hasEffortLevel, applyClaudePromptEffortPrefix, trimOrNull } from "@t3tools/shared/model"; |
| 44 | hasEffortLevel, | ||
| 45 | applyClaudePromptEffortPrefix, | ||
| 46 | getModelCapabilities, | ||
| 47 | trimOrNull, | ||
| 48 | } from "@t3tools/shared/model"; | ||
| 49 | import { | 44 | import { |
| 50 | Cause, | 45 | Cause, |
| 51 | DateTime, | 46 | DateTime, |
| ... | @@ -63,6 +58,8 @@ import { | ... | @@ -63,6 +58,8 @@ import { |
| 63 | 58 | ||
| 64 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | 59 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 65 | import { ServerConfig } from "../../config.ts"; | 60 | import { ServerConfig } from "../../config.ts"; |
| 61 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 62 | import { getClaudeModelCapabilities } from "./ClaudeProvider.ts"; | ||
| 66 | import { | 63 | import { |
| 67 | ProviderAdapterProcessError, | 64 | ProviderAdapterProcessError, |
| 68 | ProviderAdapterRequestError, | 65 | ProviderAdapterRequestError, |
| ... | @@ -353,19 +350,6 @@ function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId { | ... | @@ -353,19 +350,6 @@ function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId { |
| 353 | return RuntimeRequestId.makeUnsafe(value); | 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 | function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undefined { | 353 | function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undefined { |
| 370 | if (!resumeCursor || typeof resumeCursor !== "object") { | 354 | if (!resumeCursor || typeof resumeCursor !== "object") { |
| 371 | return undefined; | 355 | return undefined; |
| ... | @@ -525,7 +509,7 @@ function buildPromptText(input: ProviderSendTurnInput): string { | ... | @@ -525,7 +509,7 @@ function buildPromptText(input: ProviderSendTurnInput): string { |
| 525 | const requestedEffort = trimOrNull(rawEffort); | 509 | const requestedEffort = trimOrNull(rawEffort); |
| 526 | const claudeModel = | 510 | const claudeModel = |
| 527 | input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined; | 511 | input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined; |
| 528 | const caps = getModelCapabilities("claudeAgent", claudeModel); | 512 | const caps = getClaudeModelCapabilities(claudeModel); |
| 529 | const promptEffort = | 513 | const promptEffort = |
| 530 | requestedEffort === "ultrathink" && caps.reasoningEffortLevels.length > 0 | 514 | requestedEffort === "ultrathink" && caps.reasoningEffortLevels.length > 0 |
| 531 | ? "ultrathink" | 515 | ? "ultrathink" |
| ... | @@ -943,6 +927,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { | ... | @@ -943,6 +927,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { |
| 943 | 927 | ||
| 944 | const sessions = new Map<ThreadId, ClaudeSessionContext>(); | 928 | const sessions = new Map<ThreadId, ClaudeSessionContext>(); |
| 945 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); | 929 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); |
| 930 | const serverSettingsService = yield* ServerSettingsService; | ||
| 946 | 931 | ||
| 947 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); | 932 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 948 | const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); | 933 | const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); |
| ... | @@ -2727,11 +2712,23 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { | ... | @@ -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 | const modelSelection = | 2728 | const modelSelection = |
| 2732 | input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; | 2729 | input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; |
| 2733 | const requestedEffort = trimOrNull(modelSelection?.options?.effort ?? null); | 2730 | const requestedEffort = trimOrNull(modelSelection?.options?.effort ?? null); |
| 2734 | const caps = getModelCapabilities("claudeAgent", modelSelection?.model); | 2731 | const caps = getClaudeModelCapabilities(modelSelection?.model); |
| 2735 | const effort = | 2732 | const effort = |
| 2736 | requestedEffort && hasEffortLevel(caps, requestedEffort) ? requestedEffort : null; | 2733 | requestedEffort && hasEffortLevel(caps, requestedEffort) ? requestedEffort : null; |
| 2737 | const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode; | 2734 | const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode; |
| ... | @@ -2741,8 +2738,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { | ... | @@ -2741,8 +2738,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { |
| 2741 | : undefined; | 2738 | : undefined; |
| 2742 | const effectiveEffort = getEffectiveClaudeCodeEffort(effort); | 2739 | const effectiveEffort = getEffectiveClaudeCodeEffort(effort); |
| 2743 | const permissionMode = | 2740 | const permissionMode = |
| 2744 | toPermissionMode(providerOptions?.permissionMode) ?? | 2741 | input.runtimeMode === "full-access" ? "bypassPermissions" : undefined; |
| 2745 | (input.runtimeMode === "full-access" ? "bypassPermissions" : undefined); | ||
| 2746 | const settings = { | 2742 | const settings = { |
| 2747 | ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), | 2743 | ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), |
| 2748 | ...(fastMode ? { fastMode: true } : {}), | 2744 | ...(fastMode ? { fastMode: true } : {}), |
| ... | @@ -2751,16 +2747,13 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { | ... | @@ -2751,16 +2747,13 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { |
| 2751 | const queryOptions: ClaudeQueryOptions = { | 2747 | const queryOptions: ClaudeQueryOptions = { |
| 2752 | ...(input.cwd ? { cwd: input.cwd } : {}), | 2748 | ...(input.cwd ? { cwd: input.cwd } : {}), |
| 2753 | ...(modelSelection?.model ? { model: modelSelection.model } : {}), | 2749 | ...(modelSelection?.model ? { model: modelSelection.model } : {}), |
| 2754 | pathToClaudeCodeExecutable: providerOptions?.binaryPath ?? "claude", | 2750 | pathToClaudeCodeExecutable: claudeBinaryPath, |
| 2755 | settingSources: [...CLAUDE_SETTING_SOURCES], | 2751 | settingSources: [...CLAUDE_SETTING_SOURCES], |
| 2756 | ...(effectiveEffort ? { effort: effectiveEffort } : {}), | 2752 | ...(effectiveEffort ? { effort: effectiveEffort } : {}), |
| 2757 | ...(permissionMode ? { permissionMode } : {}), | 2753 | ...(permissionMode ? { permissionMode } : {}), |
| 2758 | ...(permissionMode === "bypassPermissions" | 2754 | ...(permissionMode === "bypassPermissions" |
| 2759 | ? { allowDangerouslySkipPermissions: true } | 2755 | ? { allowDangerouslySkipPermissions: true } |
| 2760 | : {}), | 2756 | : {}), |
| 2761 | ...(providerOptions?.maxThinkingTokens !== undefined | ||
| 2762 | ? { maxThinkingTokens: providerOptions.maxThinkingTokens } | ||
| 2763 | : {}), | ||
| 2764 | ...(Object.keys(settings).length > 0 ? { settings } : {}), | 2757 | ...(Object.keys(settings).length > 0 ? { settings } : {}), |
| 2765 | ...(existingResumeSessionId ? { resume: existingResumeSessionId } : {}), | 2758 | ...(existingResumeSessionId ? { resume: existingResumeSessionId } : {}), |
| 2766 | ...(newSessionId ? { sessionId: newSessionId } : {}), | 2759 | ...(newSessionId ? { sessionId: newSessionId } : {}), |
| ... | @@ -2851,9 +2844,6 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { | ... | @@ -2851,9 +2844,6 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { |
| 2851 | ...(input.cwd ? { cwd: input.cwd } : {}), | 2844 | ...(input.cwd ? { cwd: input.cwd } : {}), |
| 2852 | ...(effectiveEffort ? { effort: effectiveEffort } : {}), | 2845 | ...(effectiveEffort ? { effort: effectiveEffort } : {}), |
| 2853 | ...(permissionMode ? { permissionMode } : {}), | 2846 | ...(permissionMode ? { permissionMode } : {}), |
| 2854 | ...(providerOptions?.maxThinkingTokens !== undefined | ||
| 2855 | ? { maxThinkingTokens: providerOptions.maxThinkingTokens } | ||
| 2856 | : {}), | ||
| 2857 | ...(fastMode ? { fastMode: true } : {}), | 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,6 +22,7 @@ import { |
| 22 | type CodexAppServerSendTurnInput, | 22 | type CodexAppServerSendTurnInput, |
| 23 | } from "../../codexAppServerManager.ts"; | 23 | } from "../../codexAppServerManager.ts"; |
| 24 | import { ServerConfig } from "../../config.ts"; | 24 | import { ServerConfig } from "../../config.ts"; |
| 25 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 25 | import { ProviderAdapterValidationError } from "../Errors.ts"; | 26 | import { ProviderAdapterValidationError } from "../Errors.ts"; |
| 26 | import { CodexAdapter } from "../Services/CodexAdapter.ts"; | 27 | import { CodexAdapter } from "../Services/CodexAdapter.ts"; |
| 27 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; | 28 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; |
| ... | @@ -151,6 +152,7 @@ const validationManager = new FakeCodexManager(); | ... | @@ -151,6 +152,7 @@ const validationManager = new FakeCodexManager(); |
| 151 | const validationLayer = it.layer( | 152 | const validationLayer = it.layer( |
| 152 | makeCodexAdapterLive({ manager: validationManager }).pipe( | 153 | makeCodexAdapterLive({ manager: validationManager }).pipe( |
| 153 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | 154 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| 155 | Layer.provideMerge(ServerSettingsService.layerTest()), | ||
| 154 | Layer.provideMerge(providerSessionDirectoryTestLayer), | 156 | Layer.provideMerge(providerSessionDirectoryTestLayer), |
| 155 | Layer.provideMerge(NodeServices.layer), | 157 | Layer.provideMerge(NodeServices.layer), |
| 156 | ), | 158 | ), |
| ... | @@ -201,6 +203,7 @@ validationLayer("CodexAdapterLive validation", (it) => { | ... | @@ -201,6 +203,7 @@ validationLayer("CodexAdapterLive validation", (it) => { |
| 201 | assert.deepStrictEqual(validationManager.startSessionImpl.mock.calls[0]?.[0], { | 203 | assert.deepStrictEqual(validationManager.startSessionImpl.mock.calls[0]?.[0], { |
| 202 | provider: "codex", | 204 | provider: "codex", |
| 203 | threadId: asThreadId("thread-1"), | 205 | threadId: asThreadId("thread-1"), |
| 206 | binaryPath: "codex", | ||
| 204 | model: "gpt-5.3-codex", | 207 | model: "gpt-5.3-codex", |
| 205 | serviceTier: "fast", | 208 | serviceTier: "fast", |
| 206 | runtimeMode: "full-access", | 209 | runtimeMode: "full-access", |
| ... | @@ -216,6 +219,7 @@ sessionErrorManager.sendTurnImpl.mockImplementation(async () => { | ... | @@ -216,6 +219,7 @@ sessionErrorManager.sendTurnImpl.mockImplementation(async () => { |
| 216 | const sessionErrorLayer = it.layer( | 219 | const sessionErrorLayer = it.layer( |
| 217 | makeCodexAdapterLive({ manager: sessionErrorManager }).pipe( | 220 | makeCodexAdapterLive({ manager: sessionErrorManager }).pipe( |
| 218 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | 221 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| 222 | Layer.provideMerge(ServerSettingsService.layerTest()), | ||
| 219 | Layer.provideMerge(providerSessionDirectoryTestLayer), | 223 | Layer.provideMerge(providerSessionDirectoryTestLayer), |
| 220 | Layer.provideMerge(NodeServices.layer), | 224 | Layer.provideMerge(NodeServices.layer), |
| 221 | ), | 225 | ), |
| ... | @@ -284,6 +288,7 @@ const lifecycleManager = new FakeCodexManager(); | ... | @@ -284,6 +288,7 @@ const lifecycleManager = new FakeCodexManager(); |
| 284 | const lifecycleLayer = it.layer( | 288 | const lifecycleLayer = it.layer( |
| 285 | makeCodexAdapterLive({ manager: lifecycleManager }).pipe( | 289 | makeCodexAdapterLive({ manager: lifecycleManager }).pipe( |
| 286 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | 290 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| 291 | Layer.provideMerge(ServerSettingsService.layerTest()), | ||
| 287 | Layer.provideMerge(providerSessionDirectoryTestLayer), | 292 | Layer.provideMerge(providerSessionDirectoryTestLayer), |
| 288 | Layer.provideMerge(NodeServices.layer), | 293 | Layer.provideMerge(NodeServices.layer), |
| 289 | ), | 294 | ), |
apps/server/src/provider/Layers/CodexAdapter.ts+27-12| ... | @@ -38,6 +38,7 @@ import { | ... | @@ -38,6 +38,7 @@ import { |
| 38 | } from "../../codexAppServerManager.ts"; | 38 | } from "../../codexAppServerManager.ts"; |
| 39 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | 39 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 40 | import { ServerConfig } from "../../config.ts"; | 40 | import { ServerConfig } from "../../config.ts"; |
| 41 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 41 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | 42 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 42 | 43 | ||
| 43 | const PROVIDER = "codex" as const; | 44 | const PROVIDER = "codex" as const; |
| ... | @@ -1342,25 +1343,39 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => | ... | @@ -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 | if (input.provider !== undefined && input.provider !== PROVIDER) { | 1349 | if (input.provider !== undefined && input.provider !== PROVIDER) { |
| 1348 | return Effect.fail( | 1350 | return yield* new ProviderAdapterValidationError({ |
| 1349 | new ProviderAdapterValidationError({ | 1351 | provider: PROVIDER, |
| 1350 | provider: PROVIDER, | 1352 | operation: "startSession", |
| 1351 | operation: "startSession", | 1353 | issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, |
| 1352 | issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, | 1354 | }); |
| 1353 | }), | ||
| 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 | const managerInput: CodexAppServerStartSessionInput = { | 1371 | const managerInput: CodexAppServerStartSessionInput = { |
| 1358 | threadId: input.threadId, | 1372 | threadId: input.threadId, |
| 1359 | provider: "codex", | 1373 | provider: "codex", |
| 1360 | ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), | 1374 | ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), |
| 1361 | ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), | 1375 | ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), |
| 1362 | ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), | ||
| 1363 | runtimeMode: input.runtimeMode, | 1376 | runtimeMode: input.runtimeMode, |
| 1377 | binaryPath, | ||
| 1378 | ...(homePath ? { homePath } : {}), | ||
| 1364 | ...(input.modelSelection?.provider === "codex" | 1379 | ...(input.modelSelection?.provider === "codex" |
| 1365 | ? { model: input.modelSelection.model } | 1380 | ? { model: input.modelSelection.model } |
| 1366 | : {}), | 1381 | : {}), |
| ... | @@ -1369,7 +1384,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => | ... | @@ -1369,7 +1384,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 1369 | : {}), | 1384 | : {}), |
| 1370 | }; | 1385 | }; |
| 1371 | 1386 | ||
| 1372 | return Effect.tryPromise({ | 1387 | return yield* Effect.tryPromise({ |
| 1373 | try: () => manager.startSession(managerInput), | 1388 | try: () => manager.startSession(managerInput), |
| 1374 | catch: (cause) => | 1389 | catch: (cause) => |
| 1375 | new ProviderAdapterProcessError({ | 1390 | new ProviderAdapterProcessError({ |
| ... | @@ -1378,8 +1393,8 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => | ... | @@ -1378,8 +1393,8 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 1378 | detail: toMessage(cause, "Failed to start Codex adapter session."), | 1393 | detail: toMessage(cause, "Failed to start Codex adapter session."), |
| 1379 | cause, | 1394 | cause, |
| 1380 | }), | 1395 | }), |
| 1381 | }).pipe(Effect.map((session) => session)); | 1396 | }); |
| 1382 | }; | 1397 | }); |
| 1383 | 1398 | ||
| 1384 | const sendTurn: CodexAdapterShape["sendTurn"] = (input) => | 1399 | const sendTurn: CodexAdapterShape["sendTurn"] = (input) => |
| 1385 | Effect.gen(function* () { | 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,8 +42,11 @@ import { |
| 42 | makeSqlitePersistenceLive, | 42 | makeSqlitePersistenceLive, |
| 43 | SqlitePersistenceMemory, | 43 | SqlitePersistenceMemory, |
| 44 | } from "../../persistence/Layers/Sqlite.ts"; | 44 | } from "../../persistence/Layers/Sqlite.ts"; |
| 45 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 45 | import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; | 46 | import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; |
| 46 | 47 | ||
| 48 | const defaultServerSettingsLayer = ServerSettingsService.layerTest(); | ||
| 49 | |||
| 47 | const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); | 50 | const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); |
| 48 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | 51 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| 49 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | 52 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); |
| ... | @@ -251,6 +254,7 @@ function makeProviderServiceLayer() { | ... | @@ -251,6 +254,7 @@ function makeProviderServiceLayer() { |
| 251 | makeProviderServiceLive().pipe( | 254 | makeProviderServiceLive().pipe( |
| 252 | Layer.provide(providerAdapterLayer), | 255 | Layer.provide(providerAdapterLayer), |
| 253 | Layer.provide(directoryLayer), | 256 | Layer.provide(directoryLayer), |
| 257 | Layer.provide(defaultServerSettingsLayer), | ||
| 254 | Layer.provideMerge(AnalyticsService.layerTest), | 258 | Layer.provideMerge(AnalyticsService.layerTest), |
| 255 | ), | 259 | ), |
| 256 | directoryLayer, | 260 | directoryLayer, |
| ... | @@ -267,6 +271,55 @@ function makeProviderServiceLayer() { | ... | @@ -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 | const routing = makeProviderServiceLayer(); | 323 | const routing = makeProviderServiceLayer(); |
| 271 | it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", () => | 324 | it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", () => |
| 272 | Effect.gen(function* () { | 325 | Effect.gen(function* () { |
| ... | @@ -299,6 +352,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( | ... | @@ -299,6 +352,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( |
| 299 | const providerLayer = makeProviderServiceLive().pipe( | 352 | const providerLayer = makeProviderServiceLive().pipe( |
| 300 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), | 353 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), |
| 301 | Layer.provide(directoryLayer), | 354 | Layer.provide(directoryLayer), |
| 355 | Layer.provide(defaultServerSettingsLayer), | ||
| 302 | Layer.provide(AnalyticsService.layerTest), | 356 | Layer.provide(AnalyticsService.layerTest), |
| 303 | ); | 357 | ); |
| 304 | 358 | ||
| ... | @@ -358,6 +412,7 @@ it.effect( | ... | @@ -358,6 +412,7 @@ it.effect( |
| 358 | const firstProviderLayer = makeProviderServiceLive().pipe( | 412 | const firstProviderLayer = makeProviderServiceLive().pipe( |
| 359 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), | 413 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), |
| 360 | Layer.provide(firstDirectoryLayer), | 414 | Layer.provide(firstDirectoryLayer), |
| 415 | Layer.provide(defaultServerSettingsLayer), | ||
| 361 | Layer.provide(AnalyticsService.layerTest), | 416 | Layer.provide(AnalyticsService.layerTest), |
| 362 | ); | 417 | ); |
| 363 | const updatedResumeCursor = { | 418 | const updatedResumeCursor = { |
| ... | @@ -409,6 +464,7 @@ it.effect( | ... | @@ -409,6 +464,7 @@ it.effect( |
| 409 | const secondProviderLayer = makeProviderServiceLive().pipe( | 464 | const secondProviderLayer = makeProviderServiceLive().pipe( |
| 410 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), | 465 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), |
| 411 | Layer.provide(secondDirectoryLayer), | 466 | Layer.provide(secondDirectoryLayer), |
| 467 | Layer.provide(defaultServerSettingsLayer), | ||
| 412 | Layer.provide(AnalyticsService.layerTest), | 468 | Layer.provide(AnalyticsService.layerTest), |
| 413 | ); | 469 | ); |
| 414 | 470 | ||
| ... | @@ -769,6 +825,7 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -769,6 +825,7 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 769 | const firstProviderLayer = makeProviderServiceLive().pipe( | 825 | const firstProviderLayer = makeProviderServiceLive().pipe( |
| 770 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), | 826 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), |
| 771 | Layer.provide(firstDirectoryLayer), | 827 | Layer.provide(firstDirectoryLayer), |
| 828 | Layer.provide(defaultServerSettingsLayer), | ||
| 772 | Layer.provide(AnalyticsService.layerTest), | 829 | Layer.provide(AnalyticsService.layerTest), |
| 773 | ); | 830 | ); |
| 774 | 831 | ||
| ... | @@ -801,6 +858,7 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -801,6 +858,7 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 801 | const secondProviderLayer = makeProviderServiceLive().pipe( | 858 | const secondProviderLayer = makeProviderServiceLive().pipe( |
| 802 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), | 859 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), |
| 803 | Layer.provide(secondDirectoryLayer), | 860 | Layer.provide(secondDirectoryLayer), |
| 861 | Layer.provide(defaultServerSettingsLayer), | ||
| 804 | Layer.provide(AnalyticsService.layerTest), | 862 | Layer.provide(AnalyticsService.layerTest), |
| 805 | ); | 863 | ); |
| 806 | 864 |
apps/server/src/provider/Layers/ProviderService.ts+17-17| ... | @@ -33,6 +33,7 @@ import { | ... | @@ -33,6 +33,7 @@ import { |
| 33 | } from "../Services/ProviderSessionDirectory.ts"; | 33 | } from "../Services/ProviderSessionDirectory.ts"; |
| 34 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | 34 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 35 | import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; | 35 | import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; |
| 36 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 36 | 37 | ||
| 37 | export interface ProviderServiceLiveOptions { | 38 | export interface ProviderServiceLiveOptions { |
| 38 | readonly canonicalEventLogPath?: string; | 39 | readonly canonicalEventLogPath?: string; |
| ... | @@ -91,7 +92,6 @@ function toRuntimePayloadFromSession( | ... | @@ -91,7 +92,6 @@ function toRuntimePayloadFromSession( |
| 91 | session: ProviderSession, | 92 | session: ProviderSession, |
| 92 | extra?: { | 93 | extra?: { |
| 93 | readonly modelSelection?: unknown; | 94 | readonly modelSelection?: unknown; |
| 94 | readonly providerOptions?: unknown; | ||
| 95 | readonly lastRuntimeEvent?: string; | 95 | readonly lastRuntimeEvent?: string; |
| 96 | readonly lastRuntimeEventAt?: string; | 96 | readonly lastRuntimeEventAt?: string; |
| 97 | }, | 97 | }, |
| ... | @@ -102,7 +102,6 @@ function toRuntimePayloadFromSession( | ... | @@ -102,7 +102,6 @@ function toRuntimePayloadFromSession( |
| 102 | activeTurnId: session.activeTurnId ?? null, | 102 | activeTurnId: session.activeTurnId ?? null, |
| 103 | lastError: session.lastError ?? null, | 103 | lastError: session.lastError ?? null, |
| 104 | ...(extra?.modelSelection !== undefined ? { modelSelection: extra.modelSelection } : {}), | 104 | ...(extra?.modelSelection !== undefined ? { modelSelection: extra.modelSelection } : {}), |
| 105 | ...(extra?.providerOptions !== undefined ? { providerOptions: extra.providerOptions } : {}), | ||
| 106 | ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}), | 105 | ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}), |
| 107 | ...(extra?.lastRuntimeEventAt !== undefined | 106 | ...(extra?.lastRuntimeEventAt !== undefined |
| 108 | ? { lastRuntimeEventAt: extra.lastRuntimeEventAt } | 107 | ? { lastRuntimeEventAt: extra.lastRuntimeEventAt } |
| ... | @@ -120,17 +119,6 @@ function readPersistedModelSelection( | ... | @@ -120,17 +119,6 @@ function readPersistedModelSelection( |
| 120 | return Schema.is(ModelSelection)(raw) ? raw : undefined; | 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 | function readPersistedCwd( | 122 | function readPersistedCwd( |
| 135 | runtimePayload: ProviderRuntimeBinding["runtimePayload"], | 123 | runtimePayload: ProviderRuntimeBinding["runtimePayload"], |
| 136 | ): string | undefined { | 124 | ): string | undefined { |
| ... | @@ -146,6 +134,7 @@ function readPersistedCwd( | ... | @@ -146,6 +134,7 @@ function readPersistedCwd( |
| 146 | const makeProviderService = (options?: ProviderServiceLiveOptions) => | 134 | const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 147 | Effect.gen(function* () { | 135 | Effect.gen(function* () { |
| 148 | const analytics = yield* Effect.service(AnalyticsService); | 136 | const analytics = yield* Effect.service(AnalyticsService); |
| 137 | const serverSettings = yield* ServerSettingsService; | ||
| 149 | const canonicalEventLogger = | 138 | const canonicalEventLogger = |
| 150 | options?.canonicalEventLogger ?? | 139 | options?.canonicalEventLogger ?? |
| 151 | (options?.canonicalEventLogPath !== undefined | 140 | (options?.canonicalEventLogPath !== undefined |
| ... | @@ -173,7 +162,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -173,7 +162,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 173 | threadId: ThreadId, | 162 | threadId: ThreadId, |
| 174 | extra?: { | 163 | extra?: { |
| 175 | readonly modelSelection?: unknown; | 164 | readonly modelSelection?: unknown; |
| 176 | readonly providerOptions?: unknown; | ||
| 177 | readonly lastRuntimeEvent?: string; | 165 | readonly lastRuntimeEvent?: string; |
| 178 | readonly lastRuntimeEventAt?: string; | 166 | readonly lastRuntimeEventAt?: string; |
| 179 | }, | 167 | }, |
| ... | @@ -239,14 +227,12 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -239,14 +227,12 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 239 | 227 | ||
| 240 | const persistedCwd = readPersistedCwd(input.binding.runtimePayload); | 228 | const persistedCwd = readPersistedCwd(input.binding.runtimePayload); |
| 241 | const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); | 229 | const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); |
| 242 | const persistedProviderOptions = readPersistedProviderOptions(input.binding.runtimePayload); | ||
| 243 | 230 | ||
| 244 | const resumed = yield* adapter.startSession({ | 231 | const resumed = yield* adapter.startSession({ |
| 245 | threadId: input.binding.threadId, | 232 | threadId: input.binding.threadId, |
| 246 | provider: input.binding.provider, | 233 | provider: input.binding.provider, |
| 247 | ...(persistedCwd ? { cwd: persistedCwd } : {}), | 234 | ...(persistedCwd ? { cwd: persistedCwd } : {}), |
| 248 | ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), | 235 | ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), |
| 249 | ...(persistedProviderOptions ? { providerOptions: persistedProviderOptions } : {}), | ||
| 250 | ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), | 236 | ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), |
| 251 | runtimeMode: input.binding.runtimeMode ?? "full-access", | 237 | runtimeMode: input.binding.runtimeMode ?? "full-access", |
| 252 | }); | 238 | }); |
| ... | @@ -308,6 +294,21 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -308,6 +294,21 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 308 | threadId, | 294 | threadId, |
| 309 | provider: parsed.provider ?? "codex", | 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 | const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId)); | 312 | const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId)); |
| 312 | const effectiveResumeCursor = | 313 | const effectiveResumeCursor = |
| 313 | input.resumeCursor ?? | 314 | input.resumeCursor ?? |
| ... | @@ -329,7 +330,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -329,7 +330,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 329 | 330 | ||
| 330 | yield* upsertSessionBinding(session, threadId, { | 331 | yield* upsertSessionBinding(session, threadId, { |
| 331 | modelSelection: input.modelSelection, | 332 | modelSelection: input.modelSelection, |
| 332 | providerOptions: input.providerOptions, | ||
| 333 | }); | 333 | }); |
| 334 | yield* analytics.record("provider.session.started", { | 334 | yield* analytics.record("provider.session.started", { |
| 335 | provider: session.provider, | 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,6 +24,7 @@ import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; |
| 24 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; | 24 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; |
| 25 | import { ProviderService } from "./provider/Services/ProviderService"; | 25 | import { ProviderService } from "./provider/Services/ProviderService"; |
| 26 | import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; | 26 | import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; |
| 27 | import { ServerSettingsService } from "./serverSettings"; | ||
| 27 | 28 | ||
| 28 | import { TerminalManagerLive } from "./terminal/Layers/Manager"; | 29 | import { TerminalManagerLive } from "./terminal/Layers/Manager"; |
| 29 | import { KeybindingsLive } from "./keybindings"; | 30 | import { KeybindingsLive } from "./keybindings"; |
| ... | @@ -54,7 +55,11 @@ const makeRuntimePtyAdapterLayer = () => | ... | @@ -54,7 +55,11 @@ const makeRuntimePtyAdapterLayer = () => |
| 54 | export function makeServerProviderLayer(): Layer.Layer< | 55 | export function makeServerProviderLayer(): Layer.Layer< |
| 55 | ProviderService, | 56 | ProviderService, |
| 56 | ProviderUnsupportedError, | 57 | ProviderUnsupportedError, |
| 57 | SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem | AnalyticsService | 58 | | SqlClient.SqlClient |
| 59 | | ServerConfig | ||
| 60 | | ServerSettingsService | ||
| 61 | | FileSystem.FileSystem | ||
| 62 | | AnalyticsService | ||
| 58 | > { | 63 | > { |
| 59 | return Effect.gen(function* () { | 64 | return Effect.gen(function* () { |
| 60 | const { providerEventLogPath } = yield* ServerConfig; | 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,18 +13,20 @@ import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serve |
| 13 | 13 | ||
| 14 | import { | 14 | import { |
| 15 | DEFAULT_TERMINAL_ID, | 15 | DEFAULT_TERMINAL_ID, |
| 16 | DEFAULT_SERVER_SETTINGS, | ||
| 16 | EDITORS, | 17 | EDITORS, |
| 17 | EventId, | 18 | EventId, |
| 18 | ORCHESTRATION_WS_CHANNELS, | 19 | ORCHESTRATION_WS_CHANNELS, |
| 19 | ORCHESTRATION_WS_METHODS, | 20 | ORCHESTRATION_WS_METHODS, |
| 20 | ProviderItemId, | 21 | ProviderItemId, |
| 22 | type ServerSettings, | ||
| 21 | ThreadId, | 23 | ThreadId, |
| 22 | TurnId, | 24 | TurnId, |
| 23 | WS_CHANNELS, | 25 | WS_CHANNELS, |
| 24 | WS_METHODS, | 26 | WS_METHODS, |
| 25 | type WebSocketResponse, | 27 | type WebSocketResponse, |
| 26 | type ProviderRuntimeEvent, | 28 | type ProviderRuntimeEvent, |
| 27 | type ServerProviderStatus, | 29 | type ServerProvider, |
| 28 | type KeybindingsConfig, | 30 | type KeybindingsConfig, |
| 29 | type ResolvedKeybindingsConfig, | 31 | type ResolvedKeybindingsConfig, |
| 30 | type WsPushChannel, | 32 | type WsPushChannel, |
| ... | @@ -45,7 +47,7 @@ import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/ | ... | @@ -45,7 +47,7 @@ import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/ |
| 45 | import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; | 47 | import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; |
| 46 | import { SqlClient, SqlError } from "effect/unstable/sql"; | 48 | import { SqlClient, SqlError } from "effect/unstable/sql"; |
| 47 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; | 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 | import { Open, type OpenShape } from "./open"; | 51 | import { Open, type OpenShape } from "./open"; |
| 50 | import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; | 52 | import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; |
| 51 | import type { GitCoreShape } from "./git/Services/GitCore.ts"; | 53 | import type { GitCoreShape } from "./git/Services/GitCore.ts"; |
| ... | @@ -53,6 +55,7 @@ import { GitCore } from "./git/Services/GitCore.ts"; | ... | @@ -53,6 +55,7 @@ import { GitCore } from "./git/Services/GitCore.ts"; |
| 53 | import { GitCommandError, GitManagerError } from "./git/Errors.ts"; | 55 | import { GitCommandError, GitManagerError } from "./git/Errors.ts"; |
| 54 | import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; | 56 | import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; |
| 55 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; | 57 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; |
| 58 | import { ServerSettingsService } from "./serverSettings.ts"; | ||
| 56 | 59 | ||
| 57 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | 60 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| 58 | const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); | 61 | const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); |
| ... | @@ -64,20 +67,27 @@ const defaultOpenService: OpenShape = { | ... | @@ -64,20 +67,27 @@ const defaultOpenService: OpenShape = { |
| 64 | openInEditor: () => Effect.void, | 67 | openInEditor: () => Effect.void, |
| 65 | }; | 68 | }; |
| 66 | 69 | ||
| 67 | const defaultProviderStatuses: ReadonlyArray<ServerProviderStatus> = [ | 70 | const defaultProviderStatuses: ReadonlyArray<ServerProvider> = [ |
| 68 | { | 71 | { |
| 69 | provider: "codex", | 72 | provider: "codex", |
| 73 | enabled: true, | ||
| 74 | installed: true, | ||
| 75 | version: "0.116.0", | ||
| 70 | status: "ready", | 76 | status: "ready", |
| 71 | available: true, | ||
| 72 | authStatus: "authenticated", | 77 | authStatus: "authenticated", |
| 73 | checkedAt: "2026-01-01T00:00:00.000Z", | 78 | checkedAt: "2026-01-01T00:00:00.000Z", |
| 79 | models: [], | ||
| 74 | }, | 80 | }, |
| 75 | ]; | 81 | ]; |
| 76 | 82 | ||
| 77 | const defaultProviderHealthService: ProviderHealthShape = { | 83 | const defaultProviderRegistryService: ProviderRegistryShape = { |
| 78 | getStatuses: Effect.succeed(defaultProviderStatuses), | 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 | class MockTerminalManager implements TerminalManagerShape { | 91 | class MockTerminalManager implements TerminalManagerShape { |
| 82 | private readonly sessions = new Map<string, TerminalSessionSnapshot>(); | 92 | private readonly sessions = new Map<string, TerminalSessionSnapshot>(); |
| 83 | private readonly listeners = new Set<(event: TerminalEvent) => void>(); | 93 | private readonly listeners = new Set<(event: TerminalEvent) => void>(); |
| ... | @@ -487,11 +497,12 @@ describe("WebSocket Server", () => { | ... | @@ -487,11 +497,12 @@ describe("WebSocket Server", () => { |
| 487 | baseDir?: string; | 497 | baseDir?: string; |
| 488 | staticDir?: string; | 498 | staticDir?: string; |
| 489 | providerLayer?: Layer.Layer<ProviderService, never>; | 499 | providerLayer?: Layer.Layer<ProviderService, never>; |
| 490 | providerHealth?: ProviderHealthShape; | 500 | providerRegistry?: ProviderRegistryShape; |
| 491 | open?: OpenShape; | 501 | open?: OpenShape; |
| 492 | gitManager?: GitManagerShape; | 502 | gitManager?: GitManagerShape; |
| 493 | gitCore?: Pick<GitCoreShape, "listBranches" | "initRepo" | "pullCurrentBranch">; | 503 | gitCore?: Pick<GitCoreShape, "listBranches" | "initRepo" | "pullCurrentBranch">; |
| 494 | terminalManager?: TerminalManagerShape; | 504 | terminalManager?: TerminalManagerShape; |
| 505 | serverSettings?: Partial<ServerSettings>; | ||
| 495 | } = {}, | 506 | } = {}, |
| 496 | ): Promise<Http.Server> { | 507 | ): Promise<Http.Server> { |
| 497 | if (serverScope) { | 508 | if (serverScope) { |
| ... | @@ -504,9 +515,9 @@ describe("WebSocket Server", () => { | ... | @@ -504,9 +515,9 @@ describe("WebSocket Server", () => { |
| 504 | const scope = await Effect.runPromise(Scope.make("sequential")); | 515 | const scope = await Effect.runPromise(Scope.make("sequential")); |
| 505 | const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory; | 516 | const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory; |
| 506 | const providerLayer = options.providerLayer ?? makeServerProviderLayer(); | 517 | const providerLayer = options.providerLayer ?? makeServerProviderLayer(); |
| 507 | const providerHealthLayer = Layer.succeed( | 518 | const providerRegistryLayer = Layer.succeed( |
| 508 | ProviderHealth, | 519 | ProviderRegistry, |
| 509 | options.providerHealth ?? defaultProviderHealthService, | 520 | options.providerRegistry ?? defaultProviderRegistryService, |
| 510 | ); | 521 | ); |
| 511 | const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService); | 522 | const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService); |
| 512 | const serverConfigLayer = Layer.succeed(ServerConfig, { | 523 | const serverConfigLayer = Layer.succeed(ServerConfig, { |
| ... | @@ -543,8 +554,9 @@ describe("WebSocket Server", () => { | ... | @@ -543,8 +554,9 @@ describe("WebSocket Server", () => { |
| 543 | ); | 554 | ); |
| 544 | const dependenciesLayer = Layer.empty.pipe( | 555 | const dependenciesLayer = Layer.empty.pipe( |
| 545 | Layer.provideMerge(runtimeLayer), | 556 | Layer.provideMerge(runtimeLayer), |
| 546 | Layer.provideMerge(providerHealthLayer), | 557 | Layer.provideMerge(providerRegistryLayer), |
| 547 | Layer.provideMerge(openLayer), | 558 | Layer.provideMerge(openLayer), |
| 559 | Layer.provideMerge(ServerSettingsService.layerTest(options.serverSettings)), | ||
| 548 | Layer.provideMerge(serverConfigLayer), | 560 | Layer.provideMerge(serverConfigLayer), |
| 549 | Layer.provideMerge(AnalyticsService.layerTest), | 561 | Layer.provideMerge(AnalyticsService.layerTest), |
| 550 | Layer.provideMerge(NodeServices.layer), | 562 | Layer.provideMerge(NodeServices.layer), |
| ... | @@ -858,6 +870,7 @@ describe("WebSocket Server", () => { | ... | @@ -858,6 +870,7 @@ describe("WebSocket Server", () => { |
| 858 | issues: [], | 870 | issues: [], |
| 859 | providers: defaultProviderStatuses, | 871 | providers: defaultProviderStatuses, |
| 860 | availableEditors: expect.any(Array), | 872 | availableEditors: expect.any(Array), |
| 873 | settings: defaultServerSettings, | ||
| 861 | }); | 874 | }); |
| 862 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); | 875 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); |
| 863 | }); | 876 | }); |
| ... | @@ -883,6 +896,7 @@ describe("WebSocket Server", () => { | ... | @@ -883,6 +896,7 @@ describe("WebSocket Server", () => { |
| 883 | issues: [], | 896 | issues: [], |
| 884 | providers: defaultProviderStatuses, | 897 | providers: defaultProviderStatuses, |
| 885 | availableEditors: expect.any(Array), | 898 | availableEditors: expect.any(Array), |
| 899 | settings: defaultServerSettings, | ||
| 886 | }); | 900 | }); |
| 887 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); | 901 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); |
| 888 | 902 | ||
| ... | @@ -919,6 +933,7 @@ describe("WebSocket Server", () => { | ... | @@ -919,6 +933,7 @@ describe("WebSocket Server", () => { |
| 919 | ], | 933 | ], |
| 920 | providers: defaultProviderStatuses, | 934 | providers: defaultProviderStatuses, |
| 921 | availableEditors: expect.any(Array), | 935 | availableEditors: expect.any(Array), |
| 936 | settings: defaultServerSettings, | ||
| 922 | }); | 937 | }); |
| 923 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); | 938 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); |
| 924 | expect(fs.readFileSync(keybindingsPath, "utf8")).toBe("{ not-json"); | 939 | expect(fs.readFileSync(keybindingsPath, "utf8")).toBe("{ not-json"); |
| ... | @@ -952,7 +967,7 @@ describe("WebSocket Server", () => { | ... | @@ -952,7 +967,7 @@ describe("WebSocket Server", () => { |
| 952 | keybindingsConfigPath: string; | 967 | keybindingsConfigPath: string; |
| 953 | keybindings: ResolvedKeybindingsConfig; | 968 | keybindings: ResolvedKeybindingsConfig; |
| 954 | issues: Array<{ kind: string; index?: number; message: string }>; | 969 | issues: Array<{ kind: string; index?: number; message: string }>; |
| 955 | providers: ReadonlyArray<ServerProviderStatus>; | 970 | providers: ReadonlyArray<ServerProvider>; |
| 956 | availableEditors: unknown; | 971 | availableEditors: unknown; |
| 957 | }; | 972 | }; |
| 958 | expect(result.cwd).toBe("/my/workspace"); | 973 | expect(result.cwd).toBe("/my/workspace"); |
| ... | @@ -1000,7 +1015,6 @@ describe("WebSocket Server", () => { | ... | @@ -1000,7 +1015,6 @@ describe("WebSocket Server", () => { |
| 1000 | ); | 1015 | ); |
| 1001 | expect(malformedPush.data).toEqual({ | 1016 | expect(malformedPush.data).toEqual({ |
| 1002 | issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }], | 1017 | issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }], |
| 1003 | providers: defaultProviderStatuses, | ||
| 1004 | }); | 1018 | }); |
| 1005 | 1019 | ||
| 1006 | const successPush = await rewriteKeybindingsAndWaitForPush( | 1020 | const successPush = await rewriteKeybindingsAndWaitForPush( |
| ... | @@ -1009,7 +1023,7 @@ describe("WebSocket Server", () => { | ... | @@ -1009,7 +1023,7 @@ describe("WebSocket Server", () => { |
| 1009 | "[]", | 1023 | "[]", |
| 1010 | (push) => Array.isArray(push.data.issues) && push.data.issues.length === 0, | 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 | it("routes shell.openInEditor through the injected open service", async () => { | 1029 | it("routes shell.openInEditor through the injected open service", async () => { |
| ... | @@ -1069,6 +1083,7 @@ describe("WebSocket Server", () => { | ... | @@ -1069,6 +1083,7 @@ describe("WebSocket Server", () => { |
| 1069 | issues: [], | 1083 | issues: [], |
| 1070 | providers: defaultProviderStatuses, | 1084 | providers: defaultProviderStatuses, |
| 1071 | availableEditors: expect.any(Array), | 1085 | availableEditors: expect.any(Array), |
| 1086 | settings: defaultServerSettings, | ||
| 1072 | }); | 1087 | }); |
| 1073 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); | 1088 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); |
| 1074 | }); | 1089 | }); |
| ... | @@ -1117,6 +1132,7 @@ describe("WebSocket Server", () => { | ... | @@ -1117,6 +1132,7 @@ describe("WebSocket Server", () => { |
| 1117 | issues: [], | 1132 | issues: [], |
| 1118 | providers: defaultProviderStatuses, | 1133 | providers: defaultProviderStatuses, |
| 1119 | availableEditors: expect.any(Array), | 1134 | availableEditors: expect.any(Array), |
| 1135 | settings: defaultServerSettings, | ||
| 1120 | }); | 1136 | }); |
| 1121 | expectAvailableEditors( | 1137 | expectAvailableEditors( |
| 1122 | (configResponse.result as { availableEditors: unknown }).availableEditors, | 1138 | (configResponse.result as { availableEditors: unknown }).availableEditors, |
| ... | @@ -1273,6 +1289,7 @@ describe("WebSocket Server", () => { | ... | @@ -1273,6 +1289,7 @@ describe("WebSocket Server", () => { |
| 1273 | server = await createTestServer({ | 1289 | server = await createTestServer({ |
| 1274 | cwd: "/test", | 1290 | cwd: "/test", |
| 1275 | providerLayer, | 1291 | providerLayer, |
| 1292 | serverSettings: { enableAssistantStreaming: true }, | ||
| 1276 | }); | 1293 | }); |
| 1277 | const addr = server.address(); | 1294 | const addr = server.address(); |
| 1278 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 1295 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| ... | @@ -1323,7 +1340,6 @@ describe("WebSocket Server", () => { | ... | @@ -1323,7 +1340,6 @@ describe("WebSocket Server", () => { |
| 1323 | text: "hello", | 1340 | text: "hello", |
| 1324 | attachments: [], | 1341 | attachments: [], |
| 1325 | }, | 1342 | }, |
| 1326 | assistantDeliveryMode: "streaming", | ||
| 1327 | runtimeMode: "approval-required", | 1343 | runtimeMode: "approval-required", |
| 1328 | interactionMode: "default", | 1344 | interactionMode: "default", |
| 1329 | createdAt, | 1345 | createdAt, |
| ... | @@ -1850,10 +1866,6 @@ describe("WebSocket Server", () => { | ... | @@ -1850,10 +1866,6 @@ describe("WebSocket Server", () => { |
| 1850 | actionId: "client-action-1", | 1866 | actionId: "client-action-1", |
| 1851 | cwd: "/test", | 1867 | cwd: "/test", |
| 1852 | action: "commit_push", | 1868 | action: "commit_push", |
| 1853 | modelSelection: { | ||
| 1854 | provider: "codex", | ||
| 1855 | model: "gpt-5.4-mini", | ||
| 1856 | }, | ||
| 1857 | }, | 1869 | }, |
| 1858 | expect.objectContaining({ | 1870 | expect.objectContaining({ |
| 1859 | actionId: "client-action-1", | 1871 | actionId: "client-action-1", |
apps/server/src/wsServer.ts+49-7| ... | @@ -49,12 +49,13 @@ import { createLogger } from "./logger"; | ... | @@ -49,12 +49,13 @@ import { createLogger } from "./logger"; |
| 49 | import { GitManager } from "./git/Services/GitManager.ts"; | 49 | import { GitManager } from "./git/Services/GitManager.ts"; |
| 50 | import { TerminalManager } from "./terminal/Services/Manager.ts"; | 50 | import { TerminalManager } from "./terminal/Services/Manager.ts"; |
| 51 | import { Keybindings } from "./keybindings"; | 51 | import { Keybindings } from "./keybindings"; |
| 52 | import { ServerSettingsService } from "./serverSettings"; | ||
| 52 | import { searchWorkspaceEntries } from "./workspaceEntries"; | 53 | import { searchWorkspaceEntries } from "./workspaceEntries"; |
| 53 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; | 54 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; |
| 54 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; | 55 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; |
| 55 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; | 56 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; |
| 56 | import { ProviderService } from "./provider/Services/ProviderService"; | 57 | import { ProviderService } from "./provider/Services/ProviderService"; |
| 57 | import { ProviderHealth } from "./provider/Services/ProviderHealth"; | 58 | import { ProviderRegistry } from "./provider/Services/ProviderRegistry"; |
| 58 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; | 59 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; |
| 59 | import { clamp } from "effect/Number"; | 60 | import { clamp } from "effect/Number"; |
| 60 | import { Open, resolveAvailableEditors } from "./open"; | 61 | import { Open, resolveAvailableEditors } from "./open"; |
| ... | @@ -208,7 +209,7 @@ export type ServerCoreRuntimeServices = | ... | @@ -208,7 +209,7 @@ export type ServerCoreRuntimeServices = |
| 208 | | CheckpointDiffQuery | 209 | | CheckpointDiffQuery |
| 209 | | OrchestrationReactor | 210 | | OrchestrationReactor |
| 210 | | ProviderService | 211 | | ProviderService |
| 211 | | ProviderHealth; | 212 | | ProviderRegistry; |
| 212 | 213 | ||
| 213 | export type ServerRuntimeServices = | 214 | export type ServerRuntimeServices = |
| 214 | | ServerCoreRuntimeServices | 215 | | ServerCoreRuntimeServices |
| ... | @@ -216,6 +217,7 @@ export type ServerRuntimeServices = | ... | @@ -216,6 +217,7 @@ export type ServerRuntimeServices = |
| 216 | | GitCore | 217 | | GitCore |
| 217 | | TerminalManager | 218 | | TerminalManager |
| 218 | | Keybindings | 219 | | Keybindings |
| 220 | | ServerSettingsService | ||
| 219 | | Open | 221 | | Open |
| 220 | | AnalyticsService; | 222 | | AnalyticsService; |
| 221 | 223 | ||
| ... | @@ -253,7 +255,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -253,7 +255,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 253 | const gitManager = yield* GitManager; | 255 | const gitManager = yield* GitManager; |
| 254 | const terminalManager = yield* TerminalManager; | 256 | const terminalManager = yield* TerminalManager; |
| 255 | const keybindingsManager = yield* Keybindings; | 257 | const keybindingsManager = yield* Keybindings; |
| 256 | const providerHealth = yield* ProviderHealth; | 258 | const serverSettingsManager = yield* ServerSettingsService; |
| 259 | const providerRegistry = yield* ProviderRegistry; | ||
| 257 | const git = yield* GitCore; | 260 | const git = yield* GitCore; |
| 258 | const fileSystem = yield* FileSystem.FileSystem; | 261 | const fileSystem = yield* FileSystem.FileSystem; |
| 259 | const path = yield* Path.Path; | 262 | const path = yield* Path.Path; |
| ... | @@ -268,7 +271,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -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 | const clients = yield* Ref.make(new Set<WebSocket>()); | 276 | const clients = yield* Ref.make(new Set<WebSocket>()); |
| 274 | const logger = createLogger("ws"); | 277 | const logger = createLogger("ws"); |
| ... | @@ -295,6 +298,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -295,6 +298,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 295 | ), | 298 | ), |
| 296 | ); | 299 | ); |
| 297 | yield* readiness.markKeybindingsReady; | 300 | yield* readiness.markKeybindingsReady; |
| 301 | yield* serverSettingsManager.start.pipe( | ||
| 302 | Effect.mapError( | ||
| 303 | (cause) => new ServerLifecycleError({ operation: "serverSettingsRuntimeStart", cause }), | ||
| 304 | ), | ||
| 305 | ); | ||
| 298 | 306 | ||
| 299 | const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { | 307 | const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { |
| 300 | readonly command: ClientOrchestrationCommand; | 308 | readonly command: ClientOrchestrationCommand; |
| ... | @@ -614,7 +622,22 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -614,7 +622,22 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 614 | yield* Stream.runForEach(keybindingsManager.streamChanges, (event) => | 622 | yield* Stream.runForEach(keybindingsManager.streamChanges, (event) => |
| 615 | pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { | 623 | pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { |
| 616 | issues: event.issues, | 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 | ).pipe(Effect.forkIn(subscriptionsScope)); | 642 | ).pipe(Effect.forkIn(subscriptionsScope)); |
| 620 | 643 | ||
| ... | @@ -878,16 +901,26 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -878,16 +901,26 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 878 | return yield* terminalManager.close(body); | 901 | return yield* terminalManager.close(body); |
| 879 | } | 902 | } |
| 880 | 903 | ||
| 881 | case WS_METHODS.serverGetConfig: | 904 | case WS_METHODS.serverGetConfig: { |
| 882 | const keybindingsConfig = yield* keybindingsManager.loadConfigState; | 905 | const keybindingsConfig = yield* keybindingsManager.loadConfigState; |
| 906 | const settings = yield* serverSettingsManager.getSettings; | ||
| 907 | const providers = yield* Ref.get(providersRef); | ||
| 883 | return { | 908 | return { |
| 884 | cwd, | 909 | cwd, |
| 885 | keybindingsConfigPath, | 910 | keybindingsConfigPath, |
| 886 | keybindings: keybindingsConfig.keybindings, | 911 | keybindings: keybindingsConfig.keybindings, |
| 887 | issues: keybindingsConfig.issues, | 912 | issues: keybindingsConfig.issues, |
| 888 | providers: providerStatuses, | 913 | providers, |
| 889 | availableEditors, | 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 | case WS_METHODS.serverUpsertKeybinding: { | 925 | case WS_METHODS.serverUpsertKeybinding: { |
| 893 | const body = stripRequestTag(request.body); | 926 | const body = stripRequestTag(request.body); |
| ... | @@ -895,6 +928,15 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -895,6 +928,15 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 895 | return { keybindings: keybindingsConfig, issues: [] }; | 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 | default: { | 940 | default: { |
| 899 | const _exhaustiveCheck: never = request.body; | 941 | const _exhaustiveCheck: never = request.body; |
| 900 | return yield* new RouteRequestError({ | 942 | return yield* new RouteRequestError({ |
apps/server/src/wsServer/pushBus.test.ts-3| ... | @@ -53,7 +53,6 @@ describe("makeServerPushBus", () => { | ... | @@ -53,7 +53,6 @@ describe("makeServerPushBus", () => { |
| 53 | 53 | ||
| 54 | yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { | 54 | yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { |
| 55 | issues: [{ kind: "keybindings.malformed-config", message: "queued-before-connect" }], | 55 | issues: [{ kind: "keybindings.malformed-config", message: "queued-before-connect" }], |
| 56 | providers: [], | ||
| 57 | }); | 56 | }); |
| 58 | 57 | ||
| 59 | const delivered = yield* pushBus.publishClient( | 58 | const delivered = yield* pushBus.publishClient( |
| ... | @@ -70,7 +69,6 @@ describe("makeServerPushBus", () => { | ... | @@ -70,7 +69,6 @@ describe("makeServerPushBus", () => { |
| 70 | 69 | ||
| 71 | yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { | 70 | yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { |
| 72 | issues: [], | 71 | issues: [], |
| 73 | providers: [], | ||
| 74 | }); | 72 | }); |
| 75 | 73 | ||
| 76 | yield* Effect.promise(() => client.waitForSentCount(2)); | 74 | yield* Effect.promise(() => client.waitForSentCount(2)); |
| ... | @@ -95,7 +93,6 @@ describe("makeServerPushBus", () => { | ... | @@ -95,7 +93,6 @@ describe("makeServerPushBus", () => { |
| 95 | channel: WS_CHANNELS.serverConfigUpdated, | 93 | channel: WS_CHANNELS.serverConfigUpdated, |
| 96 | data: { | 94 | data: { |
| 97 | issues: [], | 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,6 +12,7 @@ import { |
| 12 | WS_CHANNELS, | 12 | WS_CHANNELS, |
| 13 | WS_METHODS, | 13 | WS_METHODS, |
| 14 | OrchestrationSessionStatus, | 14 | OrchestrationSessionStatus, |
| 15 | DEFAULT_SERVER_SETTINGS, | ||
| 15 | } from "@t3tools/contracts"; | 16 | } from "@t3tools/contracts"; |
| 16 | import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; | 17 | import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; |
| 17 | import { HttpResponse, http, ws } from "msw"; | 18 | import { HttpResponse, http, ws } from "msw"; |
| ... | @@ -30,6 +31,7 @@ import { isMacPlatform } from "../lib/utils"; | ... | @@ -30,6 +31,7 @@ import { isMacPlatform } from "../lib/utils"; |
| 30 | import { getRouter } from "../router"; | 31 | import { getRouter } from "../router"; |
| 31 | import { useStore } from "../store"; | 32 | import { useStore } from "../store"; |
| 32 | import { estimateTimelineMessageHeight } from "./timelineHeight"; | 33 | import { estimateTimelineMessageHeight } from "./timelineHeight"; |
| 34 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; | ||
| 33 | 35 | ||
| 34 | const THREAD_ID = "thread-browser-test" as ThreadId; | 36 | const THREAD_ID = "thread-browser-test" as ThreadId; |
| 35 | const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; | 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,13 +112,20 @@ function createBaseServerConfig(): ServerConfig { |
| 110 | providers: [ | 112 | providers: [ |
| 111 | { | 113 | { |
| 112 | provider: "codex", | 114 | provider: "codex", |
| 115 | enabled: true, | ||
| 116 | installed: true, | ||
| 117 | version: "0.116.0", | ||
| 113 | status: "ready", | 118 | status: "ready", |
| 114 | available: true, | ||
| 115 | authStatus: "authenticated", | 119 | authStatus: "authenticated", |
| 116 | checkedAt: NOW_ISO, | 120 | checkedAt: NOW_ISO, |
| 121 | models: [], | ||
| 117 | }, | 122 | }, |
| 118 | ], | 123 | ], |
| 119 | availableEditors: [], | 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,7 +13,7 @@ import { |
| 13 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | 13 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 14 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | 14 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 15 | type ResolvedKeybindingsConfig, | 15 | type ResolvedKeybindingsConfig, |
| 16 | type ServerProviderStatus, | 16 | type ServerProvider, |
| 17 | type ThreadId, | 17 | type ThreadId, |
| 18 | type TurnId, | 18 | type TurnId, |
| 19 | type EditorId, | 19 | type EditorId, |
| ... | @@ -22,11 +22,7 @@ import { | ... | @@ -22,11 +22,7 @@ import { |
| 22 | ProviderInteractionMode, | 22 | ProviderInteractionMode, |
| 23 | RuntimeMode, | 23 | RuntimeMode, |
| 24 | } from "@t3tools/contracts"; | 24 | } from "@t3tools/contracts"; |
| 25 | import { | 25 | import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; |
| 26 | applyClaudePromptEffortPrefix, | ||
| 27 | getModelCapabilities, | ||
| 28 | normalizeModelSlug, | ||
| 29 | } from "@t3tools/shared/model"; | ||
| 30 | import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | 26 | import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 31 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | 27 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 32 | import { useDebouncedValue } from "@tanstack/react-pacer"; | 28 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| ... | @@ -120,12 +116,13 @@ import { | ... | @@ -120,12 +116,13 @@ import { |
| 120 | import { SidebarTrigger } from "./ui/sidebar"; | 116 | import { SidebarTrigger } from "./ui/sidebar"; |
| 121 | import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; | 117 | import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; |
| 122 | import { readNativeApi } from "~/nativeApi"; | 118 | import { readNativeApi } from "~/nativeApi"; |
| 123 | import { getProviderStartOptions, useAppSettings } from "../appSettings"; | ||
| 124 | import { | 119 | import { |
| 125 | getCustomModelOptionsByProvider, | 120 | getProviderModelCapabilities, |
| 126 | getCustomModelsByProvider, | 121 | getProviderModels, |
| 127 | resolveAppModelSelection, | 122 | resolveSelectableProvider, |
| 128 | } from "../modelSelection"; | 123 | } from "../providerModels"; |
| 124 | import { useSettings } from "../hooks/useSettings"; | ||
| 125 | import { resolveAppModelSelection } from "../modelSelection"; | ||
| 129 | import { isTerminalFocused } from "../lib/terminalFocus"; | 126 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 130 | import { | 127 | import { |
| 131 | type ComposerImageAttachment, | 128 | type ComposerImageAttachment, |
| ... | @@ -164,7 +161,7 @@ import { | ... | @@ -164,7 +161,7 @@ import { |
| 164 | renderProviderTraitsMenuContent, | 161 | renderProviderTraitsMenuContent, |
| 165 | renderProviderTraitsPicker, | 162 | renderProviderTraitsPicker, |
| 166 | } from "./chat/composerProviderRegistry"; | 163 | } from "./chat/composerProviderRegistry"; |
| 167 | import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; | 164 | import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; |
| 168 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; | 165 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; |
| 169 | import { | 166 | import { |
| 170 | buildExpiredTerminalContextToastCopy, | 167 | buildExpiredTerminalContextToastCopy, |
| ... | @@ -191,16 +188,17 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; | ... | @@ -191,16 +188,17 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; |
| 191 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | 188 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 192 | const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; | 189 | const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; |
| 193 | const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; | 190 | const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; |
| 194 | const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; | 191 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 195 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; | 192 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; |
| 196 | 193 | ||
| 197 | function formatOutgoingPrompt(params: { | 194 | function formatOutgoingPrompt(params: { |
| 198 | provider: ProviderKind; | 195 | provider: ProviderKind; |
| 199 | model: string | null; | 196 | model: string | null; |
| 197 | models: ReadonlyArray<ServerProvider["models"][number]>; | ||
| 200 | effort: string | null; | 198 | effort: string | null; |
| 201 | text: string; | 199 | text: string; |
| 202 | }): string { | 200 | }): string { |
| 203 | const caps = getModelCapabilities(params.provider, params.model); | 201 | const caps = getProviderModelCapabilities(params.models, params.model, params.provider); |
| 204 | if (params.effort && caps.promptInjectedEffortLevels.includes(params.effort)) { | 202 | if (params.effort && caps.promptInjectedEffortLevels.includes(params.effort)) { |
| 205 | return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null); | 203 | return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null); |
| 206 | } | 204 | } |
| ... | @@ -249,7 +247,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -249,7 +247,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 249 | const syncServerReadModel = useStore((store) => store.syncServerReadModel); | 247 | const syncServerReadModel = useStore((store) => store.syncServerReadModel); |
| 250 | const setStoreThreadError = useStore((store) => store.setError); | 248 | const setStoreThreadError = useStore((store) => store.setError); |
| 251 | const setStoreThreadBranch = useStore((store) => store.setThreadBranch); | 249 | const setStoreThreadBranch = useStore((store) => store.setThreadBranch); |
| 252 | const { settings } = useAppSettings(); | 250 | const settings = useSettings(); |
| 253 | const setStickyComposerModelSelection = useComposerDraftStore( | 251 | const setStickyComposerModelSelection = useComposerDraftStore( |
| 254 | (store) => store.setStickyModelSelection, | 252 | (store) => store.setStickyModelSelection, |
| 255 | ); | 253 | ); |
| ... | @@ -608,25 +606,32 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -608,25 +606,32 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 608 | const lockedProvider: ProviderKind | null = hasThreadStarted | 606 | const lockedProvider: ProviderKind | null = hasThreadStarted |
| 609 | ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null) | 607 | ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null) |
| 610 | : null; | 608 | : null; |
| 611 | const selectedProvider: ProviderKind = | 609 | const serverConfigQuery = useQuery(serverConfigQueryOptions()); |
| 612 | lockedProvider ?? selectedProviderByThreadId ?? threadProvider ?? "codex"; | 610 | const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDERS; |
| 613 | const customModelsByProvider = useMemo(() => getCustomModelsByProvider(settings), [settings]); | 611 | const unlockedSelectedProvider = resolveSelectableProvider( |
| 612 | providerStatuses, | ||
| 613 | selectedProviderByThreadId ?? threadProvider ?? "codex", | ||
| 614 | ); | ||
| 615 | const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; | ||
| 614 | const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ | 616 | const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ |
| 615 | threadId, | 617 | threadId, |
| 618 | providers: providerStatuses, | ||
| 616 | selectedProvider, | 619 | selectedProvider, |
| 617 | threadModelSelection: activeThread?.modelSelection, | 620 | threadModelSelection: activeThread?.modelSelection, |
| 618 | projectModelSelection: activeProject?.defaultModelSelection, | 621 | projectModelSelection: activeProject?.defaultModelSelection, |
| 619 | customModelsByProvider, | 622 | settings, |
| 620 | }); | 623 | }); |
| 624 | const selectedProviderModels = getProviderModels(providerStatuses, selectedProvider); | ||
| 621 | const composerProviderState = useMemo( | 625 | const composerProviderState = useMemo( |
| 622 | () => | 626 | () => |
| 623 | getComposerProviderState({ | 627 | getComposerProviderState({ |
| 624 | provider: selectedProvider, | 628 | provider: selectedProvider, |
| 625 | model: selectedModel, | 629 | model: selectedModel, |
| 630 | models: selectedProviderModels, | ||
| 626 | prompt, | 631 | prompt, |
| 627 | modelOptions: composerModelOptions, | 632 | modelOptions: composerModelOptions, |
| 628 | }), | 633 | }), |
| 629 | [composerModelOptions, prompt, selectedModel, selectedProvider], | 634 | [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels], |
| 630 | ); | 635 | ); |
| 631 | const selectedPromptEffort = composerProviderState.promptEffort; | 636 | const selectedPromptEffort = composerProviderState.promptEffort; |
| 632 | const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; | 637 | const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; |
| ... | @@ -638,35 +643,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -638,35 +643,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 638 | }), | 643 | }), |
| 639 | [selectedModel, selectedModelOptionsForDispatch, selectedProvider], | 644 | [selectedModel, selectedModelOptionsForDispatch, selectedProvider], |
| 640 | ); | 645 | ); |
| 641 | const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]); | ||
| 642 | const selectedModelForPicker = selectedModel; | 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 | const phase = derivePhase(activeThread?.session ?? null); | 647 | const phase = derivePhase(activeThread?.session ?? null); |
| 671 | const isSendBusy = sendPhase !== "idle"; | 648 | const isSendBusy = sendPhase !== "idle"; |
| 672 | const isPreparingWorktree = sendPhase === "preparing-worktree"; | 649 | const isPreparingWorktree = sendPhase === "preparing-worktree"; |
| ... | @@ -1029,7 +1006,39 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1029,7 +1006,39 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1029 | ); | 1006 | ); |
| 1030 | const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; | 1007 | const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; |
| 1031 | const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); | 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 | const workspaceEntriesQuery = useQuery( | 1042 | const workspaceEntriesQuery = useQuery( |
| 1034 | projectSearchEntriesQueryOptions({ | 1043 | projectSearchEntriesQueryOptions({ |
| 1035 | cwd: gitCwd, | 1044 | cwd: gitCwd, |
| ... | @@ -1117,9 +1126,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1117,9 +1126,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1117 | () => new Set(nonPersistedComposerImageIds), | 1126 | () => new Set(nonPersistedComposerImageIds), |
| 1118 | [nonPersistedComposerImageIds], | 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 | const activeProviderStatus = useMemo( | 1129 | const activeProviderStatus = useMemo( |
| 1124 | () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, | 1130 | () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, |
| 1125 | [selectedProvider, providerStatuses], | 1131 | [selectedProvider, providerStatuses], |
| ... | @@ -2460,6 +2466,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2460,6 +2466,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2460 | const outgoingMessageText = formatOutgoingPrompt({ | 2466 | const outgoingMessageText = formatOutgoingPrompt({ |
| 2461 | provider: selectedProvider, | 2467 | provider: selectedProvider, |
| 2462 | model: selectedModel, | 2468 | model: selectedModel, |
| 2469 | models: selectedProviderModels, | ||
| 2463 | effort: selectedPromptEffort, | 2470 | effort: selectedPromptEffort, |
| 2464 | text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, | 2471 | text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, |
| 2465 | }); | 2472 | }); |
| ... | @@ -2645,8 +2652,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2645,8 +2652,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2645 | attachments: turnAttachments, | 2652 | attachments: turnAttachments, |
| 2646 | }, | 2653 | }, |
| 2647 | modelSelection: selectedModelSelection, | 2654 | modelSelection: selectedModelSelection, |
| 2648 | ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), | ||
| 2649 | assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", | ||
| 2650 | runtimeMode, | 2655 | runtimeMode, |
| 2651 | interactionMode, | 2656 | interactionMode, |
| 2652 | createdAt: messageCreatedAt, | 2657 | createdAt: messageCreatedAt, |
| ... | @@ -2883,6 +2888,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2883,6 +2888,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2883 | const outgoingMessageText = formatOutgoingPrompt({ | 2888 | const outgoingMessageText = formatOutgoingPrompt({ |
| 2884 | provider: selectedProvider, | 2889 | provider: selectedProvider, |
| 2885 | model: selectedModel, | 2890 | model: selectedModel, |
| 2891 | models: selectedProviderModels, | ||
| 2886 | effort: selectedPromptEffort, | 2892 | effort: selectedPromptEffort, |
| 2887 | text: trimmed, | 2893 | text: trimmed, |
| 2888 | }); | 2894 | }); |
| ... | @@ -2927,8 +2933,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2927,8 +2933,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2927 | attachments: [], | 2933 | attachments: [], |
| 2928 | }, | 2934 | }, |
| 2929 | modelSelection: selectedModelSelection, | 2935 | modelSelection: selectedModelSelection, |
| 2930 | ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), | ||
| 2931 | assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", | ||
| 2932 | runtimeMode, | 2936 | runtimeMode, |
| 2933 | interactionMode: nextInteractionMode, | 2937 | interactionMode: nextInteractionMode, |
| 2934 | ...(nextInteractionMode === "default" && activeProposedPlan | 2938 | ...(nextInteractionMode === "default" && activeProposedPlan |
| ... | @@ -2974,11 +2978,10 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2974,11 +2978,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2974 | runtimeMode, | 2978 | runtimeMode, |
| 2975 | selectedPromptEffort, | 2979 | selectedPromptEffort, |
| 2976 | selectedModelSelection, | 2980 | selectedModelSelection, |
| 2977 | providerOptionsForDispatch, | ||
| 2978 | selectedProvider, | 2981 | selectedProvider, |
| 2982 | selectedProviderModels, | ||
| 2979 | setComposerDraftInteractionMode, | 2983 | setComposerDraftInteractionMode, |
| 2980 | setThreadError, | 2984 | setThreadError, |
| 2981 | settings.enableAssistantStreaming, | ||
| 2982 | selectedModel, | 2985 | selectedModel, |
| 2983 | ], | 2986 | ], |
| 2984 | ); | 2987 | ); |
| ... | @@ -3005,6 +3008,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3005,6 +3008,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3005 | const outgoingImplementationPrompt = formatOutgoingPrompt({ | 3008 | const outgoingImplementationPrompt = formatOutgoingPrompt({ |
| 3006 | provider: selectedProvider, | 3009 | provider: selectedProvider, |
| 3007 | model: selectedModel, | 3010 | model: selectedModel, |
| 3011 | models: selectedProviderModels, | ||
| 3008 | effort: selectedPromptEffort, | 3012 | effort: selectedPromptEffort, |
| 3009 | text: implementationPrompt, | 3013 | text: implementationPrompt, |
| 3010 | }); | 3014 | }); |
| ... | @@ -3044,8 +3048,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3044,8 +3048,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3044 | attachments: [], | 3048 | attachments: [], |
| 3045 | }, | 3049 | }, |
| 3046 | modelSelection: selectedModelSelection, | 3050 | modelSelection: selectedModelSelection, |
| 3047 | ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), | ||
| 3048 | assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", | ||
| 3049 | runtimeMode, | 3051 | runtimeMode, |
| 3050 | interactionMode: "default", | 3052 | interactionMode: "default", |
| 3051 | createdAt, | 3053 | createdAt, |
| ... | @@ -3096,9 +3098,8 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3096,9 +3098,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3096 | runtimeMode, | 3098 | runtimeMode, |
| 3097 | selectedPromptEffort, | 3099 | selectedPromptEffort, |
| 3098 | selectedModelSelection, | 3100 | selectedModelSelection, |
| 3099 | providerOptionsForDispatch, | ||
| 3100 | selectedProvider, | 3101 | selectedProvider, |
| 3101 | settings.enableAssistantStreaming, | 3102 | selectedProviderModels, |
| 3102 | syncServerReadModel, | 3103 | syncServerReadModel, |
| 3103 | selectedModel, | 3104 | selectedModel, |
| 3104 | ]); | 3105 | ]); |
| ... | @@ -3110,9 +3111,15 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3110,9 +3111,15 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3110 | scheduleComposerFocus(); | 3111 | scheduleComposerFocus(); |
| 3111 | return; | 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 | const nextModelSelection: ModelSelection = { | 3121 | const nextModelSelection: ModelSelection = { |
| 3115 | provider, | 3122 | provider: resolvedProvider, |
| 3116 | model: resolvedModel, | 3123 | model: resolvedModel, |
| 3117 | }; | 3124 | }; |
| 3118 | setComposerDraftModelSelection(activeThread.id, nextModelSelection); | 3125 | setComposerDraftModelSelection(activeThread.id, nextModelSelection); |
| ... | @@ -3125,7 +3132,8 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3125,7 +3132,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3125 | scheduleComposerFocus, | 3132 | scheduleComposerFocus, |
| 3126 | setComposerDraftModelSelection, | 3133 | setComposerDraftModelSelection, |
| 3127 | setStickyComposerModelSelection, | 3134 | setStickyComposerModelSelection, |
| 3128 | customModelsByProvider, | 3135 | providerStatuses, |
| 3136 | settings, | ||
| 3129 | ], | 3137 | ], |
| 3130 | ); | 3138 | ); |
| 3131 | const setPromptFromTraits = useCallback( | 3139 | const setPromptFromTraits = useCallback( |
| ... | @@ -3148,6 +3156,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3148,6 +3156,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3148 | provider: selectedProvider, | 3156 | provider: selectedProvider, |
| 3149 | threadId, | 3157 | threadId, |
| 3150 | model: selectedModel, | 3158 | model: selectedModel, |
| 3159 | models: selectedProviderModels, | ||
| 3151 | modelOptions: composerModelOptions?.[selectedProvider], | 3160 | modelOptions: composerModelOptions?.[selectedProvider], |
| 3152 | prompt, | 3161 | prompt, |
| 3153 | onPromptChange: setPromptFromTraits, | 3162 | onPromptChange: setPromptFromTraits, |
| ... | @@ -3156,6 +3165,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3156,6 +3165,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3156 | provider: selectedProvider, | 3165 | provider: selectedProvider, |
| 3157 | threadId, | 3166 | threadId, |
| 3158 | model: selectedModel, | 3167 | model: selectedModel, |
| 3168 | models: selectedProviderModels, | ||
| 3159 | modelOptions: composerModelOptions?.[selectedProvider], | 3169 | modelOptions: composerModelOptions?.[selectedProvider], |
| 3160 | prompt, | 3170 | prompt, |
| 3161 | onPromptChange: setPromptFromTraits, | 3171 | onPromptChange: setPromptFromTraits, |
| ... | @@ -3519,7 +3529,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3519,7 +3529,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3519 | </header> | 3529 | </header> |
| 3520 | 3530 | ||
| 3521 | {/* Error banner */} | 3531 | {/* Error banner */} |
| 3522 | <ProviderHealthBanner status={activeProviderStatus} /> | 3532 | <ProviderStatusBanner status={activeProviderStatus} /> |
| 3523 | <ThreadErrorBanner | 3533 | <ThreadErrorBanner |
| 3524 | error={activeThread.error} | 3534 | error={activeThread.error} |
| 3525 | onDismiss={() => setThreadError(activeThread.id, null)} | 3535 | onDismiss={() => setThreadError(activeThread.id, null)} |
| ... | @@ -3796,6 +3806,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3796,6 +3806,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3796 | provider={selectedProvider} | 3806 | provider={selectedProvider} |
| 3797 | model={selectedModelForPickerWithCustomFallback} | 3807 | model={selectedModelForPickerWithCustomFallback} |
| 3798 | lockedProvider={lockedProvider} | 3808 | lockedProvider={lockedProvider} |
| 3809 | providers={providerStatuses} | ||
| 3799 | modelOptionsByProvider={modelOptionsByProvider} | 3810 | modelOptionsByProvider={modelOptionsByProvider} |
| 3800 | {...(composerProviderState.modelPickerIconClassName | 3811 | {...(composerProviderState.modelPickerIconClassName |
| 3801 | ? { | 3812 | ? { |
apps/web/src/components/DiffPanel.tsx+2-2| ... | @@ -30,7 +30,7 @@ import { buildPatchCacheKey } from "../lib/diffRendering"; | ... | @@ -30,7 +30,7 @@ import { buildPatchCacheKey } from "../lib/diffRendering"; |
| 30 | import { resolveDiffThemeName } from "../lib/diffRendering"; | 30 | import { resolveDiffThemeName } from "../lib/diffRendering"; |
| 31 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | 31 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 32 | import { useStore } from "../store"; | 32 | import { useStore } from "../store"; |
| 33 | import { useAppSettings } from "../appSettings"; | 33 | import { useSettings } from "../hooks/useSettings"; |
| 34 | import { formatShortTimestamp } from "../timestampFormat"; | 34 | import { formatShortTimestamp } from "../timestampFormat"; |
| 35 | import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; | 35 | import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; |
| 36 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; | 36 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; |
| ... | @@ -166,7 +166,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; | ... | @@ -166,7 +166,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; |
| 166 | export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | 166 | export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 167 | const navigate = useNavigate(); | 167 | const navigate = useNavigate(); |
| 168 | const { resolvedTheme } = useTheme(); | 168 | const { resolvedTheme } = useTheme(); |
| 169 | const { settings } = useAppSettings(); | 169 | const settings = useSettings(); |
| 170 | const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked"); | 170 | const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked"); |
| 171 | const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); | 171 | const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); |
| 172 | const patchViewportRef = useRef<HTMLDivElement>(null); | 172 | const patchViewportRef = useRef<HTMLDivElement>(null); |
apps/web/src/components/GitActionsControl.tsx-3| ... | @@ -20,7 +20,6 @@ import { | ... | @@ -20,7 +20,6 @@ import { |
| 20 | resolveQuickAction, | 20 | resolveQuickAction, |
| 21 | summarizeGitResult, | 21 | summarizeGitResult, |
| 22 | } from "./GitActionsControl.logic"; | 22 | } from "./GitActionsControl.logic"; |
| 23 | import { useAppSettings } from "~/appSettings"; | ||
| 24 | import { Button } from "~/components/ui/button"; | 23 | import { Button } from "~/components/ui/button"; |
| 25 | import { Checkbox } from "~/components/ui/checkbox"; | 24 | import { Checkbox } from "~/components/ui/checkbox"; |
| 26 | import { | 25 | import { |
| ... | @@ -205,7 +204,6 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { | ... | @@ -205,7 +204,6 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { |
| 205 | } | 204 | } |
| 206 | 205 | ||
| 207 | export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { | 206 | export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { |
| 208 | const { settings } = useAppSettings(); | ||
| 209 | const threadToastData = useMemo( | 207 | const threadToastData = useMemo( |
| 210 | () => (activeThreadId ? { threadId: activeThreadId } : undefined), | 208 | () => (activeThreadId ? { threadId: activeThreadId } : undefined), |
| 211 | [activeThreadId], | 209 | [activeThreadId], |
| ... | @@ -261,7 +259,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -261,7 +259,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 261 | gitRunStackedActionMutationOptions({ | 259 | gitRunStackedActionMutationOptions({ |
| 262 | cwd: gitCwd, | 260 | cwd: gitCwd, |
| 263 | queryClient, | 261 | queryClient, |
| 264 | modelSelection: settings.textGenerationModelSelection, | ||
| 265 | }), | 262 | }), |
| 266 | ); | 263 | ); |
| 267 | const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); | 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,13 +46,25 @@ function createBaseServerConfig(): ServerConfig { |
| 46 | providers: [ | 46 | providers: [ |
| 47 | { | 47 | { |
| 48 | provider: "codex", | 48 | provider: "codex", |
| 49 | enabled: true, | ||
| 50 | installed: true, | ||
| 51 | version: "0.116.0", | ||
| 49 | status: "ready", | 52 | status: "ready", |
| 50 | available: true, | ||
| 51 | authStatus: "authenticated", | 53 | authStatus: "authenticated", |
| 52 | checkedAt: NOW_ISO, | 54 | checkedAt: NOW_ISO, |
| 55 | models: [], | ||
| 53 | }, | 56 | }, |
| 54 | ], | 57 | ], |
| 55 | availableEditors: [], | 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,5 +1,5 @@ |
| 1 | import { memo, useState, useCallback } from "react"; | 1 | import { memo, useState, useCallback } from "react"; |
| 2 | import { type TimestampFormat } from "../appSettings"; | 2 | import { type TimestampFormat } from "@t3tools/contracts/settings"; |
| 3 | import { Badge } from "./ui/badge"; | 3 | import { Badge } from "./ui/badge"; |
| 4 | import { Button } from "./ui/button"; | 4 | import { Button } from "./ui/button"; |
| 5 | import { ScrollArea } from "./ui/scroll-area"; | 5 | import { ScrollArea } from "./ui/scroll-area"; |
apps/web/src/components/Sidebar.logic.ts+1-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "../appSettings"; | 1 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; |
| 2 | import type { Thread } from "../types"; | 2 | import type { Thread } from "../types"; |
| 3 | import { cn } from "../lib/utils"; | 3 | import { cn } from "../lib/utils"; |
| 4 | import { | 4 | import { |
apps/web/src/components/Sidebar.tsx+4-3| ... | @@ -41,8 +41,7 @@ import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; | ... | @@ -41,8 +41,7 @@ import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; |
| 41 | import { | 41 | import { |
| 42 | type SidebarProjectSortOrder, | 42 | type SidebarProjectSortOrder, |
| 43 | type SidebarThreadSortOrder, | 43 | type SidebarThreadSortOrder, |
| 44 | useAppSettings, | 44 | } from "@t3tools/contracts/settings"; |
| 45 | } from "../appSettings"; | ||
| 46 | import { isElectron } from "../env"; | 45 | import { isElectron } from "../env"; |
| 47 | import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; | 46 | import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; |
| 48 | import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; | 47 | import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; |
| ... | @@ -102,6 +101,7 @@ import { | ... | @@ -102,6 +101,7 @@ import { |
| 102 | sortThreadsForSidebar, | 101 | sortThreadsForSidebar, |
| 103 | } from "./Sidebar.logic"; | 102 | } from "./Sidebar.logic"; |
| 104 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; | 103 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 104 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; | ||
| 105 | 105 | ||
| 106 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | 106 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 107 | const THREAD_PREVIEW_LIMIT = 6; | 107 | const THREAD_PREVIEW_LIMIT = 6; |
| ... | @@ -381,7 +381,8 @@ export default function Sidebar() { | ... | @@ -381,7 +381,8 @@ export default function Sidebar() { |
| 381 | ); | 381 | ); |
| 382 | const navigate = useNavigate(); | 382 | const navigate = useNavigate(); |
| 383 | const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" }); | 383 | const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" }); |
| 384 | const { settings: appSettings, updateSettings } = useAppSettings(); | 384 | const appSettings = useSettings(); |
| 385 | const { updateSettings } = useUpdateSettings(); | ||
| 385 | const { handleNewThread } = useHandleNewThread(); | 386 | const { handleNewThread } = useHandleNewThread(); |
| 386 | const routeThreadId = useParams({ | 387 | const routeThreadId = useParams({ |
| 387 | strict: false, | 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,6 +43,70 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str |
| 43 | document.body.append(host); | 43 | document.body.append(host); |
| 44 | const onPromptChange = vi.fn(); | 44 | const onPromptChange = vi.fn(); |
| 45 | const providerOptions = props?.modelSelection?.options; | 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 | const screen = await render( | 110 | const screen = await render( |
| 47 | <CompactComposerControlsMenu | 111 | <CompactComposerControlsMenu |
| 48 | activePlan={false} | 112 | activePlan={false} |
| ... | @@ -52,6 +116,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str | ... | @@ -52,6 +116,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str |
| 52 | traitsMenuContent={ | 116 | traitsMenuContent={ |
| 53 | <TraitsMenuContent | 117 | <TraitsMenuContent |
| 54 | provider={provider} | 118 | provider={provider} |
| 119 | models={models} | ||
| 55 | threadId={threadId} | 120 | threadId={threadId} |
| 56 | model={model} | 121 | model={model} |
| 57 | prompt={props?.prompt ?? ""} | 122 | prompt={props?.prompt ?? ""} |
apps/web/src/components/chat/MessagesTimeline.tsx+1-1| ... | @@ -48,7 +48,7 @@ import { | ... | @@ -48,7 +48,7 @@ import { |
| 48 | type ParsedTerminalContextEntry, | 48 | type ParsedTerminalContextEntry, |
| 49 | } from "~/lib/terminalContext"; | 49 | } from "~/lib/terminalContext"; |
| 50 | import { cn } from "~/lib/utils"; | 50 | import { cn } from "~/lib/utils"; |
| 51 | import { type TimestampFormat } from "../../appSettings"; | 51 | import { type TimestampFormat } from "@t3tools/contracts/settings"; |
| 52 | import { formatTimestamp } from "../../timestampFormat"; | 52 | import { formatTimestamp } from "../../timestampFormat"; |
| 53 | import { | 53 | import { |
| 54 | buildInlineTerminalContextText, | 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,37 +1,134 @@ |
| 1 | import { type ModelSlug, type ProviderKind } from "@t3tools/contracts"; | 1 | import { type ModelSlug, type ProviderKind, type ServerProvider } from "@t3tools/contracts"; |
| 2 | import { page } from "vitest/browser"; | 2 | import { page } from "vitest/browser"; |
| 3 | import { afterEach, describe, expect, it, vi } from "vitest"; | 3 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 4 | import { render } from "vitest-browser-react"; | 4 | import { render } from "vitest-browser-react"; |
| 5 | 5 | ||
| 6 | import { ProviderModelPicker } from "./ProviderModelPicker"; | 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 = { | 10 | function effort(value: string, isDefault = false) { |
| 9 | claudeAgent: [ | 11 | return { |
| 10 | { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, | 12 | value, |
| 11 | { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, | 13 | label: value, |
| 12 | { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" }, | 14 | ...(isDefault ? { isDefault: true } : {}), |
| 13 | ], | 15 | }; |
| 14 | codex: [ | 16 | } |
| 15 | { slug: "gpt-5-codex", name: "GPT-5 Codex" }, | 17 | |
| 16 | { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, | 18 | const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ |
| 17 | ], | 19 | { |
| 18 | } as const satisfies Record<ProviderKind, ReadonlyArray<{ slug: ModelSlug; name: string }>>; | 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 | async function mountPicker(props: { | 108 | async function mountPicker(props: { |
| 21 | provider: ProviderKind; | 109 | provider: ProviderKind; |
| 22 | model: ModelSlug; | 110 | model: ModelSlug; |
| 23 | lockedProvider: ProviderKind | null; | 111 | lockedProvider: ProviderKind | null; |
| 112 | providers?: ReadonlyArray<ServerProvider>; | ||
| 24 | triggerVariant?: "ghost" | "outline"; | 113 | triggerVariant?: "ghost" | "outline"; |
| 25 | }) { | 114 | }) { |
| 26 | const host = document.createElement("div"); | 115 | const host = document.createElement("div"); |
| 27 | document.body.append(host); | 116 | document.body.append(host); |
| 28 | const onProviderModelChange = vi.fn(); | 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 | const screen = await render( | 125 | const screen = await render( |
| 30 | <ProviderModelPicker | 126 | <ProviderModelPicker |
| 31 | provider={props.provider} | 127 | provider={props.provider} |
| 32 | model={props.model} | 128 | model={props.model} |
| 33 | lockedProvider={props.lockedProvider} | 129 | lockedProvider={props.lockedProvider} |
| 34 | modelOptionsByProvider={MODEL_OPTIONS_BY_PROVIDER} | 130 | providers={providers} |
| 131 | modelOptionsByProvider={modelOptionsByProvider} | ||
| 35 | triggerVariant={props.triggerVariant} | 132 | triggerVariant={props.triggerVariant} |
| 36 | onProviderModelChange={onProviderModelChange} | 133 | onProviderModelChange={onProviderModelChange} |
| 37 | />, | 134 | />, |
| ... | @@ -159,6 +256,40 @@ describe("ProviderModelPicker", () => { | ... | @@ -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 | it("accepts outline trigger styling", async () => { | 293 | it("accepts outline trigger styling", async () => { |
| 163 | const mounted = await mountPicker({ | 294 | const mounted = await mountPicker({ |
| 164 | provider: "codex", | 295 | provider: "codex", |
apps/web/src/components/chat/ProviderModelPicker.tsx+28-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -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 | import { resolveSelectableModel } from "@t3tools/shared/model"; | 2 | import { resolveSelectableModel } from "@t3tools/shared/model"; |
| 3 | import { memo, useState } from "react"; | 3 | import { memo, useState } from "react"; |
| 4 | import type { VariantProps } from "class-variance-authority"; | 4 | import type { VariantProps } from "class-variance-authority"; |
| ... | @@ -20,6 +20,7 @@ import { | ... | @@ -20,6 +20,7 @@ import { |
| 20 | } from "../ui/menu"; | 20 | } from "../ui/menu"; |
| 21 | import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons"; | 21 | import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons"; |
| 22 | import { cn } from "~/lib/utils"; | 22 | import { cn } from "~/lib/utils"; |
| 23 | import { getProviderSnapshot } from "../../providerModels"; | ||
| 23 | 24 | ||
| 24 | function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { | 25 | function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { |
| 25 | value: ProviderKind; | 26 | value: ProviderKind; |
| ... | @@ -53,6 +54,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -53,6 +54,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 53 | provider: ProviderKind; | 54 | provider: ProviderKind; |
| 54 | model: ModelSlug; | 55 | model: ModelSlug; |
| 55 | lockedProvider: ProviderKind | null; | 56 | lockedProvider: ProviderKind | null; |
| 57 | providers?: ReadonlyArray<ServerProvider>; | ||
| 56 | modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>; | 58 | modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>; |
| 57 | activeProviderIconClassName?: string; | 59 | activeProviderIconClassName?: string; |
| 58 | compact?: boolean; | 60 | compact?: boolean; |
| ... | @@ -145,6 +147,31 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -145,6 +147,31 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 145 | <> | 147 | <> |
| 146 | {AVAILABLE_PROVIDER_OPTIONS.map((option) => { | 148 | {AVAILABLE_PROVIDER_OPTIONS.map((option) => { |
| 147 | const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; | 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 | return ( | 175 | return ( |
| 149 | <MenuSub key={option.value}> | 176 | <MenuSub key={option.value}> |
| 150 | <MenuSubTrigger> | 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,7 +5,9 @@ import { |
| 5 | ClaudeModelOptions, | 5 | ClaudeModelOptions, |
| 6 | CodexModelOptions, | 6 | CodexModelOptions, |
| 7 | DEFAULT_MODEL_BY_PROVIDER, | 7 | DEFAULT_MODEL_BY_PROVIDER, |
| 8 | DEFAULT_SERVER_SETTINGS, | ||
| 8 | ProjectId, | 9 | ProjectId, |
| 10 | type ServerProvider, | ||
| 9 | ThreadId, | 11 | ThreadId, |
| 10 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 11 | import { page } from "vitest/browser"; | 13 | import { page } from "vitest/browser"; |
| ... | @@ -21,10 +23,93 @@ import { | ... | @@ -21,10 +23,93 @@ import { |
| 21 | useComposerThreadDraft, | 23 | useComposerThreadDraft, |
| 22 | useEffectiveComposerModelState, | 24 | useEffectiveComposerModelState, |
| 23 | } from "../../composerDraftStore"; | 25 | } from "../../composerDraftStore"; |
| 26 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; | ||
| 24 | 27 | ||
| 25 | // ── Claude TraitsPicker tests ───────────────────────────────────────── | 28 | // ── Claude TraitsPicker tests ───────────────────────────────────────── |
| 26 | 29 | ||
| 27 | const CLAUDE_THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits"); | 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 | function ClaudeTraitsPickerHarness(props: { | 114 | function ClaudeTraitsPickerHarness(props: { |
| 30 | model: string; | 115 | model: string; |
| ... | @@ -35,10 +120,14 @@ function ClaudeTraitsPickerHarness(props: { | ... | @@ -35,10 +120,14 @@ function ClaudeTraitsPickerHarness(props: { |
| 35 | const setPrompt = useComposerDraftStore((store) => store.setPrompt); | 120 | const setPrompt = useComposerDraftStore((store) => store.setPrompt); |
| 36 | const { modelOptions, selectedModel } = useEffectiveComposerModelState({ | 121 | const { modelOptions, selectedModel } = useEffectiveComposerModelState({ |
| 37 | threadId: CLAUDE_THREAD_ID, | 122 | threadId: CLAUDE_THREAD_ID, |
| 123 | providers: TEST_PROVIDERS, | ||
| 38 | selectedProvider: "claudeAgent", | 124 | selectedProvider: "claudeAgent", |
| 39 | threadModelSelection: props.fallbackModelSelection, | 125 | threadModelSelection: props.fallbackModelSelection, |
| 40 | projectModelSelection: null, | 126 | projectModelSelection: null, |
| 41 | customModelsByProvider: { codex: [], claudeAgent: [] }, | 127 | settings: { |
| 128 | ...DEFAULT_SERVER_SETTINGS, | ||
| 129 | ...DEFAULT_CLIENT_SETTINGS, | ||
| 130 | }, | ||
| 42 | }); | 131 | }); |
| 43 | const handlePromptChange = useCallback( | 132 | const handlePromptChange = useCallback( |
| 44 | (nextPrompt: string) => { | 133 | (nextPrompt: string) => { |
| ... | @@ -50,6 +139,7 @@ function ClaudeTraitsPickerHarness(props: { | ... | @@ -50,6 +139,7 @@ function ClaudeTraitsPickerHarness(props: { |
| 50 | return ( | 139 | return ( |
| 51 | <TraitsPicker | 140 | <TraitsPicker |
| 52 | provider="claudeAgent" | 141 | provider="claudeAgent" |
| 142 | models={TEST_PROVIDERS[1]!.models} | ||
| 53 | threadId={CLAUDE_THREAD_ID} | 143 | threadId={CLAUDE_THREAD_ID} |
| 54 | model={selectedModel ?? props.model} | 144 | model={selectedModel ?? props.model} |
| 55 | prompt={prompt} | 145 | prompt={prompt} |
| ... | @@ -109,7 +199,7 @@ async function mountClaudePicker(props?: { | ... | @@ -109,7 +199,7 @@ async function mountClaudePicker(props?: { |
| 109 | ? ({ | 199 | ? ({ |
| 110 | provider: "claudeAgent", | 200 | provider: "claudeAgent", |
| 111 | model, | 201 | model, |
| 112 | options: props.fallbackModelOptions ?? undefined, | 202 | ...(props.fallbackModelOptions ? { options: props.fallbackModelOptions } : {}), |
| 113 | } satisfies ModelSelection) | 203 | } satisfies ModelSelection) |
| 114 | : null; | 204 | : null; |
| 115 | const screen = await render( | 205 | const screen = await render( |
| ... | @@ -293,6 +383,7 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt | ... | @@ -293,6 +383,7 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt |
| 293 | const screen = await render( | 383 | const screen = await render( |
| 294 | <TraitsPicker | 384 | <TraitsPicker |
| 295 | provider="codex" | 385 | provider="codex" |
| 386 | models={TEST_PROVIDERS[0]!.models} | ||
| 296 | threadId={threadId} | 387 | threadId={threadId} |
| 297 | model={props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex} | 388 | model={props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex} |
| 298 | prompt="" | 389 | prompt="" |
| ... | @@ -359,10 +450,10 @@ describe("TraitsPicker (Codex)", () => { | ... | @@ -359,10 +450,10 @@ describe("TraitsPicker (Codex)", () => { |
| 359 | 450 | ||
| 360 | await vi.waitFor(() => { | 451 | await vi.waitFor(() => { |
| 361 | const text = document.body.textContent ?? ""; | 452 | const text = document.body.textContent ?? ""; |
| 362 | expect(text).toContain("Low"); | ||
| 363 | expect(text).toContain("Medium"); | ||
| 364 | expect(text).toContain("High"); | ||
| 365 | expect(text).toContain("Extra High"); | 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,11 +3,11 @@ import { |
| 3 | type CodexModelOptions, | 3 | type CodexModelOptions, |
| 4 | type ProviderKind, | 4 | type ProviderKind, |
| 5 | type ProviderModelOptions, | 5 | type ProviderModelOptions, |
| 6 | type ServerProviderModel, | ||
| 6 | type ThreadId, | 7 | type ThreadId, |
| 7 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 8 | import { | 9 | import { |
| 9 | applyClaudePromptEffortPrefix, | 10 | applyClaudePromptEffortPrefix, |
| 10 | getModelCapabilities, | ||
| 11 | isClaudeUltrathinkPrompt, | 11 | isClaudeUltrathinkPrompt, |
| 12 | trimOrNull, | 12 | trimOrNull, |
| 13 | getDefaultEffort, | 13 | getDefaultEffort, |
| ... | @@ -27,6 +27,7 @@ import { | ... | @@ -27,6 +27,7 @@ import { |
| 27 | MenuTrigger, | 27 | MenuTrigger, |
| 28 | } from "../ui/menu"; | 28 | } from "../ui/menu"; |
| 29 | import { useComposerDraftStore } from "../../composerDraftStore"; | 29 | import { useComposerDraftStore } from "../../composerDraftStore"; |
| 30 | import { getProviderModelCapabilities } from "../../providerModels"; | ||
| 30 | import { cn } from "~/lib/utils"; | 31 | import { cn } from "~/lib/utils"; |
| 31 | 32 | ||
| 32 | type ProviderOptions = ProviderModelOptions[ProviderKind]; | 33 | type ProviderOptions = ProviderModelOptions[ProviderKind]; |
| ... | @@ -65,12 +66,13 @@ function buildNextOptions( | ... | @@ -65,12 +66,13 @@ function buildNextOptions( |
| 65 | 66 | ||
| 66 | function getSelectedTraits( | 67 | function getSelectedTraits( |
| 67 | provider: ProviderKind, | 68 | provider: ProviderKind, |
| 69 | models: ReadonlyArray<ServerProviderModel>, | ||
| 68 | model: string | null | undefined, | 70 | model: string | null | undefined, |
| 69 | prompt: string, | 71 | prompt: string, |
| 70 | modelOptions: ProviderOptions | null | undefined, | 72 | modelOptions: ProviderOptions | null | undefined, |
| 71 | allowPromptInjectedEffort: boolean, | 73 | allowPromptInjectedEffort: boolean, |
| 72 | ) { | 74 | ) { |
| 73 | const caps = getModelCapabilities(provider, model); | 75 | const caps = getProviderModelCapabilities(models, model, provider); |
| 74 | const effortLevels = allowPromptInjectedEffort | 76 | const effortLevels = allowPromptInjectedEffort |
| 75 | ? caps.reasoningEffortLevels | 77 | ? caps.reasoningEffortLevels |
| 76 | : caps.reasoningEffortLevels.filter( | 78 | : caps.reasoningEffortLevels.filter( |
| ... | @@ -120,6 +122,7 @@ function getSelectedTraits( | ... | @@ -120,6 +122,7 @@ function getSelectedTraits( |
| 120 | 122 | ||
| 121 | export interface TraitsMenuContentProps { | 123 | export interface TraitsMenuContentProps { |
| 122 | provider: ProviderKind; | 124 | provider: ProviderKind; |
| 125 | models: ReadonlyArray<ServerProviderModel>; | ||
| 123 | model: string | null | undefined; | 126 | model: string | null | undefined; |
| 124 | prompt: string; | 127 | prompt: string; |
| 125 | onPromptChange: (prompt: string) => void; | 128 | onPromptChange: (prompt: string) => void; |
| ... | @@ -131,6 +134,7 @@ export interface TraitsMenuContentProps { | ... | @@ -131,6 +134,7 @@ export interface TraitsMenuContentProps { |
| 131 | 134 | ||
| 132 | export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ | 135 | export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 133 | provider, | 136 | provider, |
| 137 | models, | ||
| 134 | model, | 138 | model, |
| 135 | prompt, | 139 | prompt, |
| 136 | onPromptChange, | 140 | onPromptChange, |
| ... | @@ -156,7 +160,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ | ... | @@ -156,7 +160,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 156 | thinkingEnabled, | 160 | thinkingEnabled, |
| 157 | fastModeEnabled, | 161 | fastModeEnabled, |
| 158 | ultrathinkPromptControlled, | 162 | ultrathinkPromptControlled, |
| 159 | } = getSelectedTraits(provider, model, prompt, modelOptions, allowPromptInjectedEffort); | 163 | } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort); |
| 160 | const defaultEffort = getDefaultEffort(caps); | 164 | const defaultEffort = getDefaultEffort(caps); |
| 161 | 165 | ||
| 162 | const handleEffortChange = useCallback( | 166 | const handleEffortChange = useCallback( |
| ... | @@ -260,6 +264,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ | ... | @@ -260,6 +264,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 260 | 264 | ||
| 261 | export const TraitsPicker = memo(function TraitsPicker({ | 265 | export const TraitsPicker = memo(function TraitsPicker({ |
| 262 | provider, | 266 | provider, |
| 267 | models, | ||
| 263 | model, | 268 | model, |
| 264 | prompt, | 269 | prompt, |
| 265 | onPromptChange, | 270 | onPromptChange, |
| ... | @@ -277,7 +282,7 @@ export const TraitsPicker = memo(function TraitsPicker({ | ... | @@ -277,7 +282,7 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 277 | thinkingEnabled, | 282 | thinkingEnabled, |
| 278 | fastModeEnabled, | 283 | fastModeEnabled, |
| 279 | ultrathinkPromptControlled, | 284 | ultrathinkPromptControlled, |
| 280 | } = getSelectedTraits(provider, model, prompt, modelOptions, allowPromptInjectedEffort); | 285 | } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort); |
| 281 | 286 | ||
| 282 | const effortLabel = effort | 287 | const effortLabel = effort |
| 283 | ? (effortLevels.find((l) => l.value === effort)?.label ?? effort) | 288 | ? (effortLevels.find((l) => l.value === effort)?.label ?? effort) |
| ... | @@ -333,6 +338,7 @@ export const TraitsPicker = memo(function TraitsPicker({ | ... | @@ -333,6 +338,7 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 333 | <MenuPopup align="start"> | 338 | <MenuPopup align="start"> |
| 334 | <TraitsMenuContent | 339 | <TraitsMenuContent |
| 335 | provider={provider} | 340 | provider={provider} |
| 341 | models={models} | ||
| 336 | model={model} | 342 | model={model} |
| 337 | prompt={prompt} | 343 | prompt={prompt} |
| 338 | onPromptChange={onPromptChange} | 344 | onPromptChange={onPromptChange} |
apps/web/src/components/chat/composerProviderRegistry.test.tsx+75| ... | @@ -1,11 +1,78 @@ | ... | @@ -1,11 +1,78 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import type { ServerProviderModel } from "@t3tools/contracts"; | ||
| 2 | import { getComposerProviderState } from "./composerProviderRegistry"; | 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 | describe("getComposerProviderState", () => { | 70 | describe("getComposerProviderState", () => { |
| 5 | it("returns codex defaults when no codex draft options exist", () => { | 71 | it("returns codex defaults when no codex draft options exist", () => { |
| 6 | const state = getComposerProviderState({ | 72 | const state = getComposerProviderState({ |
| 7 | provider: "codex", | 73 | provider: "codex", |
| 8 | model: "gpt-5.4", | 74 | model: "gpt-5.4", |
| 75 | models: CODEX_MODELS, | ||
| 9 | prompt: "", | 76 | prompt: "", |
| 10 | modelOptions: undefined, | 77 | modelOptions: undefined, |
| 11 | }); | 78 | }); |
| ... | @@ -21,6 +88,7 @@ describe("getComposerProviderState", () => { | ... | @@ -21,6 +88,7 @@ describe("getComposerProviderState", () => { |
| 21 | const state = getComposerProviderState({ | 88 | const state = getComposerProviderState({ |
| 22 | provider: "codex", | 89 | provider: "codex", |
| 23 | model: "gpt-5.4", | 90 | model: "gpt-5.4", |
| 91 | models: CODEX_MODELS, | ||
| 24 | prompt: "", | 92 | prompt: "", |
| 25 | modelOptions: { | 93 | modelOptions: { |
| 26 | codex: { | 94 | codex: { |
| ... | @@ -44,6 +112,7 @@ describe("getComposerProviderState", () => { | ... | @@ -44,6 +112,7 @@ describe("getComposerProviderState", () => { |
| 44 | const state = getComposerProviderState({ | 112 | const state = getComposerProviderState({ |
| 45 | provider: "codex", | 113 | provider: "codex", |
| 46 | model: "gpt-5.4", | 114 | model: "gpt-5.4", |
| 115 | models: CODEX_MODELS, | ||
| 47 | prompt: "", | 116 | prompt: "", |
| 48 | modelOptions: { | 117 | modelOptions: { |
| 49 | codex: { | 118 | codex: { |
| ... | @@ -65,6 +134,7 @@ describe("getComposerProviderState", () => { | ... | @@ -65,6 +134,7 @@ describe("getComposerProviderState", () => { |
| 65 | const state = getComposerProviderState({ | 134 | const state = getComposerProviderState({ |
| 66 | provider: "codex", | 135 | provider: "codex", |
| 67 | model: "gpt-5.4", | 136 | model: "gpt-5.4", |
| 137 | models: CODEX_MODELS, | ||
| 68 | prompt: "", | 138 | prompt: "", |
| 69 | modelOptions: { | 139 | modelOptions: { |
| 70 | codex: { | 140 | codex: { |
| ... | @@ -85,6 +155,7 @@ describe("getComposerProviderState", () => { | ... | @@ -85,6 +155,7 @@ describe("getComposerProviderState", () => { |
| 85 | const state = getComposerProviderState({ | 155 | const state = getComposerProviderState({ |
| 86 | provider: "claudeAgent", | 156 | provider: "claudeAgent", |
| 87 | model: "claude-sonnet-4-6", | 157 | model: "claude-sonnet-4-6", |
| 158 | models: CLAUDE_MODELS, | ||
| 88 | prompt: "", | 159 | prompt: "", |
| 89 | modelOptions: undefined, | 160 | modelOptions: undefined, |
| 90 | }); | 161 | }); |
| ... | @@ -100,6 +171,7 @@ describe("getComposerProviderState", () => { | ... | @@ -100,6 +171,7 @@ describe("getComposerProviderState", () => { |
| 100 | const state = getComposerProviderState({ | 171 | const state = getComposerProviderState({ |
| 101 | provider: "claudeAgent", | 172 | provider: "claudeAgent", |
| 102 | model: "claude-sonnet-4-6", | 173 | model: "claude-sonnet-4-6", |
| 174 | models: CLAUDE_MODELS, | ||
| 103 | prompt: "Ultrathink:\nInvestigate this failure", | 175 | prompt: "Ultrathink:\nInvestigate this failure", |
| 104 | modelOptions: { | 176 | modelOptions: { |
| 105 | claudeAgent: { | 177 | claudeAgent: { |
| ... | @@ -124,6 +196,7 @@ describe("getComposerProviderState", () => { | ... | @@ -124,6 +196,7 @@ describe("getComposerProviderState", () => { |
| 124 | const state = getComposerProviderState({ | 196 | const state = getComposerProviderState({ |
| 125 | provider: "claudeAgent", | 197 | provider: "claudeAgent", |
| 126 | model: "claude-haiku-4-5", | 198 | model: "claude-haiku-4-5", |
| 199 | models: CLAUDE_MODELS, | ||
| 127 | prompt: "", | 200 | prompt: "", |
| 128 | modelOptions: { | 201 | modelOptions: { |
| 129 | claudeAgent: { | 202 | claudeAgent: { |
| ... | @@ -146,6 +219,7 @@ describe("getComposerProviderState", () => { | ... | @@ -146,6 +219,7 @@ describe("getComposerProviderState", () => { |
| 146 | const state = getComposerProviderState({ | 219 | const state = getComposerProviderState({ |
| 147 | provider: "claudeAgent", | 220 | provider: "claudeAgent", |
| 148 | model: "claude-opus-4-6", | 221 | model: "claude-opus-4-6", |
| 222 | models: CLAUDE_MODELS, | ||
| 149 | prompt: "", | 223 | prompt: "", |
| 150 | modelOptions: { | 224 | modelOptions: { |
| 151 | claudeAgent: { | 225 | claudeAgent: { |
| ... | @@ -167,6 +241,7 @@ describe("getComposerProviderState", () => { | ... | @@ -167,6 +241,7 @@ describe("getComposerProviderState", () => { |
| 167 | const state = getComposerProviderState({ | 241 | const state = getComposerProviderState({ |
| 168 | provider: "claudeAgent", | 242 | provider: "claudeAgent", |
| 169 | model: "claude-opus-4-6", | 243 | model: "claude-opus-4-6", |
| 244 | models: CLAUDE_MODELS, | ||
| 170 | prompt: "", | 245 | prompt: "", |
| 171 | modelOptions: { | 246 | modelOptions: { |
| 172 | claudeAgent: { | 247 | claudeAgent: { |
apps/web/src/components/chat/composerProviderRegistry.tsx+39-11| ... | @@ -2,23 +2,27 @@ import { | ... | @@ -2,23 +2,27 @@ import { |
| 2 | type ModelSlug, | 2 | type ModelSlug, |
| 3 | type ProviderKind, | 3 | type ProviderKind, |
| 4 | type ProviderModelOptions, | 4 | type ProviderModelOptions, |
| 5 | type ServerProviderModel, | ||
| 5 | type ThreadId, | 6 | type ThreadId, |
| 6 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 7 | import { | 8 | import { |
| 8 | getModelCapabilities, | ||
| 9 | isClaudeUltrathinkPrompt, | 9 | isClaudeUltrathinkPrompt, |
| 10 | normalizeClaudeModelOptions, | ||
| 11 | normalizeCodexModelOptions, | ||
| 12 | trimOrNull, | 10 | trimOrNull, |
| 13 | getDefaultEffort, | 11 | getDefaultEffort, |
| 14 | hasEffortLevel, | 12 | hasEffortLevel, |
| 15 | } from "@t3tools/shared/model"; | 13 | } from "@t3tools/shared/model"; |
| 16 | import type { ReactNode } from "react"; | 14 | import type { ReactNode } from "react"; |
| 15 | import { | ||
| 16 | getProviderModelCapabilities, | ||
| 17 | normalizeClaudeModelOptionsWithCapabilities, | ||
| 18 | normalizeCodexModelOptionsWithCapabilities, | ||
| 19 | } from "../../providerModels"; | ||
| 17 | import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; | 20 | import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; |
| 18 | 21 | ||
| 19 | export type ComposerProviderStateInput = { | 22 | export type ComposerProviderStateInput = { |
| 20 | provider: ProviderKind; | 23 | provider: ProviderKind; |
| 21 | model: ModelSlug; | 24 | model: ModelSlug; |
| 25 | models: ReadonlyArray<ServerProviderModel>; | ||
| 22 | prompt: string; | 26 | prompt: string; |
| 23 | modelOptions: ProviderModelOptions | null | undefined; | 27 | modelOptions: ProviderModelOptions | null | undefined; |
| 24 | }; | 28 | }; |
| ... | @@ -37,6 +41,7 @@ type ProviderRegistryEntry = { | ... | @@ -37,6 +41,7 @@ type ProviderRegistryEntry = { |
| 37 | renderTraitsMenuContent: (input: { | 41 | renderTraitsMenuContent: (input: { |
| 38 | threadId: ThreadId; | 42 | threadId: ThreadId; |
| 39 | model: ModelSlug; | 43 | model: ModelSlug; |
| 44 | models: ReadonlyArray<ServerProviderModel>; | ||
| 40 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; | 45 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; |
| 41 | prompt: string; | 46 | prompt: string; |
| 42 | onPromptChange: (prompt: string) => void; | 47 | onPromptChange: (prompt: string) => void; |
| ... | @@ -44,6 +49,7 @@ type ProviderRegistryEntry = { | ... | @@ -44,6 +49,7 @@ type ProviderRegistryEntry = { |
| 44 | renderTraitsPicker: (input: { | 49 | renderTraitsPicker: (input: { |
| 45 | threadId: ThreadId; | 50 | threadId: ThreadId; |
| 46 | model: ModelSlug; | 51 | model: ModelSlug; |
| 52 | models: ReadonlyArray<ServerProviderModel>; | ||
| 47 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; | 53 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; |
| 48 | prompt: string; | 54 | prompt: string; |
| 49 | onPromptChange: (prompt: string) => void; | 55 | onPromptChange: (prompt: string) => void; |
| ... | @@ -53,8 +59,8 @@ type ProviderRegistryEntry = { | ... | @@ -53,8 +59,8 @@ type ProviderRegistryEntry = { |
| 53 | function getProviderStateFromCapabilities( | 59 | function getProviderStateFromCapabilities( |
| 54 | input: ComposerProviderStateInput, | 60 | input: ComposerProviderStateInput, |
| 55 | ): ComposerProviderState { | 61 | ): ComposerProviderState { |
| 56 | const { provider, model, prompt, modelOptions } = input; | 62 | const { provider, model, models, prompt, modelOptions } = input; |
| 57 | const caps = getModelCapabilities(provider, model); | 63 | const caps = getProviderModelCapabilities(models, model, provider); |
| 58 | const providerOptions = modelOptions?.[provider]; | 64 | const providerOptions = modelOptions?.[provider]; |
| 59 | 65 | ||
| 60 | // Resolve effort | 66 | // Resolve effort |
| ... | @@ -81,8 +87,8 @@ function getProviderStateFromCapabilities( | ... | @@ -81,8 +87,8 @@ function getProviderStateFromCapabilities( |
| 81 | // Normalize options for dispatch | 87 | // Normalize options for dispatch |
| 82 | const normalizedOptions = | 88 | const normalizedOptions = |
| 83 | provider === "codex" | 89 | provider === "codex" |
| 84 | ? normalizeCodexModelOptions(model, providerOptions) | 90 | ? normalizeCodexModelOptionsWithCapabilities(caps, providerOptions) |
| 85 | : normalizeClaudeModelOptions(model, providerOptions); | 91 | : normalizeClaudeModelOptionsWithCapabilities(caps, providerOptions); |
| 86 | 92 | ||
| 87 | // Ultrathink styling (driven by capabilities data, not provider identity) | 93 | // Ultrathink styling (driven by capabilities data, not provider identity) |
| 88 | const ultrathinkActive = | 94 | const ultrathinkActive = |
| ... | @@ -103,9 +109,17 @@ function getProviderStateFromCapabilities( | ... | @@ -103,9 +109,17 @@ function getProviderStateFromCapabilities( |
| 103 | const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { | 109 | const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { |
| 104 | codex: { | 110 | codex: { |
| 105 | getState: (input) => getProviderStateFromCapabilities(input), | 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 | <TraitsMenuContent | 120 | <TraitsMenuContent |
| 108 | provider="codex" | 121 | provider="codex" |
| 122 | models={models} | ||
| 109 | threadId={threadId} | 123 | threadId={threadId} |
| 110 | model={model} | 124 | model={model} |
| 111 | modelOptions={modelOptions} | 125 | modelOptions={modelOptions} |
| ... | @@ -113,9 +127,10 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { | ... | @@ -113,9 +127,10 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { |
| 113 | onPromptChange={onPromptChange} | 127 | onPromptChange={onPromptChange} |
| 114 | /> | 128 | /> |
| 115 | ), | 129 | ), |
| 116 | renderTraitsPicker: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( | 130 | renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( |
| 117 | <TraitsPicker | 131 | <TraitsPicker |
| 118 | provider="codex" | 132 | provider="codex" |
| 133 | models={models} | ||
| 119 | threadId={threadId} | 134 | threadId={threadId} |
| 120 | model={model} | 135 | model={model} |
| 121 | modelOptions={modelOptions} | 136 | modelOptions={modelOptions} |
| ... | @@ -126,9 +141,17 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { | ... | @@ -126,9 +141,17 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { |
| 126 | }, | 141 | }, |
| 127 | claudeAgent: { | 142 | claudeAgent: { |
| 128 | getState: (input) => getProviderStateFromCapabilities(input), | 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 | <TraitsMenuContent | 152 | <TraitsMenuContent |
| 131 | provider="claudeAgent" | 153 | provider="claudeAgent" |
| 154 | models={models} | ||
| 132 | threadId={threadId} | 155 | threadId={threadId} |
| 133 | model={model} | 156 | model={model} |
| 134 | modelOptions={modelOptions} | 157 | modelOptions={modelOptions} |
| ... | @@ -136,9 +159,10 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { | ... | @@ -136,9 +159,10 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { |
| 136 | onPromptChange={onPromptChange} | 159 | onPromptChange={onPromptChange} |
| 137 | /> | 160 | /> |
| 138 | ), | 161 | ), |
| 139 | renderTraitsPicker: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( | 162 | renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( |
| 140 | <TraitsPicker | 163 | <TraitsPicker |
| 141 | provider="claudeAgent" | 164 | provider="claudeAgent" |
| 165 | models={models} | ||
| 142 | threadId={threadId} | 166 | threadId={threadId} |
| 143 | model={model} | 167 | model={model} |
| 144 | modelOptions={modelOptions} | 168 | modelOptions={modelOptions} |
| ... | @@ -157,6 +181,7 @@ export function renderProviderTraitsMenuContent(input: { | ... | @@ -157,6 +181,7 @@ export function renderProviderTraitsMenuContent(input: { |
| 157 | provider: ProviderKind; | 181 | provider: ProviderKind; |
| 158 | threadId: ThreadId; | 182 | threadId: ThreadId; |
| 159 | model: ModelSlug; | 183 | model: ModelSlug; |
| 184 | models: ReadonlyArray<ServerProviderModel>; | ||
| 160 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; | 185 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; |
| 161 | prompt: string; | 186 | prompt: string; |
| 162 | onPromptChange: (prompt: string) => void; | 187 | onPromptChange: (prompt: string) => void; |
| ... | @@ -164,6 +189,7 @@ export function renderProviderTraitsMenuContent(input: { | ... | @@ -164,6 +189,7 @@ export function renderProviderTraitsMenuContent(input: { |
| 164 | return composerProviderRegistry[input.provider].renderTraitsMenuContent({ | 189 | return composerProviderRegistry[input.provider].renderTraitsMenuContent({ |
| 165 | threadId: input.threadId, | 190 | threadId: input.threadId, |
| 166 | model: input.model, | 191 | model: input.model, |
| 192 | models: input.models, | ||
| 167 | modelOptions: input.modelOptions, | 193 | modelOptions: input.modelOptions, |
| 168 | prompt: input.prompt, | 194 | prompt: input.prompt, |
| 169 | onPromptChange: input.onPromptChange, | 195 | onPromptChange: input.onPromptChange, |
| ... | @@ -174,6 +200,7 @@ export function renderProviderTraitsPicker(input: { | ... | @@ -174,6 +200,7 @@ export function renderProviderTraitsPicker(input: { |
| 174 | provider: ProviderKind; | 200 | provider: ProviderKind; |
| 175 | threadId: ThreadId; | 201 | threadId: ThreadId; |
| 176 | model: ModelSlug; | 202 | model: ModelSlug; |
| 203 | models: ReadonlyArray<ServerProviderModel>; | ||
| 177 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; | 204 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; |
| 178 | prompt: string; | 205 | prompt: string; |
| 179 | onPromptChange: (prompt: string) => void; | 206 | onPromptChange: (prompt: string) => void; |
| ... | @@ -181,6 +208,7 @@ export function renderProviderTraitsPicker(input: { | ... | @@ -181,6 +208,7 @@ export function renderProviderTraitsPicker(input: { |
| 181 | return composerProviderRegistry[input.provider].renderTraitsPicker({ | 208 | return composerProviderRegistry[input.provider].renderTraitsPicker({ |
| 182 | threadId: input.threadId, | 209 | threadId: input.threadId, |
| 183 | model: input.model, | 210 | model: input.model, |
| 211 | models: input.models, | ||
| 184 | modelOptions: input.modelOptions, | 212 | modelOptions: input.modelOptions, |
| 185 | prompt: input.prompt, | 213 | prompt: input.prompt, |
| 186 | onPromptChange: input.onPromptChange, | 214 | onPromptChange: input.onPromptChange, |
apps/web/src/composerDraftStore.ts+19-16| ... | @@ -9,16 +9,13 @@ import { | ... | @@ -9,16 +9,13 @@ import { |
| 9 | ProviderKind, | 9 | ProviderKind, |
| 10 | ProviderModelOptions, | 10 | ProviderModelOptions, |
| 11 | RuntimeMode, | 11 | RuntimeMode, |
| 12 | type ServerProvider, | ||
| 12 | ThreadId, | 13 | ThreadId, |
| 13 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| 14 | import * as Schema from "effect/Schema"; | 15 | import * as Schema from "effect/Schema"; |
| 15 | import * as Equal from "effect/Equal"; | 16 | import * as Equal from "effect/Equal"; |
| 16 | import { DeepMutable } from "effect/Types"; | 17 | import { DeepMutable } from "effect/Types"; |
| 17 | import { | 18 | import { getDefaultModel, normalizeModelSlug } from "@t3tools/shared/model"; |
| 18 | getDefaultModel, | ||
| 19 | normalizeModelSlug, | ||
| 20 | resolveModelSlugForProvider, | ||
| 21 | } from "@t3tools/shared/model"; | ||
| 22 | import { useMemo } from "react"; | 19 | import { useMemo } from "react"; |
| 23 | import { getLocalStorageItem } from "./hooks/useLocalStorage"; | 20 | import { getLocalStorageItem } from "./hooks/useLocalStorage"; |
| 24 | import { resolveAppModelSelection } from "./modelSelection"; | 21 | import { resolveAppModelSelection } from "./modelSelection"; |
| ... | @@ -31,6 +28,8 @@ import { | ... | @@ -31,6 +28,8 @@ import { |
| 31 | import { create } from "zustand"; | 28 | import { create } from "zustand"; |
| 32 | import { createJSONStorage, persist } from "zustand/middleware"; | 29 | import { createJSONStorage, persist } from "zustand/middleware"; |
| 33 | import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; | 30 | import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; |
| 31 | import { getDefaultServerModel } from "./providerModels"; | ||
| 32 | import { UnifiedSettings } from "@t3tools/contracts/settings"; | ||
| 34 | 33 | ||
| 35 | export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; | 34 | export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; |
| 36 | const COMPOSER_DRAFT_STORAGE_VERSION = 3; | 35 | const COMPOSER_DRAFT_STORAGE_VERSION = 3; |
| ... | @@ -613,22 +612,23 @@ export function deriveEffectiveComposerModelState(input: { | ... | @@ -613,22 +612,23 @@ export function deriveEffectiveComposerModelState(input: { |
| 613 | | Pick<ComposerThreadDraftState, "modelSelectionByProvider" | "activeProvider"> | 612 | | Pick<ComposerThreadDraftState, "modelSelectionByProvider" | "activeProvider"> |
| 614 | | null | 613 | | null |
| 615 | | undefined; | 614 | | undefined; |
| 615 | providers: ReadonlyArray<ServerProvider>; | ||
| 616 | selectedProvider: ProviderKind; | 616 | selectedProvider: ProviderKind; |
| 617 | threadModelSelection: ModelSelection | null | undefined; | 617 | threadModelSelection: ModelSelection | null | undefined; |
| 618 | projectModelSelection: ModelSelection | null | undefined; | 618 | projectModelSelection: ModelSelection | null | undefined; |
| 619 | customModelsByProvider: Record<ProviderKind, readonly string[]>; | 619 | settings: UnifiedSettings; |
| 620 | }): EffectiveComposerModelState { | 620 | }): EffectiveComposerModelState { |
| 621 | const baseModel = resolveModelSlugForProvider( | 621 | const baseModel = |
| 622 | input.selectedProvider, | 622 | normalizeModelSlug( |
| 623 | input.threadModelSelection?.model ?? | 623 | input.threadModelSelection?.model ?? input.projectModelSelection?.model, |
| 624 | input.projectModelSelection?.model ?? | 624 | input.selectedProvider, |
| 625 | getDefaultModel(input.selectedProvider), | 625 | ) ?? getDefaultServerModel(input.providers, input.selectedProvider); |
| 626 | ); | ||
| 627 | const activeSelection = input.draft?.modelSelectionByProvider?.[input.selectedProvider]; | 626 | const activeSelection = input.draft?.modelSelectionByProvider?.[input.selectedProvider]; |
| 628 | const selectedModel = activeSelection?.model | 627 | const selectedModel = activeSelection?.model |
| 629 | ? resolveAppModelSelection( | 628 | ? resolveAppModelSelection( |
| 630 | input.selectedProvider, | 629 | input.selectedProvider, |
| 631 | input.customModelsByProvider, | 630 | input.settings, |
| 631 | input.providers, | ||
| 632 | activeSelection.model, | 632 | activeSelection.model, |
| 633 | ) | 633 | ) |
| 634 | : baseModel; | 634 | : baseModel; |
| ... | @@ -2157,10 +2157,11 @@ export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftS | ... | @@ -2157,10 +2157,11 @@ export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftS |
| 2157 | 2157 | ||
| 2158 | export function useEffectiveComposerModelState(input: { | 2158 | export function useEffectiveComposerModelState(input: { |
| 2159 | threadId: ThreadId; | 2159 | threadId: ThreadId; |
| 2160 | providers: ReadonlyArray<ServerProvider>; | ||
| 2160 | selectedProvider: ProviderKind; | 2161 | selectedProvider: ProviderKind; |
| 2161 | threadModelSelection: ModelSelection | null | undefined; | 2162 | threadModelSelection: ModelSelection | null | undefined; |
| 2162 | projectModelSelection: ModelSelection | null | undefined; | 2163 | projectModelSelection: ModelSelection | null | undefined; |
| 2163 | customModelsByProvider: Record<ProviderKind, readonly string[]>; | 2164 | settings: UnifiedSettings; |
| 2164 | }): EffectiveComposerModelState { | 2165 | }): EffectiveComposerModelState { |
| 2165 | const draft = useComposerThreadDraft(input.threadId); | 2166 | const draft = useComposerThreadDraft(input.threadId); |
| 2166 | 2167 | ||
| ... | @@ -2168,14 +2169,16 @@ export function useEffectiveComposerModelState(input: { | ... | @@ -2168,14 +2169,16 @@ export function useEffectiveComposerModelState(input: { |
| 2168 | () => | 2169 | () => |
| 2169 | deriveEffectiveComposerModelState({ | 2170 | deriveEffectiveComposerModelState({ |
| 2170 | draft, | 2171 | draft, |
| 2172 | providers: input.providers, | ||
| 2171 | selectedProvider: input.selectedProvider, | 2173 | selectedProvider: input.selectedProvider, |
| 2172 | threadModelSelection: input.threadModelSelection, | 2174 | threadModelSelection: input.threadModelSelection, |
| 2173 | projectModelSelection: input.projectModelSelection, | 2175 | projectModelSelection: input.projectModelSelection, |
| 2174 | customModelsByProvider: input.customModelsByProvider, | 2176 | settings: input.settings, |
| 2175 | }), | 2177 | }), |
| 2176 | [ | 2178 | [ |
| 2177 | draft, | 2179 | draft, |
| 2178 | input.customModelsByProvider, | 2180 | input.providers, |
| 2181 | input.settings, | ||
| 2179 | input.projectModelSelection, | 2182 | input.projectModelSelection, |
| 2180 | input.selectedProvider, | 2183 | input.selectedProvider, |
| 2181 | input.threadModelSelection, | 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,10 +32,6 @@ describe("git mutation options", () => { |
| 32 | const options = gitRunStackedActionMutationOptions({ | 32 | const options = gitRunStackedActionMutationOptions({ |
| 33 | cwd: "/repo/a", | 33 | cwd: "/repo/a", |
| 34 | queryClient, | 34 | queryClient, |
| 35 | modelSelection: { | ||
| 36 | provider: "codex", | ||
| 37 | model: "gpt-5.4", | ||
| 38 | }, | ||
| 39 | }); | 35 | }); |
| 40 | expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction("/repo/a")); | 36 | expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction("/repo/a")); |
| 41 | }); | 37 | }); |
apps/web/src/lib/gitReactQuery.ts+1-3| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { type GitStackedAction, type ModelSelection } from "@t3tools/contracts"; | 1 | import { type GitStackedAction } from "@t3tools/contracts"; |
| 2 | import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; | 2 | import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; |
| 3 | import { ensureNativeApi } from "../nativeApi"; | 3 | import { ensureNativeApi } from "../nativeApi"; |
| 4 | 4 | ||
| ... | @@ -112,7 +112,6 @@ export function gitCheckoutMutationOptions(input: { | ... | @@ -112,7 +112,6 @@ export function gitCheckoutMutationOptions(input: { |
| 112 | export function gitRunStackedActionMutationOptions(input: { | 112 | export function gitRunStackedActionMutationOptions(input: { |
| 113 | cwd: string | null; | 113 | cwd: string | null; |
| 114 | queryClient: QueryClient; | 114 | queryClient: QueryClient; |
| 115 | modelSelection: ModelSelection; | ||
| 116 | }) { | 115 | }) { |
| 117 | return mutationOptions({ | 116 | return mutationOptions({ |
| 118 | mutationKey: gitMutationKeys.runStackedAction(input.cwd), | 117 | mutationKey: gitMutationKeys.runStackedAction(input.cwd), |
| ... | @@ -134,7 +133,6 @@ export function gitRunStackedActionMutationOptions(input: { | ... | @@ -134,7 +133,6 @@ export function gitRunStackedActionMutationOptions(input: { |
| 134 | return api.git.runStackedAction({ | 133 | return api.git.runStackedAction({ |
| 135 | actionId, | 134 | actionId, |
| 136 | cwd: input.cwd, | 135 | cwd: input.cwd, |
| 137 | modelSelection: input.modelSelection, | ||
| 138 | action, | 136 | action, |
| 139 | ...(commitMessage ? { commitMessage } : {}), | 137 | ...(commitMessage ? { commitMessage } : {}), |
| 140 | ...(featureBranch ? { featureBranch } : {}), | 138 | ...(featureBranch ? { featureBranch } : {}), |
apps/web/src/lib/serverReactQuery.ts+7-1| ... | @@ -6,6 +6,13 @@ export const serverQueryKeys = { | ... | @@ -6,6 +6,13 @@ export const serverQueryKeys = { |
| 6 | config: () => ["server", "config"] as const, | 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 | export function serverConfigQueryOptions() { | 16 | export function serverConfigQueryOptions() { |
| 10 | return queryOptions({ | 17 | return queryOptions({ |
| 11 | queryKey: serverQueryKeys.config(), | 18 | queryKey: serverQueryKeys.config(), |
| ... | @@ -13,6 +20,5 @@ export function serverConfigQueryOptions() { | ... | @@ -13,6 +20,5 @@ export function serverConfigQueryOptions() { |
| 13 | const api = ensureNativeApi(); | 20 | const api = ensureNativeApi(); |
| 14 | return api.server.getConfig(); | 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,27 +2,22 @@ import { |
| 2 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, | 2 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, |
| 3 | type ModelSelection, | 3 | type ModelSelection, |
| 4 | type ProviderKind, | 4 | type ProviderKind, |
| 5 | type ServerProvider, | ||
| 5 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 6 | import { | 7 | import { normalizeModelSlug, resolveSelectableModel } from "@t3tools/shared/model"; |
| 7 | getDefaultModel, | ||
| 8 | getModelOptions, | ||
| 9 | normalizeModelSlug, | ||
| 10 | resolveSelectableModel, | ||
| 11 | } from "@t3tools/shared/model"; | ||
| 12 | import { getComposerProviderState } from "./components/chat/composerProviderRegistry"; | 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 | const MAX_CUSTOM_MODEL_COUNT = 32; | 16 | const MAX_CUSTOM_MODEL_COUNT = 32; |
| 15 | export const MAX_CUSTOM_MODEL_LENGTH = 256; | 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 | export type ProviderCustomModelConfig = { | 19 | export type ProviderCustomModelConfig = { |
| 23 | provider: ProviderKind; | 20 | provider: ProviderKind; |
| 24 | settingsKey: keyof CustomModelSettings; | ||
| 25 | defaultSettingsKey: keyof CustomModelSettings; | ||
| 26 | title: string; | 21 | title: string; |
| 27 | description: string; | 22 | description: string; |
| 28 | placeholder: string; | 23 | placeholder: string; |
| ... | @@ -38,8 +33,6 @@ export interface AppModelOption { | ... | @@ -38,8 +33,6 @@ export interface AppModelOption { |
| 38 | const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConfig> = { | 33 | const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConfig> = { |
| 39 | codex: { | 34 | codex: { |
| 40 | provider: "codex", | 35 | provider: "codex", |
| 41 | settingsKey: "customCodexModels", | ||
| 42 | defaultSettingsKey: "customCodexModels", | ||
| 43 | title: "Codex", | 36 | title: "Codex", |
| 44 | description: "Save additional Codex model slugs for the picker and `/model` command.", | 37 | description: "Save additional Codex model slugs for the picker and `/model` command.", |
| 45 | placeholder: "your-codex-model-slug", | 38 | placeholder: "your-codex-model-slug", |
| ... | @@ -47,8 +40,6 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConf | ... | @@ -47,8 +40,6 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConf |
| 47 | }, | 40 | }, |
| 48 | claudeAgent: { | 41 | claudeAgent: { |
| 49 | provider: "claudeAgent", | 42 | provider: "claudeAgent", |
| 50 | settingsKey: "customClaudeModels", | ||
| 51 | defaultSettingsKey: "customClaudeModels", | ||
| 52 | title: "Claude", | 43 | title: "Claude", |
| 53 | description: "Save additional Claude model slugs for the picker and `/model` command.", | 44 | description: "Save additional Claude model slugs for the picker and `/model` command.", |
| 54 | placeholder: "your-claude-model-slug", | 45 | placeholder: "your-claude-model-slug", |
| ... | @@ -56,20 +47,15 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConf | ... | @@ -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 | export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG); | 50 | export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG); |
| 65 | 51 | ||
| 66 | export function normalizeCustomModelSlugs( | 52 | export function normalizeCustomModelSlugs( |
| 67 | models: Iterable<string | null | undefined>, | 53 | models: Iterable<string | null | undefined>, |
| 54 | builtInModelSlugs: ReadonlySet<string>, | ||
| 68 | provider: ProviderKind = "codex", | 55 | provider: ProviderKind = "codex", |
| 69 | ): string[] { | 56 | ): string[] { |
| 70 | const normalizedModels: string[] = []; | 57 | const normalizedModels: string[] = []; |
| 71 | const seen = new Set<string>(); | 58 | const seen = new Set<string>(); |
| 72 | const builtInModelSlugs = BUILT_IN_MODEL_SLUGS_BY_PROVIDER[provider]; | ||
| 73 | 59 | ||
| 74 | for (const candidate of models) { | 60 | for (const candidate of models) { |
| 75 | const normalized = normalizeModelSlug(candidate, provider); | 61 | const normalized = normalizeModelSlug(candidate, provider); |
| ... | @@ -92,52 +78,29 @@ export function normalizeCustomModelSlugs( | ... | @@ -92,52 +78,29 @@ export function normalizeCustomModelSlugs( |
| 92 | return normalizedModels; | 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 | export function getAppModelOptions( | 81 | export function getAppModelOptions( |
| 82 | settings: UnifiedSettings, | ||
| 83 | providers: ReadonlyArray<ServerProvider>, | ||
| 128 | provider: ProviderKind, | 84 | provider: ProviderKind, |
| 129 | customModels: readonly string[], | ||
| 130 | selectedModel?: string | null, | 85 | selectedModel?: string | null, |
| 131 | ): AppModelOption[] { | 86 | ): AppModelOption[] { |
| 132 | const options: AppModelOption[] = getModelOptions(provider).map(({ slug, name }) => ({ | 87 | const options: AppModelOption[] = getProviderModels(providers, provider).map( |
| 133 | slug, | 88 | ({ slug, name, isCustom }) => ({ |
| 134 | name, | 89 | slug, |
| 135 | isCustom: false, | 90 | name, |
| 136 | })); | 91 | isCustom, |
| 92 | }), | ||
| 93 | ); | ||
| 137 | const seen = new Set(options.map((option) => option.slug)); | 94 | const seen = new Set(options.map((option) => option.slug)); |
| 138 | const trimmedSelectedModel = selectedModel?.trim().toLowerCase(); | 95 | const trimmedSelectedModel = selectedModel?.trim().toLowerCase(); |
| 139 | 96 | const builtInModelSlugs = new Set( | |
| 140 | for (const slug of normalizeCustomModelSlugs(customModels, provider)) { | 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 | if (seen.has(slug)) { | 104 | if (seen.has(slug)) { |
| 142 | continue; | 105 | continue; |
| 143 | } | 106 | } |
| ... | @@ -171,52 +134,61 @@ export function getAppModelOptions( | ... | @@ -171,52 +134,61 @@ export function getAppModelOptions( |
| 171 | 134 | ||
| 172 | export function resolveAppModelSelection( | 135 | export function resolveAppModelSelection( |
| 173 | provider: ProviderKind, | 136 | provider: ProviderKind, |
| 174 | customModels: Record<ProviderKind, readonly string[]>, | 137 | settings: UnifiedSettings, |
| 138 | providers: ReadonlyArray<ServerProvider>, | ||
| 175 | selectedModel: string | null | undefined, | 139 | selectedModel: string | null | undefined, |
| 176 | ): string { | 140 | ): string { |
| 177 | const customModelsForProvider = customModels[provider]; | 141 | const resolvedProvider = resolveSelectableProvider(providers, provider); |
| 178 | const options = getAppModelOptions(provider, customModelsForProvider, selectedModel); | 142 | const options = getAppModelOptions(settings, providers, resolvedProvider, selectedModel); |
| 179 | return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider); | 143 | return ( |
| 144 | resolveSelectableModel(resolvedProvider, selectedModel, options) ?? | ||
| 145 | getDefaultServerModel(providers, resolvedProvider) | ||
| 146 | ); | ||
| 180 | } | 147 | } |
| 181 | 148 | ||
| 182 | export function getCustomModelOptionsByProvider( | 149 | export function getCustomModelOptionsByProvider( |
| 183 | settings: CustomModelSettings, | 150 | settings: UnifiedSettings, |
| 151 | providers: ReadonlyArray<ServerProvider>, | ||
| 184 | selectedProvider?: ProviderKind | null, | 152 | selectedProvider?: ProviderKind | null, |
| 185 | selectedModel?: string | null, | 153 | selectedModel?: string | null, |
| 186 | ): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> { | 154 | ): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> { |
| 187 | const customModelsByProvider = getCustomModelsByProvider(settings); | ||
| 188 | return { | 155 | return { |
| 189 | codex: getAppModelOptions( | 156 | codex: getAppModelOptions( |
| 157 | settings, | ||
| 158 | providers, | ||
| 190 | "codex", | 159 | "codex", |
| 191 | customModelsByProvider.codex, | ||
| 192 | selectedProvider === "codex" ? selectedModel : undefined, | 160 | selectedProvider === "codex" ? selectedModel : undefined, |
| 193 | ), | 161 | ), |
| 194 | claudeAgent: getAppModelOptions( | 162 | claudeAgent: getAppModelOptions( |
| 163 | settings, | ||
| 164 | providers, | ||
| 195 | "claudeAgent", | 165 | "claudeAgent", |
| 196 | customModelsByProvider.claudeAgent, | ||
| 197 | selectedProvider === "claudeAgent" ? selectedModel : undefined, | 166 | selectedProvider === "claudeAgent" ? selectedModel : undefined, |
| 198 | ), | 167 | ), |
| 199 | }; | 168 | }; |
| 200 | } | 169 | } |
| 201 | 170 | ||
| 202 | export function resolveAppModelSelectionState( | 171 | export function resolveAppModelSelectionState( |
| 203 | settings: CustomModelSettings & { | 172 | settings: UnifiedSettings, |
| 204 | textGenerationModelSelection: ModelSelection | undefined; | 173 | providers: ReadonlyArray<ServerProvider>, |
| 205 | }, | ||
| 206 | ): ModelSelection { | 174 | ): ModelSelection { |
| 207 | const selection = settings.textGenerationModelSelection ?? { | 175 | const selection = settings.textGenerationModelSelection ?? { |
| 208 | provider: "codex" as const, | 176 | provider: "codex" as const, |
| 209 | model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, | 177 | model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, |
| 210 | }; | 178 | }; |
| 211 | const provider = selection.provider; | 179 | const provider = resolveSelectableProvider(providers, selection.provider); |
| 212 | const customModelsByProvider = getCustomModelsByProvider(settings); | 180 | |
| 213 | const model = resolveAppModelSelection(provider, customModelsByProvider, selection.model); | 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 | const { modelOptionsForDispatch } = getComposerProviderState({ | 185 | const { modelOptionsForDispatch } = getComposerProviderState({ |
| 215 | provider, | 186 | provider, |
| 216 | model, | 187 | model, |
| 188 | models: getProviderModels(providers, provider), | ||
| 217 | prompt: "", | 189 | prompt: "", |
| 218 | modelOptions: { | 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,7 +20,8 @@ import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDra |
| 20 | import { useStore } from "../store"; | 20 | import { useStore } from "../store"; |
| 21 | import { useTerminalStateStore } from "../terminalStateStore"; | 21 | import { useTerminalStateStore } from "../terminalStateStore"; |
| 22 | import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; | 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 | import { providerQueryKeys } from "../lib/providerReactQuery"; | 25 | import { providerQueryKeys } from "../lib/providerReactQuery"; |
| 25 | import { projectQueryKeys } from "../lib/projectReactQuery"; | 26 | import { projectQueryKeys } from "../lib/projectReactQuery"; |
| 26 | import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; | 27 | import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; |
| ... | @@ -230,6 +231,8 @@ function EventRouter() { | ... | @@ -230,6 +231,8 @@ function EventRouter() { |
| 230 | ); | 231 | ); |
| 231 | }); | 232 | }); |
| 232 | const unsubWelcome = onServerWelcome((payload) => { | 233 | const unsubWelcome = onServerWelcome((payload) => { |
| 234 | // Migrate old localStorage settings to server on first connect | ||
| 235 | migrateLocalSettingsToServer(); | ||
| 233 | void (async () => { | 236 | void (async () => { |
| 234 | await syncSnapshot(); | 237 | await syncSnapshot(); |
| 235 | if (disposed) { | 238 | if (disposed) { |
| ... | @@ -260,8 +263,14 @@ function EventRouter() { | ... | @@ -260,8 +263,14 @@ function EventRouter() { |
| 260 | // don't produce duplicate toasts. | 263 | // don't produce duplicate toasts. |
| 261 | let subscribed = false; | 264 | let subscribed = false; |
| 262 | const unsubServerConfigUpdated = onServerConfigUpdated((payload) => { | 265 | const unsubServerConfigUpdated = onServerConfigUpdated((payload) => { |
| 266 | // Invalidate the config query so active observers refetch fresh data. | ||
| 263 | void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); | 267 | void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); |
| 268 | |||
| 264 | if (!subscribed) return; | 269 | if (!subscribed) return; |
| 270 | |||
| 271 | // Only show keybindings toasts for keybindings changes (no settings in payload) | ||
| 272 | if (payload.settings) return; | ||
| 273 | |||
| 265 | const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); | 274 | const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); |
| 266 | if (!issue) { | 275 | if (!issue) { |
| 267 | toastManager.add({ | 276 | toastManager.add({ |
| ... | @@ -300,6 +309,9 @@ function EventRouter() { | ... | @@ -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 | subscribed = true; | 315 | subscribed = true; |
| 304 | return () => { | 316 | return () => { |
| 305 | disposed = true; | 317 | disposed = true; |
| ... | @@ -309,6 +321,7 @@ function EventRouter() { | ... | @@ -309,6 +321,7 @@ function EventRouter() { |
| 309 | unsubTerminalEvent(); | 321 | unsubTerminalEvent(); |
| 310 | unsubWelcome(); | 322 | unsubWelcome(); |
| 311 | unsubServerConfigUpdated(); | 323 | unsubServerConfigUpdated(); |
| 324 | unsubProvidersUpdated(); | ||
| 312 | }; | 325 | }; |
| 313 | }, [ | 326 | }, [ |
| 314 | navigate, | 327 | navigate, |
apps/web/src/routes/_chat.settings.tsx+684-379| ... | @@ -1,16 +1,27 @@ | ... | @@ -1,16 +1,27 @@ |
| 1 | import { createFileRoute } from "@tanstack/react-router"; | 1 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | import { useQuery } from "@tanstack/react-query"; | 2 | import { useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | import { ChevronDownIcon, PlusIcon, RotateCcwIcon, Undo2Icon, XIcon } from "lucide-react"; | 3 | import { |
| 4 | import { type ReactNode, useCallback, useState } from "react"; | 4 | ChevronDownIcon, |
| 5 | import { type ProviderKind } from "@t3tools/contracts"; | 5 | InfoIcon, |
| 6 | import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; | 6 | LoaderIcon, |
| 7 | import { useAppSettings } from "../appSettings"; | 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 | import { | 22 | import { |
| 9 | getCustomModelOptionsByProvider, | 23 | getCustomModelOptionsByProvider, |
| 10 | getCustomModelsForProvider, | ||
| 11 | MAX_CUSTOM_MODEL_LENGTH, | 24 | MAX_CUSTOM_MODEL_LENGTH, |
| 12 | MODEL_PROVIDER_SETTINGS, | ||
| 13 | patchCustomModels, | ||
| 14 | resolveAppModelSelectionState, | 25 | resolveAppModelSelectionState, |
| 15 | } from "../modelSelection"; | 26 | } from "../modelSelection"; |
| 16 | import { APP_VERSION } from "../branding"; | 27 | import { APP_VERSION } from "../branding"; |
| ... | @@ -33,9 +44,12 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip" | ... | @@ -33,9 +44,12 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip" |
| 33 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; | 44 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; |
| 34 | import { isElectron } from "../env"; | 45 | import { isElectron } from "../env"; |
| 35 | import { useTheme } from "../hooks/useTheme"; | 46 | import { useTheme } from "../hooks/useTheme"; |
| 36 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | 47 | import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; |
| 37 | import { cn } from "../lib/utils"; | 48 | import { cn } from "../lib/utils"; |
| 49 | import { formatRelativeTime } from "../timestampFormat"; | ||
| 38 | import { ensureNativeApi, readNativeApi } from "../nativeApi"; | 50 | import { ensureNativeApi, readNativeApi } from "../nativeApi"; |
| 51 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | ||
| 52 | import { Equal } from "effect"; | ||
| 39 | 53 | ||
| 40 | const THEME_OPTIONS = [ | 54 | const THEME_OPTIONS = [ |
| 41 | { | 55 | { |
| ... | @@ -61,11 +75,11 @@ const TIMESTAMP_FORMAT_LABELS = { | ... | @@ -61,11 +75,11 @@ const TIMESTAMP_FORMAT_LABELS = { |
| 61 | "24-hour": "24-hour", | 75 | "24-hour": "24-hour", |
| 62 | } as const; | 76 | } as const; |
| 63 | 77 | ||
| 64 | type InstallBinarySettingsKey = "claudeBinaryPath" | "codexBinaryPath"; | 78 | const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = []; |
| 79 | |||
| 65 | type InstallProviderSettings = { | 80 | type InstallProviderSettings = { |
| 66 | provider: ProviderKind; | 81 | provider: ProviderKind; |
| 67 | title: string; | 82 | title: string; |
| 68 | binaryPathKey: InstallBinarySettingsKey; | ||
| 69 | binaryPlaceholder: string; | 83 | binaryPlaceholder: string; |
| 70 | binaryDescription: ReactNode; | 84 | binaryDescription: ReactNode; |
| 71 | homePathKey?: "codexHomePath"; | 85 | homePathKey?: "codexHomePath"; |
| ... | @@ -73,17 +87,12 @@ type InstallProviderSettings = { | ... | @@ -73,17 +87,12 @@ type InstallProviderSettings = { |
| 73 | homeDescription?: ReactNode; | 87 | homeDescription?: ReactNode; |
| 74 | }; | 88 | }; |
| 75 | 89 | ||
| 76 | const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ | 90 | const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ |
| 77 | { | 91 | { |
| 78 | provider: "codex", | 92 | provider: "codex", |
| 79 | title: "Codex", | 93 | title: "Codex", |
| 80 | binaryPathKey: "codexBinaryPath", | ||
| 81 | binaryPlaceholder: "Codex binary path", | 94 | binaryPlaceholder: "Codex binary path", |
| 82 | binaryDescription: ( | 95 | binaryDescription: "Path to the Codex binary", |
| 83 | <> | ||
| 84 | Leave blank to use <code>codex</code> from your PATH. | ||
| 85 | </> | ||
| 86 | ), | ||
| 87 | homePathKey: "codexHomePath", | 96 | homePathKey: "codexHomePath", |
| 88 | homePlaceholder: "CODEX_HOME", | 97 | homePlaceholder: "CODEX_HOME", |
| 89 | homeDescription: "Optional custom Codex home and config directory.", | 98 | homeDescription: "Optional custom Codex home and config directory.", |
| ... | @@ -91,22 +100,116 @@ const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ | ... | @@ -91,22 +100,116 @@ const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ |
| 91 | { | 100 | { |
| 92 | provider: "claudeAgent", | 101 | provider: "claudeAgent", |
| 93 | title: "Claude", | 102 | title: "Claude", |
| 94 | binaryPathKey: "claudeBinaryPath", | ||
| 95 | binaryPlaceholder: "Claude binary path", | 103 | binaryPlaceholder: "Claude binary path", |
| 96 | binaryDescription: ( | 104 | binaryDescription: "Path to the Claude binary", |
| 97 | <> | ||
| 98 | Leave blank to use <code>claude</code> from your PATH. | ||
| 99 | </> | ||
| 100 | ), | ||
| 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 | return ( | 205 | return ( |
| 106 | <section className="space-y-3"> | 206 | <section className="space-y-3"> |
| 107 | <h2 className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground"> | 207 | <div className="flex items-center justify-between"> |
| 108 | {title} | 208 | <h2 className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground"> |
| 109 | </h2> | 209 | {title} |
| 210 | </h2> | ||
| 211 | {headerAction} | ||
| 212 | </div> | ||
| 110 | <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%)]"> | 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 | {children} | 214 | {children} |
| 112 | </div> | 215 | </div> |
| ... | @@ -121,7 +224,6 @@ function SettingsRow({ | ... | @@ -121,7 +224,6 @@ function SettingsRow({ |
| 121 | resetAction, | 224 | resetAction, |
| 122 | control, | 225 | control, |
| 123 | children, | 226 | children, |
| 124 | onClick, | ||
| 125 | }: { | 227 | }: { |
| 126 | title: string; | 228 | title: string; |
| 127 | description: string; | 229 | description: string; |
| ... | @@ -129,20 +231,13 @@ function SettingsRow({ | ... | @@ -129,20 +231,13 @@ function SettingsRow({ |
| 129 | resetAction?: ReactNode; | 231 | resetAction?: ReactNode; |
| 130 | control?: ReactNode; | 232 | control?: ReactNode; |
| 131 | children?: ReactNode; | 233 | children?: ReactNode; |
| 132 | onClick?: () => void; | ||
| 133 | }) { | 234 | }) { |
| 134 | return ( | 235 | return ( |
| 135 | <div | 236 | <div |
| 136 | className="border-t border-border px-4 py-4 first:border-t-0 sm:px-5" | 237 | className="border-t border-border px-4 py-4 first:border-t-0 sm:px-5" |
| 137 | data-slot="settings-row" | 238 | data-slot="settings-row" |
| 138 | > | 239 | > |
| 139 | <div | 240 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
| 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 | > | ||
| 146 | <div className="min-w-0 flex-1 space-y-1"> | 241 | <div className="min-w-0 flex-1 space-y-1"> |
| 147 | <div className="flex min-h-5 items-center gap-1.5"> | 242 | <div className="flex min-h-5 items-center gap-1.5"> |
| 148 | <h3 className="text-sm font-medium text-foreground">{title}</h3> | 243 | <h3 className="text-sm font-medium text-foreground">{title}</h3> |
| ... | @@ -190,16 +285,23 @@ function SettingResetButton({ label, onClick }: { label: string; onClick: () => | ... | @@ -190,16 +285,23 @@ function SettingResetButton({ label, onClick }: { label: string; onClick: () => |
| 190 | 285 | ||
| 191 | function SettingsRouteView() { | 286 | function SettingsRouteView() { |
| 192 | const { theme, setTheme } = useTheme(); | 287 | const { theme, setTheme } = useTheme(); |
| 193 | const { settings, defaults, updateSettings, resetSettings } = useAppSettings(); | 288 | const settings = useSettings(); |
| 289 | const { updateSettings, resetSettings } = useUpdateSettings(); | ||
| 194 | const serverConfigQuery = useQuery(serverConfigQueryOptions()); | 290 | const serverConfigQuery = useQuery(serverConfigQueryOptions()); |
| 195 | const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); | 291 | const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); |
| 196 | const [openKeybindingsError, setOpenKeybindingsError] = useState<string | null>(null); | 292 | const [openKeybindingsError, setOpenKeybindingsError] = useState<string | null>(null); |
| 197 | const [openInstallProviders, setOpenInstallProviders] = useState<Record<ProviderKind, boolean>>({ | 293 | const [openProviderDetails, setOpenProviderDetails] = useState<Record<ProviderKind, boolean>>({ |
| 198 | codex: Boolean(settings.codexBinaryPath || settings.codexHomePath), | 294 | codex: Boolean( |
| 199 | claudeAgent: Boolean(settings.claudeBinaryPath), | 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 | const [customModelInputByProvider, setCustomModelInputByProvider] = useState< | 305 | const [customModelInputByProvider, setCustomModelInputByProvider] = useState< |
| 204 | Record<ProviderKind, string> | 306 | Record<ProviderKind, string> |
| 205 | >({ | 307 | >({ |
| ... | @@ -209,63 +311,73 @@ function SettingsRouteView() { | ... | @@ -209,63 +311,73 @@ function SettingsRouteView() { |
| 209 | const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< | 311 | const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< |
| 210 | Partial<Record<ProviderKind, string | null>> | 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; | 336 | const modelListRefs = useRef<Partial<Record<ProviderKind, HTMLDivElement | null>>>({}); |
| 215 | const codexHomePath = settings.codexHomePath; | 337 | |
| 216 | const claudeBinaryPath = settings.claudeBinaryPath; | 338 | const codexHomePath = settings.providers.codex.homePath; |
| 217 | const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; | 339 | const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; |
| 218 | const availableEditors = serverConfigQuery.data?.availableEditors; | 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 | const textGenProvider = textGenerationModelSelection.provider; | 344 | const textGenProvider = textGenerationModelSelection.provider; |
| 222 | const textGenModel = textGenerationModelSelection.model; | 345 | const textGenModel = textGenerationModelSelection.model; |
| 223 | const textGenModelOptions = textGenerationModelSelection.options; | 346 | const textGenModelOptions = textGenerationModelSelection.options; |
| 224 | const gitModelOptionsByProvider = getCustomModelOptionsByProvider( | 347 | const gitModelOptionsByProvider = getCustomModelOptionsByProvider( |
| 225 | settings, | 348 | settings, |
| 349 | serverProviders, | ||
| 226 | textGenProvider, | 350 | textGenProvider, |
| 227 | textGenModel, | 351 | textGenModel, |
| 228 | ); | 352 | ); |
| 229 | const selectedCustomModelProviderSettings = MODEL_PROVIDER_SETTINGS.find( | 353 | const areProviderSettingsDirty = PROVIDER_SETTINGS.some((providerSettings) => { |
| 230 | (providerSettings) => providerSettings.provider === selectedCustomModelProvider, | 354 | const currentSettings = settings.providers[providerSettings.provider]; |
| 231 | )!; | 355 | const defaultSettings = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider]; |
| 232 | const selectedCustomModelInput = customModelInputByProvider[selectedCustomModelProvider]; | 356 | return !Equal.equals(currentSettings, defaultSettings); |
| 233 | const selectedCustomModelError = customModelErrorByProvider[selectedCustomModelProvider] ?? null; | 357 | }); |
| 234 | const totalCustomModels = settings.customCodexModels.length + settings.customClaudeModels.length; | 358 | const isGitWritingModelDirty = !Equal.equals( |
| 235 | const savedCustomModelRows = MODEL_PROVIDER_SETTINGS.flatMap((providerSettings) => | 359 | settings.textGenerationModelSelection ?? null, |
| 236 | getCustomModelsForProvider(settings, providerSettings.provider).map((slug) => ({ | 360 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, |
| 237 | key: `${providerSettings.provider}:${slug}`, | ||
| 238 | provider: providerSettings.provider, | ||
| 239 | providerTitle: providerSettings.title, | ||
| 240 | slug, | ||
| 241 | })), | ||
| 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 | const changedSettingLabels = [ | 362 | const changedSettingLabels = [ |
| 251 | ...(theme !== "system" ? ["Theme"] : []), | 363 | ...(theme !== "system" ? ["Theme"] : []), |
| 252 | ...(settings.timestampFormat !== defaults.timestampFormat ? ["Time format"] : []), | 364 | ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat |
| 253 | ...(settings.diffWordWrap !== defaults.diffWordWrap ? ["Diff line wrapping"] : []), | 365 | ? ["Time format"] |
| 254 | ...(settings.enableAssistantStreaming !== defaults.enableAssistantStreaming | ||
| 255 | ? ["Assistant output"] | ||
| 256 | : []), | 366 | : []), |
| 257 | ...(settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ["New thread mode"] : []), | 367 | ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap |
| 258 | ...(settings.confirmThreadDelete !== defaults.confirmThreadDelete | 368 | ? ["Diff line wrapping"] |
| 259 | ? ["Delete confirmation"] | 369 | : []), |
| 370 | ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming | ||
| 371 | ? ["Assistant output"] | ||
| 260 | : []), | 372 | : []), |
| 261 | ...(JSON.stringify(settings.textGenerationModelSelection ?? null) !== | 373 | ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode |
| 262 | JSON.stringify(defaults.textGenerationModelSelection ?? null) | 374 | ? ["New thread mode"] |
| 263 | ? ["Git writing model"] | ||
| 264 | : []), | 375 | : []), |
| 265 | ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0 | 376 | ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete |
| 266 | ? ["Custom models"] | 377 | ? ["Delete confirmation"] |
| 267 | : []), | 378 | : []), |
| 268 | ...(isInstallSettingsDirty ? ["Provider installs"] : []), | 379 | ...(isGitWritingModelDirty ? ["Git writing model"] : []), |
| 380 | ...(areProviderSettingsDirty ? ["Providers"] : []), | ||
| 269 | ]; | 381 | ]; |
| 270 | 382 | ||
| 271 | const openKeybindingsFile = useCallback(() => { | 383 | const openKeybindingsFile = useCallback(() => { |
| ... | @@ -294,7 +406,7 @@ function SettingsRouteView() { | ... | @@ -294,7 +406,7 @@ function SettingsRouteView() { |
| 294 | const addCustomModel = useCallback( | 406 | const addCustomModel = useCallback( |
| 295 | (provider: ProviderKind) => { | 407 | (provider: ProviderKind) => { |
| 296 | const customModelInput = customModelInputByProvider[provider]; | 408 | const customModelInput = customModelInputByProvider[provider]; |
| 297 | const customModels = getCustomModelsForProvider(settings, provider); | 409 | const customModels = settings.providers[provider].customModels; |
| 298 | const normalized = normalizeModelSlug(customModelInput, provider); | 410 | const normalized = normalizeModelSlug(customModelInput, provider); |
| 299 | if (!normalized) { | 411 | if (!normalized) { |
| 300 | setCustomModelErrorByProvider((existing) => ({ | 412 | setCustomModelErrorByProvider((existing) => ({ |
| ... | @@ -303,7 +415,11 @@ function SettingsRouteView() { | ... | @@ -303,7 +415,11 @@ function SettingsRouteView() { |
| 303 | })); | 415 | })); |
| 304 | return; | 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 | setCustomModelErrorByProvider((existing) => ({ | 423 | setCustomModelErrorByProvider((existing) => ({ |
| 308 | ...existing, | 424 | ...existing, |
| 309 | [provider]: "That model is already built in.", | 425 | [provider]: "That model is already built in.", |
| ... | @@ -325,7 +441,15 @@ function SettingsRouteView() { | ... | @@ -325,7 +441,15 @@ function SettingsRouteView() { |
| 325 | return; | 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 | setCustomModelInputByProvider((existing) => ({ | 453 | setCustomModelInputByProvider((existing) => ({ |
| 330 | ...existing, | 454 | ...existing, |
| 331 | [provider]: "", | 455 | [provider]: "", |
| ... | @@ -334,19 +458,37 @@ function SettingsRouteView() { | ... | @@ -334,19 +458,37 @@ function SettingsRouteView() { |
| 334 | ...existing, | 458 | ...existing, |
| 335 | [provider]: null, | 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 | const removeCustomModel = useCallback( | 480 | const removeCustomModel = useCallback( |
| 342 | (provider: ProviderKind, slug: string) => { | 481 | (provider: ProviderKind, slug: string) => { |
| 343 | const customModels = getCustomModelsForProvider(settings, provider); | 482 | const customModels = settings.providers[provider].customModels; |
| 344 | updateSettings( | 483 | updateSettings({ |
| 345 | patchCustomModels( | 484 | providers: { |
| 346 | provider, | 485 | ...settings.providers, |
| 347 | customModels.filter((model) => model !== slug), | 486 | [provider]: { |
| 348 | ), | 487 | ...settings.providers[provider], |
| 349 | ); | 488 | customModels: customModels.filter((model) => model !== slug), |
| 489 | }, | ||
| 490 | }, | ||
| 491 | }); | ||
| 350 | setCustomModelErrorByProvider((existing) => ({ | 492 | setCustomModelErrorByProvider((existing) => ({ |
| 351 | ...existing, | 493 | ...existing, |
| 352 | [provider]: null, | 494 | [provider]: null, |
| ... | @@ -355,6 +497,46 @@ function SettingsRouteView() { | ... | @@ -355,6 +497,46 @@ function SettingsRouteView() { |
| 355 | [settings, updateSettings], | 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 | async function restoreDefaults() { | 540 | async function restoreDefaults() { |
| 359 | if (changedSettingLabels.length === 0) return; | 541 | if (changedSettingLabels.length === 0) return; |
| 360 | 542 | ||
| ... | @@ -368,11 +550,10 @@ function SettingsRouteView() { | ... | @@ -368,11 +550,10 @@ function SettingsRouteView() { |
| 368 | 550 | ||
| 369 | setTheme("system"); | 551 | setTheme("system"); |
| 370 | resetSettings(); | 552 | resetSettings(); |
| 371 | setOpenInstallProviders({ | 553 | setOpenProviderDetails({ |
| 372 | codex: false, | 554 | codex: false, |
| 373 | claudeAgent: false, | 555 | claudeAgent: false, |
| 374 | }); | 556 | }); |
| 375 | setSelectedCustomModelProvider("codex"); | ||
| 376 | setCustomModelInputByProvider({ | 557 | setCustomModelInputByProvider({ |
| 377 | codex: "", | 558 | codex: "", |
| 378 | claudeAgent: "", | 559 | claudeAgent: "", |
| ... | @@ -461,12 +642,12 @@ function SettingsRouteView() { | ... | @@ -461,12 +642,12 @@ function SettingsRouteView() { |
| 461 | title="Time format" | 642 | title="Time format" |
| 462 | description="System default follows your browser or OS clock preference." | 643 | description="System default follows your browser or OS clock preference." |
| 463 | resetAction={ | 644 | resetAction={ |
| 464 | settings.timestampFormat !== defaults.timestampFormat ? ( | 645 | settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ( |
| 465 | <SettingResetButton | 646 | <SettingResetButton |
| 466 | label="time format" | 647 | label="time format" |
| 467 | onClick={() => | 648 | onClick={() => |
| 468 | updateSettings({ | 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,12 +687,12 @@ function SettingsRouteView() { |
| 506 | title="Diff line wrapping" | 687 | title="Diff line wrapping" |
| 507 | description="Set the default wrap state when the diff panel opens. The in-panel wrap toggle only affects the current diff session." | 688 | description="Set the default wrap state when the diff panel opens. The in-panel wrap toggle only affects the current diff session." |
| 508 | resetAction={ | 689 | resetAction={ |
| 509 | settings.diffWordWrap !== defaults.diffWordWrap ? ( | 690 | settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap ? ( |
| 510 | <SettingResetButton | 691 | <SettingResetButton |
| 511 | label="diff line wrapping" | 692 | label="diff line wrapping" |
| 512 | onClick={() => | 693 | onClick={() => |
| 513 | updateSettings({ | 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,12 +715,14 @@ function SettingsRouteView() { |
| 534 | title="Assistant output" | 715 | title="Assistant output" |
| 535 | description="Show token-by-token output while a response is in progress." | 716 | description="Show token-by-token output while a response is in progress." |
| 536 | resetAction={ | 717 | resetAction={ |
| 537 | settings.enableAssistantStreaming !== defaults.enableAssistantStreaming ? ( | 718 | settings.enableAssistantStreaming !== |
| 719 | DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? ( | ||
| 538 | <SettingResetButton | 720 | <SettingResetButton |
| 539 | label="assistant output" | 721 | label="assistant output" |
| 540 | onClick={() => | 722 | onClick={() => |
| 541 | updateSettings({ | 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,12 +745,13 @@ function SettingsRouteView() { |
| 562 | title="New threads" | 745 | title="New threads" |
| 563 | description="Pick the default workspace mode for newly created draft threads." | 746 | description="Pick the default workspace mode for newly created draft threads." |
| 564 | resetAction={ | 747 | resetAction={ |
| 565 | settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ( | 748 | settings.defaultThreadEnvMode !== |
| 749 | DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ( | ||
| 566 | <SettingResetButton | 750 | <SettingResetButton |
| 567 | label="new threads" | 751 | label="new threads" |
| 568 | onClick={() => | 752 | onClick={() => |
| 569 | updateSettings({ | 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,12 +788,12 @@ function SettingsRouteView() { |
| 604 | title="Delete confirmation" | 788 | title="Delete confirmation" |
| 605 | description="Ask before deleting a thread and its chat history." | 789 | description="Ask before deleting a thread and its chat history." |
| 606 | resetAction={ | 790 | resetAction={ |
| 607 | settings.confirmThreadDelete !== defaults.confirmThreadDelete ? ( | 791 | settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ( |
| 608 | <SettingResetButton | 792 | <SettingResetButton |
| 609 | label="delete confirmation" | 793 | label="delete confirmation" |
| 610 | onClick={() => | 794 | onClick={() => |
| 611 | updateSettings({ | 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,20 +811,18 @@ function SettingsRouteView() { |
| 627 | /> | 811 | /> |
| 628 | } | 812 | } |
| 629 | /> | 813 | /> |
| 630 | </SettingsSection> | ||
| 631 | |||
| 632 | <SettingsSection title="Models"> | ||
| 633 | <SettingsRow | 814 | <SettingsRow |
| 634 | title="Git writing model" | 815 | title="Text generation model" |
| 635 | description="Provider and model used for auto-generated git content." | 816 | description="Configure the model used for text generation (commit messages, PR content etc.)" |
| 636 | resetAction={ | 817 | resetAction={ |
| 637 | JSON.stringify(settings.textGenerationModelSelection ?? null) !== | 818 | JSON.stringify(settings.textGenerationModelSelection ?? null) !== |
| 638 | JSON.stringify(defaults.textGenerationModelSelection ?? null) ? ( | 819 | JSON.stringify(DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null) ? ( |
| 639 | <SettingResetButton | 820 | <SettingResetButton |
| 640 | label="git writing model" | 821 | label="text generation model" |
| 641 | onClick={() => { | 822 | onClick={() => { |
| 642 | updateSettings({ | 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,20 +834,28 @@ function SettingsRouteView() { |
| 652 | provider={textGenProvider} | 834 | provider={textGenProvider} |
| 653 | model={textGenModel} | 835 | model={textGenModel} |
| 654 | lockedProvider={null} | 836 | lockedProvider={null} |
| 837 | providers={serverProviders} | ||
| 655 | modelOptionsByProvider={gitModelOptionsByProvider} | 838 | modelOptionsByProvider={gitModelOptionsByProvider} |
| 656 | triggerVariant="outline" | 839 | triggerVariant="outline" |
| 657 | triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" | 840 | triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" |
| 658 | onProviderModelChange={(provider, model) => { | 841 | onProviderModelChange={(provider, model) => { |
| 659 | updateSettings({ | 842 | updateSettings({ |
| 660 | textGenerationModelSelection: resolveAppModelSelectionState({ | 843 | textGenerationModelSelection: resolveAppModelSelectionState( |
| 661 | ...settings, | 844 | { |
| 662 | textGenerationModelSelection: { provider, model }, | 845 | ...settings, |
| 663 | }), | 846 | textGenerationModelSelection: { provider, model }, |
| 847 | }, | ||
| 848 | serverProviders, | ||
| 849 | ), | ||
| 664 | }); | 850 | }); |
| 665 | }} | 851 | }} |
| 666 | /> | 852 | /> |
| 667 | <TraitsPicker | 853 | <TraitsPicker |
| 668 | provider={textGenProvider} | 854 | provider={textGenProvider} |
| 855 | models={ | ||
| 856 | serverProviders.find((provider) => provider.provider === textGenProvider) | ||
| 857 | ?.models ?? [] | ||
| 858 | } | ||
| 669 | model={textGenModel} | 859 | model={textGenModel} |
| 670 | prompt="" | 860 | prompt="" |
| 671 | onPromptChange={() => {}} | 861 | onPromptChange={() => {}} |
| ... | @@ -675,292 +865,407 @@ function SettingsRouteView() { | ... | @@ -675,292 +865,407 @@ function SettingsRouteView() { |
| 675 | triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" | 865 | triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" |
| 676 | onModelOptionsChange={(nextOptions) => { | 866 | onModelOptionsChange={(nextOptions) => { |
| 677 | updateSettings({ | 867 | updateSettings({ |
| 678 | textGenerationModelSelection: resolveAppModelSelectionState({ | 868 | textGenerationModelSelection: resolveAppModelSelectionState( |
| 679 | ...settings, | 869 | { |
| 680 | textGenerationModelSelection: { | 870 | ...settings, |
| 681 | provider: textGenProvider, | 871 | textGenerationModelSelection: { |
| 682 | model: textGenModel, | 872 | provider: textGenProvider, |
| 683 | ...(nextOptions ? { options: nextOptions } : {}), | 873 | model: textGenModel, |
| 874 | ...(nextOptions ? { options: nextOptions } : {}), | ||
| 875 | }, | ||
| 684 | }, | 876 | }, |
| 685 | }), | 877 | serverProviders, |
| 878 | ), | ||
| 686 | }); | 879 | }); |
| 687 | }} | 880 | }} |
| 688 | /> | 881 | /> |
| 689 | </div> | 882 | </div> |
| 690 | } | 883 | } |
| 691 | /> | 884 | /> |
| 885 | </SettingsSection> | ||
| 692 | 886 | ||
| 693 | <SettingsRow | 887 | <SettingsSection |
| 694 | title="Custom models" | 888 | title="Providers" |
| 695 | description="Add custom model slugs for supported providers." | 889 | headerAction={ |
| 696 | resetAction={ | 890 | <div className="flex items-center gap-1.5"> |
| 697 | totalCustomModels > 0 ? ( | 891 | {serverProviders.length > 0 ? ( |
| 698 | <SettingResetButton | 892 | <span className="text-[11px] text-muted-foreground/60"> |
| 699 | label="custom models" | 893 | {(() => { |
| 700 | onClick={() => { | 894 | const rel = formatRelativeTime( |
| 701 | updateSettings({ | 895 | serverProviders.reduce( |
| 702 | customCodexModels: defaults.customCodexModels, | 896 | (latest, provider) => |
| 703 | customClaudeModels: defaults.customClaudeModels, | 897 | provider.checkedAt > latest ? provider.checkedAt : latest, |
| 704 | }); | 898 | serverProviders[0]!.checkedAt, |
| 705 | setCustomModelErrorByProvider({}); | 899 | ), |
| 706 | setShowAllCustomModels(false); | 900 | ); |
| 707 | }} | 901 | return rel.suffix ? ( |
| 708 | /> | 902 | <> |
| 709 | ) : null | 903 | Checked <span className="font-mono tabular-nums">{rel.value}</span>{" "} |
| 710 | } | 904 | {rel.suffix} |
| 711 | > | 905 | </> |
| 712 | <div className="mt-4 border-t border-border pt-4"> | 906 | ) : ( |
| 713 | <div className="flex flex-col gap-2 sm:flex-row sm:items-center"> | 907 | <>Checked {rel.value}</> |
| 714 | <Select | 908 | ); |
| 715 | value={selectedCustomModelProvider} | 909 | })()} |
| 716 | onValueChange={(value) => { | 910 | </span> |
| 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> | ||
| 779 | ) : null} | 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 ? ( | 942 | return ( |
| 782 | <div className="mt-3"> | 943 | <div |
| 783 | <div> | 944 | key={providerCard.provider} |
| 784 | {visibleCustomModelRows.map((row) => ( | 945 | className="border-t border-border first:border-t-0" |
| 785 | <div | 946 | data-slot="settings-row" |
| 786 | key={row.key} | 947 | > |
| 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" | 948 | <div className="px-4 py-4 sm:px-5"> |
| 788 | > | 949 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
| 789 | <span className="truncate text-xs text-muted-foreground"> | 950 | <div className="min-w-0 flex-1 space-y-1"> |
| 790 | {row.providerTitle} | 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 | </span> | 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 | </div> | 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 | </div> | 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 | </div> | 1046 | </div> |
| 819 | ) : null} | ||
| 820 | </div> | ||
| 821 | </SettingsRow> | ||
| 822 | </SettingsSection> | ||
| 823 | 1047 | ||
| 824 | <SettingsSection title="Advanced"> | 1048 | <Collapsible |
| 825 | <SettingsRow | 1049 | open={openProviderDetails[providerCard.provider]} |
| 826 | title="Provider installs" | 1050 | onOpenChange={(open) => |
| 827 | description="Override the CLI used for new sessions." | 1051 | setOpenProviderDetails((existing) => ({ |
| 828 | resetAction={ | 1052 | ...existing, |
| 829 | isInstallSettingsDirty ? ( | 1053 | [providerCard.provider]: open, |
| 830 | <SettingResetButton | 1054 | })) |
| 831 | label="provider installs" | 1055 | } |
| 832 | onClick={() => { | 1056 | > |
| 833 | updateSettings({ | 1057 | <CollapsibleContent> |
| 834 | claudeBinaryPath: defaults.claudeBinaryPath, | 1058 | <div className="space-y-0"> |
| 835 | codexBinaryPath: defaults.codexBinaryPath, | 1059 | {/* Binary path */} |
| 836 | codexHomePath: defaults.codexHomePath, | 1060 | <div className="border-t border-border/60 px-4 py-3 sm:px-5"> |
| 837 | }); | 1061 | <label |
| 838 | setOpenInstallProviders({ | 1062 | htmlFor={`provider-install-${providerCard.provider}-binary-path`} |
| 839 | codex: false, | 1063 | className="block" |
| 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 | } | ||
| 882 | > | 1064 | > |
| 883 | <span className="min-w-0 flex-1 text-sm font-medium text-foreground"> | 1065 | <span className="text-xs font-medium text-foreground"> |
| 884 | {providerSettings.title} | 1066 | {providerDisplayName} binary path |
| 885 | </span> | 1067 | </span> |
| 886 | {isDirty ? ( | 1068 | <Input |
| 887 | <span className="text-[11px] text-muted-foreground">Custom</span> | 1069 | id={`provider-install-${providerCard.provider}-binary-path`} |
| 888 | ) : null} | 1070 | className="mt-1.5" |
| 889 | <ChevronDownIcon | 1071 | value={providerCard.binaryPathValue} |
| 890 | className={cn( | 1072 | onChange={(event) => |
| 891 | "size-4 shrink-0 text-muted-foreground transition-transform", | 1073 | updateSettings({ |
| 892 | isOpen && "rotate-180", | 1074 | providers: { |
| 893 | )} | 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> | 1086 | <span className="mt-1 block text-xs text-muted-foreground"> |
| 896 | 1087 | {providerCard.binaryDescription} | |
| 897 | <CollapsibleContent> | 1088 | </span> |
| 898 | <div className="border-t border-border/70 px-4 py-4"> | 1089 | </label> |
| 899 | <div className="space-y-3"> | 1090 | </div> |
| 900 | <label | 1091 | |
| 901 | htmlFor={`provider-install-${providerSettings.binaryPathKey}`} | 1092 | {/* Home path (Codex only) */} |
| 902 | className="block" | 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"> | 1160 | <span className="min-w-0 truncate text-xs text-foreground/90"> |
| 905 | {providerSettings.title} binary path | 1161 | {model.name} |
| 906 | </span> | 1162 | </span> |
| 907 | <Input | 1163 | {hasDetails ? ( |
| 908 | id={`provider-install-${providerSettings.binaryPathKey}`} | 1164 | <Tooltip> |
| 909 | className="mt-1" | 1165 | <TooltipTrigger |
| 910 | value={binaryPathValue} | 1166 | render={ |
| 911 | onChange={(event) => | 1167 | <button |
| 912 | updateSettings( | 1168 | type="button" |
| 913 | providerSettings.binaryPathKey === "claudeBinaryPath" | 1169 | className="shrink-0 text-muted-foreground/40 transition-colors hover:text-muted-foreground" |
| 914 | ? { claudeBinaryPath: event.target.value } | 1170 | aria-label={`Details for ${model.name}`} |
| 915 | : { codexBinaryPath: event.target.value }, | 1171 | /> |
| 916 | ) | 1172 | } |
| 917 | } | 1173 | > |
| 918 | placeholder={providerSettings.binaryPlaceholder} | 1174 | <InfoIcon className="size-3" /> |
| 919 | spellCheck={false} | 1175 | </TooltipTrigger> |
| 920 | /> | 1176 | <TooltipPopup side="top" className="max-w-56"> |
| 921 | <span className="mt-1 block text-xs text-muted-foreground"> | 1177 | <div className="space-y-1"> |
| 922 | {providerSettings.binaryDescription} | 1178 | <code className="block text-[11px] text-foreground"> |
| 923 | </span> | 1179 | {model.slug} |
| 924 | </label> | 1180 | </code> |
| 925 | 1181 | {capLabels.length > 0 ? ( | |
| 926 | {providerSettings.homePathKey ? ( | 1182 | <div className="flex flex-wrap gap-x-2 gap-y-0.5"> |
| 927 | <label | 1183 | {capLabels.map((label) => ( |
| 928 | htmlFor={`provider-install-${providerSettings.homePathKey}`} | 1184 | <span |
| 929 | className="block" | 1185 | key={label} |
| 930 | > | 1186 | className="text-[10px] text-muted-foreground" |
| 931 | <span className="block text-xs font-medium text-foreground"> | 1187 | > |
| 932 | CODEX_HOME path | 1188 | {label} |
| 933 | </span> | 1189 | </span> |
| 934 | <Input | 1190 | ))} |
| 935 | id={`provider-install-${providerSettings.homePathKey}`} | 1191 | </div> |
| 936 | className="mt-1" | 1192 | ) : null} |
| 937 | value={codexHomePath} | 1193 | </div> |
| 938 | onChange={(event) => | 1194 | </TooltipPopup> |
| 939 | updateSettings({ | 1195 | </Tooltip> |
| 940 | codexHomePath: event.target.value, | 1196 | ) : null} |
| 941 | }) | 1197 | {model.isCustom ? ( |
| 942 | } | 1198 | <div className="ml-auto flex shrink-0 items-center gap-1.5"> |
| 943 | placeholder={providerSettings.homePlaceholder} | 1199 | <span className="text-[10px] text-muted-foreground"> |
| 944 | spellCheck={false} | 1200 | custom |
| 945 | /> | ||
| 946 | {providerSettings.homeDescription ? ( | ||
| 947 | <span className="mt-1 block text-xs text-muted-foreground"> | ||
| 948 | {providerSettings.homeDescription} | ||
| 949 | </span> | 1201 | </span> |
| 950 | ) : null} | 1202 | <button |
| 951 | </label> | 1203 | type="button" |
| 952 | ) : null} | 1204 | className="text-muted-foreground transition-colors hover:text-foreground" |
| 953 | </div> | 1205 | aria-label={`Remove ${model.slug}`} |
| 954 | </div> | 1206 | onClick={() => |
| 955 | </CollapsibleContent> | 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 | </div> | 1259 | </div> |
| 957 | </Collapsible> | 1260 | </div> |
| 958 | ); | 1261 | </CollapsibleContent> |
| 959 | })} | 1262 | </Collapsible> |
| 960 | </div> | 1263 | </div> |
| 961 | </div> | 1264 | ); |
| 962 | </SettingsRow> | 1265 | })} |
| 1266 | </SettingsSection> | ||
| 963 | 1267 | ||
| 1268 | <SettingsSection title="Advanced"> | ||
| 964 | <SettingsRow | 1269 | <SettingsRow |
| 965 | title="Keybindings" | 1270 | title="Keybindings" |
| 966 | description="Open the persisted `keybindings.json` file to edit advanced bindings directly." | 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,7 +11,7 @@ import { resolveShortcutCommand } from "../keybindings"; |
| 11 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; | 11 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 12 | import { useThreadSelectionStore } from "../threadSelectionStore"; | 12 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 13 | import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; | 13 | import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; |
| 14 | import { useAppSettings } from "~/appSettings"; | 14 | import { useSettings } from "~/hooks/useSettings"; |
| 15 | import { Sidebar, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; | 15 | import { Sidebar, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; |
| 16 | 16 | ||
| 17 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | 17 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| ... | @@ -31,7 +31,7 @@ function ChatRouteGlobalShortcuts() { | ... | @@ -31,7 +31,7 @@ function ChatRouteGlobalShortcuts() { |
| 31 | ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen | 31 | ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen |
| 32 | : false, | 32 | : false, |
| 33 | ); | 33 | ); |
| 34 | const { settings: appSettings } = useAppSettings(); | 34 | const appSettings = useSettings(); |
| 35 | 35 | ||
| 36 | useEffect(() => { | 36 | useEffect(() => { |
| 37 | const onWindowKeyDown = (event: KeyboardEvent) => { | 37 | const onWindowKeyDown = (event: KeyboardEvent) => { |
apps/web/src/timestampFormat.ts+20-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { type TimestampFormat } from "./appSettings"; | 1 | import { type TimestampFormat } from "@t3tools/contracts/settings"; |
| 2 | 2 | ||
| 3 | export function getTimestampFormatOptions( | 3 | export function getTimestampFormatOptions( |
| 4 | timestampFormat: TimestampFormat, | 4 | timestampFormat: TimestampFormat, |
| ... | @@ -47,3 +47,22 @@ export function formatTimestamp(isoDate: string, timestampFormat: TimestampForma | ... | @@ -47,3 +47,22 @@ export function formatTimestamp(isoDate: string, timestampFormat: TimestampForma |
| 47 | export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { | 47 | export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { |
| 48 | return getTimestampFormatter(timestampFormat, false).format(new Date(isoDate)); | 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,7 +13,7 @@ import { |
| 13 | WS_CHANNELS, | 13 | WS_CHANNELS, |
| 14 | WS_METHODS, | 14 | WS_METHODS, |
| 15 | type WsPush, | 15 | type WsPush, |
| 16 | type ServerProviderStatus, | 16 | type ServerProvider, |
| 17 | } from "@t3tools/contracts"; | 17 | } from "@t3tools/contracts"; |
| 18 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 18 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 19 | 19 | ||
| ... | @@ -92,13 +92,16 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn | ... | @@ -92,13 +92,16 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn |
| 92 | return testGlobal.window; | 92 | return testGlobal.window; |
| 93 | } | 93 | } |
| 94 | 94 | ||
| 95 | const defaultProviders: ReadonlyArray<ServerProviderStatus> = [ | 95 | const defaultProviders: ReadonlyArray<ServerProvider> = [ |
| 96 | { | 96 | { |
| 97 | provider: "codex", | 97 | provider: "codex", |
| 98 | enabled: true, | ||
| 99 | installed: true, | ||
| 100 | version: "0.116.0", | ||
| 98 | status: "ready", | 101 | status: "ready", |
| 99 | available: true, | ||
| 100 | authStatus: "authenticated", | 102 | authStatus: "authenticated", |
| 101 | checkedAt: "2026-01-01T00:00:00.000Z", | 103 | checkedAt: "2026-01-01T00:00:00.000Z", |
| 104 | models: [], | ||
| 102 | }, | 105 | }, |
| 103 | ]; | 106 | ]; |
| 104 | 107 | ||
| ... | @@ -197,7 +200,6 @@ describe("wsNativeApi", () => { | ... | @@ -197,7 +200,6 @@ describe("wsNativeApi", () => { |
| 197 | message: "Entry at index 1 is invalid.", | 200 | message: "Entry at index 1 is invalid.", |
| 198 | }, | 201 | }, |
| 199 | ], | 202 | ], |
| 200 | providers: defaultProviders, | ||
| 201 | } as const; | 203 | } as const; |
| 202 | emitPush(WS_CHANNELS.serverConfigUpdated, payload); | 204 | emitPush(WS_CHANNELS.serverConfigUpdated, payload); |
| 203 | 205 | ||
| ... | @@ -219,20 +221,38 @@ describe("wsNativeApi", () => { | ... | @@ -219,20 +221,38 @@ describe("wsNativeApi", () => { |
| 219 | 221 | ||
| 220 | emitPush(WS_CHANNELS.serverConfigUpdated, { | 222 | emitPush(WS_CHANNELS.serverConfigUpdated, { |
| 221 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], | 223 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], |
| 222 | providers: defaultProviders, | ||
| 223 | }); | 224 | }); |
| 224 | emitPush(WS_CHANNELS.serverConfigUpdated, { | 225 | emitPush(WS_CHANNELS.serverConfigUpdated, { |
| 225 | issues: [], | 226 | issues: [], |
| 226 | providers: defaultProviders, | ||
| 227 | }); | 227 | }); |
| 228 | 228 | ||
| 229 | expect(listener).toHaveBeenCalledTimes(2); | 229 | expect(listener).toHaveBeenCalledTimes(2); |
| 230 | expect(listener).toHaveBeenLastCalledWith({ | 230 | expect(listener).toHaveBeenLastCalledWith({ |
| 231 | issues: [], | 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 | it("forwards valid terminal and orchestration events", async () => { | 256 | it("forwards valid terminal and orchestration events", async () => { |
| 237 | const { createWsNativeApi } = await import("./wsNativeApi"); | 257 | const { createWsNativeApi } = await import("./wsNativeApi"); |
| 238 | 258 | ||
| ... | @@ -357,10 +377,6 @@ describe("wsNativeApi", () => { | ... | @@ -357,10 +377,6 @@ describe("wsNativeApi", () => { |
| 357 | actionId: "action-1", | 377 | actionId: "action-1", |
| 358 | cwd: "/repo", | 378 | cwd: "/repo", |
| 359 | action: "commit", | 379 | action: "commit", |
| 360 | modelSelection: { | ||
| 361 | provider: "codex", | ||
| 362 | model: "gpt-5.4-mini", | ||
| 363 | }, | ||
| 364 | }); | 380 | }); |
| 365 | 381 | ||
| 366 | expect(requestMock).toHaveBeenCalledWith( | 382 | expect(requestMock).toHaveBeenCalledWith( |
| ... | @@ -369,10 +385,6 @@ describe("wsNativeApi", () => { | ... | @@ -369,10 +385,6 @@ describe("wsNativeApi", () => { |
| 369 | actionId: "action-1", | 385 | actionId: "action-1", |
| 370 | cwd: "/repo", | 386 | cwd: "/repo", |
| 371 | action: "commit", | 387 | action: "commit", |
| 372 | modelSelection: { | ||
| 373 | provider: "codex", | ||
| 374 | model: "gpt-5.4-mini", | ||
| 375 | }, | ||
| 376 | }, | 388 | }, |
| 377 | { timeoutMs: null }, | 389 | { timeoutMs: null }, |
| 378 | ); | 390 | ); |
apps/web/src/wsNativeApi.ts+35| ... | @@ -5,6 +5,7 @@ import { | ... | @@ -5,6 +5,7 @@ import { |
| 5 | type ContextMenuItem, | 5 | type ContextMenuItem, |
| 6 | type NativeApi, | 6 | type NativeApi, |
| 7 | ServerConfigUpdatedPayload, | 7 | ServerConfigUpdatedPayload, |
| 8 | ServerProviderUpdatedPayload, | ||
| 8 | WS_CHANNELS, | 9 | WS_CHANNELS, |
| 9 | WS_METHODS, | 10 | WS_METHODS, |
| 10 | type WsWelcomePayload, | 11 | type WsWelcomePayload, |
| ... | @@ -16,6 +17,7 @@ import { WsTransport } from "./wsTransport"; | ... | @@ -16,6 +17,7 @@ import { WsTransport } from "./wsTransport"; |
| 16 | let instance: { api: NativeApi; transport: WsTransport } | null = null; | 17 | let instance: { api: NativeApi; transport: WsTransport } | null = null; |
| 17 | const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>(); | 18 | const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>(); |
| 18 | const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>(); | 19 | const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>(); |
| 20 | const providersUpdatedListeners = new Set<(payload: ServerProviderUpdatedPayload) => void>(); | ||
| 19 | const gitActionProgressListeners = new Set<(payload: GitActionProgressEvent) => void>(); | 21 | const gitActionProgressListeners = new Set<(payload: GitActionProgressEvent) => void>(); |
| 20 | 22 | ||
| 21 | /** | 23 | /** |
| ... | @@ -64,6 +66,26 @@ export function onServerConfigUpdated( | ... | @@ -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 | export function createWsNativeApi(): NativeApi { | 89 | export function createWsNativeApi(): NativeApi { |
| 68 | if (instance) return instance.api; | 90 | if (instance) return instance.api; |
| 69 | 91 | ||
| ... | @@ -89,6 +111,16 @@ export function createWsNativeApi(): NativeApi { | ... | @@ -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 | transport.subscribe(WS_CHANNELS.gitActionProgress, (message) => { | 124 | transport.subscribe(WS_CHANNELS.gitActionProgress, (message) => { |
| 93 | const payload = message.data; | 125 | const payload = message.data; |
| 94 | for (const listener of gitActionProgressListeners) { | 126 | for (const listener of gitActionProgressListeners) { |
| ... | @@ -178,7 +210,10 @@ export function createWsNativeApi(): NativeApi { | ... | @@ -178,7 +210,10 @@ export function createWsNativeApi(): NativeApi { |
| 178 | }, | 210 | }, |
| 179 | server: { | 211 | server: { |
| 180 | getConfig: () => transport.request(WS_METHODS.serverGetConfig), | 212 | getConfig: () => transport.request(WS_METHODS.serverGetConfig), |
| 213 | refreshProviders: () => transport.request(WS_METHODS.serverRefreshProviders), | ||
| 181 | upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), | 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 | orchestration: { | 218 | orchestration: { |
| 184 | getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot), | 219 | getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot), |
apps/web/src/wsTransport.test.ts+4-4| ... | @@ -98,7 +98,7 @@ describe("WsTransport", () => { | ... | @@ -98,7 +98,7 @@ describe("WsTransport", () => { |
| 98 | type: "push", | 98 | type: "push", |
| 99 | sequence: 1, | 99 | sequence: 1, |
| 100 | channel: WS_CHANNELS.serverConfigUpdated, | 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,7 +107,7 @@ describe("WsTransport", () => { |
| 107 | type: "push", | 107 | type: "push", |
| 108 | sequence: 1, | 108 | sequence: 1, |
| 109 | channel: WS_CHANNELS.serverConfigUpdated, | 109 | channel: WS_CHANNELS.serverConfigUpdated, |
| 110 | data: { issues: [], providers: [] }, | 110 | data: { issues: [] }, |
| 111 | }); | 111 | }); |
| 112 | 112 | ||
| 113 | transport.dispose(); | 113 | transport.dispose(); |
| ... | @@ -160,7 +160,7 @@ describe("WsTransport", () => { | ... | @@ -160,7 +160,7 @@ describe("WsTransport", () => { |
| 160 | type: "push", | 160 | type: "push", |
| 161 | sequence: 3, | 161 | sequence: 3, |
| 162 | channel: WS_CHANNELS.serverConfigUpdated, | 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,7 +169,7 @@ describe("WsTransport", () => { |
| 169 | type: "push", | 169 | type: "push", |
| 170 | sequence: 3, | 170 | sequence: 3, |
| 171 | channel: WS_CHANNELS.serverConfigUpdated, | 171 | channel: WS_CHANNELS.serverConfigUpdated, |
| 172 | data: { issues: [], providers: [] }, | 172 | data: { issues: [] }, |
| 173 | }); | 173 | }); |
| 174 | expect(warnSpy).toHaveBeenCalledTimes(2); | 174 | expect(warnSpy).toHaveBeenCalledTimes(2); |
| 175 | expect(warnSpy).toHaveBeenNthCalledWith( | 175 | expect(warnSpy).toHaveBeenNthCalledWith( |
packages/contracts/package.json+5| ... | @@ -10,6 +10,11 @@ | ... | @@ -10,6 +10,11 @@ |
| 10 | "module": "./dist/index.mjs", | 10 | "module": "./dist/index.mjs", |
| 11 | "types": "./src/index.ts", | 11 | "types": "./src/index.ts", |
| 12 | "exports": { | 12 | "exports": { |
| 13 | "./settings": { | ||
| 14 | "types": "./src/settings.ts", | ||
| 15 | "import": "./src/settings.ts", | ||
| 16 | "require": "./src/settings.ts" | ||
| 17 | }, | ||
| 13 | ".": { | 18 | ".": { |
| 14 | "types": "./src/index.ts", | 19 | "types": "./src/index.ts", |
| 15 | "import": "./src/index.ts", | 20 | "import": "./src/index.ts", |
packages/contracts/src/git.test.ts-15| ... | @@ -74,19 +74,4 @@ describe("GitRunStackedActionInput", () => { | ... | @@ -74,19 +74,4 @@ describe("GitRunStackedActionInput", () => { |
| 74 | expect(parsed.actionId).toBe("action-1"); | 74 | expect(parsed.actionId).toBe("action-1"); |
| 75 | expect(parsed.action).toBe("commit"); | 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,6 +1,5 @@ |
| 1 | import { Schema } from "effect"; | 1 | import { Schema } from "effect"; |
| 2 | import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; | 2 | import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; |
| 3 | import { ModelSelection } from "./orchestration"; | ||
| 4 | 3 | ||
| 5 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; | 4 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; |
| 6 | 5 | ||
| ... | @@ -80,7 +79,6 @@ export const GitRunStackedActionInput = Schema.Struct({ | ... | @@ -80,7 +79,6 @@ export const GitRunStackedActionInput = Schema.Struct({ |
| 80 | filePaths: Schema.optional( | 79 | filePaths: Schema.optional( |
| 81 | Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)), | 80 | Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)), |
| 82 | ), | 81 | ), |
| 83 | modelSelection: ModelSelection, | ||
| 84 | }); | 82 | }); |
| 85 | export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; | 83 | export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; |
| 86 | 84 |
packages/contracts/src/index.ts+1| ... | @@ -7,6 +7,7 @@ export * from "./model"; | ... | @@ -7,6 +7,7 @@ export * from "./model"; |
| 7 | export * from "./ws"; | 7 | export * from "./ws"; |
| 8 | export * from "./keybindings"; | 8 | export * from "./keybindings"; |
| 9 | export * from "./server"; | 9 | export * from "./server"; |
| 10 | export * from "./settings"; | ||
| 10 | export * from "./git"; | 11 | export * from "./git"; |
| 11 | export * from "./orchestration"; | 12 | export * from "./orchestration"; |
| 12 | export * from "./editor"; | 13 | export * from "./editor"; |
packages/contracts/src/ipc.ts+10-2| ... | @@ -25,7 +25,11 @@ import type { | ... | @@ -25,7 +25,11 @@ import type { |
| 25 | ProjectWriteFileInput, | 25 | ProjectWriteFileInput, |
| 26 | ProjectWriteFileResult, | 26 | ProjectWriteFileResult, |
| 27 | } from "./project"; | 27 | } from "./project"; |
| 28 | import type { ServerConfig } from "./server"; | 28 | import type { |
| 29 | ServerConfig, | ||
| 30 | ServerProviderUpdatedPayload, | ||
| 31 | ServerUpsertKeybindingResult, | ||
| 32 | } from "./server"; | ||
| 29 | import type { | 33 | import type { |
| 30 | TerminalClearInput, | 34 | TerminalClearInput, |
| 31 | TerminalCloseInput, | 35 | TerminalCloseInput, |
| ... | @@ -36,7 +40,7 @@ import type { | ... | @@ -36,7 +40,7 @@ import type { |
| 36 | TerminalSessionSnapshot, | 40 | TerminalSessionSnapshot, |
| 37 | TerminalWriteInput, | 41 | TerminalWriteInput, |
| 38 | } from "./terminal"; | 42 | } from "./terminal"; |
| 39 | import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; | 43 | import type { ServerUpsertKeybindingInput } from "./server"; |
| 40 | import type { | 44 | import type { |
| 41 | ClientOrchestrationCommand, | 45 | ClientOrchestrationCommand, |
| 42 | OrchestrationGetFullThreadDiffInput, | 46 | OrchestrationGetFullThreadDiffInput, |
| ... | @@ -47,6 +51,7 @@ import type { | ... | @@ -47,6 +51,7 @@ import type { |
| 47 | OrchestrationReadModel, | 51 | OrchestrationReadModel, |
| 48 | } from "./orchestration"; | 52 | } from "./orchestration"; |
| 49 | import { EditorId } from "./editor"; | 53 | import { EditorId } from "./editor"; |
| 54 | import { ServerSettings, ServerSettingsPatch } from "./settings"; | ||
| 50 | 55 | ||
| 51 | export interface ContextMenuItem<T extends string = string> { | 56 | export interface ContextMenuItem<T extends string = string> { |
| 52 | id: T; | 57 | id: T; |
| ... | @@ -160,7 +165,10 @@ export interface NativeApi { | ... | @@ -160,7 +165,10 @@ export interface NativeApi { |
| 160 | }; | 165 | }; |
| 161 | server: { | 166 | server: { |
| 162 | getConfig: () => Promise<ServerConfig>; | 167 | getConfig: () => Promise<ServerConfig>; |
| 168 | refreshProviders: () => Promise<ServerProviderUpdatedPayload>; | ||
| 163 | upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>; | 169 | upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>; |
| 170 | getSettings: () => Promise<ServerSettings>; | ||
| 171 | updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>; | ||
| 164 | }; | 172 | }; |
| 165 | orchestration: { | 173 | orchestration: { |
| 166 | getSnapshot: () => Promise<OrchestrationReadModel>; | 174 | getSnapshot: () => Promise<OrchestrationReadModel>; |
packages/contracts/src/model.ts+15-173| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { Schema } from "effect"; | 1 | import { Schema } from "effect"; |
| 2 | import { TrimmedNonEmptyString } from "./baseSchemas"; | ||
| 2 | import type { ProviderKind } from "./orchestration"; | 3 | import type { ProviderKind } from "./orchestration"; |
| 3 | 4 | ||
| 4 | export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const; | 5 | export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const; |
| ... | @@ -26,178 +27,28 @@ export const ProviderModelOptions = Schema.Struct({ | ... | @@ -26,178 +27,28 @@ export const ProviderModelOptions = Schema.Struct({ |
| 26 | }); | 27 | }); |
| 27 | export type ProviderModelOptions = typeof ProviderModelOptions.Type; | 28 | export type ProviderModelOptions = typeof ProviderModelOptions.Type; |
| 28 | 29 | ||
| 29 | export type EffortOption = { | 30 | export const EffortOption = Schema.Struct({ |
| 30 | readonly value: string; | 31 | value: TrimmedNonEmptyString, |
| 31 | readonly label: string; | 32 | label: TrimmedNonEmptyString, |
| 32 | readonly isDefault?: true; | 33 | isDefault: Schema.optional(Schema.Boolean), |
| 33 | }; | 34 | }); |
| 34 | 35 | export type EffortOption = typeof EffortOption.Type; | |
| 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 | }; | ||
| 47 | 36 | ||
| 48 | /** | 37 | export const ModelCapabilities = Schema.Struct({ |
| 49 | * TODO: This should not be a static array, each provider | 38 | reasoningEffortLevels: Schema.Array(EffortOption), |
| 50 | * should return its own model list over the WS API. | 39 | supportsFastMode: Schema.Boolean, |
| 51 | */ | 40 | supportsThinkingToggle: Schema.Boolean, |
| 52 | export const MODEL_OPTIONS_BY_PROVIDER = { | 41 | promptInjectedEffortLevels: Schema.Array(TrimmedNonEmptyString), |
| 53 | codex: [ | 42 | }); |
| 54 | { | 43 | export type ModelCapabilities = typeof ModelCapabilities.Type; |
| 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; | ||
| 190 | 44 | ||
| 191 | type BuiltInModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"]; | 45 | export type ModelSlug = string & {}; |
| 192 | export type ModelSlug = BuiltInModelSlug | (string & {}); | ||
| 193 | 46 | ||
| 194 | export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, ModelSlug> = { | 47 | export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, ModelSlug> = { |
| 195 | codex: "gpt-5.4", | 48 | codex: "gpt-5.4", |
| 196 | claudeAgent: "claude-sonnet-4-6", | 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 | export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; | 52 | export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; |
| 202 | 53 | ||
| 203 | /** Per-provider text generation model defaults. */ | 54 | /** Per-provider text generation model defaults. */ |
| ... | @@ -230,15 +81,6 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, | ... | @@ -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 | // ── Provider display names ──────────────────────────────────────────── | 84 | // ── Provider display names ──────────────────────────────────────────── |
| 243 | 85 | ||
| 244 | export const PROVIDER_DISPLAY_NAMES: Record<ProviderKind, string> = { | 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,37 +47,20 @@ export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; |
| 47 | export const CodexModelSelection = Schema.Struct({ | 47 | export const CodexModelSelection = Schema.Struct({ |
| 48 | provider: Schema.Literal("codex"), | 48 | provider: Schema.Literal("codex"), |
| 49 | model: TrimmedNonEmptyString, | 49 | model: TrimmedNonEmptyString, |
| 50 | options: Schema.optional(CodexModelOptions), | 50 | options: Schema.optionalKey(CodexModelOptions), |
| 51 | }); | 51 | }); |
| 52 | export type CodexModelSelection = typeof CodexModelSelection.Type; | 52 | export type CodexModelSelection = typeof CodexModelSelection.Type; |
| 53 | 53 | ||
| 54 | export const ClaudeModelSelection = Schema.Struct({ | 54 | export const ClaudeModelSelection = Schema.Struct({ |
| 55 | provider: Schema.Literal("claudeAgent"), | 55 | provider: Schema.Literal("claudeAgent"), |
| 56 | model: TrimmedNonEmptyString, | 56 | model: TrimmedNonEmptyString, |
| 57 | options: Schema.optional(ClaudeModelOptions), | 57 | options: Schema.optionalKey(ClaudeModelOptions), |
| 58 | }); | 58 | }); |
| 59 | export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; | 59 | export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; |
| 60 | 60 | ||
| 61 | export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]); | 61 | export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]); |
| 62 | export type ModelSelection = typeof ModelSelection.Type; | 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 | export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); | 64 | export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); |
| 82 | export type RuntimeMode = typeof RuntimeMode.Type; | 65 | export type RuntimeMode = typeof RuntimeMode.Type; |
| 83 | export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; | 66 | export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| ... | @@ -402,8 +385,6 @@ export const ThreadTurnStartCommand = Schema.Struct({ | ... | @@ -402,8 +385,6 @@ export const ThreadTurnStartCommand = Schema.Struct({ |
| 402 | attachments: Schema.Array(ChatAttachment), | 385 | attachments: Schema.Array(ChatAttachment), |
| 403 | }), | 386 | }), |
| 404 | modelSelection: Schema.optional(ModelSelection), | 387 | modelSelection: Schema.optional(ModelSelection), |
| 405 | providerOptions: Schema.optional(ProviderStartOptions), | ||
| 406 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), | ||
| 407 | runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), | 388 | runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), |
| 408 | interactionMode: ProviderInteractionMode.pipe( | 389 | interactionMode: ProviderInteractionMode.pipe( |
| 409 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), | 390 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), |
| ... | @@ -423,8 +404,6 @@ const ClientThreadTurnStartCommand = Schema.Struct({ | ... | @@ -423,8 +404,6 @@ const ClientThreadTurnStartCommand = Schema.Struct({ |
| 423 | attachments: Schema.Array(UploadChatAttachment), | 404 | attachments: Schema.Array(UploadChatAttachment), |
| 424 | }), | 405 | }), |
| 425 | modelSelection: Schema.optional(ModelSelection), | 406 | modelSelection: Schema.optional(ModelSelection), |
| 426 | providerOptions: Schema.optional(ProviderStartOptions), | ||
| 427 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), | ||
| 428 | runtimeMode: RuntimeMode, | 407 | runtimeMode: RuntimeMode, |
| 429 | interactionMode: ProviderInteractionMode, | 408 | interactionMode: ProviderInteractionMode, |
| 430 | sourceProposedPlan: Schema.optional(SourceProposedPlanReference), | 409 | sourceProposedPlan: Schema.optional(SourceProposedPlanReference), |
| ... | @@ -702,7 +681,6 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ | ... | @@ -702,7 +681,6 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ |
| 702 | threadId: ThreadId, | 681 | threadId: ThreadId, |
| 703 | messageId: MessageId, | 682 | messageId: MessageId, |
| 704 | modelSelection: Schema.optional(ModelSelection), | 683 | modelSelection: Schema.optional(ModelSelection), |
| 705 | providerOptions: Schema.optional(ProviderStartOptions), | ||
| 706 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), | 684 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), |
| 707 | runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), | 685 | runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), |
| 708 | interactionMode: ProviderInteractionMode.pipe( | 686 | interactionMode: ProviderInteractionMode.pipe( |
packages/contracts/src/provider.test.ts-18| ... | @@ -21,12 +21,6 @@ describe("ProviderSessionStartInput", () => { | ... | @@ -21,12 +21,6 @@ describe("ProviderSessionStartInput", () => { |
| 21 | }, | 21 | }, |
| 22 | }, | 22 | }, |
| 23 | runtimeMode: "full-access", | 23 | runtimeMode: "full-access", |
| 24 | providerOptions: { | ||
| 25 | codex: { | ||
| 26 | binaryPath: "/usr/local/bin/codex", | ||
| 27 | homePath: "/tmp/.codex", | ||
| 28 | }, | ||
| 29 | }, | ||
| 30 | }); | 24 | }); |
| 31 | expect(parsed.runtimeMode).toBe("full-access"); | 25 | expect(parsed.runtimeMode).toBe("full-access"); |
| 32 | expect(parsed.modelSelection?.provider).toBe("codex"); | 26 | expect(parsed.modelSelection?.provider).toBe("codex"); |
| ... | @@ -36,8 +30,6 @@ describe("ProviderSessionStartInput", () => { | ... | @@ -36,8 +30,6 @@ describe("ProviderSessionStartInput", () => { |
| 36 | } | 30 | } |
| 37 | expect(parsed.modelSelection.options?.reasoningEffort).toBe("high"); | 31 | expect(parsed.modelSelection.options?.reasoningEffort).toBe("high"); |
| 38 | expect(parsed.modelSelection.options?.fastMode).toBe(true); | 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 | it("rejects payloads without runtime mode", () => { | 35 | it("rejects payloads without runtime mode", () => { |
| ... | @@ -63,13 +55,6 @@ describe("ProviderSessionStartInput", () => { | ... | @@ -63,13 +55,6 @@ describe("ProviderSessionStartInput", () => { |
| 63 | fastMode: true, | 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 | runtimeMode: "full-access", | 58 | runtimeMode: "full-access", |
| 74 | }); | 59 | }); |
| 75 | expect(parsed.provider).toBe("claudeAgent"); | 60 | expect(parsed.provider).toBe("claudeAgent"); |
| ... | @@ -81,9 +66,6 @@ describe("ProviderSessionStartInput", () => { | ... | @@ -81,9 +66,6 @@ describe("ProviderSessionStartInput", () => { |
| 81 | expect(parsed.modelSelection.options?.thinking).toBe(true); | 66 | expect(parsed.modelSelection.options?.thinking).toBe(true); |
| 82 | expect(parsed.modelSelection.options?.effort).toBe("max"); | 67 | expect(parsed.modelSelection.options?.effort).toBe("max"); |
| 83 | expect(parsed.modelSelection.options?.fastMode).toBe(true); | 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 | expect(parsed.runtimeMode).toBe("full-access"); | 69 | expect(parsed.runtimeMode).toBe("full-access"); |
| 88 | }); | 70 | }); |
| 89 | }); | 71 | }); |
packages/contracts/src/provider.ts-2| ... | @@ -19,7 +19,6 @@ import { | ... | @@ -19,7 +19,6 @@ import { |
| 19 | ProviderKind, | 19 | ProviderKind, |
| 20 | ProviderRequestKind, | 20 | ProviderRequestKind, |
| 21 | ProviderSandboxMode, | 21 | ProviderSandboxMode, |
| 22 | ProviderStartOptions, | ||
| 23 | ProviderUserInputAnswers, | 22 | ProviderUserInputAnswers, |
| 24 | RuntimeMode, | 23 | RuntimeMode, |
| 25 | } from "./orchestration"; | 24 | } from "./orchestration"; |
| ... | @@ -55,7 +54,6 @@ export const ProviderSessionStartInput = Schema.Struct({ | ... | @@ -55,7 +54,6 @@ export const ProviderSessionStartInput = Schema.Struct({ |
| 55 | resumeCursor: Schema.optional(Schema.Unknown), | 54 | resumeCursor: Schema.optional(Schema.Unknown), |
| 56 | approvalPolicy: Schema.optional(ProviderApprovalPolicy), | 55 | approvalPolicy: Schema.optional(ProviderApprovalPolicy), |
| 57 | sandboxMode: Schema.optional(ProviderSandboxMode), | 56 | sandboxMode: Schema.optional(ProviderSandboxMode), |
| 58 | providerOptions: Schema.optional(ProviderStartOptions), | ||
| 59 | runtimeMode: RuntimeMode, | 57 | runtimeMode: RuntimeMode, |
| 60 | }); | 58 | }); |
| 61 | export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type; | 59 | export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type; |
packages/contracts/src/server.ts+28-9| ... | @@ -2,7 +2,9 @@ import { Schema } from "effect"; | ... | @@ -2,7 +2,9 @@ import { Schema } from "effect"; |
| 2 | import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas"; | 2 | import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas"; |
| 3 | import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; | 3 | import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; |
| 4 | import { EditorId } from "./editor"; | 4 | import { EditorId } from "./editor"; |
| 5 | import { ModelCapabilities } from "./model"; | ||
| 5 | import { ProviderKind } from "./orchestration"; | 6 | import { ProviderKind } from "./orchestration"; |
| 7 | import { ServerSettings } from "./settings"; | ||
| 6 | 8 | ||
| 7 | const KeybindingsMalformedConfigIssue = Schema.Struct({ | 9 | const KeybindingsMalformedConfigIssue = Schema.Struct({ |
| 8 | kind: Schema.Literal("keybindings.malformed-config"), | 10 | kind: Schema.Literal("keybindings.malformed-config"), |
| ... | @@ -23,8 +25,8 @@ export type ServerConfigIssue = typeof ServerConfigIssue.Type; | ... | @@ -23,8 +25,8 @@ export type ServerConfigIssue = typeof ServerConfigIssue.Type; |
| 23 | 25 | ||
| 24 | const ServerConfigIssues = Schema.Array(ServerConfigIssue); | 26 | const ServerConfigIssues = Schema.Array(ServerConfigIssue); |
| 25 | 27 | ||
| 26 | export const ServerProviderStatusState = Schema.Literals(["ready", "warning", "error"]); | 28 | export const ServerProviderState = Schema.Literals(["ready", "warning", "error", "disabled"]); |
| 27 | export type ServerProviderStatusState = typeof ServerProviderStatusState.Type; | 29 | export type ServerProviderState = typeof ServerProviderState.Type; |
| 28 | 30 | ||
| 29 | export const ServerProviderAuthStatus = Schema.Literals([ | 31 | export const ServerProviderAuthStatus = Schema.Literals([ |
| 30 | "authenticated", | 32 | "authenticated", |
| ... | @@ -33,25 +35,37 @@ export const ServerProviderAuthStatus = Schema.Literals([ | ... | @@ -33,25 +35,37 @@ export const ServerProviderAuthStatus = Schema.Literals([ |
| 33 | ]); | 35 | ]); |
| 34 | export type ServerProviderAuthStatus = typeof ServerProviderAuthStatus.Type; | 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 | provider: ProviderKind, | 47 | provider: ProviderKind, |
| 38 | status: ServerProviderStatusState, | 48 | enabled: Schema.Boolean, |
| 39 | available: Schema.Boolean, | 49 | installed: Schema.Boolean, |
| 50 | version: Schema.NullOr(TrimmedNonEmptyString), | ||
| 51 | status: ServerProviderState, | ||
| 40 | authStatus: ServerProviderAuthStatus, | 52 | authStatus: ServerProviderAuthStatus, |
| 41 | checkedAt: IsoDateTime, | 53 | checkedAt: IsoDateTime, |
| 42 | message: Schema.optional(TrimmedNonEmptyString), | 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 | export const ServerConfig = Schema.Struct({ | 61 | export const ServerConfig = Schema.Struct({ |
| 49 | cwd: TrimmedNonEmptyString, | 62 | cwd: TrimmedNonEmptyString, |
| 50 | keybindingsConfigPath: TrimmedNonEmptyString, | 63 | keybindingsConfigPath: TrimmedNonEmptyString, |
| 51 | keybindings: ResolvedKeybindingsConfig, | 64 | keybindings: ResolvedKeybindingsConfig, |
| 52 | issues: ServerConfigIssues, | 65 | issues: ServerConfigIssues, |
| 53 | providers: ServerProviderStatuses, | 66 | providers: ServerProviders, |
| 54 | availableEditors: Schema.Array(EditorId), | 67 | availableEditors: Schema.Array(EditorId), |
| 68 | settings: ServerSettings, | ||
| 55 | }); | 69 | }); |
| 56 | export type ServerConfig = typeof ServerConfig.Type; | 70 | export type ServerConfig = typeof ServerConfig.Type; |
| 57 | 71 | ||
| ... | @@ -66,6 +80,11 @@ export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.T | ... | @@ -66,6 +80,11 @@ export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.T |
| 66 | 80 | ||
| 67 | export const ServerConfigUpdatedPayload = Schema.Struct({ | 81 | export const ServerConfigUpdatedPayload = Schema.Struct({ |
| 68 | issues: ServerConfigIssues, | 82 | issues: ServerConfigIssues, |
| 69 | providers: ServerProviderStatuses, | 83 | settings: Schema.optional(ServerSettings), |
| 70 | }); | 84 | }); |
| 71 | export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type; | 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,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 | it.effect("rejects push envelopes when channel payload does not match the channel schema", () => | 141 | it.effect("rejects push envelopes when channel payload does not match the channel schema", () => |
| 123 | Effect.gen(function* () { | 142 | Effect.gen(function* () { |
| 124 | const result = yield* Effect.exit( | 143 | const result = yield* Effect.exit( |
packages/contracts/src/ws.ts+16-1| ... | @@ -37,7 +37,8 @@ import { | ... | @@ -37,7 +37,8 @@ import { |
| 37 | import { KeybindingRule } from "./keybindings"; | 37 | import { KeybindingRule } from "./keybindings"; |
| 38 | import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; | 38 | import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; |
| 39 | import { OpenInEditorInput } from "./editor"; | 39 | import { OpenInEditorInput } from "./editor"; |
| 40 | import { ServerConfigUpdatedPayload } from "./server"; | 40 | import { ServerConfigUpdatedPayload, ServerProviderUpdatedPayload } from "./server"; |
| 41 | import { ServerSettingsPatch } from "./settings"; | ||
| 41 | 42 | ||
| 42 | // ── WebSocket RPC Method Names ─────────────────────────────────────── | 43 | // ── WebSocket RPC Method Names ─────────────────────────────────────── |
| 43 | 44 | ||
| ... | @@ -75,7 +76,10 @@ export const WS_METHODS = { | ... | @@ -75,7 +76,10 @@ export const WS_METHODS = { |
| 75 | 76 | ||
| 76 | // Server meta | 77 | // Server meta |
| 77 | serverGetConfig: "server.getConfig", | 78 | serverGetConfig: "server.getConfig", |
| 79 | serverRefreshProviders: "server.refreshProviders", | ||
| 78 | serverUpsertKeybinding: "server.upsertKeybinding", | 80 | serverUpsertKeybinding: "server.upsertKeybinding", |
| 81 | serverGetSettings: "server.getSettings", | ||
| 82 | serverUpdateSettings: "server.updateSettings", | ||
| 79 | } as const; | 83 | } as const; |
| 80 | 84 | ||
| 81 | // ── Push Event Channels ────────────────────────────────────────────── | 85 | // ── Push Event Channels ────────────────────────────────────────────── |
| ... | @@ -85,6 +89,7 @@ export const WS_CHANNELS = { | ... | @@ -85,6 +89,7 @@ export const WS_CHANNELS = { |
| 85 | terminalEvent: "terminal.event", | 89 | terminalEvent: "terminal.event", |
| 86 | serverWelcome: "server.welcome", | 90 | serverWelcome: "server.welcome", |
| 87 | serverConfigUpdated: "server.configUpdated", | 91 | serverConfigUpdated: "server.configUpdated", |
| 92 | serverProvidersUpdated: "server.providersUpdated", | ||
| 88 | } as const; | 93 | } as const; |
| 89 | 94 | ||
| 90 | // -- Tagged Union of all request body schemas ───────────────────────── | 95 | // -- Tagged Union of all request body schemas ───────────────────────── |
| ... | @@ -140,7 +145,10 @@ const WebSocketRequestBody = Schema.Union([ | ... | @@ -140,7 +145,10 @@ const WebSocketRequestBody = Schema.Union([ |
| 140 | 145 | ||
| 141 | // Server meta | 146 | // Server meta |
| 142 | tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), | 147 | tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), |
| 148 | tagRequestBody(WS_METHODS.serverRefreshProviders, Schema.Struct({})), | ||
| 143 | tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), | 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 | export const WebSocketRequest = Schema.Struct({ | 154 | export const WebSocketRequest = Schema.Struct({ |
| ... | @@ -174,6 +182,7 @@ export type WsWelcomePayload = typeof WsWelcomePayload.Type; | ... | @@ -174,6 +182,7 @@ export type WsWelcomePayload = typeof WsWelcomePayload.Type; |
| 174 | export interface WsPushPayloadByChannel { | 182 | export interface WsPushPayloadByChannel { |
| 175 | readonly [WS_CHANNELS.serverWelcome]: WsWelcomePayload; | 183 | readonly [WS_CHANNELS.serverWelcome]: WsWelcomePayload; |
| 176 | readonly [WS_CHANNELS.serverConfigUpdated]: typeof ServerConfigUpdatedPayload.Type; | 184 | readonly [WS_CHANNELS.serverConfigUpdated]: typeof ServerConfigUpdatedPayload.Type; |
| 185 | readonly [WS_CHANNELS.serverProvidersUpdated]: typeof ServerProviderUpdatedPayload.Type; | ||
| 177 | readonly [WS_CHANNELS.gitActionProgress]: typeof GitActionProgressEvent.Type; | 186 | readonly [WS_CHANNELS.gitActionProgress]: typeof GitActionProgressEvent.Type; |
| 178 | readonly [WS_CHANNELS.terminalEvent]: typeof TerminalEvent.Type; | 187 | readonly [WS_CHANNELS.terminalEvent]: typeof TerminalEvent.Type; |
| 179 | readonly [ORCHESTRATION_WS_CHANNELS.domainEvent]: OrchestrationEvent; | 188 | readonly [ORCHESTRATION_WS_CHANNELS.domainEvent]: OrchestrationEvent; |
| ... | @@ -198,6 +207,10 @@ export const WsPushServerConfigUpdated = makeWsPushSchema( | ... | @@ -198,6 +207,10 @@ export const WsPushServerConfigUpdated = makeWsPushSchema( |
| 198 | WS_CHANNELS.serverConfigUpdated, | 207 | WS_CHANNELS.serverConfigUpdated, |
| 199 | ServerConfigUpdatedPayload, | 208 | ServerConfigUpdatedPayload, |
| 200 | ); | 209 | ); |
| 210 | export const WsPushServerProvidersUpdated = makeWsPushSchema( | ||
| 211 | WS_CHANNELS.serverProvidersUpdated, | ||
| 212 | ServerProviderUpdatedPayload, | ||
| 213 | ); | ||
| 201 | export const WsPushGitActionProgress = makeWsPushSchema( | 214 | export const WsPushGitActionProgress = makeWsPushSchema( |
| 202 | WS_CHANNELS.gitActionProgress, | 215 | WS_CHANNELS.gitActionProgress, |
| 203 | GitActionProgressEvent, | 216 | GitActionProgressEvent, |
| ... | @@ -212,6 +225,7 @@ export const WsPushChannelSchema = Schema.Literals([ | ... | @@ -212,6 +225,7 @@ export const WsPushChannelSchema = Schema.Literals([ |
| 212 | WS_CHANNELS.gitActionProgress, | 225 | WS_CHANNELS.gitActionProgress, |
| 213 | WS_CHANNELS.serverWelcome, | 226 | WS_CHANNELS.serverWelcome, |
| 214 | WS_CHANNELS.serverConfigUpdated, | 227 | WS_CHANNELS.serverConfigUpdated, |
| 228 | WS_CHANNELS.serverProvidersUpdated, | ||
| 215 | WS_CHANNELS.terminalEvent, | 229 | WS_CHANNELS.terminalEvent, |
| 216 | ORCHESTRATION_WS_CHANNELS.domainEvent, | 230 | ORCHESTRATION_WS_CHANNELS.domainEvent, |
| 217 | ]); | 231 | ]); |
| ... | @@ -220,6 +234,7 @@ export type WsPushChannelSchema = typeof WsPushChannelSchema.Type; | ... | @@ -220,6 +234,7 @@ export type WsPushChannelSchema = typeof WsPushChannelSchema.Type; |
| 220 | export const WsPush = Schema.Union([ | 234 | export const WsPush = Schema.Union([ |
| 221 | WsPushServerWelcome, | 235 | WsPushServerWelcome, |
| 222 | WsPushServerConfigUpdated, | 236 | WsPushServerConfigUpdated, |
| 237 | WsPushServerProvidersUpdated, | ||
| 223 | WsPushGitActionProgress, | 238 | WsPushGitActionProgress, |
| 224 | WsPushTerminalEvent, | 239 | WsPushTerminalEvent, |
| 225 | WsPushOrchestrationDomainEvent, | 240 | WsPushOrchestrationDomainEvent, |
packages/shared/package.json+4| ... | @@ -31,6 +31,10 @@ | ... | @@ -31,6 +31,10 @@ |
| 31 | "./schemaJson": { | 31 | "./schemaJson": { |
| 32 | "types": "./src/schemaJson.ts", | 32 | "types": "./src/schemaJson.ts", |
| 33 | "import": "./src/schemaJson.ts" | 33 | "import": "./src/schemaJson.ts" |
| 34 | }, | ||
| 35 | "./Struct": { | ||
| 36 | "types": "./src/Struct.ts", | ||
| 37 | "import": "./src/Struct.ts" | ||
| 34 | } | 38 | } |
| 35 | }, | 39 | }, |
| 36 | "scripts": { | 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,31 +2,46 @@ import { describe, expect, it } from "vitest"; |
| 2 | import { | 2 | import { |
| 3 | DEFAULT_MODEL, | 3 | DEFAULT_MODEL, |
| 4 | DEFAULT_MODEL_BY_PROVIDER, | 4 | DEFAULT_MODEL_BY_PROVIDER, |
| 5 | MODEL_OPTIONS, | 5 | type ModelCapabilities, |
| 6 | MODEL_OPTIONS_BY_PROVIDER, | ||
| 7 | CODEX_REASONING_EFFORT_OPTIONS, | ||
| 8 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 9 | 7 | ||
| 10 | import { | 8 | import { |
| 11 | applyClaudePromptEffortPrefix, | 9 | applyClaudePromptEffortPrefix, |
| 12 | getDefaultModel, | 10 | getDefaultEffort, |
| 13 | getModelCapabilities, | 11 | hasEffortLevel, |
| 14 | getModelOptions, | ||
| 15 | isClaudeUltrathinkPrompt, | 12 | isClaudeUltrathinkPrompt, |
| 16 | normalizeClaudeModelOptions, | ||
| 17 | normalizeCodexModelOptions, | ||
| 18 | normalizeModelSlug, | 13 | normalizeModelSlug, |
| 19 | resolveSelectableModel, | ||
| 20 | resolveModelSlug, | 14 | resolveModelSlug, |
| 21 | resolveModelSlugForProvider, | 15 | resolveModelSlugForProvider, |
| 22 | getDefaultEffort, | 16 | resolveSelectableModel, |
| 23 | hasEffortLevel, | 17 | trimOrNull, |
| 24 | } from "./model"; | 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 | describe("normalizeModelSlug", () => { | 41 | describe("normalizeModelSlug", () => { |
| 27 | it("maps known aliases to canonical slugs", () => { | 42 | it("maps known aliases to canonical slugs", () => { |
| 28 | expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); | 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 | it("returns null for empty or missing values", () => { | 47 | it("returns null for empty or missing values", () => { |
| ... | @@ -35,290 +50,62 @@ describe("normalizeModelSlug", () => { | ... | @@ -35,290 +50,62 @@ describe("normalizeModelSlug", () => { |
| 35 | expect(normalizeModelSlug(null)).toBeNull(); | 50 | expect(normalizeModelSlug(null)).toBeNull(); |
| 36 | expect(normalizeModelSlug(undefined)).toBeNull(); | 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 | describe("resolveModelSlug", () => { | 55 | describe("resolveModelSlug", () => { |
| 57 | it("returns default only when the model is missing", () => { | 56 | it("returns defaults when the model is missing", () => { |
| 58 | expect(resolveModelSlug(undefined)).toBe(DEFAULT_MODEL); | 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 | expect(resolveModelSlugForProvider("claudeAgent", undefined)).toBe( | 58 | expect(resolveModelSlugForProvider("claudeAgent", undefined)).toBe( |
| 75 | DEFAULT_MODEL_BY_PROVIDER.claudeAgent, | 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", () => { | 63 | it("preserves normalized unknown models", () => { |
| 84 | expect(getDefaultModel()).toBe(DEFAULT_MODEL); | 64 | expect(resolveModelSlug("custom/internal-model")).toBe("custom/internal-model"); |
| 85 | expect(getModelOptions()).toEqual(MODEL_OPTIONS); | ||
| 86 | expect(getModelOptions("claudeAgent")).toEqual(MODEL_OPTIONS_BY_PROVIDER.claudeAgent); | ||
| 87 | }); | 65 | }); |
| 88 | }); | 66 | }); |
| 89 | 67 | ||
| 90 | describe("resolveSelectableModel", () => { | 68 | describe("resolveSelectableModel", () => { |
| 91 | it("resolves exact slug matches", () => { | 69 | it("resolves exact slugs, labels, and aliases", () => { |
| 92 | expect( | 70 | const options = [ |
| 93 | resolveSelectableModel("codex", "gpt-5.3-codex", [ | 71 | { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, |
| 94 | { slug: "gpt-5.4", name: "GPT-5.4" }, | 72 | { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, |
| 95 | { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, | 73 | ]; |
| 96 | ]), | 74 | expect(resolveSelectableModel("codex", "gpt-5.3-codex", options)).toBe("gpt-5.3-codex"); |
| 97 | ).toBe("gpt-5.3-codex"); | 75 | expect(resolveSelectableModel("codex", "gpt-5.3 codex", options)).toBe("gpt-5.3-codex"); |
| 98 | }); | 76 | expect(resolveSelectableModel("claudeAgent", "sonnet", options)).toBe("claude-sonnet-4-6"); |
| 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" }); | ||
| 193 | }); | 77 | }); |
| 194 | }); | 78 | }); |
| 195 | 79 | ||
| 196 | describe("getDefaultEffort", () => { | 80 | describe("capability helpers", () => { |
| 197 | it("returns the default effort from capabilities", () => { | 81 | it("reads default efforts", () => { |
| 198 | expect(getDefaultEffort(getModelCapabilities("codex", "gpt-5.4"))).toBe("high"); | 82 | expect(getDefaultEffort(codexCaps)).toBe("high"); |
| 199 | expect(getDefaultEffort(getModelCapabilities("claudeAgent", "claude-opus-4-6"))).toBe("high"); | 83 | expect(getDefaultEffort(claudeCaps)).toBe("high"); |
| 200 | expect(getDefaultEffort(getModelCapabilities("claudeAgent", "claude-haiku-4-5"))).toBeNull(); | ||
| 201 | }); | 84 | }); |
| 202 | }); | ||
| 203 | 85 | ||
| 204 | describe("hasEffortLevel", () => { | 86 | it("checks effort support", () => { |
| 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"); | ||
| 211 | expect(hasEffortLevel(codexCaps, "xhigh")).toBe(true); | 87 | expect(hasEffortLevel(codexCaps, "xhigh")).toBe(true); |
| 212 | expect(hasEffortLevel(codexCaps, "max")).toBe(false); | 88 | expect(hasEffortLevel(codexCaps, "max")).toBe(false); |
| 213 | }); | 89 | }); |
| 214 | }); | 90 | }); |
| 215 | 91 | ||
| 216 | describe("applyClaudePromptEffortPrefix", () => { | 92 | describe("misc helpers", () => { |
| 217 | it("prefixes ultrathink prompts exactly once", () => { | 93 | it("detects ultrathink prompts", () => { |
| 218 | expect(applyClaudePromptEffortPrefix("Investigate this", "ultrathink")).toBe( | 94 | expect(isClaudeUltrathinkPrompt("Ultrathink:\nInvestigate")).toBe(true); |
| 219 | "Ultrathink:\nInvestigate this", | 95 | expect(isClaudeUltrathinkPrompt("Investigate")).toBe(false); |
| 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); | ||
| 304 | }); | 96 | }); |
| 305 | 97 | ||
| 306 | it("only enables the Claude thinking toggle for Haiku 4.5", () => { | 98 | it("prefixes ultrathink prompts once", () => { |
| 307 | const has = (m: string | undefined) => | 99 | expect(applyClaudePromptEffortPrefix("Investigate", "ultrathink")).toBe( |
| 308 | getModelCapabilities("claudeAgent", m).supportsThinkingToggle; | 100 | "Ultrathink:\nInvestigate", |
| 309 | expect(has("claude-opus-4-6")).toBe(false); | 101 | ); |
| 310 | expect(has("claude-sonnet-4-6")).toBe(false); | 102 | expect(applyClaudePromptEffortPrefix("Ultrathink:\nInvestigate", "ultrathink")).toBe( |
| 311 | expect(has("claude-haiku-4-5")).toBe(true); | 103 | "Ultrathink:\nInvestigate", |
| 312 | expect(has("haiku")).toBe(true); | 104 | ); |
| 313 | expect(has(undefined)).toBe(false); | ||
| 314 | }); | 105 | }); |
| 315 | }); | ||
| 316 | 106 | ||
| 317 | describe("isClaudeUltrathinkPrompt", () => { | 107 | it("trims strings to null", () => { |
| 318 | it("detects ultrathink prompts case-insensitively", () => { | 108 | expect(trimOrNull(" hi ")).toBe("hi"); |
| 319 | expect(isClaudeUltrathinkPrompt("Please ultrathink about this")).toBe(true); | 109 | expect(trimOrNull(" ")).toBeNull(); |
| 320 | expect(isClaudeUltrathinkPrompt("Ultrathink:\nInvestigate")).toBe(true); | ||
| 321 | expect(isClaudeUltrathinkPrompt("Think hard about this")).toBe(false); | ||
| 322 | expect(isClaudeUltrathinkPrompt(undefined)).toBe(false); | ||
| 323 | }); | 110 | }); |
| 324 | }); | 111 | }); |
packages/shared/src/model.ts+1-77| ... | @@ -1,31 +1,17 @@ | ... | @@ -1,31 +1,17 @@ |
| 1 | import { | 1 | import { |
| 2 | DEFAULT_MODEL_BY_PROVIDER, | 2 | DEFAULT_MODEL_BY_PROVIDER, |
| 3 | MODEL_CAPABILITIES_INDEX, | ||
| 4 | MODEL_OPTIONS_BY_PROVIDER, | ||
| 5 | MODEL_SLUG_ALIASES_BY_PROVIDER, | 3 | MODEL_SLUG_ALIASES_BY_PROVIDER, |
| 6 | type ClaudeModelOptions, | ||
| 7 | type ClaudeCodeEffort, | 4 | type ClaudeCodeEffort, |
| 8 | type CodexModelOptions, | ||
| 9 | type ModelCapabilities, | 5 | type ModelCapabilities, |
| 10 | type ModelSlug, | 6 | type ModelSlug, |
| 11 | type ProviderKind, | 7 | type ProviderKind, |
| 12 | CodexReasoningEffort, | ||
| 13 | } from "@t3tools/contracts"; | 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 | export interface SelectableModelOption { | 10 | export interface SelectableModelOption { |
| 21 | slug: string; | 11 | slug: string; |
| 22 | name: string; | 12 | name: string; |
| 23 | } | 13 | } |
| 24 | 14 | ||
| 25 | export function getModelOptions(provider: ProviderKind = "codex") { | ||
| 26 | return MODEL_OPTIONS_BY_PROVIDER[provider]; | ||
| 27 | } | ||
| 28 | |||
| 29 | export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug { | 15 | export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug { |
| 30 | return DEFAULT_MODEL_BY_PROVIDER[provider]; | 16 | return DEFAULT_MODEL_BY_PROVIDER[provider]; |
| 31 | } | 17 | } |
| ... | @@ -42,24 +28,6 @@ export function getDefaultEffort(caps: ModelCapabilities): string | null { | ... | @@ -42,24 +28,6 @@ export function getDefaultEffort(caps: ModelCapabilities): string | null { |
| 42 | return caps.reasoningEffortLevels.find((l) => l.isDefault)?.value ?? null; | 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 | export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { | 31 | export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { |
| 64 | return typeof text === "string" && /\bultrathink\b/i.test(text); | 32 | return typeof text === "string" && /\bultrathink\b/i.test(text); |
| 65 | } | 33 | } |
| ... | @@ -125,10 +93,7 @@ export function resolveModelSlug( | ... | @@ -125,10 +93,7 @@ export function resolveModelSlug( |
| 125 | if (!normalized) { | 93 | if (!normalized) { |
| 126 | return DEFAULT_MODEL_BY_PROVIDER[provider]; | 94 | return DEFAULT_MODEL_BY_PROVIDER[provider]; |
| 127 | } | 95 | } |
| 128 | 96 | return normalized; | |
| 129 | return MODEL_SLUG_SET_BY_PROVIDER[provider].has(normalized) | ||
| 130 | ? normalized | ||
| 131 | : DEFAULT_MODEL_BY_PROVIDER[provider]; | ||
| 132 | } | 97 | } |
| 133 | 98 | ||
| 134 | export function resolveModelSlugForProvider( | 99 | export function resolveModelSlugForProvider( |
| ... | @@ -145,47 +110,6 @@ export function trimOrNull<T extends string>(value: T | null | undefined): T | n | ... | @@ -145,47 +110,6 @@ export function trimOrNull<T extends string>(value: T | null | undefined): T | n |
| 145 | return trimmed || null; | 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 | export function applyClaudePromptEffortPrefix( | 113 | export function applyClaudePromptEffortPrefix( |
| 190 | text: string, | 114 | text: string, |
| 191 | effort: ClaudeCodeEffort | null | undefined, | 115 | effort: ClaudeCodeEffort | null | undefined, |
packages/shared/src/schemaJson.ts+63-1| ... | @@ -1,4 +1,14 @@ | ... | @@ -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 | export const decodeJsonResult = <S extends Schema.Codec<unknown, unknown, never, never>>( | 13 | export const decodeJsonResult = <S extends Schema.Codec<unknown, unknown, never, never>>( |
| 4 | schema: S, | 14 | schema: S, |
| ... | @@ -32,3 +42,55 @@ export const formatSchemaError = (cause: Cause.Cause<Schema.SchemaError>) => { | ... | @@ -32,3 +42,55 @@ export const formatSchemaError = (cause: Cause.Cause<Schema.SchemaError>) => { |
| 32 | ? SchemaIssue.makeFormatterDefault()(squashed.issue) | 42 | ? SchemaIssue.makeFormatterDefault()(squashed.issue) |
| 33 | : Cause.pretty(cause); | 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)); |