authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-25 23:58:58-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-25 23:58:58-07:00
log9403429a7af114a1b96a79c96ecda218f9e0c0b2
tree88ef5f97e09eedbb5be88e68433ffe77b7e20087
parent37cf0c1144595faa5a17d6d4b2c1fd98d03376bf
signature Signed by PGP key B5690EEEBB952194

Make server settings authoritative for runtime behavior (#1421)


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
38import { ProviderUnsupportedError } from "../src/provider/Errors.ts";38import { ProviderUnsupportedError } from "../src/provider/Errors.ts";
39import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";39import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";
40import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";40import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";
41import { ServerSettingsService } from "../src/serverSettings.ts";
41import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";42import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";
42import { makeCodexAdapterLive } from "../src/provider/Layers/CodexAdapter.ts";43import { makeCodexAdapterLive } from "../src/provider/Layers/CodexAdapter.ts";
43import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts";44import { 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 @@
1import type { ProviderRuntimeEvent } from "@t3tools/contracts";1import type { ProviderRuntimeEvent } from "@t3tools/contracts";
2import { ThreadId } from "@t3tools/contracts";2import { ThreadId } from "@t3tools/contracts";
3import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings";
3import * as NodeServices from "@effect/platform-node/NodeServices";4import * as NodeServices from "@effect/platform-node/NodeServices";
4import { it, assert } from "@effect/vitest";5import { it, assert } from "@effect/vitest";
5import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect";6import { 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";
16import { ServerSettingsService } from "../src/serverSettings.ts";
15import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts";17import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts";
16import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts";18import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts";
17import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts";19import { 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));
6568
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 });
958956
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 });
993987
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}
137137
...@@ -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 };
543543
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}
15931592
1594function 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
1608function assertSupportedCodexCliVersion(input: {1593function 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}
16601645
1661function readResumeThreadId(input: CodexAppServerStartSessionInput): string | undefined {1646function 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}
16641653
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";
6import { ServerConfig } from "../../config.ts";6import { ServerConfig } from "../../config.ts";
7import { TextGeneration } from "../Services/TextGeneration.ts";7import { TextGeneration } from "../Services/TextGeneration.ts";
8import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";8import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";
9import { ServerSettingsService } from "../../serverSettings.ts";
910
10const ClaudeTextGenerationTestLayer = ClaudeTextGenerationLive.pipe(11const 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";
11import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";11import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1212
13import { ClaudeModelSelection } from "@t3tools/contracts";13import { ClaudeModelSelection } from "@t3tools/contracts";
14import { normalizeClaudeModelOptions } from "@t3tools/shared/model";
15import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";14import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
1615
17import { TextGenerationError } from "../Errors.ts";16import { 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";
29import { normalizeClaudeModelOptions } from "../../provider/Layers/ClaudeProvider.ts";
30import { ServerSettingsService } from "../../serverSettings.ts";
3031
31const CLAUDE_TIMEOUT_MS = 180_000;32const CLAUDE_TIMEOUT_MS = 180_000;
3233
...@@ -40,6 +41,7 @@ const ClaudeOutputEnvelope = Schema.Struct({...@@ -40,6 +41,7 @@ const ClaudeOutputEnvelope = Schema.Struct({
4041
41const makeClaudeTextGeneration = Effect.gen(function* () {42const makeClaudeTextGeneration = Effect.gen(function* () {
42 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;43 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
44 const serverSettingsService = yield* Effect.service(ServerSettingsService);
4345
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 };
8890
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";
7import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";7import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
8import { TextGenerationError } from "../Errors.ts";8import { TextGenerationError } from "../Errors.ts";
9import { TextGeneration } from "../Services/TextGeneration.ts";9import { TextGeneration } from "../Services/TextGeneration.ts";
10import { ServerSettingsService } from "../../serverSettings.ts";
1011
11const DEFAULT_TEST_MODEL_SELECTION = {12const 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};
1516
16const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe(17const 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);
2426
25function makeFakeCodexBinary(dir: string) {27function 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}
107151
...@@ -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 });
190180 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({
208186 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 })
214192 .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}
268195
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 "
4import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";4import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
55
6import { CodexModelSelection } from "@t3tools/contracts";6import { CodexModelSelection } from "@t3tools/contracts";
7import { normalizeCodexModelOptions } from "@t3tools/shared/model";
8import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";7import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
98
10import { resolveAttachmentPath } from "../../attachmentStore.ts";9import { 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";
28import { normalizeCodexModelOptions } from "../../provider/Layers/CodexProvider.ts";
29import { ServerSettingsService } from "../../serverSettings.ts";
2930
30const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low";31const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low";
31const CODEX_TIMEOUT_MS = 180_000;32const 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);
3840
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", "");
140142
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";
20import { GitCore } from "../Services/GitCore.ts";20import { GitCore } from "../Services/GitCore.ts";
21import { makeGitManager } from "./GitManager.ts";21import { makeGitManager } from "./GitManager.ts";
22import { ServerConfig } from "../../config.ts";22import { ServerConfig } from "../../config.ts";
2323import { ServerSettingsService } from "../../serverSettings.ts";
24const DEFAULT_TEST_MODEL_SELECTION = {
25 provider: "codex",
26 model: "gpt-5.4-mini",
27} as const;
2824
29interface FakeGhScenario {25interface 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 });
495490
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));
506504
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 {
19import { GitCore } from "../Services/GitCore.ts";19import { GitCore } from "../Services/GitCore.ts";
20import { GitHubCli } from "../Services/GitHubCli.ts";20import { GitHubCli } from "../Services/GitHubCli.ts";
21import { TextGeneration } from "../Services/TextGeneration.ts";21import { TextGeneration } from "../Services/TextGeneration.ts";
22import { ServerSettingsService } from "../../serverSettings.ts";
2223
23const COMMIT_TIMEOUT_MS = 10 * 60_000;24const COMMIT_TIMEOUT_MS = 10 * 60_000;
24const MAX_PROGRESS_TEXT_LENGTH = 500;25const 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;
361363
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;
11751177
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* () {
11981207
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";
43import * as Semaphore from "effect/Semaphore";44import * as Semaphore from "effect/Semaphore";
44import { ServerConfig } from "./config";45import { ServerConfig } from "./config";
46import { fromLenientJson } from "@t3tools/shared/schemaJson";
4547
46export class KeybindingsConfigError extends Schema.TaggedErrorClass<KeybindingsConfigError>()(48export 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 {
408410
409const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS);411const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS);
410412
411const RawKeybindingsEntries = Schema.fromJsonString(Schema.Array(Schema.Unknown));413const RawKeybindingsEntries = fromLenientJson(Schema.Array(Schema.Unknown));
412const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig);414const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig);
413const PrettyJsonString = SchemaGetter.parseJson<string>().compose(415const 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* () {
817820
818 const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true }));821 const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true }));
819822
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 });
831843
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";
17import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";17import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
18import { AnalyticsService } from "./telemetry/Services/AnalyticsService";18import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
19import { Server, type ServerShape } from "./wsServer";19import { Server, type ServerShape } from "./wsServer";
20import { ServerSettingsService } from "./serverSettings";
2021
21const start = vi.fn(() => undefined);22const start = vi.fn(() => undefined);
22const stop = vi.fn(() => undefined);23const 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";
22import * as SqlitePersistence from "./persistence/Layers/Sqlite";22import * as SqlitePersistence from "./persistence/Layers/Sqlite";
23import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";23import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
24import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";24import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
25import { ProviderHealthLive } from "./provider/Layers/ProviderHealth";25import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry";
26import { Server } from "./wsServer";26import { Server } from "./wsServer";
27import { ServerLoggerLive } from "./serverLogger";27import { ServerLoggerLive } from "./serverLogger";
28import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService";28import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService";
29import { AnalyticsService } from "./telemetry/Services/AnalyticsService";29import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
30import { readBootstrapEnvelope } from "./bootstrap";30import { readBootstrapEnvelope } from "./bootstrap";
31import { ServerSettingsLive } from "./serverSettings";
3132
32export class StartupError extends Data.TaggedError("StartupError")<{33export 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 );
302304
...@@ -331,12 +333,10 @@ export const recordStartupHeartbeat = Effect.gen(function* () {...@@ -331,12 +333,10 @@ export const recordStartupHeartbeat = Effect.gen(function* () {
331 });333 });
332});334});
333335
334const makeServerProgram = (input: CliInput) =>336const 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;
340340
341 const config = yield* ServerConfig;341 const config = yield* ServerConfig;
342342
...@@ -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)));
380380
381const 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";
34import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";34import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
35import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";35import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
36import * as NodeServices from "@effect/platform-node/NodeServices";36import * as NodeServices from "@effect/platform-node/NodeServices";
37import { ServerSettingsService } from "../../serverSettings.ts";
3738
38const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);39const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
39const asApprovalRequestId = (value: string): ApprovalRequestId =>40const 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 @@
1import {1import {
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";
27import { ServerSettingsService } from "../../serverSettings.ts";
2928
30type ProviderIntentEvent = Extract<29type 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 );
153153
154 const threadProviderOptions = new Map<string, ProviderStartOptions>();
155 const threadModelSelections = new Map<string, ModelSelection>();154 const threadModelSelections = new Map<string, ModelSelection>();
156155
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* textGeneration427 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,
454449 });
455 const targetBranch = buildGeneratedWorktreeBranchName(generated.branch);450 }).pipe(
456 if (targetBranch === oldBranch) return Effect.void;451 Effect.catchCause((cause) =>
457452 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 });
478461
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 !== undefined501 ...(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 !== undefined671 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";
39import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";40import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
40import { ServerConfig } from "../../config.ts";41import { ServerConfig } from "../../config.ts";
42import { ServerSettingsService } from "../../serverSettings.ts";
41import * as NodeServices from "@effect/platform-node/NodeServices";43import * as NodeServices from "@effect/platform-node/NodeServices";
4244
45function makeTestServerSettingsLayer(overrides: Partial<ServerSettings> = {}) {
46 return ServerSettingsService.layerTest(overrides);
47}
48
43const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);49const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
44const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);50const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
45const asEventId = (value: string): EventId => EventId.makeUnsafe(value);51const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
...@@ -155,7 +161,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -155,7 +161,7 @@ describe("ProviderRuntimeIngestion", () => {
155 }161 }
156 });162 });
157163
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 });
13581365
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();
13621369
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";
16import { Cache, Cause, Duration, Effect, Layer, Option, Ref, Stream } from "effect";16import { Cache, Cause, Duration, Effect, Layer, Option, Stream } from "effect";
17import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";17import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
1818
19import { ProviderService } from "../../provider/Services/ProviderService.ts";19import { 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";
29import { ServerSettingsService } from "../../serverSettings.ts";
2930
30const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`;31const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`;
31const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId =>32const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId =>
32 CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`);33 CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`);
3334
34const DEFAULT_ASSISTANT_DELIVERY_MODE: AssistantDeliveryMode = "buffered";
35const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000;35const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000;
36const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120);36const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120);
37const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000;37const 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;
540540 const serverSettingsService = yield* ServerSettingsService;
541 const assistantDeliveryModeRef = yield* Ref.make<AssistantDeliveryMode>(
542 DEFAULT_ASSISTANT_DELIVERY_MODE,
543 );
544541
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 }
10501047
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 });
12581258
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 );
12641260
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";
1515
16const nowIso = () => new Date().toISOString();16const nowIso = () => new Date().toISOString();
17const DEFAULT_ASSISTANT_DELIVERY_MODE = "buffered" as const;
1817
19const defaultMetadata: Omit<OrchestrationEvent, "sequence" | "type" | "payload"> = {18const 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 !== undefined329 ...(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";
2121
22import { attachmentRelativePath } from "../../attachmentStore.ts";22import { attachmentRelativePath } from "../../attachmentStore.ts";
23import { ServerConfig } from "../../config.ts";23import { ServerConfig } from "../../config.ts";
24import { ServerSettingsService } from "../../serverSettings.ts";
24import { ProviderAdapterValidationError } from "../Errors.ts";25import { ProviderAdapterValidationError } from "../Errors.ts";
25import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";26import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";
26import { makeClaudeAdapterLive, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts";27import { 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 });
303305
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 });
318315
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 );
12001198
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";
43import {43import { hasEffortLevel, applyClaudePromptEffortPrefix, trimOrNull } from "@t3tools/shared/model";
44 hasEffortLevel,
45 applyClaudePromptEffortPrefix,
46 getModelCapabilities,
47 trimOrNull,
48} from "@t3tools/shared/model";
49import {44import {
50 Cause,45 Cause,
51 DateTime,46 DateTime,
...@@ -63,6 +58,8 @@ import {...@@ -63,6 +58,8 @@ import {
6358
64import { resolveAttachmentPath } from "../../attachmentStore.ts";59import { resolveAttachmentPath } from "../../attachmentStore.ts";
65import { ServerConfig } from "../../config.ts";60import { ServerConfig } from "../../config.ts";
61import { ServerSettingsService } from "../../serverSettings.ts";
62import { getClaudeModelCapabilities } from "./ClaudeProvider.ts";
66import {63import {
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}
355352
356function 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
369function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undefined {353function 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 > 0514 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) {
943927
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;
946931
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 );
27292714
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 @@
1import type {
2 ClaudeSettings,
3 ClaudeModelOptions,
4 ModelCapabilities,
5 ServerProvider,
6 ServerProviderModel,
7 ServerProviderAuthStatus,
8 ServerProviderState,
9} from "@t3tools/contracts";
10import { Effect, Equal, Layer, Option, Result, Stream } from "effect";
11import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
12import { getDefaultEffort, hasEffortLevel, trimOrNull } from "@t3tools/shared/model";
13
14import {
15 buildServerProvider,
16 collectStreamAsString,
17 DEFAULT_TIMEOUT_MS,
18 detailFromResult,
19 extractAuthBoolean,
20 isCommandMissingCause,
21 parseGenericCliVersion,
22 providerModelsFromSettings,
23 type CommandResult,
24} from "../providerSnapshot";
25import { makeManagedServerProvider } from "../makeManagedServerProvider";
26import { ClaudeProvider } from "../Services/ClaudeProvider";
27import { ServerSettingsError, ServerSettingsService } from "../../serverSettings";
28
29const PROVIDER = "claudeAgent" as const;
30const 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
77export 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
89export 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
115export 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
196const 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
220export 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
374export 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";
24import { ServerConfig } from "../../config.ts";24import { ServerConfig } from "../../config.ts";
25import { ServerSettingsService } from "../../serverSettings.ts";
25import { ProviderAdapterValidationError } from "../Errors.ts";26import { ProviderAdapterValidationError } from "../Errors.ts";
26import { CodexAdapter } from "../Services/CodexAdapter.ts";27import { CodexAdapter } from "../Services/CodexAdapter.ts";
27import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";28import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
...@@ -151,6 +152,7 @@ const validationManager = new FakeCodexManager();...@@ -151,6 +152,7 @@ const validationManager = new FakeCodexManager();
151const validationLayer = it.layer(152const 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 () => {
216const sessionErrorLayer = it.layer(219const 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();
284const lifecycleLayer = it.layer(288const 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";
39import { resolveAttachmentPath } from "../../attachmentStore.ts";39import { resolveAttachmentPath } from "../../attachmentStore.ts";
40import { ServerConfig } from "../../config.ts";40import { ServerConfig } from "../../config.ts";
41import { ServerSettingsService } from "../../serverSettings.ts";
41import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";42import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
4243
43const PROVIDER = "codex" as const;44const 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;
13451347
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 }
13561356
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 };
13711386
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 });
13831398
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 @@
1import * as OS from "node:os";
2import type {
3 ModelCapabilities,
4 CodexModelOptions,
5 CodexSettings,
6 ServerProvider,
7 ServerProviderModel,
8 ServerProviderAuthStatus,
9 ServerProviderState,
10} from "@t3tools/contracts";
11import { Effect, Equal, FileSystem, Layer, Option, Path, Result, Stream } from "effect";
12import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
13import { getDefaultEffort, trimOrNull } from "@t3tools/shared/model";
14
15import {
16 buildServerProvider,
17 collectStreamAsString,
18 DEFAULT_TIMEOUT_MS,
19 detailFromResult,
20 extractAuthBoolean,
21 isCommandMissingCause,
22 parseGenericCliVersion,
23 providerModelsFromSettings,
24 type CommandResult,
25} from "../providerSnapshot";
26import { makeManagedServerProvider } from "../makeManagedServerProvider";
27import {
28 formatCodexCliUpgradeMessage,
29 isCodexCliVersionSupported,
30 parseCodexCliVersion,
31} from "../codexCliVersion";
32import { CodexProvider } from "../Services/CodexProvider";
33import { ServerSettingsError, ServerSettingsService } from "../../serverSettings";
34
35const PROVIDER = "codex" as const;
36const OPENAI_AUTH_PROVIDERS = new Set(["openai"]);
37const 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
136export 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
148export 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
165export 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
245export 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
282export const hasCustomModelProvider = readCodexConfigModelProvider().pipe(
283 Effect.map((provider) => provider !== undefined && !OPENAI_AUTH_PROVIDERS.has(provider)),
284 Effect.orElseSucceed(() => false),
285);
286
287const 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
315export 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
504export 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, it, assert } from "@effect/vitest";
3import { Effect, FileSystem, Layer, Path, Sink, Stream } from "effect";
4import * as PlatformError from "effect/PlatformError";
5import { ChildProcessSpawner } from "effect/unstable/process";
6
7import {
8 checkClaudeProviderStatus,
9 checkCodexProviderStatus,
10 hasCustomModelProvider,
11 parseAuthStatusFromOutput,
12 parseClaudeAuthStatusFromOutput,
13 readCodexConfigModelProvider,
14} from "./ProviderHealth";
15
16// ── Test helpers ────────────────────────────────────────────────────
17
18const encoder = new TextEncoder();
19
20function 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
35function 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
47function 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 */
67function 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
97it.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 */
11import * as OS from "node:os";
12import type {
13 ServerProviderAuthStatus,
14 ServerProviderStatus,
15 ServerProviderStatusState,
16} from "@t3tools/contracts";
17import { Array, Effect, Fiber, FileSystem, Layer, Option, Path, Result, Stream } from "effect";
18import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
19
20import {
21 formatCodexCliUpgradeMessage,
22 isCodexCliVersionSupported,
23 parseCodexCliVersion,
24} from "../codexCliVersion";
25import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth";
26
27const DEFAULT_TIMEOUT_MS = 4_000;
28const CODEX_PROVIDER = "codex" as const;
29const CLAUDE_AGENT_PROVIDER = "claudeAgent" as const;
30
31// ── Pure helpers ────────────────────────────────────────────────────
32
33export interface CommandResult {
34 readonly stdout: string;
35 readonly stderr: string;
36 readonly code: number;
37}
38
39function 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
45function 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
51function 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
65function 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
87export 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 */
176const 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 */
188export 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 */
228export const hasCustomModelProvider = Effect.map(
229 readCodexConfigModelProvider,
230 (provider) => provider !== undefined && !OPENAI_AUTH_PROVIDERS.has(provider),
231);
232
233// ── Effect-native command execution ─────────────────────────────────
234
235const 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
242const 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
263const 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
286export 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
412export 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
494export 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
592export 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, it, assert } from "@effect/vitest";
3import {
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";
16import {
17 DEFAULT_SERVER_SETTINGS,
18 ServerSettings,
19 type ServerProvider,
20 type ServerSettings as ContractServerSettings,
21} from "@t3tools/contracts";
22import * as PlatformError from "effect/PlatformError";
23import { ChildProcessSpawner } from "effect/unstable/process";
24import { deepMerge } from "@t3tools/shared/Struct";
25
26import {
27 checkCodexProviderStatus,
28 hasCustomModelProvider,
29 parseAuthStatusFromOutput,
30 readCodexConfigModelProvider,
31} from "./CodexProvider";
32import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider";
33import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry";
34import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings";
35import { ProviderRegistry } from "../Services/ProviderRegistry";
36
37// ── Test helpers ────────────────────────────────────────────────────
38
39const encoder = new TextEncoder();
40
41function 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
56function 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
68function 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
83function 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
99function 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 */
127function 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
157it.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 */
6import type { ProviderKind, ServerProvider } from "@t3tools/contracts";
7import { Effect, Equal, Layer, PubSub, Ref, Stream } from "effect";
8
9import { ClaudeProviderLive } from "./ClaudeProvider";
10import { CodexProviderLive } from "./CodexProvider";
11import type { ClaudeProviderShape } from "../Services/ClaudeProvider";
12import { ClaudeProvider } from "../Services/ClaudeProvider";
13import type { CodexProviderShape } from "../Services/CodexProvider";
14import { CodexProvider } from "../Services/CodexProvider";
15import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry";
16
17const 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
25export const haveProvidersChanged = (
26 previousProviders: ReadonlyArray<ServerProvider>,
27 nextProviders: ReadonlyArray<ServerProvider>,
28): boolean => !Equal.equals(previousProviders, nextProviders);
29
30export 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";
45import { ServerSettingsService } from "../../serverSettings.ts";
45import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts";46import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts";
4647
48const defaultServerSettingsLayer = ServerSettingsService.layerTest();
49
47const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value);50const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value);
48const asEventId = (value: string): EventId => EventId.makeUnsafe(value);51const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
49const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);52const 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}
269273
274it.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
270const routing = makeProviderServiceLayer();323const routing = makeProviderServiceLayer();
271it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", () =>324it.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 );
304358
...@@ -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 );
414470
...@@ -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 );
774831
...@@ -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 );
806864
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";
34import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";34import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
35import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts";35import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts";
36import { ServerSettingsService } from "../../serverSettings.ts";
3637
37export interface ProviderServiceLiveOptions {38export 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 !== undefined106 ...(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}
122121
123function 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
134function readPersistedCwd(122function 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(
146const makeProviderService = (options?: ProviderServiceLiveOptions) =>134const 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 !== undefined140 (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) =>
239227
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);
243230
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) =>
329330
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 @@
1import { ServiceMap } from "effect";
2
3import type { ServerProviderShape } from "./ServerProvider";
4
5export interface ClaudeProviderShape extends ServerProviderShape {}
6
7export 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 @@
1import { ServiceMap } from "effect";
2
3import type { ServerProviderShape } from "./ServerProvider";
4
5export interface CodexProviderShape extends ServerProviderShape {}
6
7export 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 */
9import type { ServerProviderStatus } from "@t3tools/contracts";
10import { ServiceMap } from "effect";
11import type { Effect } from "effect";
12
13export interface ProviderHealthShape {
14 /**
15 * Read the latest provider health statuses.
16 */
17 readonly getStatuses: Effect.Effect<ReadonlyArray<ServerProviderStatus>>;
18}
19
20export 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 */
9import type { ProviderKind, ServerProvider } from "@t3tools/contracts";
10import { ServiceMap } from "effect";
11import type { Effect, Stream } from "effect";
12
13export 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
30export 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 @@
1import type { ServerProvider } from "@t3tools/contracts";
2import type { Effect, Stream } from "effect";
3
4export 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 @@
1import type { ServerProvider } from "@t3tools/contracts";
2import { Duration, Effect, PubSub, Ref, Scope, Stream } from "effect";
3import * as Semaphore from "effect/Semaphore";
4
5import type { ServerProviderShape } from "./Services/ServerProvider";
6import { ServerSettingsError } from "../serverSettings";
7
8export 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 @@
1import type {
2 ServerProvider,
3 ServerProviderAuthStatus,
4 ServerProviderModel,
5 ServerProviderState,
6} from "@t3tools/contracts";
7import { Effect, Stream } from "effect";
8import { normalizeModelSlug } from "@t3tools/shared/model";
9
10export const DEFAULT_TIMEOUT_MS = 4_000;
11
12export interface CommandResult {
13 readonly stdout: string;
14 readonly stderr: string;
15 readonly code: number;
16}
17
18export 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
26export 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
32export 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
38export 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
52export 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
74export function parseGenericCliVersion(output: string): string | null {
75 const match = output.match(/\b(\d+\.\d+\.\d+)\b/);
76 return match?.[1] ?? null;
77}
78
79export 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
105export 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
125export 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";
24import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";24import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
25import { ProviderService } from "./provider/Services/ProviderService";25import { ProviderService } from "./provider/Services/ProviderService";
26import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";26import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";
27import { ServerSettingsService } from "./serverSettings";
2728
28import { TerminalManagerLive } from "./terminal/Layers/Manager";29import { TerminalManagerLive } from "./terminal/Layers/Manager";
29import { KeybindingsLive } from "./keybindings";30import { KeybindingsLive } from "./keybindings";
...@@ -54,7 +55,11 @@ const makeRuntimePtyAdapterLayer = () =>...@@ -54,7 +55,11 @@ const makeRuntimePtyAdapterLayer = () =>
54export function makeServerProviderLayer(): Layer.Layer<55export function makeServerProviderLayer(): Layer.Layer<
55 ProviderService,56 ProviderService,
56 ProviderUnsupportedError,57 ProviderUnsupportedError,
57 SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem | AnalyticsService58 | 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { DEFAULT_SERVER_SETTINGS, ServerSettingsPatch } from "@t3tools/contracts";
3import { assert, it } from "@effect/vitest";
4import { Effect, FileSystem, Layer, Schema } from "effect";
5import { ServerConfig } from "./config";
6import { ServerSettingsLive, ServerSettingsService } from "./serverSettings";
7
8const 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
19it.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 */
13import {
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";
21import {
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";
38import * as Semaphore from "effect/Semaphore";
39import { ServerConfig } from "./config";
40import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
41import { fromLenientJson } from "@t3tools/shared/schemaJson";
42
43export 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
56export 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
75export 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
102const ServerSettingsJson = fromLenientJson(ServerSettings);
103
104const 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 */
112function 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
133function 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
161const 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
339export 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
1313
14import {14import {
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/
45import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";47import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";
46import { SqlClient, SqlError } from "effect/unstable/sql";48import { SqlClient, SqlError } from "effect/unstable/sql";
47import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";49import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
48import { ProviderHealth, type ProviderHealthShape } from "./provider/Services/ProviderHealth";50import { ProviderRegistry, type ProviderRegistryShape } from "./provider/Services/ProviderRegistry";
49import { Open, type OpenShape } from "./open";51import { Open, type OpenShape } from "./open";
50import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";52import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
51import type { GitCoreShape } from "./git/Services/GitCore.ts";53import 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";
53import { GitCommandError, GitManagerError } from "./git/Errors.ts";55import { GitCommandError, GitManagerError } from "./git/Errors.ts";
54import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator";56import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator";
55import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";57import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";
58import { ServerSettingsService } from "./serverSettings.ts";
5659
57const asEventId = (value: string): EventId => EventId.makeUnsafe(value);60const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
58const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);61const 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};
6669
67const defaultProviderStatuses: ReadonlyArray<ServerProviderStatus> = [70const 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];
7682
77const defaultProviderHealthService: ProviderHealthShape = {83const defaultProviderRegistryService: ProviderRegistryShape = {
78 getStatuses: Effect.succeed(defaultProviderStatuses),84 getProviders: Effect.succeed(defaultProviderStatuses),
85 refresh: () => Effect.succeed(defaultProviderStatuses),
86 streamChanges: Stream.empty,
79};87};
8088
89const defaultServerSettings = DEFAULT_SERVER_SETTINGS;
90
81class MockTerminalManager implements TerminalManagerShape {91class 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);
888902
...@@ -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 });
10051019
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 });
10141028
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";
49import { GitManager } from "./git/Services/GitManager.ts";49import { GitManager } from "./git/Services/GitManager.ts";
50import { TerminalManager } from "./terminal/Services/Manager.ts";50import { TerminalManager } from "./terminal/Services/Manager.ts";
51import { Keybindings } from "./keybindings";51import { Keybindings } from "./keybindings";
52import { ServerSettingsService } from "./serverSettings";
52import { searchWorkspaceEntries } from "./workspaceEntries";53import { searchWorkspaceEntries } from "./workspaceEntries";
53import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";54import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
54import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";55import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
55import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";56import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
56import { ProviderService } from "./provider/Services/ProviderService";57import { ProviderService } from "./provider/Services/ProviderService";
57import { ProviderHealth } from "./provider/Services/ProviderHealth";58import { ProviderRegistry } from "./provider/Services/ProviderRegistry";
58import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";59import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";
59import { clamp } from "effect/Number";60import { clamp } from "effect/Number";
60import { Open, resolveAvailableEditors } from "./open";61import { Open, resolveAvailableEditors } from "./open";
...@@ -208,7 +209,7 @@ export type ServerCoreRuntimeServices =...@@ -208,7 +209,7 @@ export type ServerCoreRuntimeServices =
208 | CheckpointDiffQuery209 | CheckpointDiffQuery
209 | OrchestrationReactor210 | OrchestrationReactor
210 | ProviderService211 | ProviderService
211 | ProviderHealth;212 | ProviderRegistry;
212213
213export type ServerRuntimeServices =214export type ServerRuntimeServices =
214 | ServerCoreRuntimeServices215 | ServerCoreRuntimeServices
...@@ -216,6 +217,7 @@ export type ServerRuntimeServices =...@@ -216,6 +217,7 @@ export type ServerRuntimeServices =
216 | GitCore217 | GitCore
217 | TerminalManager218 | TerminalManager
218 | Keybindings219 | Keybindings
220 | ServerSettingsService
219 | Open221 | Open
220 | AnalyticsService;222 | AnalyticsService;
221223
...@@ -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 );
270273
271 const providerStatuses = yield* providerHealth.getStatuses;274 const providersRef = yield* Ref.make(yield* providerRegistry.getProviders);
272275
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 );
298306
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));
620643
...@@ -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 }
880903
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 }
891924
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 }
897930
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", () => {
5353
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 });
5857
59 const delivered = yield* pushBus.publishClient(58 const delivered = yield* pushBus.publishClient(
...@@ -70,7 +69,6 @@ describe("makeServerPushBus", () => {...@@ -70,7 +69,6 @@ describe("makeServerPushBus", () => {
7069
71 yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, {70 yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, {
72 issues: [],71 issues: [],
73 providers: [],
74 });72 });
7573
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 @@
1import { Schema } from "effect";
2import { describe, expect, it } from "vitest";
3
4import {
5 AppSettingsSchema,
6 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
7 DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
8 DEFAULT_TIMESTAMP_FORMAT,
9 getProviderStartOptions,
10} from "./appSettings";
11import {
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
24describe("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
46describe("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
79describe("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
113describe("timestamp format defaults", () => {
114 it("defaults timestamp format to locale", () => {
115 expect(DEFAULT_TIMESTAMP_FORMAT).toBe("locale");
116 });
117});
118
119describe("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
129describe("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
137describe("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
166describe("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
245describe("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
272describe("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 @@
1import { useCallback } from "react";
2import { Option, Schema } from "effect";
3import {
4 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
5 ModelSelection,
6 type ProviderStartOptions,
7} from "@t3tools/contracts";
8import { useLocalStorage } from "./hooks/useLocalStorage";
9import { EnvMode } from "./components/BranchToolbar.logic";
10import { normalizeCustomModelSlugs } from "./modelSelection";
11
12const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
13
14export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]);
15export type TimestampFormat = typeof TimestampFormat.Type;
16export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";
17export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]);
18export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type;
19export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at";
20export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]);
21export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type;
22export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at";
23
24const 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
37export 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});
61export type AppSettings = typeof AppSettingsSchema.Type;
62
63const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});
64
65function normalizeAppSettings(settings: AppSettings): AppSettings {
66 return {
67 ...settings,
68 customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"),
69 customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"),
70 };
71}
72
73export 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
97export 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";
16import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";17import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
17import { HttpResponse, http, ws } from "msw";18import { HttpResponse, http, ws } from "msw";
...@@ -30,6 +31,7 @@ import { isMacPlatform } from "../lib/utils";...@@ -30,6 +31,7 @@ import { isMacPlatform } from "../lib/utils";
30import { getRouter } from "../router";31import { getRouter } from "../router";
31import { useStore } from "../store";32import { useStore } from "../store";
32import { estimateTimelineMessageHeight } from "./timelineHeight";33import { estimateTimelineMessageHeight } from "./timelineHeight";
34import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
3335
34const THREAD_ID = "thread-browser-test" as ThreadId;36const THREAD_ID = "thread-browser-test" as ThreadId;
35const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;37const 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}
122131
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";
25import {25import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model";
26 applyClaudePromptEffortPrefix,
27 getModelCapabilities,
28 normalizeModelSlug,
29} from "@t3tools/shared/model";
30import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";26import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
31import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";27import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
32import { useDebouncedValue } from "@tanstack/react-pacer";28import { useDebouncedValue } from "@tanstack/react-pacer";
...@@ -120,12 +116,13 @@ import {...@@ -120,12 +116,13 @@ import {
120import { SidebarTrigger } from "./ui/sidebar";116import { SidebarTrigger } from "./ui/sidebar";
121import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";117import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
122import { readNativeApi } from "~/nativeApi";118import { readNativeApi } from "~/nativeApi";
123import { getProviderStartOptions, useAppSettings } from "../appSettings";
124import {119import {
125 getCustomModelOptionsByProvider,120 getProviderModelCapabilities,
126 getCustomModelsByProvider,121 getProviderModels,
127 resolveAppModelSelection,122 resolveSelectableProvider,
128} from "../modelSelection";123} from "../providerModels";
124import { useSettings } from "../hooks/useSettings";
125import { resolveAppModelSelection } from "../modelSelection";
129import { isTerminalFocused } from "../lib/terminalFocus";126import { isTerminalFocused } from "../lib/terminalFocus";
130import {127import {
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";
167import { ProviderHealthBanner } from "./chat/ProviderHealthBanner";164import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";
168import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";165import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
169import {166import {
170 buildExpiredTerminalContextToastCopy,167 buildExpiredTerminalContextToastCopy,
...@@ -191,16 +188,17 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];...@@ -191,16 +188,17 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
191const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];188const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
192const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];189const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
193const EMPTY_AVAILABLE_EDITORS: EditorId[] = [];190const EMPTY_AVAILABLE_EDITORS: EditorId[] = [];
194const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = [];191const EMPTY_PROVIDERS: ServerProvider[] = [];
195const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};192const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
196193
197function formatOutgoingPrompt(params: {194function 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 = hasThreadStarted606 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" && activeProposedPlan2938 ...(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>
35203530
3521 {/* Error banner */}3531 {/* Error banner */}
3522 <ProviderHealthBanner status={activeProviderStatus} />3532 <ProviderStatusBanner status={activeProviderStatus} />
3523 <ThreadErrorBanner3533 <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.modelPickerIconClassName3811 {...(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";
30import { resolveDiffThemeName } from "../lib/diffRendering";30import { resolveDiffThemeName } from "../lib/diffRendering";
31import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";31import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
32import { useStore } from "../store";32import { useStore } from "../store";
33import { useAppSettings } from "../appSettings";33import { useSettings } from "../hooks/useSettings";
34import { formatShortTimestamp } from "../timestampFormat";34import { formatShortTimestamp } from "../timestampFormat";
35import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";35import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
36import { ToggleGroup, Toggle } from "./ui/toggle-group";36import { ToggleGroup, Toggle } from "./ui/toggle-group";
...@@ -166,7 +166,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";...@@ -166,7 +166,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
166export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {166export 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";
23import { useAppSettings } from "~/appSettings";
24import { Button } from "~/components/ui/button";23import { Button } from "~/components/ui/button";
25import { Checkbox } from "~/components/ui/checkbox";24import { Checkbox } from "~/components/ui/checkbox";
26import {25import {
...@@ -205,7 +204,6 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {...@@ -205,7 +204,6 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
205}204}
206205
207export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) {206export 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}
5870
apps/web/src/components/PlanSidebar.tsx+1-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { memo, useState, useCallback } from "react";1import { memo, useState, useCallback } from "react";
2import { type TimestampFormat } from "../appSettings";2import { type TimestampFormat } from "@t3tools/contracts/settings";
3import { Badge } from "./ui/badge";3import { Badge } from "./ui/badge";
4import { Button } from "./ui/button";4import { Button } from "./ui/button";
5import { ScrollArea } from "./ui/scroll-area";5import { ScrollArea } from "./ui/scroll-area";
apps/web/src/components/Sidebar.logic.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "../appSettings";1import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
2import type { Thread } from "../types";2import type { Thread } from "../types";
3import { cn } from "../lib/utils";3import { cn } from "../lib/utils";
4import {4import {
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";
41import {41import {
42 type SidebarProjectSortOrder,42 type SidebarProjectSortOrder,
43 type SidebarThreadSortOrder,43 type SidebarThreadSortOrder,
44 useAppSettings,44} from "@t3tools/contracts/settings";
45} from "../appSettings";
46import { isElectron } from "../env";45import { isElectron } from "../env";
47import { APP_STAGE_LABEL, APP_VERSION } from "../branding";46import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
48import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";47import { 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";
104import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";103import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
104import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
105105
106const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];106const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
107const THREAD_PREVIEW_LIMIT = 6;107const 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 <CompactComposerControlsMenu111 <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 <TraitsMenuContent117 <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";
50import { cn } from "~/lib/utils";50import { cn } from "~/lib/utils";
51import { type TimestampFormat } from "../../appSettings";51import { type TimestampFormat } from "@t3tools/contracts/settings";
52import { formatTimestamp } from "../../timestampFormat";52import { formatTimestamp } from "../../timestampFormat";
53import {53import {
54 buildInlineTerminalContextText,54 buildInlineTerminalContextText,
apps/web/src/components/chat/ProviderHealthBanner.tsx deleted-33
...@@ -1,33 +0,0 @@
1import { PROVIDER_DISPLAY_NAMES, type ServerProviderStatus } from "@t3tools/contracts";
2import { memo } from "react";
3import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
4import { CircleAlertIcon } from "lucide-react";
5
6export 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 @@
1import { type ModelSlug, type ProviderKind } from "@t3tools/contracts";1import { type ModelSlug, type ProviderKind, type ServerProvider } from "@t3tools/contracts";
2import { page } from "vitest/browser";2import { page } from "vitest/browser";
3import { afterEach, describe, expect, it, vi } from "vitest";3import { afterEach, describe, expect, it, vi } from "vitest";
4import { render } from "vitest-browser-react";4import { render } from "vitest-browser-react";
55
6import { ProviderModelPicker } from "./ProviderModelPicker";6import { ProviderModelPicker } from "./ProviderModelPicker";
7import { getCustomModelOptionsByProvider } from "../../modelSelection";
8import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
79
8const MODEL_OPTIONS_BY_PROVIDER = {10function 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" },18const 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];
19107
20async function mountPicker(props: {108async 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 <ProviderModelPicker126 <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 });
161258
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 @@
1import { type ModelSlug, type ProviderKind } from "@t3tools/contracts";1import { type ModelSlug, type ProviderKind, type ServerProvider } from "@t3tools/contracts";
2import { resolveSelectableModel } from "@t3tools/shared/model";2import { resolveSelectableModel } from "@t3tools/shared/model";
3import { memo, useState } from "react";3import { memo, useState } from "react";
4import type { VariantProps } from "class-variance-authority";4import type { VariantProps } from "class-variance-authority";
...@@ -20,6 +20,7 @@ import {...@@ -20,6 +20,7 @@ import {
20} from "../ui/menu";20} from "../ui/menu";
21import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons";21import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons";
22import { cn } from "~/lib/utils";22import { cn } from "~/lib/utils";
23import { getProviderSnapshot } from "../../providerModels";
2324
24function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is {25function 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 @@
1import { PROVIDER_DISPLAY_NAMES, type ServerProvider } from "@t3tools/contracts";
2import { memo } from "react";
3import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
4import { CircleAlertIcon } from "lucide-react";
5
6export 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";
11import { page } from "vitest/browser";13import { 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";
26import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
2427
25// ── Claude TraitsPicker tests ─────────────────────────────────────────28// ── Claude TraitsPicker tests ─────────────────────────────────────────
2629
27const CLAUDE_THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits");30const CLAUDE_THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits");
31const 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];
28113
29function ClaudeTraitsPickerHarness(props: {114function 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 <TraitsPicker140 <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 <TraitsPicker384 <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)", () => {
359450
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 });
368459
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";
8import {9import {
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";
29import { useComposerDraftStore } from "../../composerDraftStore";29import { useComposerDraftStore } from "../../composerDraftStore";
30import { getProviderModelCapabilities } from "../../providerModels";
30import { cn } from "~/lib/utils";31import { cn } from "~/lib/utils";
3132
32type ProviderOptions = ProviderModelOptions[ProviderKind];33type ProviderOptions = ProviderModelOptions[ProviderKind];
...@@ -65,12 +66,13 @@ function buildNextOptions(...@@ -65,12 +66,13 @@ function buildNextOptions(
6566
66function getSelectedTraits(67function 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 = allowPromptInjectedEffort76 const effortLevels = allowPromptInjectedEffort
75 ? caps.reasoningEffortLevels77 ? caps.reasoningEffortLevels
76 : caps.reasoningEffortLevels.filter(78 : caps.reasoningEffortLevels.filter(
...@@ -120,6 +122,7 @@ function getSelectedTraits(...@@ -120,6 +122,7 @@ function getSelectedTraits(
120122
121export interface TraitsMenuContentProps {123export 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 {
131134
132export const TraitsMenuContent = memo(function TraitsMenuContentImpl({135export 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);
161165
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({
260264
261export const TraitsPicker = memo(function TraitsPicker({265export 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);
281286
282 const effortLabel = effort287 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 <TraitsMenuContent339 <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 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import type { ServerProviderModel } from "@t3tools/contracts";
2import { getComposerProviderState } from "./composerProviderRegistry";3import { getComposerProviderState } from "./composerProviderRegistry";
34
5const 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
24const 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
4describe("getComposerProviderState", () => {70describe("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";
7import {8import {
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";
16import type { ReactNode } from "react";14import type { ReactNode } from "react";
15import {
16 getProviderModelCapabilities,
17 normalizeClaudeModelOptionsWithCapabilities,
18 normalizeCodexModelOptionsWithCapabilities,
19} from "../../providerModels";
17import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker";20import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
1821
19export type ComposerProviderStateInput = {22export 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 = {
53function getProviderStateFromCapabilities(59function 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];
5965
60 // Resolve effort66 // Resolve effort
...@@ -81,8 +87,8 @@ function getProviderStateFromCapabilities(...@@ -81,8 +87,8 @@ function getProviderStateFromCapabilities(
81 // Normalize options for dispatch87 // 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);
8692
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(
103const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {109const 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 <TraitsMenuContent120 <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 <TraitsPicker131 <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 <TraitsMenuContent152 <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 <TraitsPicker163 <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";
14import * as Schema from "effect/Schema";15import * as Schema from "effect/Schema";
15import * as Equal from "effect/Equal";16import * as Equal from "effect/Equal";
16import { DeepMutable } from "effect/Types";17import { DeepMutable } from "effect/Types";
17import {18import { getDefaultModel, normalizeModelSlug } from "@t3tools/shared/model";
18 getDefaultModel,
19 normalizeModelSlug,
20 resolveModelSlugForProvider,
21} from "@t3tools/shared/model";
22import { useMemo } from "react";19import { useMemo } from "react";
23import { getLocalStorageItem } from "./hooks/useLocalStorage";20import { getLocalStorageItem } from "./hooks/useLocalStorage";
24import { resolveAppModelSelection } from "./modelSelection";21import { resolveAppModelSelection } from "./modelSelection";
...@@ -31,6 +28,8 @@ import {...@@ -31,6 +28,8 @@ import {
31import { create } from "zustand";28import { create } from "zustand";
32import { createJSONStorage, persist } from "zustand/middleware";29import { createJSONStorage, persist } from "zustand/middleware";
33import { createDebouncedStorage, createMemoryStorage } from "./lib/storage";30import { createDebouncedStorage, createMemoryStorage } from "./lib/storage";
31import { getDefaultServerModel } from "./providerModels";
32import { UnifiedSettings } from "@t3tools/contracts/settings";
3433
35export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";34export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
36const COMPOSER_DRAFT_STORAGE_VERSION = 3;35const 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 | null613 | 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?.model627 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
21572157
2158export function useEffectiveComposerModelState(input: {2158export 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);
21662167
...@@ -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 */
12import { useCallback, useMemo } from "react";
13import { useQuery, useQueryClient } from "@tanstack/react-query";
14import {
15 ServerSettings,
16 ServerSettingsPatch,
17 ServerConfig,
18 ModelSelection,
19 ThreadEnvMode,
20} from "@t3tools/contracts";
21import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
22import {
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";
32import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
33import { ensureNativeApi } from "~/nativeApi";
34import { useLocalStorage } from "./useLocalStorage";
35import { normalizeCustomModelSlugs } from "~/modelSelection";
36import { Predicate, Schema, Struct } from "effect";
37import { DeepMutable } from "effect/Types";
38import { deepMerge } from "@t3tools/shared/Struct";
39
40const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1";
41const OLD_SETTINGS_KEY = "t3code:app-settings:v1";
42
43// ── Key sets for routing patches ─────────────────────────────────────
44
45const SERVER_SETTINGS_KEYS = new Set<string>(Struct.keys(ServerSettings.fields));
46
47function 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
73export 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 */
100export 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
144export 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
200export 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 */
233export 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 @@
1import { type GitStackedAction, type ModelSelection } from "@t3tools/contracts";1import { type GitStackedAction } from "@t3tools/contracts";
2import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";2import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";
3import { ensureNativeApi } from "../nativeApi";3import { ensureNativeApi } from "../nativeApi";
44
...@@ -112,7 +112,6 @@ export function gitCheckoutMutationOptions(input: {...@@ -112,7 +112,6 @@ export function gitCheckoutMutationOptions(input: {
112export function gitRunStackedActionMutationOptions(input: {112export 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};
88
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 */
9export function serverConfigQueryOptions() {16export 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";
6import {7import { normalizeModelSlug, resolveSelectableModel } from "@t3tools/shared/model";
7 getDefaultModel,
8 getModelOptions,
9 normalizeModelSlug,
10 resolveSelectableModel,
11} from "@t3tools/shared/model";
12import { getComposerProviderState } from "./components/chat/composerProviderRegistry";8import { getComposerProviderState } from "./components/chat/composerProviderRegistry";
9import { UnifiedSettings } from "@t3tools/contracts/settings";
10import {
11 getDefaultServerModel,
12 getProviderModels,
13 resolveSelectableProvider,
14} from "./providerModels";
1315
14const MAX_CUSTOM_MODEL_COUNT = 32;16const MAX_CUSTOM_MODEL_COUNT = 32;
15export const MAX_CUSTOM_MODEL_LENGTH = 256;17export const MAX_CUSTOM_MODEL_LENGTH = 256;
1618
17export type CustomModelSettings = {
18 customCodexModels: readonly string[];
19 customClaudeModels: readonly string[];
20};
21
22export type ProviderCustomModelConfig = {19export 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 {
38const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConfig> = {33const 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};
5849
59const 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
64export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG);50export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG);
6551
66export function normalizeCustomModelSlugs(52export 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];
7359
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}
9480
95export function getCustomModelsForProvider(
96 settings: CustomModelSettings,
97 provider: ProviderKind,
98): readonly string[] {
99 return settings[PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey];
100}
101
102export function getDefaultCustomModelsForProvider(
103 defaults: CustomModelSettings,
104 provider: ProviderKind,
105): readonly string[] {
106 return defaults[PROVIDER_CUSTOM_MODEL_CONFIG[provider].defaultSettingsKey];
107}
108
109export function patchCustomModels(
110 provider: ProviderKind,
111 models: string[],
112): Partial<CustomModelSettings> {
113 return {
114 [PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]: models,
115 };
116}
117
118export 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
127export function getAppModelOptions(81export 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();
13996 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(
171134
172export function resolveAppModelSelection(135export 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}
181148
182export function getCustomModelOptionsByProvider(149export 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}
201170
202export function resolveAppModelSelectionState(171export 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 });
222194
apps/web/src/providerModels.ts created+116
...@@ -0,0 +1,116 @@
1import {
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";
10import {
11 getDefaultEffort,
12 hasEffortLevel,
13 normalizeModelSlug,
14 trimOrNull,
15} from "@t3tools/shared/model";
16
17const EMPTY_CAPABILITIES: ModelCapabilities = {
18 reasoningEffortLevels: [],
19 supportsFastMode: false,
20 supportsThinkingToggle: false,
21 promptInjectedEffortLevels: [],
22};
23
24export function getProviderModels(
25 providers: ReadonlyArray<ServerProvider>,
26 provider: ProviderKind,
27): ReadonlyArray<ServerProviderModel> {
28 return providers.find((candidate) => candidate.provider === provider)?.models ?? [];
29}
30
31export function getProviderSnapshot(
32 providers: ReadonlyArray<ServerProvider>,
33 provider: ProviderKind,
34): ServerProvider | undefined {
35 return providers.find((candidate) => candidate.provider === provider);
36}
37
38export function isProviderEnabled(
39 providers: ReadonlyArray<ServerProvider>,
40 provider: ProviderKind,
41): boolean {
42 return getProviderSnapshot(providers, provider)?.enabled ?? true;
43}
44
45export 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
56export 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
65export 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
77export 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
93export 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
20import { useStore } from "../store";20import { useStore } from "../store";
21import { useTerminalStateStore } from "../terminalStateStore";21import { useTerminalStateStore } from "../terminalStateStore";
22import { terminalRunningSubprocessFromEvent } from "../terminalActivity";22import { terminalRunningSubprocessFromEvent } from "../terminalActivity";
23import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi";23import { onServerConfigUpdated, onServerProvidersUpdated, onServerWelcome } from "../wsNativeApi";
24import { migrateLocalSettingsToServer } from "../hooks/useSettings";
24import { providerQueryKeys } from "../lib/providerReactQuery";25import { providerQueryKeys } from "../lib/providerReactQuery";
25import { projectQueryKeys } from "../lib/projectReactQuery";26import { projectQueryKeys } from "../lib/projectReactQuery";
26import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup";27import { 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 @@
1import { createFileRoute } from "@tanstack/react-router";1import { createFileRoute } from "@tanstack/react-router";
2import { useQuery } from "@tanstack/react-query";2import { useQuery, useQueryClient } from "@tanstack/react-query";
3import { ChevronDownIcon, PlusIcon, RotateCcwIcon, Undo2Icon, XIcon } from "lucide-react";3import {
4import { type ReactNode, useCallback, useState } from "react";4 ChevronDownIcon,
5import { type ProviderKind } from "@t3tools/contracts";5 InfoIcon,
6import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";6 LoaderIcon,
7import { useAppSettings } from "../appSettings";7 PlusIcon,
8 RefreshCwIcon,
9 RotateCcwIcon,
10 Undo2Icon,
11 XIcon,
12} from "lucide-react";
13import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
14import {
15 PROVIDER_DISPLAY_NAMES,
16 type ProviderKind,
17 type ServerProvider,
18 type ServerProviderModel,
19} from "@t3tools/contracts";
20import { normalizeModelSlug } from "@t3tools/shared/model";
21import { useSettings, useUpdateSettings } from "../hooks/useSettings";
8import {22import {
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";
16import { APP_VERSION } from "../branding";27import { 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"
33import { resolveAndPersistPreferredEditor } from "../editorPreferences";44import { resolveAndPersistPreferredEditor } from "../editorPreferences";
34import { isElectron } from "../env";45import { isElectron } from "../env";
35import { useTheme } from "../hooks/useTheme";46import { useTheme } from "../hooks/useTheme";
36import { serverConfigQueryOptions } from "../lib/serverReactQuery";47import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";
37import { cn } from "../lib/utils";48import { cn } from "../lib/utils";
49import { formatRelativeTime } from "../timestampFormat";
38import { ensureNativeApi, readNativeApi } from "../nativeApi";50import { ensureNativeApi, readNativeApi } from "../nativeApi";
51import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
52import { Equal } from "effect";
3953
40const THEME_OPTIONS = [54const 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;
6377
64type InstallBinarySettingsKey = "claudeBinaryPath" | "codexBinaryPath";78const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = [];
79
65type InstallProviderSettings = {80type 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};
7589
76const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [90const 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];
103107
104function SettingsSection({ title, children }: { title: string; children: ReactNode }) {108const 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
127function 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
181function 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. */
187function 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
196function 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 <div236 <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 <div240 <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: () =>
190285
191function SettingsRouteView() {286function 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]);
213335
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;
219342
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.confirmThreadDelete368 ? ["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 > 0376 ...(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 ];
270382
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 }
327443
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 );
340479
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 );
357499
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;
360542
...@@ -368,11 +550,10 @@ function SettingsRouteView() {...@@ -368,11 +550,10 @@ function SettingsRouteView() {
368550
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 <SettingResetButton646 <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 <SettingResetButton691 <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 <SettingResetButton720 <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 <SettingResetButton750 <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 <SettingResetButton792 <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 <SettingsRow814 <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 <SettingResetButton820 <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 <TraitsPicker853 <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>
692886
693 <SettingsRow887 <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 <SettingResetButton892 <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 ) : null903 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 <Select908 );
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;
780941
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 <div946 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>
8231047
824 <SettingsSection title="Advanced">1048 <Collapsible
825 <SettingsRow1049 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 <SettingResetButton1054 }))
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 <ChevronDownIcon1071 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">
8961087 {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 <label1091
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 path1161 {model.name}
906 </span>1162 </span>
907 <Input1163 {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>
9251181 {capLabels.length > 0 ? (
926 {providerSettings.homePathKey ? (1182 <div className="flex flex-wrap gap-x-2 gap-y-0.5">
927 <label1183 {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 path1188 {label}
933 </span>1189 </span>
934 <Input1190 ))}
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>
9631267
1268 <SettingsSection title="Advanced">
964 <SettingsRow1269 <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";
11import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";11import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
12import { useThreadSelectionStore } from "../threadSelectionStore";12import { useThreadSelectionStore } from "../threadSelectionStore";
13import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic";13import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic";
14import { useAppSettings } from "~/appSettings";14import { useSettings } from "~/hooks/useSettings";
15import { Sidebar, SidebarProvider, SidebarRail } from "~/components/ui/sidebar";15import { Sidebar, SidebarProvider, SidebarRail } from "~/components/ui/sidebar";
1616
17const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];17const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
...@@ -31,7 +31,7 @@ function ChatRouteGlobalShortcuts() {...@@ -31,7 +31,7 @@ function ChatRouteGlobalShortcuts() {
31 ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen31 ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen
32 : false,32 : false,
33 );33 );
34 const { settings: appSettings } = useAppSettings();34 const appSettings = useSettings();
3535
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 @@
1import { type TimestampFormat } from "./appSettings";1import { type TimestampFormat } from "@t3tools/contracts/settings";
22
3export function getTimestampFormatOptions(3export 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
47export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string {47export 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 */
56export 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";
18import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";18import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1919
...@@ -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}
9494
95const defaultProviders: ReadonlyArray<ServerProviderStatus> = [95const 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];
104107
...@@ -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);
203205
...@@ -219,20 +221,38 @@ describe("wsNativeApi", () => {...@@ -219,20 +221,38 @@ describe("wsNativeApi", () => {
219221
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 });
228228
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 });
235234
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");
238258
...@@ -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 });
365381
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";
16let instance: { api: NativeApi; transport: WsTransport } | null = null;17let instance: { api: NativeApi; transport: WsTransport } | null = null;
17const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>();18const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>();
18const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>();19const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>();
20const providersUpdatedListeners = new Set<(payload: ServerProviderUpdatedPayload) => void>();
19const gitActionProgressListeners = new Set<(payload: GitActionProgressEvent) => void>();21const gitActionProgressListeners = new Set<(payload: GitActionProgressEvent) => void>();
2022
21/**23/**
...@@ -64,6 +66,26 @@ export function onServerConfigUpdated(...@@ -64,6 +66,26 @@ export function onServerConfigUpdated(
64 };66 };
65}67}
6668
69export 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
67export function createWsNativeApi(): NativeApi {89export function createWsNativeApi(): NativeApi {
68 if (instance) return instance.api;90 if (instance) return instance.api;
6991
...@@ -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 );
104104
...@@ -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 });
112112
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 );
166166
...@@ -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 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";2import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";
3import { ModelSelection } from "./orchestration";
43
5const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;4const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
65
...@@ -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});
85export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;83export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;
8684
packages/contracts/src/index.ts+1
...@@ -7,6 +7,7 @@ export * from "./model";...@@ -7,6 +7,7 @@ export * from "./model";
7export * from "./ws";7export * from "./ws";
8export * from "./keybindings";8export * from "./keybindings";
9export * from "./server";9export * from "./server";
10export * from "./settings";
10export * from "./git";11export * from "./git";
11export * from "./orchestration";12export * from "./orchestration";
12export * from "./editor";13export * 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";
28import type { ServerConfig } from "./server";28import type {
29 ServerConfig,
30 ServerProviderUpdatedPayload,
31 ServerUpsertKeybindingResult,
32} from "./server";
29import type {33import 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";
39import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server";43import type { ServerUpsertKeybindingInput } from "./server";
40import type {44import 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";
49import { EditorId } from "./editor";53import { EditorId } from "./editor";
54import { ServerSettings, ServerSettingsPatch } from "./settings";
5055
51export interface ContextMenuItem<T extends string = string> {56export 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 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";
2import type { ProviderKind } from "./orchestration";3import type { ProviderKind } from "./orchestration";
34
4export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const;5export 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});
27export type ProviderModelOptions = typeof ProviderModelOptions.Type;28export type ProviderModelOptions = typeof ProviderModelOptions.Type;
2829
29export type EffortOption = {30export 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});
3435export type EffortOption = typeof EffortOption.Type;
35export type ModelCapabilities = {
36 readonly reasoningEffortLevels: readonly EffortOption[];
37 readonly supportsFastMode: boolean;
38 readonly supportsThinkingToggle: boolean;
39 readonly promptInjectedEffortLevels: readonly string[];
40};
41
42type ModelDefinition = {
43 readonly slug: string;
44 readonly name: string;
45 readonly capabilities: ModelCapabilities;
46};
4736
48/**37export const ModelCapabilities = Schema.Struct({
49 * TODO: This should not be a static array, each provider38 reasoningEffortLevels: Schema.Array(EffortOption),
50 * should return its own model list over the WS API.39 supportsFastMode: Schema.Boolean,
51 */40 supportsThinkingToggle: Schema.Boolean,
52export const MODEL_OPTIONS_BY_PROVIDER = {41 promptInjectedEffortLevels: Schema.Array(TrimmedNonEmptyString),
53 codex: [42});
54 {43export 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[]>;
189export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER;
19044
191type BuiltInModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"];45export type ModelSlug = string & {};
192export type ModelSlug = BuiltInModelSlug | (string & {});
19346
194export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, ModelSlug> = {47export 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};
19851
199// Backward compatibility for existing Codex-only call sites.
200export const MODEL_OPTIONS = MODEL_OPTIONS_BY_PROVIDER.codex;
201export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex;52export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex;
20253
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};
23283
233// ── Model capabilities index ──────────────────────────────────────────
234
235export 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 ────────────────────────────────────────────
24385
244export const PROVIDER_DISPLAY_NAMES: Record<ProviderKind, string> = {86export 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";
47export const CodexModelSelection = Schema.Struct({47export 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});
52export type CodexModelSelection = typeof CodexModelSelection.Type;52export type CodexModelSelection = typeof CodexModelSelection.Type;
5353
54export const ClaudeModelSelection = Schema.Struct({54export 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});
59export type ClaudeModelSelection = typeof ClaudeModelSelection.Type;59export type ClaudeModelSelection = typeof ClaudeModelSelection.Type;
6060
61export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]);61export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]);
62export type ModelSelection = typeof ModelSelection.Type;62export type ModelSelection = typeof ModelSelection.Type;
6363
64export const CodexProviderStartOptions = Schema.Struct({
65 binaryPath: Schema.optional(TrimmedNonEmptyString),
66 homePath: Schema.optional(TrimmedNonEmptyString),
67});
68
69export const ClaudeProviderStartOptions = Schema.Struct({
70 binaryPath: Schema.optional(TrimmedNonEmptyString),
71 permissionMode: Schema.optional(TrimmedNonEmptyString),
72 maxThinkingTokens: Schema.optional(NonNegativeInt),
73});
74
75export const ProviderStartOptions = Schema.Struct({
76 codex: Schema.optional(CodexProviderStartOptions),
77 claudeAgent: Schema.optional(ClaudeProviderStartOptions),
78});
79export type ProviderStartOptions = typeof ProviderStartOptions.Type;
80
81export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]);64export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]);
82export type RuntimeMode = typeof RuntimeMode.Type;65export type RuntimeMode = typeof RuntimeMode.Type;
83export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";66export 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 });
4234
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});
61export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type;59export 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";
2import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas";2import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas";
3import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";3import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";
4import { EditorId } from "./editor";4import { EditorId } from "./editor";
5import { ModelCapabilities } from "./model";
5import { ProviderKind } from "./orchestration";6import { ProviderKind } from "./orchestration";
7import { ServerSettings } from "./settings";
68
7const KeybindingsMalformedConfigIssue = Schema.Struct({9const 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;
2325
24const ServerConfigIssues = Schema.Array(ServerConfigIssue);26const ServerConfigIssues = Schema.Array(ServerConfigIssue);
2527
26export const ServerProviderStatusState = Schema.Literals(["ready", "warning", "error"]);28export const ServerProviderState = Schema.Literals(["ready", "warning", "error", "disabled"]);
27export type ServerProviderStatusState = typeof ServerProviderStatusState.Type;29export type ServerProviderState = typeof ServerProviderState.Type;
2830
29export const ServerProviderAuthStatus = Schema.Literals([31export 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]);
34export type ServerProviderAuthStatus = typeof ServerProviderAuthStatus.Type;36export type ServerProviderAuthStatus = typeof ServerProviderAuthStatus.Type;
3537
36export const ServerProviderStatus = Schema.Struct({38export const ServerProviderModel = Schema.Struct({
39 slug: TrimmedNonEmptyString,
40 name: TrimmedNonEmptyString,
41 isCustom: Schema.Boolean,
42 capabilities: Schema.NullOr(ModelCapabilities),
43});
44export type ServerProviderModel = typeof ServerProviderModel.Type;
45
46export 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});
44export type ServerProviderStatus = typeof ServerProviderStatus.Type;57export type ServerProvider = typeof ServerProvider.Type;
4558
46const ServerProviderStatuses = Schema.Array(ServerProviderStatus);59const ServerProviders = Schema.Array(ServerProvider);
4760
48export const ServerConfig = Schema.Struct({61export 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});
56export type ServerConfig = typeof ServerConfig.Type;70export type ServerConfig = typeof ServerConfig.Type;
5771
...@@ -66,6 +80,11 @@ export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.T...@@ -66,6 +80,11 @@ export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.T
6680
67export const ServerConfigUpdatedPayload = Schema.Struct({81export const ServerConfigUpdatedPayload = Schema.Struct({
68 issues: ServerConfigIssues,82 issues: ServerConfigIssues,
69 providers: ServerProviderStatuses,83 settings: Schema.optional(ServerSettings),
70});84});
71export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type;85export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type;
86
87export const ServerProviderUpdatedPayload = Schema.Struct({
88 providers: ServerProviders,
89});
90export type ServerProviderUpdatedPayload = typeof ServerProviderUpdatedPayload.Type;
packages/contracts/src/settings.ts created+153
...@@ -0,0 +1,153 @@
1import { Effect } from "effect";
2import * as Schema from "effect/Schema";
3import * as SchemaTransformation from "effect/SchemaTransformation";
4import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas";
5import {
6 ClaudeModelOptions,
7 CodexModelOptions,
8 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
9} from "./model";
10import { ModelSelection } from "./orchestration";
11
12// ── Client Settings (local-only) ───────────────────────────────
13
14export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]);
15export type TimestampFormat = typeof TimestampFormat.Type;
16export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";
17
18export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]);
19export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type;
20export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at";
21
22export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]);
23export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type;
24export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at";
25
26export 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});
37export type ClientSettings = typeof ClientSettingsSchema.Type;
38
39export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientSettingsSchema)({});
40
41// ── Server Settings (server-authoritative) ────────────────────
42
43export const ThreadEnvMode = Schema.Literals(["local", "worktree"]);
44export type ThreadEnvMode = typeof ThreadEnvMode.Type;
45
46const 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
58export 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});
64export type CodexSettings = typeof CodexSettings.Type;
65
66export 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});
71export type ClaudeSettings = typeof ClaudeSettings.Type;
72
73export 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});
91export type ServerSettings = typeof ServerSettings.Type;
92
93export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerSettings)({});
94
95// ── Unified type ─────────────────────────────────────────────────────
96
97export type UnifiedSettings = ServerSettings & ClientSettings;
98export 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
105const CodexModelOptionsPatch = Schema.Struct({
106 reasoningEffort: Schema.optionalKey(CodexModelOptions.fields.reasoningEffort),
107 fastMode: Schema.optionalKey(CodexModelOptions.fields.fastMode),
108});
109
110const 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
116const 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
129const 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
136const 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
142export 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});
153export 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);
121121
122it.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
122it.effect("rejects push envelopes when channel payload does not match the channel schema", () =>141it.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 {
37import { KeybindingRule } from "./keybindings";37import { KeybindingRule } from "./keybindings";
38import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project";38import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project";
39import { OpenInEditorInput } from "./editor";39import { OpenInEditorInput } from "./editor";
40import { ServerConfigUpdatedPayload } from "./server";40import { ServerConfigUpdatedPayload, ServerProviderUpdatedPayload } from "./server";
41import { ServerSettingsPatch } from "./settings";
4142
42// ── WebSocket RPC Method Names ───────────────────────────────────────43// ── WebSocket RPC Method Names ───────────────────────────────────────
4344
...@@ -75,7 +76,10 @@ export const WS_METHODS = {...@@ -75,7 +76,10 @@ export const WS_METHODS = {
7576
76 // Server meta77 // 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;
8084
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;
8994
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([
140145
141 // Server meta146 // 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]);
145153
146export const WebSocketRequest = Schema.Struct({154export const WebSocketRequest = Schema.Struct({
...@@ -174,6 +182,7 @@ export type WsWelcomePayload = typeof WsWelcomePayload.Type;...@@ -174,6 +182,7 @@ export type WsWelcomePayload = typeof WsWelcomePayload.Type;
174export interface WsPushPayloadByChannel {182export 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);
210export const WsPushServerProvidersUpdated = makeWsPushSchema(
211 WS_CHANNELS.serverProvidersUpdated,
212 ServerProviderUpdatedPayload,
213);
201export const WsPushGitActionProgress = makeWsPushSchema(214export 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;
220export const WsPush = Schema.Union([234export 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 @@
1import * as P from "effect/Predicate";
2
3export 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
9export 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";
2import {2import {
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";
97
10import {8import {
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";
2519
20const 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
30const 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
26describe("normalizeModelSlug", () => {41describe("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 });
3146
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});
5554
56describe("resolveModelSlug", () => {55describe("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 });
8262
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});
8967
90describe("resolveSelectableModel", () => {68describe("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
154describe("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});
19579
196describe("getDefaultEffort", () => {80describe("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});
20385
204describe("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});
21591
216describe("applyClaudePromptEffortPrefix", () => {92describe("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
231describe("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
248describe("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
270describe("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 });
30597
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});
316106
317describe("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 @@
1import {1import {
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";
149
15const 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
20export interface SelectableModelOption {10export interface SelectableModelOption {
21 slug: string;11 slug: string;
22 name: string;12 name: string;
23}13}
2414
25export function getModelOptions(provider: ProviderKind = "codex") {
26 return MODEL_OPTIONS_BY_PROVIDER[provider];
27}
28
29export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug {15export 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}
4430
45// ── Data-driven capability resolver ───────────────────────────────────
46
47export 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
63export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean {31export 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 }
12896 return normalized;
129 return MODEL_SLUG_SET_BY_PROVIDER[provider].has(normalized)
130 ? normalized
131 : DEFAULT_MODEL_BY_PROVIDER[provider];
132}97}
13398
134export function resolveModelSlugForProvider(99export 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}
147112
148export 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
163export 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
189export function applyClaudePromptEffortPrefix(113export 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 @@
1import { Cause, Exit, Result, Schema, SchemaIssue } from "effect";1import {
2 Cause,
3 Effect,
4 Exit,
5 Option,
6 Result,
7 Schema,
8 SchemaGetter,
9 SchemaIssue,
10 SchemaTransformation,
11} from "effect";
212
3export const decodeJsonResult = <S extends Schema.Codec<unknown, unknown, never, never>>(13export 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 */
53const 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 */
84export 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 */
95export const fromLenientJson = <S extends Schema.Top>(schema: S) =>
96 Schema.String.pipe(Schema.decodeTo(schema, fromLenientJsonString));