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
3838import { ProviderUnsupportedError } from "../src/provider/Errors.ts";
3939import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";
4040import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";
41import { ServerSettingsService } from "../src/serverSettings.ts";
4142import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";
4243import { makeCodexAdapterLive } from "../src/provider/Layers/CodexAdapter.ts";
4344import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts";
......@@ -295,8 +296,10 @@ export const makeOrchestrationIntegrationHarness = (
295296 providerLayer,
296297 RuntimeReceiptBusLive,
297298 );
299 const serverSettingsLayer = ServerSettingsService.layerTest();
298300 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
299301 Layer.provideMerge(runtimeServicesLayer),
302 Layer.provideMerge(serverSettingsLayer),
300303 );
301304 const gitCoreLayer = Layer.succeed(GitCore, {
302305 renameBranch: (input: Parameters<GitCoreShape["renameBranch"]>[0]) =>
......@@ -309,6 +312,7 @@ export const makeOrchestrationIntegrationHarness = (
309312 Layer.provideMerge(runtimeServicesLayer),
310313 Layer.provideMerge(gitCoreLayer),
311314 Layer.provideMerge(textGenerationLayer),
315 Layer.provideMerge(serverSettingsLayer),
312316 );
313317 const checkpointReactorLayer = CheckpointReactorLive.pipe(
314318 Layer.provideMerge(runtimeServicesLayer),
......@@ -320,6 +324,7 @@ export const makeOrchestrationIntegrationHarness = (
320324 );
321325 const layer = orchestrationReactorLayer.pipe(
322326 Layer.provide(persistenceLayer),
327 Layer.provideMerge(ServerSettingsService.layerTest()),
323328 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)),
324329 Layer.provideMerge(NodeServices.layer),
325330 );
apps/server/integration/providerService.integration.test.ts+3
......@@ -1,5 +1,6 @@
11import type { ProviderRuntimeEvent } from "@t3tools/contracts";
22import { ThreadId } from "@t3tools/contracts";
3import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings";
34import * as NodeServices from "@effect/platform-node/NodeServices";
45import { it, assert } from "@effect/vitest";
56import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect";
......@@ -12,6 +13,7 @@ import {
1213 ProviderService,
1314 type ProviderServiceShape,
1415} from "../src/provider/Services/ProviderService.ts";
16import { ServerSettingsService } from "../src/serverSettings.ts";
1517import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts";
1618import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts";
1719import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts";
......@@ -60,6 +62,7 @@ const makeIntegrationFixture = Effect.gen(function* () {
6062 const shared = Layer.mergeAll(
6163 directoryLayer,
6264 Layer.succeed(ProviderAdapterRegistry, registry),
65 ServerSettingsService.layerTest(DEFAULT_SERVER_SETTINGS),
6366 AnalyticsService.layerTest,
6467 ).pipe(Layer.provide(SqlitePersistenceMemory));
6568
apps/server/src/codexAppServerManager.test.ts+6-12
......@@ -342,6 +342,7 @@ describe("startSession", () => {
342342 manager.startSession({
343343 threadId: asThreadId("thread-1"),
344344 provider: "codex",
345 binaryPath: "codex",
345346 runtimeMode: "full-access",
346347 }),
347348 ).rejects.toThrow("cwd missing");
......@@ -390,6 +391,7 @@ describe("startSession", () => {
390391 manager.startSession({
391392 threadId: asThreadId("thread-1"),
392393 provider: "codex",
394 binaryPath: "codex",
393395 runtimeMode: "full-access",
394396 }),
395397 ).rejects.toThrow(
......@@ -948,12 +950,8 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume"
948950 provider: "codex",
949951 cwd: workspaceDir,
950952 runtimeMode: "full-access",
951 providerOptions: {
952 codex: {
953 ...(process.env.CODEX_BINARY_PATH ? { binaryPath: process.env.CODEX_BINARY_PATH } : {}),
954 ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}),
955 },
956 },
953 binaryPath: process.env.CODEX_BINARY_PATH!,
954 ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}),
957955 });
958956
959957 const firstTurn = await manager.sendTurn({
......@@ -983,12 +981,8 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume"
983981 cwd: workspaceDir,
984982 runtimeMode: "approval-required",
985983 resumeCursor: firstSession.resumeCursor,
986 providerOptions: {
987 codex: {
988 ...(process.env.CODEX_BINARY_PATH ? { binaryPath: process.env.CODEX_BINARY_PATH } : {}),
989 ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}),
990 },
991 },
984 binaryPath: process.env.CODEX_BINARY_PATH!,
985 ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}),
992986 });
993987
994988 expect(resumedSession.threadId).toBe(originalThreadId);
apps/server/src/codexAppServerManager.ts+9-20
......@@ -14,7 +14,6 @@ import {
1414 type ProviderApprovalDecision,
1515 type ProviderEvent,
1616 type ProviderSession,
17 type ProviderSessionStartInput,
1817 type ProviderTurnStartResult,
1918 RuntimeMode,
2019 ProviderInteractionMode,
......@@ -131,7 +130,8 @@ export interface CodexAppServerStartSessionInput {
131130 readonly model?: string;
132131 readonly serviceTier?: string;
133132 readonly resumeCursor?: unknown;
134 readonly providerOptions?: ProviderSessionStartInput["providerOptions"];
133 readonly binaryPath: string;
134 readonly homePath?: string;
135135 readonly runtimeMode: RuntimeMode;
136136}
137137
......@@ -541,9 +541,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
541541 updatedAt: now,
542542 };
543543
544 const codexOptions = readCodexProviderOptions(input);
545 const codexBinaryPath = codexOptions.binaryPath ?? "codex";
546 const codexHomePath = codexOptions.homePath;
544 const codexBinaryPath = input.binaryPath;
545 const codexHomePath = input.homePath;
547546 this.assertSupportedCodexCliVersion({
548547 binaryPath: codexBinaryPath,
549548 cwd: resolvedCwd,
......@@ -1591,20 +1590,6 @@ function normalizeProviderThreadId(value: string | undefined): string | undefine
15911590 return brandIfNonEmpty(value, (normalized) => normalized);
15921591}
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
16081593function assertSupportedCodexCliVersion(input: {
16091594 readonly binaryPath: string;
16101595 readonly cwd: string;
......@@ -1658,7 +1643,11 @@ function readResumeCursorThreadId(resumeCursor: unknown): string | undefined {
16581643 return typeof rawThreadId === "string" ? normalizeProviderThreadId(rawThreadId) : undefined;
16591644}
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 {
16621651 return readResumeCursorThreadId(input.resumeCursor);
16631652}
16641653
apps/server/src/config.ts+2
......@@ -19,6 +19,7 @@ export interface ServerDerivedPaths {
1919 readonly stateDir: string;
2020 readonly dbPath: string;
2121 readonly keybindingsConfigPath: string;
22 readonly settingsPath: string;
2223 readonly worktreesDir: string;
2324 readonly attachmentsDir: string;
2425 readonly logsDir: string;
......@@ -60,6 +61,7 @@ export const deriveServerPaths = Effect.fn(function* (
6061 stateDir,
6162 dbPath,
6263 keybindingsConfigPath: join(stateDir, "keybindings.json"),
64 settingsPath: join(stateDir, "settings.json"),
6365 worktreesDir: join(baseDir, "worktrees"),
6466 attachmentsDir,
6567 logsDir,
apps/server/src/git/Layers/ClaudeTextGeneration.test.ts+2
......@@ -6,8 +6,10 @@ import { expect } from "vitest";
66import { ServerConfig } from "../../config.ts";
77import { TextGeneration } from "../Services/TextGeneration.ts";
88import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";
9import { ServerSettingsService } from "../../serverSettings.ts";
910
1011const ClaudeTextGenerationTestLayer = ClaudeTextGenerationLive.pipe(
12 Layer.provideMerge(ServerSettingsService.layerTest()),
1113 Layer.provideMerge(
1214 ServerConfig.layerTest(process.cwd(), {
1315 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";
1111import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1212
1313import { ClaudeModelSelection } from "@t3tools/contracts";
14import { normalizeClaudeModelOptions } from "@t3tools/shared/model";
1514import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
1615
1716import { TextGenerationError } from "../Errors.ts";
......@@ -27,6 +26,8 @@ import {
2726 sanitizePrTitle,
2827 toJsonSchemaObject,
2928} from "../Utils.ts";
29import { normalizeClaudeModelOptions } from "../../provider/Layers/ClaudeProvider.ts";
30import { ServerSettingsService } from "../../serverSettings.ts";
3031
3132const CLAUDE_TIMEOUT_MS = 180_000;
3233
......@@ -40,6 +41,7 @@ const ClaudeOutputEnvelope = Schema.Struct({
4041
4142const makeClaudeTextGeneration = Effect.gen(function* () {
4243 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
44 const serverSettingsService = yield* Effect.service(ServerSettingsService);
4345
4446 const readStreamAsString = <E>(
4547 operation: string,
......@@ -86,9 +88,14 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
8688 ...(normalizedOptions?.fastMode ? { fastMode: true } : {}),
8789 };
8890
91 const claudeSettings = yield* Effect.map(
92 serverSettingsService.getSettings,
93 (settings) => settings.providers.claudeAgent,
94 ).pipe(Effect.catch(() => Effect.undefined));
95
8996 const runClaudeCommand = Effect.gen(function* () {
9097 const command = ChildProcess.make(
91 "claude",
98 claudeSettings?.binaryPath || "claude",
9299 [
93100 "-p",
94101 "--output-format",
apps/server/src/git/Layers/CodexTextGeneration.test.ts+99-172
......@@ -7,6 +7,7 @@ import { ServerConfig } from "../../config.ts";
77import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
88import { TextGenerationError } from "../Errors.ts";
99import { TextGeneration } from "../Services/TextGeneration.ts";
10import { ServerSettingsService } from "../../serverSettings.ts";
1011
1112const DEFAULT_TEST_MODEL_SELECTION = {
1213 provider: "codex" as const,
......@@ -14,6 +15,7 @@ const DEFAULT_TEST_MODEL_SELECTION = {
1415};
1516
1617const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe(
18 Layer.provideMerge(ServerSettingsService.layerTest()),
1719 Layer.provideMerge(
1820 ServerConfig.layerTest(process.cwd(), {
1921 prefix: "t3code-codex-text-generation-test-",
......@@ -22,7 +24,20 @@ const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe(
2224 Layer.provideMerge(NodeServices.layer),
2325);
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) {
2641 return Effect.gen(function* () {
2742 const fs = yield* FileSystem.FileSystem;
2843 const path = yield* Path.Path;
......@@ -35,12 +50,16 @@ function makeFakeCodexBinary(dir: string) {
3550 [
3651 "#!/bin/sh",
3752 'output_path=""',
53 'seen_image="0"',
54 'seen_fast_service_tier="0"',
55 'seen_reasoning_effort=""',
3856 "while [ $# -gt 0 ]; do",
3957 ' if [ "$1" = "--image" ]; then',
4058 " shift",
4159 ' if [ -n "$1" ]; then',
4260 ' seen_image="1"',
4361 " fi",
62 " shift",
4463 " continue",
4564 " fi",
4665 ' if [ "$1" = "--config" ]; then',
......@@ -53,55 +72,80 @@ function makeFakeCodexBinary(dir: string) {
5372 ' seen_reasoning_effort="$1"',
5473 " ;;",
5574 " esac",
75 " shift",
5676 " continue",
5777 " fi",
5878 ' if [ "$1" = "--output-last-message" ]; then',
5979 " shift",
6080 ' output_path="$1"',
81 " shift",
82 " continue",
6183 " fi",
6284 " shift",
6385 "done",
6486 'stdin_content="$(cat)"',
65 'if [ "$T3_FAKE_CODEX_REQUIRE_IMAGE" = "1" ] && [ "$seen_image" != "1" ]; then',
66 ' printf "%s\\n" "missing --image input" >&2',
67 " exit 2",
68 "fi",
69 'if [ "$T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER" = "1" ] && [ "$seen_fast_service_tier" != "1" ]; then',
70 ' printf "%s\\n" "missing fast service tier config" >&2',
71 " exit 5",
72 "fi",
73 'if [ -n "$T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT" ] && [ "$seen_reasoning_effort" != "model_reasoning_effort=\\"$T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT\\"" ]; then',
74 ' printf "%s\\n" "unexpected reasoning effort config: $seen_reasoning_effort" >&2',
75 " exit 6",
76 "fi",
77 'if [ "$T3_FAKE_CODEX_FORBID_REASONING_EFFORT" = "1" ] && [ -n "$seen_reasoning_effort" ]; then',
78 ' printf "%s\\n" "reasoning effort config should be omitted: $seen_reasoning_effort" >&2',
79 " exit 7",
80 "fi",
81 'if [ -n "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" ]; then',
82 ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" >/dev/null || {',
83 ' printf "%s\\n" "stdin missing expected content" >&2',
84 " exit 3",
85 " }",
86 "fi",
87 'if [ -n "$T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN" ]; then',
88 ' if printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN" >/dev/null; then',
89 ' printf "%s\\n" "stdin contained forbidden content" >&2',
90 " exit 4",
91 " fi",
92 "fi",
93 'if [ -n "$T3_FAKE_CODEX_STDERR" ]; then',
94 ' printf "%s\\n" "$T3_FAKE_CODEX_STDERR" >&2',
95 "fi",
87 ...(input.requireImage
88 ? [
89 'if [ "$seen_image" != "1" ]; then',
90 ' printf "%s\\n" "missing --image input" >&2',
91 ` exit 2`,
92 "fi",
93 ]
94 : []),
95 ...(input.requireFastServiceTier
96 ? [
97 'if [ "$seen_fast_service_tier" != "1" ]; then',
98 ' printf "%s\\n" "missing fast service tier config" >&2',
99 ` exit 5`,
100 "fi",
101 ]
102 : []),
103 ...(input.requireReasoningEffort !== undefined
104 ? [
105 `if [ "$seen_reasoning_effort" != "model_reasoning_effort=\\"${input.requireReasoningEffort}\\"" ]; then`,
106 ' printf "%s\\n" "unexpected reasoning effort config: $seen_reasoning_effort" >&2',
107 ` exit 6`,
108 "fi",
109 ]
110 : []),
111 ...(input.forbidReasoningEffort
112 ? [
113 'if [ -n "$seen_reasoning_effort" ]; then',
114 ' printf "%s\\n" "reasoning effort config should be omitted: $seen_reasoning_effort" >&2',
115 ` exit 7`,
116 "fi",
117 ]
118 : []),
119 ...(input.stdinMustContain !== undefined
120 ? [
121 `if ! printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustContain)} >/dev/null; then`,
122 ' printf "%s\\n" "stdin missing expected content" >&2',
123 ` exit 3`,
124 "fi",
125 ]
126 : []),
127 ...(input.stdinMustNotContain !== undefined
128 ? [
129 `if printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustNotContain)} >/dev/null; then`,
130 ' printf "%s\\n" "stdin contained forbidden content" >&2',
131 ` exit 4`,
132 "fi",
133 ]
134 : []),
135 ...(input.stderr !== undefined
136 ? [`printf "%s\\n" ${JSON.stringify(input.stderr)} >&2`]
137 : []),
96138 '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__",
98142 "fi",
99 'exit "${T3_FAKE_CODEX_EXIT_CODE:-0}"',
143 `exit ${input.exitCode ?? 0}`,
100144 "",
101145 ].join("\n"),
102146 );
103147 yield* fs.chmod(codexPath, 0o755);
104 return binDir;
148 return codexPath;
105149 });
106150}
107151
......@@ -123,146 +167,29 @@ function withFakeCodexEnv<A, E, R>(
123167 Effect.gen(function* () {
124168 const fs = yield* FileSystem.FileSystem;
125169 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" });
126 const binDir = yield* makeFakeCodexBinary(tempDir);
127 const previousPath = process.env.PATH;
128 const previousOutput = process.env.T3_FAKE_CODEX_OUTPUT_B64;
129 const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE;
130 const previousStderr = process.env.T3_FAKE_CODEX_STDERR;
131 const previousRequireImage = process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;
132 const previousRequireFastServiceTier = process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER;
133 const previousRequireReasoningEffort = process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT;
134 const previousForbidReasoningEffort = process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT;
135 const previousStdinMustContain = process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN;
136 const previousStdinMustNotContain = process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN;
137
138 yield* Effect.sync(() => {
139 process.env.PATH = `${binDir}:${previousPath ?? ""}`;
140 process.env.T3_FAKE_CODEX_OUTPUT_B64 = Buffer.from(input.output, "utf8").toString("base64");
141
142 if (input.exitCode !== undefined) {
143 process.env.T3_FAKE_CODEX_EXIT_CODE = String(input.exitCode);
144 } else {
145 delete process.env.T3_FAKE_CODEX_EXIT_CODE;
146 }
147
148 if (input.stderr !== undefined) {
149 process.env.T3_FAKE_CODEX_STDERR = input.stderr;
150 } else {
151 delete process.env.T3_FAKE_CODEX_STDERR;
152 }
153
154 if (input.requireImage) {
155 process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = "1";
156 } else {
157 delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;
158 }
159
160 if (input.requireFastServiceTier) {
161 process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER = "1";
162 } else {
163 delete process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER;
164 }
165
166 if (input.requireReasoningEffort !== undefined) {
167 process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT = input.requireReasoningEffort;
168 } else {
169 delete process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT;
170 }
171
172 if (input.forbidReasoningEffort) {
173 process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT = "1";
174 } else {
175 delete process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT;
176 }
177
178 if (input.stdinMustContain !== undefined) {
179 process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN = input.stdinMustContain;
180 } else {
181 delete process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN;
182 }
183
184 if (input.stdinMustNotContain !== undefined) {
185 process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN = input.stdinMustNotContain;
186 } else {
187 delete process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN;
188 }
170 const codexPath = yield* makeFakeCodexBinary(tempDir, input);
171 const serverSettings = yield* ServerSettingsService;
172 const previousSettings = yield* serverSettings.getSettings;
173 yield* serverSettings.updateSettings({
174 providers: {
175 codex: {
176 binaryPath: codexPath,
177 },
178 },
189179 });
190
191 return {
192 previousPath,
193 previousOutput,
194 previousExitCode,
195 previousStderr,
196 previousRequireImage,
197 previousRequireFastServiceTier,
198 previousRequireReasoningEffort,
199 previousForbidReasoningEffort,
200 previousStdinMustContain,
201 previousStdinMustNotContain,
202 };
180 return { serverSettings, previousBinaryPath: previousSettings.providers.codex.binaryPath };
203181 }),
204182 () => effect,
205 (previous) =>
206 Effect.sync(() => {
207 process.env.PATH = previous.previousPath;
208
209 if (previous.previousOutput === undefined) {
210 delete process.env.T3_FAKE_CODEX_OUTPUT_B64;
211 } else {
212 process.env.T3_FAKE_CODEX_OUTPUT_B64 = previous.previousOutput;
213 }
214
215 if (previous.previousExitCode === undefined) {
216 delete process.env.T3_FAKE_CODEX_EXIT_CODE;
217 } else {
218 process.env.T3_FAKE_CODEX_EXIT_CODE = previous.previousExitCode;
219 }
220
221 if (previous.previousStderr === undefined) {
222 delete process.env.T3_FAKE_CODEX_STDERR;
223 } else {
224 process.env.T3_FAKE_CODEX_STDERR = previous.previousStderr;
225 }
226
227 if (previous.previousRequireImage === undefined) {
228 delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;
229 } else {
230 process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = previous.previousRequireImage;
231 }
232
233 if (previous.previousRequireFastServiceTier === undefined) {
234 delete process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER;
235 } else {
236 process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER =
237 previous.previousRequireFastServiceTier;
238 }
239
240 if (previous.previousRequireReasoningEffort === undefined) {
241 delete process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT;
242 } else {
243 process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT =
244 previous.previousRequireReasoningEffort;
245 }
246
247 if (previous.previousForbidReasoningEffort === undefined) {
248 delete process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT;
249 } else {
250 process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT =
251 previous.previousForbidReasoningEffort;
252 }
253
254 if (previous.previousStdinMustContain === undefined) {
255 delete process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN;
256 } else {
257 process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN = previous.previousStdinMustContain;
258 }
259
260 if (previous.previousStdinMustNotContain === undefined) {
261 delete process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN;
262 } else {
263 process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN = previous.previousStdinMustNotContain;
264 }
265 }),
183 ({ serverSettings, previousBinaryPath }) =>
184 serverSettings
185 .updateSettings({
186 providers: {
187 codex: {
188 binaryPath: previousBinaryPath,
189 },
190 },
191 })
192 .pipe(Effect.asVoid),
266193 );
267194}
268195
apps/server/src/git/Layers/CodexTextGeneration.ts+13-2
......@@ -4,7 +4,6 @@ import { Effect, FileSystem, Layer, Option, Path, Schema, Scope, Stream } from "
44import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
55
66import { CodexModelSelection } from "@t3tools/contracts";
7import { normalizeCodexModelOptions } from "@t3tools/shared/model";
87import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
98
109import { resolveAttachmentPath } from "../../attachmentStore.ts";
......@@ -26,6 +25,8 @@ import {
2625 sanitizePrTitle,
2726 toJsonSchemaObject,
2827} from "../Utils.ts";
28import { normalizeCodexModelOptions } from "../../provider/Layers/CodexProvider.ts";
29import { ServerSettingsService } from "../../serverSettings.ts";
2930
3031const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low";
3132const CODEX_TIMEOUT_MS = 180_000;
......@@ -35,6 +36,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
3536 const path = yield* Path.Path;
3637 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3738 const serverConfig = yield* Effect.service(ServerConfig);
39 const serverSettingsService = yield* Effect.service(ServerSettingsService);
3840
3941 type MaterializedImageAttachments = {
4042 readonly imagePaths: ReadonlyArray<string>;
......@@ -138,6 +140,11 @@ const makeCodexTextGeneration = Effect.gen(function* () {
138140 );
139141 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
141148 const runCodexCommand = Effect.gen(function* () {
142149 const normalizedOptions = normalizeCodexModelOptions(
143150 modelSelection.model,
......@@ -146,7 +153,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
146153 const reasoningEffort =
147154 modelSelection.options?.reasoningEffort ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT;
148155 const command = ChildProcess.make(
149 "codex",
156 codexSettings?.binaryPath || "codex",
150157 [
151158 "exec",
152159 "--ephemeral",
......@@ -165,6 +172,10 @@ const makeCodexTextGeneration = Effect.gen(function* () {
165172 "-",
166173 ],
167174 {
175 env: {
176 ...process.env,
177 ...(codexSettings?.homePath ? { CODEX_HOME: codexSettings.homePath } : {}),
178 },
168179 cwd,
169180 shell: process.platform === "win32",
170181 stdin: {
apps/server/src/git/Layers/GitManager.test.ts+4-6
......@@ -20,11 +20,7 @@ import { GitCoreLive } from "./GitCore.ts";
2020import { GitCore } from "../Services/GitCore.ts";
2121import { makeGitManager } from "./GitManager.ts";
2222import { ServerConfig } from "../../config.ts";
23
24const DEFAULT_TEST_MODEL_SELECTION = {
25 provider: "codex",
26 model: "gpt-5.4-mini",
27} as const;
23import { ServerSettingsService } from "../../serverSettings.ts";
2824
2925interface FakeGhScenario {
3026 prListSequence?: string[];
......@@ -466,7 +462,6 @@ function runStackedAction(
466462 {
467463 ...input,
468464 actionId: input.actionId ?? "test-action-id",
469 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
470465 },
471466 options,
472467 );
......@@ -493,6 +488,8 @@ function makeManager(input?: {
493488 prefix: "t3-git-manager-test-",
494489 });
495490
491 const serverSettingsLayer = ServerSettingsService.layerTest();
492
496493 const gitCoreLayer = GitCoreLive.pipe(
497494 Layer.provideMerge(NodeServices.layer),
498495 Layer.provideMerge(ServerConfigLayer),
......@@ -502,6 +499,7 @@ function makeManager(input?: {
502499 Layer.succeed(GitHubCli, gitHubCli),
503500 Layer.succeed(TextGeneration, textGeneration),
504501 gitCoreLayer,
502 serverSettingsLayer,
505503 ).pipe(Layer.provideMerge(NodeServices.layer));
506504
507505 return makeGitManager.pipe(
apps/server/src/git/Layers/GitManager.ts+12-3
......@@ -19,6 +19,7 @@ import {
1919import { GitCore } from "../Services/GitCore.ts";
2020import { GitHubCli } from "../Services/GitHubCli.ts";
2121import { TextGeneration } from "../Services/TextGeneration.ts";
22import { ServerSettingsService } from "../../serverSettings.ts";
2223
2324const COMMIT_TIMEOUT_MS = 10 * 60_000;
2425const MAX_PROGRESS_TEXT_LENGTH = 500;
......@@ -358,6 +359,7 @@ export const makeGitManager = Effect.gen(function* () {
358359 const gitCore = yield* GitCore;
359360 const gitHubCli = yield* GitHubCli;
360361 const textGeneration = yield* TextGeneration;
362 const serverSettingsService = yield* ServerSettingsService;
361363
362364 const createProgressEmitter = (
363365 input: { cwd: string; action: "commit" | "commit_push" | "commit_push_pr" },
......@@ -1173,6 +1175,13 @@ export const makeGitManager = Effect.gen(function* () {
11731175 let commitMessageForStep = input.commitMessage;
11741176 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
11761185 if (input.featureBranch) {
11771186 currentPhase = "branch";
11781187 yield* progress.emit({
......@@ -1181,7 +1190,7 @@ export const makeGitManager = Effect.gen(function* () {
11811190 label: "Preparing feature branch...",
11821191 });
11831192 const result = yield* runFeatureBranchStep(
1184 input.modelSelection,
1193 modelSelection,
11851194 input.cwd,
11861195 initialStatus.branch,
11871196 input.commitMessage,
......@@ -1198,7 +1207,7 @@ export const makeGitManager = Effect.gen(function* () {
11981207
11991208 currentPhase = "commit";
12001209 const commit = yield* runCommitStep(
1201 input.modelSelection,
1210 modelSelection,
12021211 input.cwd,
12031212 input.action,
12041213 currentBranch,
......@@ -1237,7 +1246,7 @@ export const makeGitManager = Effect.gen(function* () {
12371246 Effect.flatMap(() =>
12381247 Effect.gen(function* () {
12391248 currentPhase = "pr";
1240 return yield* runPrStep(input.modelSelection, input.cwd, currentBranch);
1249 return yield* runPrStep(modelSelection, input.cwd, currentBranch);
12411250 }),
12421251 ),
12431252 )
apps/server/src/keybindings.ts+23-11
......@@ -23,6 +23,7 @@ import {
2323 Cache,
2424 Cause,
2525 Deferred,
26 Duration,
2627 Effect,
2728 Exit,
2829 FileSystem,
......@@ -42,6 +43,7 @@ import {
4243} from "effect";
4344import * as Semaphore from "effect/Semaphore";
4445import { ServerConfig } from "./config";
46import { fromLenientJson } from "@t3tools/shared/schemaJson";
4547
4648export class KeybindingsConfigError extends Schema.TaggedErrorClass<KeybindingsConfigError>()(
4749 "KeybindingsConfigParseError",
......@@ -408,7 +410,7 @@ function encodeWhenAst(node: KeybindingWhenNode): string {
408410
409411const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS);
410412
411const RawKeybindingsEntries = Schema.fromJsonString(Schema.Array(Schema.Unknown));
413const RawKeybindingsEntries = fromLenientJson(Schema.Array(Schema.Unknown));
412414const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig);
413415const PrettyJsonString = SchemaGetter.parseJson<string>().compose(
414416 SchemaGetter.stringifyJson({ space: 2 }),
......@@ -672,6 +674,7 @@ const makeKeybindings = Effect.gen(function* () {
672674 Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })),
673675 Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)),
674676 Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)),
677 Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))),
675678 Effect.mapError(
676679 (cause) =>
677680 new KeybindingsConfigError({
......@@ -817,16 +820,25 @@ const makeKeybindings = Effect.gen(function* () {
817820
818821 const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true }));
819822
820 yield* Stream.runForEach(fs.watch(keybindingsConfigDir), (event) => {
821 const isTargetConfigEvent =
822 event.path === keybindingsConfigFile ||
823 event.path === keybindingsConfigPath ||
824 path.resolve(keybindingsConfigDir, event.path) === keybindingsConfigPathResolved;
825 if (!isTargetConfigEvent) {
826 return Effect.void;
827 }
828 return revalidateAndEmitSafely;
829 }).pipe(Effect.ignoreCause({ log: true }), Effect.forkIn(watcherScope), Effect.asVoid);
823 // Debounce watch events so the file is fully written before we read it.
824 // Editors emit multiple events per save (truncate, write, rename) and
825 // `fs.watch` can fire before the content has been flushed to disk.
826 const debouncedKeybindingsEvents = fs.watch(keybindingsConfigDir).pipe(
827 Stream.filter((event) => {
828 return (
829 event.path === keybindingsConfigFile ||
830 event.path === keybindingsConfigPath ||
831 path.resolve(keybindingsConfigDir, event.path) === keybindingsConfigPathResolved
832 );
833 }),
834 Stream.debounce(Duration.millis(100)),
835 );
836
837 yield* Stream.runForEach(debouncedKeybindingsEvents, () => revalidateAndEmitSafely).pipe(
838 Effect.ignoreCause({ log: true }),
839 Effect.forkIn(watcherScope),
840 Effect.asVoid,
841 );
830842 });
831843
832844 const start = Effect.gen(function* () {
apps/server/src/main.test.ts+2
......@@ -17,6 +17,7 @@ import { Open, type OpenShape } from "./open";
1717import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
1818import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
1919import { Server, type ServerShape } from "./wsServer";
20import { ServerSettingsService } from "./serverSettings";
2021
2122const start = vi.fn(() => undefined);
2223const stop = vi.fn(() => undefined);
......@@ -52,6 +53,7 @@ const testLayer = Layer.mergeAll(
5253 openBrowser: (_target: string) => Effect.void,
5354 openInEditor: () => Effect.void,
5455 } satisfies OpenShape),
56 ServerSettingsService.layerTest(),
5557 AnalyticsService.layerTest,
5658 FetchHttpClient.layer,
5759 NodeServices.layer,
apps/server/src/main.ts+12-5
......@@ -22,12 +22,13 @@ import { Open } from "./open";
2222import * as SqlitePersistence from "./persistence/Layers/Sqlite";
2323import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
2424import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
25import { ProviderHealthLive } from "./provider/Layers/ProviderHealth";
25import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry";
2626import { Server } from "./wsServer";
2727import { ServerLoggerLive } from "./serverLogger";
2828import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService";
2929import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
3030import { readBootstrapEnvelope } from "./bootstrap";
31import { ServerSettingsLive } from "./serverSettings";
3132
3233export class StartupError extends Data.TaggedError("StartupError")<{
3334 readonly message: string;
......@@ -293,10 +294,11 @@ const LayerLive = (input: CliInput) =>
293294 Layer.empty.pipe(
294295 Layer.provideMerge(makeServerRuntimeServicesLayer()),
295296 Layer.provideMerge(makeServerProviderLayer()),
296 Layer.provideMerge(ProviderHealthLive),
297 Layer.provideMerge(ProviderRegistryLive),
297298 Layer.provideMerge(SqlitePersistence.layerConfig),
298299 Layer.provideMerge(ServerLoggerLive),
299300 Layer.provideMerge(AnalyticsServiceLayerLive),
301 Layer.provideMerge(ServerSettingsLive),
300302 Layer.provideMerge(ServerConfigLive(input)),
301303 );
302304
......@@ -331,12 +333,10 @@ export const recordStartupHeartbeat = Effect.gen(function* () {
331333 });
332334});
333335
334const makeServerProgram = (input: CliInput) =>
336const makeServerRuntimeProgram = (input: CliInput) =>
335337 Effect.gen(function* () {
336 const cliConfig = yield* CliConfig;
337338 const { start, stopSignal } = yield* Server;
338339 const openDeps = yield* Open;
339 yield* cliConfig.fixPath;
340340
341341 const config = yield* ServerConfig;
342342
......@@ -378,6 +378,13 @@ const makeServerProgram = (input: CliInput) =>
378378 return yield* stopSignal;
379379 }).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
381388/**
382389 * These flags mirrors the environment variables and the config shape.
383390 */
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+2
......@@ -34,6 +34,7 @@ import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts";
3434import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
3535import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
3636import * as NodeServices from "@effect/platform-node/NodeServices";
37import { ServerSettingsService } from "../../serverSettings.ts";
3738
3839const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
3940const asApprovalRequestId = (value: string): ApprovalRequestId =>
......@@ -214,6 +215,7 @@ describe("ProviderCommandReactor", () => {
214215 Layer.provideMerge(
215216 Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape),
216217 ),
218 Layer.provideMerge(ServerSettingsService.layerTest()),
217219 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),
218220 Layer.provideMerge(NodeServices.layer),
219221 );
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+41-63
......@@ -1,12 +1,10 @@
11import {
22 type ChatAttachment,
33 CommandId,
4 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
54 EventId,
65 type ModelSelection,
76 type OrchestrationEvent,
87 ProviderKind,
9 type ProviderStartOptions,
108 type OrchestrationSession,
119 ThreadId,
1210 type ProviderSession,
......@@ -26,6 +24,7 @@ import {
2624 ProviderCommandReactor,
2725 type ProviderCommandReactorShape,
2826} from "../Services/ProviderCommandReactor.ts";
27import { ServerSettingsService } from "../../serverSettings.ts";
2928
3029type ProviderIntentEvent = Extract<
3130 OrchestrationEvent,
......@@ -138,6 +137,7 @@ const make = Effect.gen(function* () {
138137 const providerService = yield* ProviderService;
139138 const git = yield* GitCore;
140139 const textGeneration = yield* TextGeneration;
140 const serverSettingsService = yield* ServerSettingsService;
141141 const handledTurnStartKeys = yield* Cache.make<string, true>({
142142 capacity: HANDLED_TURN_START_KEY_MAX,
143143 timeToLive: HANDLED_TURN_START_KEY_TTL,
......@@ -151,7 +151,6 @@ const make = Effect.gen(function* () {
151151 ),
152152 );
153153
154 const threadProviderOptions = new Map<string, ProviderStartOptions>();
155154 const threadModelSelections = new Map<string, ModelSelection>();
156155
157156 const appendProviderFailureActivity = (input: {
......@@ -210,7 +209,6 @@ const make = Effect.gen(function* () {
210209 createdAt: string,
211210 options?: {
212211 readonly modelSelection?: ModelSelection;
213 readonly providerOptions?: ProviderStartOptions;
214212 },
215213 ) {
216214 const readModel = yield* orchestrationEngine.getReadModel();
......@@ -258,9 +256,6 @@ const make = Effect.gen(function* () {
258256 ...(preferredProvider ? { provider: preferredProvider } : {}),
259257 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
260258 modelSelection: desiredModelSelection,
261 ...(options?.providerOptions !== undefined
262 ? { providerOptions: options.providerOptions }
263 : {}),
264259 ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
265260 runtimeMode: desiredRuntimeMode,
266261 });
......@@ -354,7 +349,6 @@ const make = Effect.gen(function* () {
354349 readonly messageText: string;
355350 readonly attachments?: ReadonlyArray<ChatAttachment>;
356351 readonly modelSelection?: ModelSelection;
357 readonly providerOptions?: ProviderStartOptions;
358352 readonly interactionMode?: "default" | "plan";
359353 readonly createdAt: string;
360354 }) {
......@@ -362,13 +356,11 @@ const make = Effect.gen(function* () {
362356 if (!thread) {
363357 return;
364358 }
365 yield* ensureSessionForThread(input.threadId, input.createdAt, {
366 ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}),
367 ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}),
368 });
369 if (input.providerOptions !== undefined) {
370 threadProviderOptions.set(input.threadId, input.providerOptions);
371 }
359 yield* ensureSessionForThread(
360 input.threadId,
361 input.createdAt,
362 input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {},
363 );
372364 if (input.modelSelection !== undefined) {
373365 threadModelSelections.set(input.threadId, input.modelSelection);
374366 }
......@@ -432,48 +424,39 @@ const make = Effect.gen(function* () {
432424 const oldBranch = input.branch;
433425 const cwd = input.worktreePath;
434426 const attachments = input.attachments ?? [];
435 yield* textGeneration
436 .generateBranchName({
427 yield* Effect.gen(function* () {
428 const { textGenerationModelSelection: modelSelection } =
429 yield* serverSettingsService.getSettings;
430
431 const generated = yield* textGeneration.generateBranchName({
437432 cwd,
438433 message: input.messageText,
439434 ...(attachments.length > 0 ? { attachments } : {}),
440 modelSelection: {
441 provider: "codex",
442 model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex,
443 },
444 })
445 .pipe(
446 Effect.catch((error) =>
447 Effect.logWarning(
448 "provider command reactor failed to generate worktree branch name; skipping rename",
449 { threadId: input.threadId, cwd, oldBranch, reason: error.message },
450 ),
451 ),
452 Effect.flatMap((generated) => {
453 if (!generated) return Effect.void;
454
455 const targetBranch = buildGeneratedWorktreeBranchName(generated.branch);
456 if (targetBranch === oldBranch) return Effect.void;
457
458 return Effect.flatMap(
459 git.renameBranch({ cwd, oldBranch, newBranch: targetBranch }),
460 (renamed) =>
461 orchestrationEngine.dispatch({
462 type: "thread.meta.update",
463 commandId: serverCommandId("worktree-branch-rename"),
464 threadId: input.threadId,
465 branch: renamed.branch,
466 worktreePath: cwd,
467 }),
468 );
435 modelSelection,
436 });
437 if (!generated) return;
438
439 const targetBranch = buildGeneratedWorktreeBranchName(generated.branch);
440 if (targetBranch === oldBranch) return;
441
442 const renamed = yield* git.renameBranch({ cwd, oldBranch, newBranch: targetBranch });
443 yield* orchestrationEngine.dispatch({
444 type: "thread.meta.update",
445 commandId: serverCommandId("worktree-branch-rename"),
446 threadId: input.threadId,
447 branch: renamed.branch,
448 worktreePath: cwd,
449 });
450 }).pipe(
451 Effect.catchCause((cause) =>
452 Effect.logWarning("provider command reactor failed to generate or rename worktree branch", {
453 threadId: input.threadId,
454 cwd,
455 oldBranch,
456 cause: Cause.pretty(cause),
469457 }),
470 Effect.catchCause((cause) =>
471 Effect.logWarning(
472 "provider command reactor failed to generate or rename worktree branch",
473 { threadId: input.threadId, cwd, oldBranch, cause: Cause.pretty(cause) },
474 ),
475 ),
476 );
458 ),
459 );
477460 });
478461
479462 const processTurnStartRequested = Effect.fnUntraced(function* (
......@@ -518,9 +501,6 @@ const make = Effect.gen(function* () {
518501 ...(event.payload.modelSelection !== undefined
519502 ? { modelSelection: event.payload.modelSelection }
520503 : {}),
521 ...(event.payload.providerOptions !== undefined
522 ? { providerOptions: event.payload.providerOptions }
523 : {}),
524504 interactionMode: event.payload.interactionMode,
525505 createdAt: event.payload.createdAt,
526506 }).pipe(
......@@ -686,14 +666,12 @@ const make = Effect.gen(function* () {
686666 if (!thread?.session || thread.session.status === "stopped") {
687667 return;
688668 }
689 const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId);
690669 const cachedModelSelection = threadModelSelections.get(event.payload.threadId);
691 yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, {
692 ...(cachedProviderOptions !== undefined
693 ? { providerOptions: cachedProviderOptions }
694 : {}),
695 ...(cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {}),
696 });
670 yield* ensureSessionForThread(
671 event.payload.threadId,
672 event.occurredAt,
673 cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {},
674 );
697675 return;
698676 }
699677 case "thread.turn-start-requested":
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+9-3
......@@ -16,6 +16,7 @@ import {
1616 MessageId,
1717 ProjectId,
1818 ProviderItemId,
19 type ServerSettings,
1920 ThreadId,
2021 TurnId,
2122} from "@t3tools/contracts";
......@@ -38,8 +39,13 @@ import {
3839} from "../Services/OrchestrationEngine.ts";
3940import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
4041import { ServerConfig } from "../../config.ts";
42import { ServerSettingsService } from "../../serverSettings.ts";
4143import * as NodeServices from "@effect/platform-node/NodeServices";
4244
45function makeTestServerSettingsLayer(overrides: Partial<ServerSettings> = {}) {
46 return ServerSettingsService.layerTest(overrides);
47}
48
4349const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
4450const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
4551const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
......@@ -155,7 +161,7 @@ describe("ProviderRuntimeIngestion", () => {
155161 }
156162 });
157163
158 async function createHarness() {
164 async function createHarness(options?: { serverSettings?: Partial<ServerSettings> }) {
159165 const workspaceRoot = makeTempDir("t3-provider-project-");
160166 fs.mkdirSync(path.join(workspaceRoot, ".git"));
161167 const provider = createProviderServiceHarness();
......@@ -169,6 +175,7 @@ describe("ProviderRuntimeIngestion", () => {
169175 Layer.provideMerge(orchestrationLayer),
170176 Layer.provideMerge(SqlitePersistenceMemory),
171177 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
178 Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)),
172179 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
173180 Layer.provideMerge(NodeServices.layer),
174181 );
......@@ -1357,7 +1364,7 @@ describe("ProviderRuntimeIngestion", () => {
13571364 });
13581365
13591366 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 } });
13611368 const now = new Date().toISOString();
13621369
13631370 await Effect.runPromise(
......@@ -1371,7 +1378,6 @@ describe("ProviderRuntimeIngestion", () => {
13711378 text: "stream please",
13721379 attachments: [],
13731380 },
1374 assistantDeliveryMode: "streaming",
13751381 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
13761382 runtimeMode: "approval-required",
13771383 createdAt: now,
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+8-12
......@@ -13,7 +13,7 @@ import {
1313 type OrchestrationThreadActivity,
1414 type ProviderRuntimeEvent,
1515} from "@t3tools/contracts";
16import { Cache, Cause, Duration, Effect, Layer, Option, Ref, Stream } from "effect";
16import { Cache, Cause, Duration, Effect, Layer, Option, Stream } from "effect";
1717import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
1818
1919import { ProviderService } from "../../provider/Services/ProviderService.ts";
......@@ -26,12 +26,12 @@ import {
2626 ProviderRuntimeIngestionService,
2727 type ProviderRuntimeIngestionShape,
2828} from "../Services/ProviderRuntimeIngestion.ts";
29import { ServerSettingsService } from "../../serverSettings.ts";
2930
3031const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`;
3132const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId =>
3233 CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`);
3334
34const DEFAULT_ASSISTANT_DELIVERY_MODE: AssistantDeliveryMode = "buffered";
3535const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000;
3636const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120);
3737const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000;
......@@ -537,10 +537,7 @@ const make = Effect.gen(function* () {
537537 const orchestrationEngine = yield* OrchestrationEngineService;
538538 const providerService = yield* ProviderService;
539539 const projectionTurnRepository = yield* ProjectionTurnRepository;
540
541 const assistantDeliveryModeRef = yield* Ref.make<AssistantDeliveryMode>(
542 DEFAULT_ASSISTANT_DELIVERY_MODE,
543 );
540 const serverSettingsService = yield* ServerSettingsService;
544541
545542 const turnMessageIdsByTurnKey = yield* Cache.make<string, Set<MessageId>>({
546543 capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY,
......@@ -1048,7 +1045,10 @@ const make = Effect.gen(function* () {
10481045 yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId);
10491046 }
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 );
10521052 if (assistantDeliveryMode === "buffered") {
10531053 const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta);
10541054 if (spillChunk.length > 0) {
......@@ -1256,11 +1256,7 @@ const make = Effect.gen(function* () {
12561256 ).pipe(Effect.asVoid);
12571257 });
12581258
1259 const processDomainEvent = (event: TurnStartRequestedDomainEvent) =>
1260 Ref.set(
1261 assistantDeliveryModeRef,
1262 event.payload.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE,
1263 );
1259 const processDomainEvent = (_event: TurnStartRequestedDomainEvent) => Effect.void;
12641260
12651261 const processInput = (input: RuntimeIngestionInput) =>
12661262 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", () => {
188188 if (turnStartEvent?.type !== "thread.turn-start-requested") {
189189 return;
190190 }
191 expect(turnStartEvent.payload.assistantDeliveryMode).toBe("buffered");
192191 expect(turnStartEvent.payload).toMatchObject({
193192 threadId: ThreadId.makeUnsafe("thread-1"),
194193 messageId: asMessageId("message-user-1"),
apps/server/src/orchestration/decider.ts-5
......@@ -14,7 +14,6 @@ import {
1414} from "./commandInvariants.ts";
1515
1616const nowIso = () => new Date().toISOString();
17const DEFAULT_ASSISTANT_DELIVERY_MODE = "buffered" as const;
1817
1918const defaultMetadata: Omit<OrchestrationEvent, "sequence" | "type" | "payload"> = {
2019 eventId: crypto.randomUUID() as OrchestrationEvent["eventId"],
......@@ -330,10 +329,6 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
330329 ...(command.modelSelection !== undefined
331330 ? { modelSelection: command.modelSelection }
332331 : {}),
333 ...(command.providerOptions !== undefined
334 ? { providerOptions: command.providerOptions }
335 : {}),
336 assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE,
337332 runtimeMode: targetThread.runtimeMode,
338333 interactionMode: targetThread.interactionMode,
339334 ...(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";
2121
2222import { attachmentRelativePath } from "../../attachmentStore.ts";
2323import { ServerConfig } from "../../config.ts";
24import { ServerSettingsService } from "../../serverSettings.ts";
2425import { ProviderAdapterValidationError } from "../Errors.ts";
2526import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";
2627import { makeClaudeAdapterLive, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts";
......@@ -168,6 +169,7 @@ function makeHarness(config?: {
168169 config?.baseDir ?? "/tmp",
169170 ),
170171 ),
172 Layer.provideMerge(ServerSettingsService.layerTest()),
171173 Layer.provideMerge(NodeServices.layer),
172174 ),
173175 query,
......@@ -301,7 +303,7 @@ describe("ClaudeAdapterLive", () => {
301303 );
302304 });
303305
304 it.effect("keeps explicit claude permission mode over runtime-derived defaults", () => {
306 it.effect("uses bypass permissions for full-access claude sessions", () => {
305307 const harness = makeHarness();
306308 return Effect.gen(function* () {
307309 const adapter = yield* ClaudeAdapter;
......@@ -309,16 +311,11 @@ describe("ClaudeAdapterLive", () => {
309311 threadId: THREAD_ID,
310312 provider: "claudeAgent",
311313 runtimeMode: "full-access",
312 providerOptions: {
313 claudeAgent: {
314 permissionMode: "plan",
315 },
316 },
317314 });
318315
319316 const createInput = harness.getLastCreateQueryInput();
320 assert.equal(createInput?.options.permissionMode, "plan");
321 assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined);
317 assert.equal(createInput?.options.permissionMode, "bypassPermissions");
318 assert.equal(createInput?.options.allowDangerouslySkipPermissions, true);
322319 }).pipe(
323320 Effect.provideService(Random.Random, makeDeterministicRandomService()),
324321 Effect.provide(harness.layer),
......@@ -1195,6 +1192,7 @@ describe("ClaudeAdapterLive", () => {
11951192 },
11961193 }).pipe(
11971194 Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")),
1195 Layer.provideMerge(ServerSettingsService.layerTest()),
11981196 Layer.provideMerge(NodeServices.layer),
11991197 );
12001198
apps/server/src/provider/Layers/ClaudeAdapter.ts+21-31
......@@ -40,12 +40,7 @@ import {
4040 type UserInputQuestion,
4141 ClaudeCodeEffort,
4242} from "@t3tools/contracts";
43import {
44 hasEffortLevel,
45 applyClaudePromptEffortPrefix,
46 getModelCapabilities,
47 trimOrNull,
48} from "@t3tools/shared/model";
43import { hasEffortLevel, applyClaudePromptEffortPrefix, trimOrNull } from "@t3tools/shared/model";
4944import {
5045 Cause,
5146 DateTime,
......@@ -63,6 +58,8 @@ import {
6358
6459import { resolveAttachmentPath } from "../../attachmentStore.ts";
6560import { ServerConfig } from "../../config.ts";
61import { ServerSettingsService } from "../../serverSettings.ts";
62import { getClaudeModelCapabilities } from "./ClaudeProvider.ts";
6663import {
6764 ProviderAdapterProcessError,
6865 ProviderAdapterRequestError,
......@@ -353,19 +350,6 @@ function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId {
353350 return RuntimeRequestId.makeUnsafe(value);
354351}
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
369353function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undefined {
370354 if (!resumeCursor || typeof resumeCursor !== "object") {
371355 return undefined;
......@@ -525,7 +509,7 @@ function buildPromptText(input: ProviderSendTurnInput): string {
525509 const requestedEffort = trimOrNull(rawEffort);
526510 const claudeModel =
527511 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined;
528 const caps = getModelCapabilities("claudeAgent", claudeModel);
512 const caps = getClaudeModelCapabilities(claudeModel);
529513 const promptEffort =
530514 requestedEffort === "ultrathink" && caps.reasoningEffortLevels.length > 0
531515 ? "ultrathink"
......@@ -943,6 +927,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
943927
944928 const sessions = new Map<ThreadId, ClaudeSessionContext>();
945929 const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();
930 const serverSettingsService = yield* ServerSettingsService;
946931
947932 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
948933 const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id));
......@@ -2727,11 +2712,23 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
27272712 }),
27282713 );
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;
27312728 const modelSelection =
27322729 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined;
27332730 const requestedEffort = trimOrNull(modelSelection?.options?.effort ?? null);
2734 const caps = getModelCapabilities("claudeAgent", modelSelection?.model);
2731 const caps = getClaudeModelCapabilities(modelSelection?.model);
27352732 const effort =
27362733 requestedEffort && hasEffortLevel(caps, requestedEffort) ? requestedEffort : null;
27372734 const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode;
......@@ -2741,8 +2738,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
27412738 : undefined;
27422739 const effectiveEffort = getEffectiveClaudeCodeEffort(effort);
27432740 const permissionMode =
2744 toPermissionMode(providerOptions?.permissionMode) ??
2745 (input.runtimeMode === "full-access" ? "bypassPermissions" : undefined);
2741 input.runtimeMode === "full-access" ? "bypassPermissions" : undefined;
27462742 const settings = {
27472743 ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),
27482744 ...(fastMode ? { fastMode: true } : {}),
......@@ -2751,16 +2747,13 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
27512747 const queryOptions: ClaudeQueryOptions = {
27522748 ...(input.cwd ? { cwd: input.cwd } : {}),
27532749 ...(modelSelection?.model ? { model: modelSelection.model } : {}),
2754 pathToClaudeCodeExecutable: providerOptions?.binaryPath ?? "claude",
2750 pathToClaudeCodeExecutable: claudeBinaryPath,
27552751 settingSources: [...CLAUDE_SETTING_SOURCES],
27562752 ...(effectiveEffort ? { effort: effectiveEffort } : {}),
27572753 ...(permissionMode ? { permissionMode } : {}),
27582754 ...(permissionMode === "bypassPermissions"
27592755 ? { allowDangerouslySkipPermissions: true }
27602756 : {}),
2761 ...(providerOptions?.maxThinkingTokens !== undefined
2762 ? { maxThinkingTokens: providerOptions.maxThinkingTokens }
2763 : {}),
27642757 ...(Object.keys(settings).length > 0 ? { settings } : {}),
27652758 ...(existingResumeSessionId ? { resume: existingResumeSessionId } : {}),
27662759 ...(newSessionId ? { sessionId: newSessionId } : {}),
......@@ -2851,9 +2844,6 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
28512844 ...(input.cwd ? { cwd: input.cwd } : {}),
28522845 ...(effectiveEffort ? { effort: effectiveEffort } : {}),
28532846 ...(permissionMode ? { permissionMode } : {}),
2854 ...(providerOptions?.maxThinkingTokens !== undefined
2855 ? { maxThinkingTokens: providerOptions.maxThinkingTokens }
2856 : {}),
28572847 ...(fastMode ? { fastMode: true } : {}),
28582848 },
28592849 },
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 {
2222 type CodexAppServerSendTurnInput,
2323} from "../../codexAppServerManager.ts";
2424import { ServerConfig } from "../../config.ts";
25import { ServerSettingsService } from "../../serverSettings.ts";
2526import { ProviderAdapterValidationError } from "../Errors.ts";
2627import { CodexAdapter } from "../Services/CodexAdapter.ts";
2728import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
......@@ -151,6 +152,7 @@ const validationManager = new FakeCodexManager();
151152const validationLayer = it.layer(
152153 makeCodexAdapterLive({ manager: validationManager }).pipe(
153154 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
155 Layer.provideMerge(ServerSettingsService.layerTest()),
154156 Layer.provideMerge(providerSessionDirectoryTestLayer),
155157 Layer.provideMerge(NodeServices.layer),
156158 ),
......@@ -201,6 +203,7 @@ validationLayer("CodexAdapterLive validation", (it) => {
201203 assert.deepStrictEqual(validationManager.startSessionImpl.mock.calls[0]?.[0], {
202204 provider: "codex",
203205 threadId: asThreadId("thread-1"),
206 binaryPath: "codex",
204207 model: "gpt-5.3-codex",
205208 serviceTier: "fast",
206209 runtimeMode: "full-access",
......@@ -216,6 +219,7 @@ sessionErrorManager.sendTurnImpl.mockImplementation(async () => {
216219const sessionErrorLayer = it.layer(
217220 makeCodexAdapterLive({ manager: sessionErrorManager }).pipe(
218221 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
222 Layer.provideMerge(ServerSettingsService.layerTest()),
219223 Layer.provideMerge(providerSessionDirectoryTestLayer),
220224 Layer.provideMerge(NodeServices.layer),
221225 ),
......@@ -284,6 +288,7 @@ const lifecycleManager = new FakeCodexManager();
284288const lifecycleLayer = it.layer(
285289 makeCodexAdapterLive({ manager: lifecycleManager }).pipe(
286290 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
291 Layer.provideMerge(ServerSettingsService.layerTest()),
287292 Layer.provideMerge(providerSessionDirectoryTestLayer),
288293 Layer.provideMerge(NodeServices.layer),
289294 ),
apps/server/src/provider/Layers/CodexAdapter.ts+27-12
......@@ -38,6 +38,7 @@ import {
3838} from "../../codexAppServerManager.ts";
3939import { resolveAttachmentPath } from "../../attachmentStore.ts";
4040import { ServerConfig } from "../../config.ts";
41import { ServerSettingsService } from "../../serverSettings.ts";
4142import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
4243
4344const PROVIDER = "codex" as const;
......@@ -1342,25 +1343,39 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
13421343 }
13431344 }),
13441345 );
1346 const serverSettingsService = yield* ServerSettingsService;
13451347
1346 const startSession: CodexAdapterShape["startSession"] = (input) => {
1348 const startSession: CodexAdapterShape["startSession"] = Effect.fn(function* (input) {
13471349 if (input.provider !== undefined && input.provider !== PROVIDER) {
1348 return Effect.fail(
1349 new ProviderAdapterValidationError({
1350 provider: PROVIDER,
1351 operation: "startSession",
1352 issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`,
1353 }),
1354 );
1350 return yield* new ProviderAdapterValidationError({
1351 provider: PROVIDER,
1352 operation: "startSession",
1353 issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`,
1354 });
13551355 }
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;
13571371 const managerInput: CodexAppServerStartSessionInput = {
13581372 threadId: input.threadId,
13591373 provider: "codex",
13601374 ...(input.cwd !== undefined ? { cwd: input.cwd } : {}),
13611375 ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
1362 ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}),
13631376 runtimeMode: input.runtimeMode,
1377 binaryPath,
1378 ...(homePath ? { homePath } : {}),
13641379 ...(input.modelSelection?.provider === "codex"
13651380 ? { model: input.modelSelection.model }
13661381 : {}),
......@@ -1369,7 +1384,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
13691384 : {}),
13701385 };
13711386
1372 return Effect.tryPromise({
1387 return yield* Effect.tryPromise({
13731388 try: () => manager.startSession(managerInput),
13741389 catch: (cause) =>
13751390 new ProviderAdapterProcessError({
......@@ -1378,8 +1393,8 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
13781393 detail: toMessage(cause, "Failed to start Codex adapter session."),
13791394 cause,
13801395 }),
1381 }).pipe(Effect.map((session) => session));
1382 };
1396 });
1397 });
13831398
13841399 const sendTurn: CodexAdapterShape["sendTurn"] = (input) =>
13851400 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 {
4242 makeSqlitePersistenceLive,
4343 SqlitePersistenceMemory,
4444} from "../../persistence/Layers/Sqlite.ts";
45import { ServerSettingsService } from "../../serverSettings.ts";
4546import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts";
4647
48const defaultServerSettingsLayer = ServerSettingsService.layerTest();
49
4750const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value);
4851const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
4952const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
......@@ -251,6 +254,7 @@ function makeProviderServiceLayer() {
251254 makeProviderServiceLive().pipe(
252255 Layer.provide(providerAdapterLayer),
253256 Layer.provide(directoryLayer),
257 Layer.provide(defaultServerSettingsLayer),
254258 Layer.provideMerge(AnalyticsService.layerTest),
255259 ),
256260 directoryLayer,
......@@ -267,6 +271,55 @@ function makeProviderServiceLayer() {
267271 };
268272}
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
270323const routing = makeProviderServiceLayer();
271324it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", () =>
272325 Effect.gen(function* () {
......@@ -299,6 +352,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (
299352 const providerLayer = makeProviderServiceLive().pipe(
300353 Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)),
301354 Layer.provide(directoryLayer),
355 Layer.provide(defaultServerSettingsLayer),
302356 Layer.provide(AnalyticsService.layerTest),
303357 );
304358
......@@ -358,6 +412,7 @@ it.effect(
358412 const firstProviderLayer = makeProviderServiceLive().pipe(
359413 Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)),
360414 Layer.provide(firstDirectoryLayer),
415 Layer.provide(defaultServerSettingsLayer),
361416 Layer.provide(AnalyticsService.layerTest),
362417 );
363418 const updatedResumeCursor = {
......@@ -409,6 +464,7 @@ it.effect(
409464 const secondProviderLayer = makeProviderServiceLive().pipe(
410465 Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)),
411466 Layer.provide(secondDirectoryLayer),
467 Layer.provide(defaultServerSettingsLayer),
412468 Layer.provide(AnalyticsService.layerTest),
413469 );
414470
......@@ -769,6 +825,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
769825 const firstProviderLayer = makeProviderServiceLive().pipe(
770826 Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)),
771827 Layer.provide(firstDirectoryLayer),
828 Layer.provide(defaultServerSettingsLayer),
772829 Layer.provide(AnalyticsService.layerTest),
773830 );
774831
......@@ -801,6 +858,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
801858 const secondProviderLayer = makeProviderServiceLive().pipe(
802859 Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)),
803860 Layer.provide(secondDirectoryLayer),
861 Layer.provide(defaultServerSettingsLayer),
804862 Layer.provide(AnalyticsService.layerTest),
805863 );
806864
apps/server/src/provider/Layers/ProviderService.ts+17-17
......@@ -33,6 +33,7 @@ import {
3333} from "../Services/ProviderSessionDirectory.ts";
3434import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
3535import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts";
36import { ServerSettingsService } from "../../serverSettings.ts";
3637
3738export interface ProviderServiceLiveOptions {
3839 readonly canonicalEventLogPath?: string;
......@@ -91,7 +92,6 @@ function toRuntimePayloadFromSession(
9192 session: ProviderSession,
9293 extra?: {
9394 readonly modelSelection?: unknown;
94 readonly providerOptions?: unknown;
9595 readonly lastRuntimeEvent?: string;
9696 readonly lastRuntimeEventAt?: string;
9797 },
......@@ -102,7 +102,6 @@ function toRuntimePayloadFromSession(
102102 activeTurnId: session.activeTurnId ?? null,
103103 lastError: session.lastError ?? null,
104104 ...(extra?.modelSelection !== undefined ? { modelSelection: extra.modelSelection } : {}),
105 ...(extra?.providerOptions !== undefined ? { providerOptions: extra.providerOptions } : {}),
106105 ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}),
107106 ...(extra?.lastRuntimeEventAt !== undefined
108107 ? { lastRuntimeEventAt: extra.lastRuntimeEventAt }
......@@ -120,17 +119,6 @@ function readPersistedModelSelection(
120119 return Schema.is(ModelSelection)(raw) ? raw : undefined;
121120}
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
134122function readPersistedCwd(
135123 runtimePayload: ProviderRuntimeBinding["runtimePayload"],
136124): string | undefined {
......@@ -146,6 +134,7 @@ function readPersistedCwd(
146134const makeProviderService = (options?: ProviderServiceLiveOptions) =>
147135 Effect.gen(function* () {
148136 const analytics = yield* Effect.service(AnalyticsService);
137 const serverSettings = yield* ServerSettingsService;
149138 const canonicalEventLogger =
150139 options?.canonicalEventLogger ??
151140 (options?.canonicalEventLogPath !== undefined
......@@ -173,7 +162,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
173162 threadId: ThreadId,
174163 extra?: {
175164 readonly modelSelection?: unknown;
176 readonly providerOptions?: unknown;
177165 readonly lastRuntimeEvent?: string;
178166 readonly lastRuntimeEventAt?: string;
179167 },
......@@ -239,14 +227,12 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
239227
240228 const persistedCwd = readPersistedCwd(input.binding.runtimePayload);
241229 const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload);
242 const persistedProviderOptions = readPersistedProviderOptions(input.binding.runtimePayload);
243230
244231 const resumed = yield* adapter.startSession({
245232 threadId: input.binding.threadId,
246233 provider: input.binding.provider,
247234 ...(persistedCwd ? { cwd: persistedCwd } : {}),
248235 ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}),
249 ...(persistedProviderOptions ? { providerOptions: persistedProviderOptions } : {}),
250236 ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}),
251237 runtimeMode: input.binding.runtimeMode ?? "full-access",
252238 });
......@@ -308,6 +294,21 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
308294 threadId,
309295 provider: parsed.provider ?? "codex",
310296 };
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 }
311312 const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId));
312313 const effectiveResumeCursor =
313314 input.resumeCursor ??
......@@ -329,7 +330,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
329330
330331 yield* upsertSessionBinding(session, threadId, {
331332 modelSelection: input.modelSelection,
332 providerOptions: input.providerOptions,
333333 });
334334 yield* analytics.record("provider.session.started", {
335335 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";
2424import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
2525import { ProviderService } from "./provider/Services/ProviderService";
2626import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";
27import { ServerSettingsService } from "./serverSettings";
2728
2829import { TerminalManagerLive } from "./terminal/Layers/Manager";
2930import { KeybindingsLive } from "./keybindings";
......@@ -54,7 +55,11 @@ const makeRuntimePtyAdapterLayer = () =>
5455export function makeServerProviderLayer(): Layer.Layer<
5556 ProviderService,
5657 ProviderUnsupportedError,
57 SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem | AnalyticsService
58 | SqlClient.SqlClient
59 | ServerConfig
60 | ServerSettingsService
61 | FileSystem.FileSystem
62 | AnalyticsService
5863> {
5964 return Effect.gen(function* () {
6065 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
1313
1414import {
1515 DEFAULT_TERMINAL_ID,
16 DEFAULT_SERVER_SETTINGS,
1617 EDITORS,
1718 EventId,
1819 ORCHESTRATION_WS_CHANNELS,
1920 ORCHESTRATION_WS_METHODS,
2021 ProviderItemId,
22 type ServerSettings,
2123 ThreadId,
2224 TurnId,
2325 WS_CHANNELS,
2426 WS_METHODS,
2527 type WebSocketResponse,
2628 type ProviderRuntimeEvent,
27 type ServerProviderStatus,
29 type ServerProvider,
2830 type KeybindingsConfig,
2931 type ResolvedKeybindingsConfig,
3032 type WsPushChannel,
......@@ -45,7 +47,7 @@ import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/
4547import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";
4648import { SqlClient, SqlError } from "effect/unstable/sql";
4749import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
48import { ProviderHealth, type ProviderHealthShape } from "./provider/Services/ProviderHealth";
50import { ProviderRegistry, type ProviderRegistryShape } from "./provider/Services/ProviderRegistry";
4951import { Open, type OpenShape } from "./open";
5052import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
5153import type { GitCoreShape } from "./git/Services/GitCore.ts";
......@@ -53,6 +55,7 @@ import { GitCore } from "./git/Services/GitCore.ts";
5355import { GitCommandError, GitManagerError } from "./git/Errors.ts";
5456import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator";
5557import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";
58import { ServerSettingsService } from "./serverSettings.ts";
5659
5760const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
5861const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
......@@ -64,20 +67,27 @@ const defaultOpenService: OpenShape = {
6467 openInEditor: () => Effect.void,
6568};
6669
67const defaultProviderStatuses: ReadonlyArray<ServerProviderStatus> = [
70const defaultProviderStatuses: ReadonlyArray<ServerProvider> = [
6871 {
6972 provider: "codex",
73 enabled: true,
74 installed: true,
75 version: "0.116.0",
7076 status: "ready",
71 available: true,
7277 authStatus: "authenticated",
7378 checkedAt: "2026-01-01T00:00:00.000Z",
79 models: [],
7480 },
7581];
7682
77const defaultProviderHealthService: ProviderHealthShape = {
78 getStatuses: Effect.succeed(defaultProviderStatuses),
83const defaultProviderRegistryService: ProviderRegistryShape = {
84 getProviders: Effect.succeed(defaultProviderStatuses),
85 refresh: () => Effect.succeed(defaultProviderStatuses),
86 streamChanges: Stream.empty,
7987};
8088
89const defaultServerSettings = DEFAULT_SERVER_SETTINGS;
90
8191class MockTerminalManager implements TerminalManagerShape {
8292 private readonly sessions = new Map<string, TerminalSessionSnapshot>();
8393 private readonly listeners = new Set<(event: TerminalEvent) => void>();
......@@ -487,11 +497,12 @@ describe("WebSocket Server", () => {
487497 baseDir?: string;
488498 staticDir?: string;
489499 providerLayer?: Layer.Layer<ProviderService, never>;
490 providerHealth?: ProviderHealthShape;
500 providerRegistry?: ProviderRegistryShape;
491501 open?: OpenShape;
492502 gitManager?: GitManagerShape;
493503 gitCore?: Pick<GitCoreShape, "listBranches" | "initRepo" | "pullCurrentBranch">;
494504 terminalManager?: TerminalManagerShape;
505 serverSettings?: Partial<ServerSettings>;
495506 } = {},
496507 ): Promise<Http.Server> {
497508 if (serverScope) {
......@@ -504,9 +515,9 @@ describe("WebSocket Server", () => {
504515 const scope = await Effect.runPromise(Scope.make("sequential"));
505516 const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory;
506517 const providerLayer = options.providerLayer ?? makeServerProviderLayer();
507 const providerHealthLayer = Layer.succeed(
508 ProviderHealth,
509 options.providerHealth ?? defaultProviderHealthService,
518 const providerRegistryLayer = Layer.succeed(
519 ProviderRegistry,
520 options.providerRegistry ?? defaultProviderRegistryService,
510521 );
511522 const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService);
512523 const serverConfigLayer = Layer.succeed(ServerConfig, {
......@@ -543,8 +554,9 @@ describe("WebSocket Server", () => {
543554 );
544555 const dependenciesLayer = Layer.empty.pipe(
545556 Layer.provideMerge(runtimeLayer),
546 Layer.provideMerge(providerHealthLayer),
557 Layer.provideMerge(providerRegistryLayer),
547558 Layer.provideMerge(openLayer),
559 Layer.provideMerge(ServerSettingsService.layerTest(options.serverSettings)),
548560 Layer.provideMerge(serverConfigLayer),
549561 Layer.provideMerge(AnalyticsService.layerTest),
550562 Layer.provideMerge(NodeServices.layer),
......@@ -858,6 +870,7 @@ describe("WebSocket Server", () => {
858870 issues: [],
859871 providers: defaultProviderStatuses,
860872 availableEditors: expect.any(Array),
873 settings: defaultServerSettings,
861874 });
862875 expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
863876 });
......@@ -883,6 +896,7 @@ describe("WebSocket Server", () => {
883896 issues: [],
884897 providers: defaultProviderStatuses,
885898 availableEditors: expect.any(Array),
899 settings: defaultServerSettings,
886900 });
887901 expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
888902
......@@ -919,6 +933,7 @@ describe("WebSocket Server", () => {
919933 ],
920934 providers: defaultProviderStatuses,
921935 availableEditors: expect.any(Array),
936 settings: defaultServerSettings,
922937 });
923938 expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
924939 expect(fs.readFileSync(keybindingsPath, "utf8")).toBe("{ not-json");
......@@ -952,7 +967,7 @@ describe("WebSocket Server", () => {
952967 keybindingsConfigPath: string;
953968 keybindings: ResolvedKeybindingsConfig;
954969 issues: Array<{ kind: string; index?: number; message: string }>;
955 providers: ReadonlyArray<ServerProviderStatus>;
970 providers: ReadonlyArray<ServerProvider>;
956971 availableEditors: unknown;
957972 };
958973 expect(result.cwd).toBe("/my/workspace");
......@@ -1000,7 +1015,6 @@ describe("WebSocket Server", () => {
10001015 );
10011016 expect(malformedPush.data).toEqual({
10021017 issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }],
1003 providers: defaultProviderStatuses,
10041018 });
10051019
10061020 const successPush = await rewriteKeybindingsAndWaitForPush(
......@@ -1009,7 +1023,7 @@ describe("WebSocket Server", () => {
10091023 "[]",
10101024 (push) => Array.isArray(push.data.issues) && push.data.issues.length === 0,
10111025 );
1012 expect(successPush.data).toEqual({ issues: [], providers: defaultProviderStatuses });
1026 expect(successPush.data).toEqual({ issues: [] });
10131027 });
10141028
10151029 it("routes shell.openInEditor through the injected open service", async () => {
......@@ -1069,6 +1083,7 @@ describe("WebSocket Server", () => {
10691083 issues: [],
10701084 providers: defaultProviderStatuses,
10711085 availableEditors: expect.any(Array),
1086 settings: defaultServerSettings,
10721087 });
10731088 expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
10741089 });
......@@ -1117,6 +1132,7 @@ describe("WebSocket Server", () => {
11171132 issues: [],
11181133 providers: defaultProviderStatuses,
11191134 availableEditors: expect.any(Array),
1135 settings: defaultServerSettings,
11201136 });
11211137 expectAvailableEditors(
11221138 (configResponse.result as { availableEditors: unknown }).availableEditors,
......@@ -1273,6 +1289,7 @@ describe("WebSocket Server", () => {
12731289 server = await createTestServer({
12741290 cwd: "/test",
12751291 providerLayer,
1292 serverSettings: { enableAssistantStreaming: true },
12761293 });
12771294 const addr = server.address();
12781295 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
......@@ -1323,7 +1340,6 @@ describe("WebSocket Server", () => {
13231340 text: "hello",
13241341 attachments: [],
13251342 },
1326 assistantDeliveryMode: "streaming",
13271343 runtimeMode: "approval-required",
13281344 interactionMode: "default",
13291345 createdAt,
......@@ -1850,10 +1866,6 @@ describe("WebSocket Server", () => {
18501866 actionId: "client-action-1",
18511867 cwd: "/test",
18521868 action: "commit_push",
1853 modelSelection: {
1854 provider: "codex",
1855 model: "gpt-5.4-mini",
1856 },
18571869 },
18581870 expect.objectContaining({
18591871 actionId: "client-action-1",
apps/server/src/wsServer.ts+49-7
......@@ -49,12 +49,13 @@ import { createLogger } from "./logger";
4949import { GitManager } from "./git/Services/GitManager.ts";
5050import { TerminalManager } from "./terminal/Services/Manager.ts";
5151import { Keybindings } from "./keybindings";
52import { ServerSettingsService } from "./serverSettings";
5253import { searchWorkspaceEntries } from "./workspaceEntries";
5354import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
5455import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
5556import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
5657import { ProviderService } from "./provider/Services/ProviderService";
57import { ProviderHealth } from "./provider/Services/ProviderHealth";
58import { ProviderRegistry } from "./provider/Services/ProviderRegistry";
5859import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";
5960import { clamp } from "effect/Number";
6061import { Open, resolveAvailableEditors } from "./open";
......@@ -208,7 +209,7 @@ export type ServerCoreRuntimeServices =
208209 | CheckpointDiffQuery
209210 | OrchestrationReactor
210211 | ProviderService
211 | ProviderHealth;
212 | ProviderRegistry;
212213
213214export type ServerRuntimeServices =
214215 | ServerCoreRuntimeServices
......@@ -216,6 +217,7 @@ export type ServerRuntimeServices =
216217 | GitCore
217218 | TerminalManager
218219 | Keybindings
220 | ServerSettingsService
219221 | Open
220222 | AnalyticsService;
221223
......@@ -253,7 +255,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
253255 const gitManager = yield* GitManager;
254256 const terminalManager = yield* TerminalManager;
255257 const keybindingsManager = yield* Keybindings;
256 const providerHealth = yield* ProviderHealth;
258 const serverSettingsManager = yield* ServerSettingsService;
259 const providerRegistry = yield* ProviderRegistry;
257260 const git = yield* GitCore;
258261 const fileSystem = yield* FileSystem.FileSystem;
259262 const path = yield* Path.Path;
......@@ -268,7 +271,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
268271 ),
269272 );
270273
271 const providerStatuses = yield* providerHealth.getStatuses;
274 const providersRef = yield* Ref.make(yield* providerRegistry.getProviders);
272275
273276 const clients = yield* Ref.make(new Set<WebSocket>());
274277 const logger = createLogger("ws");
......@@ -295,6 +298,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
295298 ),
296299 );
297300 yield* readiness.markKeybindingsReady;
301 yield* serverSettingsManager.start.pipe(
302 Effect.mapError(
303 (cause) => new ServerLifecycleError({ operation: "serverSettingsRuntimeStart", cause }),
304 ),
305 );
298306
299307 const normalizeDispatchCommand = Effect.fnUntraced(function* (input: {
300308 readonly command: ClientOrchestrationCommand;
......@@ -614,7 +622,22 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
614622 yield* Stream.runForEach(keybindingsManager.streamChanges, (event) =>
615623 pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, {
616624 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 });
618641 }),
619642 ).pipe(Effect.forkIn(subscriptionsScope));
620643
......@@ -878,16 +901,26 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
878901 return yield* terminalManager.close(body);
879902 }
880903
881 case WS_METHODS.serverGetConfig:
904 case WS_METHODS.serverGetConfig: {
882905 const keybindingsConfig = yield* keybindingsManager.loadConfigState;
906 const settings = yield* serverSettingsManager.getSettings;
907 const providers = yield* Ref.get(providersRef);
883908 return {
884909 cwd,
885910 keybindingsConfigPath,
886911 keybindings: keybindingsConfig.keybindings,
887912 issues: keybindingsConfig.issues,
888 providers: providerStatuses,
913 providers,
889914 availableEditors,
915 settings,
890916 };
917 }
918
919 case WS_METHODS.serverRefreshProviders: {
920 const providers = yield* providerRegistry.refresh();
921 yield* Ref.set(providersRef, providers);
922 return { providers };
923 }
891924
892925 case WS_METHODS.serverUpsertKeybinding: {
893926 const body = stripRequestTag(request.body);
......@@ -895,6 +928,15 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
895928 return { keybindings: keybindingsConfig, issues: [] };
896929 }
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
898940 default: {
899941 const _exhaustiveCheck: never = request.body;
900942 return yield* new RouteRequestError({
apps/server/src/wsServer/pushBus.test.ts-3
......@@ -53,7 +53,6 @@ describe("makeServerPushBus", () => {
5353
5454 yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, {
5555 issues: [{ kind: "keybindings.malformed-config", message: "queued-before-connect" }],
56 providers: [],
5756 });
5857
5958 const delivered = yield* pushBus.publishClient(
......@@ -70,7 +69,6 @@ describe("makeServerPushBus", () => {
7069
7170 yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, {
7271 issues: [],
73 providers: [],
7472 });
7573
7674 yield* Effect.promise(() => client.waitForSentCount(2));
......@@ -95,7 +93,6 @@ describe("makeServerPushBus", () => {
9593 channel: WS_CHANNELS.serverConfigUpdated,
9694 data: {
9795 issues: [],
98 providers: [],
9996 },
10097 });
10198 }),
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 {
1212 WS_CHANNELS,
1313 WS_METHODS,
1414 OrchestrationSessionStatus,
15 DEFAULT_SERVER_SETTINGS,
1516} from "@t3tools/contracts";
1617import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
1718import { HttpResponse, http, ws } from "msw";
......@@ -30,6 +31,7 @@ import { isMacPlatform } from "../lib/utils";
3031import { getRouter } from "../router";
3132import { useStore } from "../store";
3233import { estimateTimelineMessageHeight } from "./timelineHeight";
34import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
3335
3436const THREAD_ID = "thread-browser-test" as ThreadId;
3537const 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 {
110112 providers: [
111113 {
112114 provider: "codex",
115 enabled: true,
116 installed: true,
117 version: "0.116.0",
113118 status: "ready",
114 available: true,
115119 authStatus: "authenticated",
116120 checkedAt: NOW_ISO,
121 models: [],
117122 },
118123 ],
119124 availableEditors: [],
125 settings: {
126 ...DEFAULT_SERVER_SETTINGS,
127 ...DEFAULT_CLIENT_SETTINGS,
128 },
120129 };
121130}
122131
apps/web/src/components/ChatView.tsx+77-66
......@@ -13,7 +13,7 @@ import {
1313 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1414 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
1515 type ResolvedKeybindingsConfig,
16 type ServerProviderStatus,
16 type ServerProvider,
1717 type ThreadId,
1818 type TurnId,
1919 type EditorId,
......@@ -22,11 +22,7 @@ import {
2222 ProviderInteractionMode,
2323 RuntimeMode,
2424} from "@t3tools/contracts";
25import {
26 applyClaudePromptEffortPrefix,
27 getModelCapabilities,
28 normalizeModelSlug,
29} from "@t3tools/shared/model";
25import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model";
3026import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3127import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3228import { useDebouncedValue } from "@tanstack/react-pacer";
......@@ -120,12 +116,13 @@ import {
120116import { SidebarTrigger } from "./ui/sidebar";
121117import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
122118import { readNativeApi } from "~/nativeApi";
123import { getProviderStartOptions, useAppSettings } from "../appSettings";
124119import {
125 getCustomModelOptionsByProvider,
126 getCustomModelsByProvider,
127 resolveAppModelSelection,
128} from "../modelSelection";
120 getProviderModelCapabilities,
121 getProviderModels,
122 resolveSelectableProvider,
123} from "../providerModels";
124import { useSettings } from "../hooks/useSettings";
125import { resolveAppModelSelection } from "../modelSelection";
129126import { isTerminalFocused } from "../lib/terminalFocus";
130127import {
131128 type ComposerImageAttachment,
......@@ -164,7 +161,7 @@ import {
164161 renderProviderTraitsMenuContent,
165162 renderProviderTraitsPicker,
166163} from "./chat/composerProviderRegistry";
167import { ProviderHealthBanner } from "./chat/ProviderHealthBanner";
164import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";
168165import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
169166import {
170167 buildExpiredTerminalContextToastCopy,
......@@ -191,16 +188,17 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
191188const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
192189const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
193190const EMPTY_AVAILABLE_EDITORS: EditorId[] = [];
194const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = [];
191const EMPTY_PROVIDERS: ServerProvider[] = [];
195192const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
196193
197194function formatOutgoingPrompt(params: {
198195 provider: ProviderKind;
199196 model: string | null;
197 models: ReadonlyArray<ServerProvider["models"][number]>;
200198 effort: string | null;
201199 text: string;
202200}): string {
203 const caps = getModelCapabilities(params.provider, params.model);
201 const caps = getProviderModelCapabilities(params.models, params.model, params.provider);
204202 if (params.effort && caps.promptInjectedEffortLevels.includes(params.effort)) {
205203 return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null);
206204 }
......@@ -249,7 +247,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
249247 const syncServerReadModel = useStore((store) => store.syncServerReadModel);
250248 const setStoreThreadError = useStore((store) => store.setError);
251249 const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
252 const { settings } = useAppSettings();
250 const settings = useSettings();
253251 const setStickyComposerModelSelection = useComposerDraftStore(
254252 (store) => store.setStickyModelSelection,
255253 );
......@@ -608,25 +606,32 @@ export default function ChatView({ threadId }: ChatViewProps) {
608606 const lockedProvider: ProviderKind | null = hasThreadStarted
609607 ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null)
610608 : null;
611 const selectedProvider: ProviderKind =
612 lockedProvider ?? selectedProviderByThreadId ?? threadProvider ?? "codex";
613 const customModelsByProvider = useMemo(() => getCustomModelsByProvider(settings), [settings]);
609 const serverConfigQuery = useQuery(serverConfigQueryOptions());
610 const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDERS;
611 const unlockedSelectedProvider = resolveSelectableProvider(
612 providerStatuses,
613 selectedProviderByThreadId ?? threadProvider ?? "codex",
614 );
615 const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider;
614616 const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({
615617 threadId,
618 providers: providerStatuses,
616619 selectedProvider,
617620 threadModelSelection: activeThread?.modelSelection,
618621 projectModelSelection: activeProject?.defaultModelSelection,
619 customModelsByProvider,
622 settings,
620623 });
624 const selectedProviderModels = getProviderModels(providerStatuses, selectedProvider);
621625 const composerProviderState = useMemo(
622626 () =>
623627 getComposerProviderState({
624628 provider: selectedProvider,
625629 model: selectedModel,
630 models: selectedProviderModels,
626631 prompt,
627632 modelOptions: composerModelOptions,
628633 }),
629 [composerModelOptions, prompt, selectedModel, selectedProvider],
634 [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels],
630635 );
631636 const selectedPromptEffort = composerProviderState.promptEffort;
632637 const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;
......@@ -638,35 +643,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
638643 }),
639644 [selectedModel, selectedModelOptionsForDispatch, selectedProvider],
640645 );
641 const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]);
642646 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 );
670647 const phase = derivePhase(activeThread?.session ?? null);
671648 const isSendBusy = sendPhase !== "idle";
672649 const isPreparingWorktree = sendPhase === "preparing-worktree";
......@@ -1029,7 +1006,39 @@ export default function ChatView({ threadId }: ChatViewProps) {
10291006 );
10301007 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
10311008 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 );
10331042 const workspaceEntriesQuery = useQuery(
10341043 projectSearchEntriesQueryOptions({
10351044 cwd: gitCwd,
......@@ -1117,9 +1126,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
11171126 () => new Set(nonPersistedComposerImageIds),
11181127 [nonPersistedComposerImageIds],
11191128 );
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;
11231129 const activeProviderStatus = useMemo(
11241130 () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null,
11251131 [selectedProvider, providerStatuses],
......@@ -2460,6 +2466,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
24602466 const outgoingMessageText = formatOutgoingPrompt({
24612467 provider: selectedProvider,
24622468 model: selectedModel,
2469 models: selectedProviderModels,
24632470 effort: selectedPromptEffort,
24642471 text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT,
24652472 });
......@@ -2645,8 +2652,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
26452652 attachments: turnAttachments,
26462653 },
26472654 modelSelection: selectedModelSelection,
2648 ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}),
2649 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
26502655 runtimeMode,
26512656 interactionMode,
26522657 createdAt: messageCreatedAt,
......@@ -2883,6 +2888,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
28832888 const outgoingMessageText = formatOutgoingPrompt({
28842889 provider: selectedProvider,
28852890 model: selectedModel,
2891 models: selectedProviderModels,
28862892 effort: selectedPromptEffort,
28872893 text: trimmed,
28882894 });
......@@ -2927,8 +2933,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
29272933 attachments: [],
29282934 },
29292935 modelSelection: selectedModelSelection,
2930 ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}),
2931 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
29322936 runtimeMode,
29332937 interactionMode: nextInteractionMode,
29342938 ...(nextInteractionMode === "default" && activeProposedPlan
......@@ -2974,11 +2978,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
29742978 runtimeMode,
29752979 selectedPromptEffort,
29762980 selectedModelSelection,
2977 providerOptionsForDispatch,
29782981 selectedProvider,
2982 selectedProviderModels,
29792983 setComposerDraftInteractionMode,
29802984 setThreadError,
2981 settings.enableAssistantStreaming,
29822985 selectedModel,
29832986 ],
29842987 );
......@@ -3005,6 +3008,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
30053008 const outgoingImplementationPrompt = formatOutgoingPrompt({
30063009 provider: selectedProvider,
30073010 model: selectedModel,
3011 models: selectedProviderModels,
30083012 effort: selectedPromptEffort,
30093013 text: implementationPrompt,
30103014 });
......@@ -3044,8 +3048,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
30443048 attachments: [],
30453049 },
30463050 modelSelection: selectedModelSelection,
3047 ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}),
3048 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
30493051 runtimeMode,
30503052 interactionMode: "default",
30513053 createdAt,
......@@ -3096,9 +3098,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
30963098 runtimeMode,
30973099 selectedPromptEffort,
30983100 selectedModelSelection,
3099 providerOptionsForDispatch,
31003101 selectedProvider,
3101 settings.enableAssistantStreaming,
3102 selectedProviderModels,
31023103 syncServerReadModel,
31033104 selectedModel,
31043105 ]);
......@@ -3110,9 +3111,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
31103111 scheduleComposerFocus();
31113112 return;
31123113 }
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 );
31143121 const nextModelSelection: ModelSelection = {
3115 provider,
3122 provider: resolvedProvider,
31163123 model: resolvedModel,
31173124 };
31183125 setComposerDraftModelSelection(activeThread.id, nextModelSelection);
......@@ -3125,7 +3132,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
31253132 scheduleComposerFocus,
31263133 setComposerDraftModelSelection,
31273134 setStickyComposerModelSelection,
3128 customModelsByProvider,
3135 providerStatuses,
3136 settings,
31293137 ],
31303138 );
31313139 const setPromptFromTraits = useCallback(
......@@ -3148,6 +3156,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
31483156 provider: selectedProvider,
31493157 threadId,
31503158 model: selectedModel,
3159 models: selectedProviderModels,
31513160 modelOptions: composerModelOptions?.[selectedProvider],
31523161 prompt,
31533162 onPromptChange: setPromptFromTraits,
......@@ -3156,6 +3165,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
31563165 provider: selectedProvider,
31573166 threadId,
31583167 model: selectedModel,
3168 models: selectedProviderModels,
31593169 modelOptions: composerModelOptions?.[selectedProvider],
31603170 prompt,
31613171 onPromptChange: setPromptFromTraits,
......@@ -3519,7 +3529,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
35193529 </header>
35203530
35213531 {/* Error banner */}
3522 <ProviderHealthBanner status={activeProviderStatus} />
3532 <ProviderStatusBanner status={activeProviderStatus} />
35233533 <ThreadErrorBanner
35243534 error={activeThread.error}
35253535 onDismiss={() => setThreadError(activeThread.id, null)}
......@@ -3796,6 +3806,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
37963806 provider={selectedProvider}
37973807 model={selectedModelForPickerWithCustomFallback}
37983808 lockedProvider={lockedProvider}
3809 providers={providerStatuses}
37993810 modelOptionsByProvider={modelOptionsByProvider}
38003811 {...(composerProviderState.modelPickerIconClassName
38013812 ? {
apps/web/src/components/DiffPanel.tsx+2-2
......@@ -30,7 +30,7 @@ import { buildPatchCacheKey } from "../lib/diffRendering";
3030import { resolveDiffThemeName } from "../lib/diffRendering";
3131import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
3232import { useStore } from "../store";
33import { useAppSettings } from "../appSettings";
33import { useSettings } from "../hooks/useSettings";
3434import { formatShortTimestamp } from "../timestampFormat";
3535import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
3636import { ToggleGroup, Toggle } from "./ui/toggle-group";
......@@ -166,7 +166,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
166166export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
167167 const navigate = useNavigate();
168168 const { resolvedTheme } = useTheme();
169 const { settings } = useAppSettings();
169 const settings = useSettings();
170170 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
171171 const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap);
172172 const patchViewportRef = useRef<HTMLDivElement>(null);
apps/web/src/components/GitActionsControl.tsx-3
......@@ -20,7 +20,6 @@ import {
2020 resolveQuickAction,
2121 summarizeGitResult,
2222} from "./GitActionsControl.logic";
23import { useAppSettings } from "~/appSettings";
2423import { Button } from "~/components/ui/button";
2524import { Checkbox } from "~/components/ui/checkbox";
2625import {
......@@ -205,7 +204,6 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
205204}
206205
207206export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) {
208 const { settings } = useAppSettings();
209207 const threadToastData = useMemo(
210208 () => (activeThreadId ? { threadId: activeThreadId } : undefined),
211209 [activeThreadId],
......@@ -261,7 +259,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
261259 gitRunStackedActionMutationOptions({
262260 cwd: gitCwd,
263261 queryClient,
264 modelSelection: settings.textGenerationModelSelection,
265262 }),
266263 );
267264 const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));
apps/web/src/components/KeybindingsToast.browser.tsx+13-1
......@@ -46,13 +46,25 @@ function createBaseServerConfig(): ServerConfig {
4646 providers: [
4747 {
4848 provider: "codex",
49 enabled: true,
50 installed: true,
51 version: "0.116.0",
4952 status: "ready",
50 available: true,
5153 authStatus: "authenticated",
5254 checkedAt: NOW_ISO,
55 models: [],
5356 },
5457 ],
5558 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 },
5668 };
5769}
5870
apps/web/src/components/PlanSidebar.tsx+1-1
......@@ -1,5 +1,5 @@
11import { memo, useState, useCallback } from "react";
2import { type TimestampFormat } from "../appSettings";
2import { type TimestampFormat } from "@t3tools/contracts/settings";
33import { Badge } from "./ui/badge";
44import { Button } from "./ui/button";
55import { ScrollArea } from "./ui/scroll-area";
apps/web/src/components/Sidebar.logic.ts+1-1
......@@ -1,4 +1,4 @@
1import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "../appSettings";
1import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
22import type { Thread } from "../types";
33import { cn } from "../lib/utils";
44import {
apps/web/src/components/Sidebar.tsx+4-3
......@@ -41,8 +41,7 @@ import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
4141import {
4242 type SidebarProjectSortOrder,
4343 type SidebarThreadSortOrder,
44 useAppSettings,
45} from "../appSettings";
44} from "@t3tools/contracts/settings";
4645import { isElectron } from "../env";
4746import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
4847import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";
......@@ -102,6 +101,7 @@ import {
102101 sortThreadsForSidebar,
103102} from "./Sidebar.logic";
104103import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
104import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
105105
106106const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
107107const THREAD_PREVIEW_LIMIT = 6;
......@@ -381,7 +381,8 @@ export default function Sidebar() {
381381 );
382382 const navigate = useNavigate();
383383 const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" });
384 const { settings: appSettings, updateSettings } = useAppSettings();
384 const appSettings = useSettings();
385 const { updateSettings } = useUpdateSettings();
385386 const { handleNewThread } = useHandleNewThread();
386387 const routeThreadId = useParams({
387388 strict: false,
apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx+65
......@@ -43,6 +43,70 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str
4343 document.body.append(host);
4444 const onPromptChange = vi.fn();
4545 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 ];
46110 const screen = await render(
47111 <CompactComposerControlsMenu
48112 activePlan={false}
......@@ -52,6 +116,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str
52116 traitsMenuContent={
53117 <TraitsMenuContent
54118 provider={provider}
119 models={models}
55120 threadId={threadId}
56121 model={model}
57122 prompt={props?.prompt ?? ""}
apps/web/src/components/chat/MessagesTimeline.tsx+1-1
......@@ -48,7 +48,7 @@ import {
4848 type ParsedTerminalContextEntry,
4949} from "~/lib/terminalContext";
5050import { cn } from "~/lib/utils";
51import { type TimestampFormat } from "../../appSettings";
51import { type TimestampFormat } from "@t3tools/contracts/settings";
5252import { formatTimestamp } from "../../timestampFormat";
5353import {
5454 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 @@
1import { type ModelSlug, type ProviderKind } from "@t3tools/contracts";
1import { type ModelSlug, type ProviderKind, type ServerProvider } from "@t3tools/contracts";
22import { page } from "vitest/browser";
33import { afterEach, describe, expect, it, vi } from "vitest";
44import { render } from "vitest-browser-react";
55
66import { ProviderModelPicker } from "./ProviderModelPicker";
7import { getCustomModelOptionsByProvider } from "../../modelSelection";
8import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
79
8const MODEL_OPTIONS_BY_PROVIDER = {
9 claudeAgent: [
10 { slug: "claude-opus-4-6", name: "Claude Opus 4.6" },
11 { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
12 { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" },
13 ],
14 codex: [
15 { slug: "gpt-5-codex", name: "GPT-5 Codex" },
16 { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
17 ],
18} as const satisfies Record<ProviderKind, ReadonlyArray<{ slug: ModelSlug; name: string }>>;
10function effort(value: string, isDefault = false) {
11 return {
12 value,
13 label: value,
14 ...(isDefault ? { isDefault: true } : {}),
15 };
16}
17
18const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
19 {
20 provider: "codex",
21 enabled: true,
22 installed: true,
23 version: "0.116.0",
24 status: "ready",
25 authStatus: "authenticated",
26 checkedAt: new Date().toISOString(),
27 models: [
28 {
29 slug: "gpt-5-codex",
30 name: "GPT-5 Codex",
31 isCustom: false,
32 capabilities: {
33 reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")],
34 supportsFastMode: true,
35 supportsThinkingToggle: false,
36 promptInjectedEffortLevels: [],
37 },
38 },
39 {
40 slug: "gpt-5.3-codex",
41 name: "GPT-5.3 Codex",
42 isCustom: false,
43 capabilities: {
44 reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")],
45 supportsFastMode: true,
46 supportsThinkingToggle: false,
47 promptInjectedEffortLevels: [],
48 },
49 },
50 ],
51 },
52 {
53 provider: "claudeAgent",
54 enabled: true,
55 installed: true,
56 version: "1.0.0",
57 status: "ready",
58 authStatus: "authenticated",
59 checkedAt: new Date().toISOString(),
60 models: [
61 {
62 slug: "claude-opus-4-6",
63 name: "Claude Opus 4.6",
64 isCustom: false,
65 capabilities: {
66 reasoningEffortLevels: [
67 effort("low"),
68 effort("medium", true),
69 effort("high"),
70 effort("max"),
71 ],
72 supportsFastMode: false,
73 supportsThinkingToggle: true,
74 promptInjectedEffortLevels: [],
75 },
76 },
77 {
78 slug: "claude-sonnet-4-6",
79 name: "Claude Sonnet 4.6",
80 isCustom: false,
81 capabilities: {
82 reasoningEffortLevels: [
83 effort("low"),
84 effort("medium", true),
85 effort("high"),
86 effort("max"),
87 ],
88 supportsFastMode: false,
89 supportsThinkingToggle: true,
90 promptInjectedEffortLevels: [],
91 },
92 },
93 {
94 slug: "claude-haiku-4-5",
95 name: "Claude Haiku 4.5",
96 isCustom: false,
97 capabilities: {
98 reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")],
99 supportsFastMode: false,
100 supportsThinkingToggle: true,
101 promptInjectedEffortLevels: [],
102 },
103 },
104 ],
105 },
106];
19107
20108async function mountPicker(props: {
21109 provider: ProviderKind;
22110 model: ModelSlug;
23111 lockedProvider: ProviderKind | null;
112 providers?: ReadonlyArray<ServerProvider>;
24113 triggerVariant?: "ghost" | "outline";
25114}) {
26115 const host = document.createElement("div");
27116 document.body.append(host);
28117 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 );
29125 const screen = await render(
30126 <ProviderModelPicker
31127 provider={props.provider}
32128 model={props.model}
33129 lockedProvider={props.lockedProvider}
34 modelOptionsByProvider={MODEL_OPTIONS_BY_PROVIDER}
130 providers={providers}
131 modelOptionsByProvider={modelOptionsByProvider}
35132 triggerVariant={props.triggerVariant}
36133 onProviderModelChange={onProviderModelChange}
37134 />,
......@@ -159,6 +256,40 @@ describe("ProviderModelPicker", () => {
159256 }
160257 });
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
162293 it("accepts outline trigger styling", async () => {
163294 const mounted = await mountPicker({
164295 provider: "codex",
apps/web/src/components/chat/ProviderModelPicker.tsx+28-1
......@@ -1,4 +1,4 @@
1import { type ModelSlug, type ProviderKind } from "@t3tools/contracts";
1import { type ModelSlug, type ProviderKind, type ServerProvider } from "@t3tools/contracts";
22import { resolveSelectableModel } from "@t3tools/shared/model";
33import { memo, useState } from "react";
44import type { VariantProps } from "class-variance-authority";
......@@ -20,6 +20,7 @@ import {
2020} from "../ui/menu";
2121import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons";
2222import { cn } from "~/lib/utils";
23import { getProviderSnapshot } from "../../providerModels";
2324
2425function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is {
2526 value: ProviderKind;
......@@ -53,6 +54,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
5354 provider: ProviderKind;
5455 model: ModelSlug;
5556 lockedProvider: ProviderKind | null;
57 providers?: ReadonlyArray<ServerProvider>;
5658 modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;
5759 activeProviderIconClassName?: string;
5860 compact?: boolean;
......@@ -145,6 +147,31 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
145147 <>
146148 {AVAILABLE_PROVIDER_OPTIONS.map((option) => {
147149 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 }
148175 return (
149176 <MenuSub key={option.value}>
150177 <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 {
55 ClaudeModelOptions,
66 CodexModelOptions,
77 DEFAULT_MODEL_BY_PROVIDER,
8 DEFAULT_SERVER_SETTINGS,
89 ProjectId,
10 type ServerProvider,
911 ThreadId,
1012} from "@t3tools/contracts";
1113import { page } from "vitest/browser";
......@@ -21,10 +23,93 @@ import {
2123 useComposerThreadDraft,
2224 useEffectiveComposerModelState,
2325} from "../../composerDraftStore";
26import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
2427
2528// ── Claude TraitsPicker tests ─────────────────────────────────────────
2629
2730const 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
29114function ClaudeTraitsPickerHarness(props: {
30115 model: string;
......@@ -35,10 +120,14 @@ function ClaudeTraitsPickerHarness(props: {
35120 const setPrompt = useComposerDraftStore((store) => store.setPrompt);
36121 const { modelOptions, selectedModel } = useEffectiveComposerModelState({
37122 threadId: CLAUDE_THREAD_ID,
123 providers: TEST_PROVIDERS,
38124 selectedProvider: "claudeAgent",
39125 threadModelSelection: props.fallbackModelSelection,
40126 projectModelSelection: null,
41 customModelsByProvider: { codex: [], claudeAgent: [] },
127 settings: {
128 ...DEFAULT_SERVER_SETTINGS,
129 ...DEFAULT_CLIENT_SETTINGS,
130 },
42131 });
43132 const handlePromptChange = useCallback(
44133 (nextPrompt: string) => {
......@@ -50,6 +139,7 @@ function ClaudeTraitsPickerHarness(props: {
50139 return (
51140 <TraitsPicker
52141 provider="claudeAgent"
142 models={TEST_PROVIDERS[1]!.models}
53143 threadId={CLAUDE_THREAD_ID}
54144 model={selectedModel ?? props.model}
55145 prompt={prompt}
......@@ -109,7 +199,7 @@ async function mountClaudePicker(props?: {
109199 ? ({
110200 provider: "claudeAgent",
111201 model,
112 options: props.fallbackModelOptions ?? undefined,
202 ...(props.fallbackModelOptions ? { options: props.fallbackModelOptions } : {}),
113203 } satisfies ModelSelection)
114204 : null;
115205 const screen = await render(
......@@ -293,6 +383,7 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt
293383 const screen = await render(
294384 <TraitsPicker
295385 provider="codex"
386 models={TEST_PROVIDERS[0]!.models}
296387 threadId={threadId}
297388 model={props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex}
298389 prompt=""
......@@ -359,10 +450,10 @@ describe("TraitsPicker (Codex)", () => {
359450
360451 await vi.waitFor(() => {
361452 const text = document.body.textContent ?? "";
362 expect(text).toContain("Low");
363 expect(text).toContain("Medium");
364 expect(text).toContain("High");
365453 expect(text).toContain("Extra High");
454 expect(text).toContain("High");
455 expect(text).not.toContain("Low");
456 expect(text).not.toContain("Medium");
366457 });
367458 });
368459
apps/web/src/components/chat/TraitsPicker.tsx+10-4
......@@ -3,11 +3,11 @@ import {
33 type CodexModelOptions,
44 type ProviderKind,
55 type ProviderModelOptions,
6 type ServerProviderModel,
67 type ThreadId,
78} from "@t3tools/contracts";
89import {
910 applyClaudePromptEffortPrefix,
10 getModelCapabilities,
1111 isClaudeUltrathinkPrompt,
1212 trimOrNull,
1313 getDefaultEffort,
......@@ -27,6 +27,7 @@ import {
2727 MenuTrigger,
2828} from "../ui/menu";
2929import { useComposerDraftStore } from "../../composerDraftStore";
30import { getProviderModelCapabilities } from "../../providerModels";
3031import { cn } from "~/lib/utils";
3132
3233type ProviderOptions = ProviderModelOptions[ProviderKind];
......@@ -65,12 +66,13 @@ function buildNextOptions(
6566
6667function getSelectedTraits(
6768 provider: ProviderKind,
69 models: ReadonlyArray<ServerProviderModel>,
6870 model: string | null | undefined,
6971 prompt: string,
7072 modelOptions: ProviderOptions | null | undefined,
7173 allowPromptInjectedEffort: boolean,
7274) {
73 const caps = getModelCapabilities(provider, model);
75 const caps = getProviderModelCapabilities(models, model, provider);
7476 const effortLevels = allowPromptInjectedEffort
7577 ? caps.reasoningEffortLevels
7678 : caps.reasoningEffortLevels.filter(
......@@ -120,6 +122,7 @@ function getSelectedTraits(
120122
121123export interface TraitsMenuContentProps {
122124 provider: ProviderKind;
125 models: ReadonlyArray<ServerProviderModel>;
123126 model: string | null | undefined;
124127 prompt: string;
125128 onPromptChange: (prompt: string) => void;
......@@ -131,6 +134,7 @@ export interface TraitsMenuContentProps {
131134
132135export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
133136 provider,
137 models,
134138 model,
135139 prompt,
136140 onPromptChange,
......@@ -156,7 +160,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
156160 thinkingEnabled,
157161 fastModeEnabled,
158162 ultrathinkPromptControlled,
159 } = getSelectedTraits(provider, model, prompt, modelOptions, allowPromptInjectedEffort);
163 } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort);
160164 const defaultEffort = getDefaultEffort(caps);
161165
162166 const handleEffortChange = useCallback(
......@@ -260,6 +264,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
260264
261265export const TraitsPicker = memo(function TraitsPicker({
262266 provider,
267 models,
263268 model,
264269 prompt,
265270 onPromptChange,
......@@ -277,7 +282,7 @@ export const TraitsPicker = memo(function TraitsPicker({
277282 thinkingEnabled,
278283 fastModeEnabled,
279284 ultrathinkPromptControlled,
280 } = getSelectedTraits(provider, model, prompt, modelOptions, allowPromptInjectedEffort);
285 } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort);
281286
282287 const effortLabel = effort
283288 ? (effortLevels.find((l) => l.value === effort)?.label ?? effort)
......@@ -333,6 +338,7 @@ export const TraitsPicker = memo(function TraitsPicker({
333338 <MenuPopup align="start">
334339 <TraitsMenuContent
335340 provider={provider}
341 models={models}
336342 model={model}
337343 prompt={prompt}
338344 onPromptChange={onPromptChange}
apps/web/src/components/chat/composerProviderRegistry.test.tsx+75
......@@ -1,11 +1,78 @@
11import { describe, expect, it } from "vitest";
2import type { ServerProviderModel } from "@t3tools/contracts";
23import { 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
470describe("getComposerProviderState", () => {
571 it("returns codex defaults when no codex draft options exist", () => {
672 const state = getComposerProviderState({
773 provider: "codex",
874 model: "gpt-5.4",
75 models: CODEX_MODELS,
976 prompt: "",
1077 modelOptions: undefined,
1178 });
......@@ -21,6 +88,7 @@ describe("getComposerProviderState", () => {
2188 const state = getComposerProviderState({
2289 provider: "codex",
2390 model: "gpt-5.4",
91 models: CODEX_MODELS,
2492 prompt: "",
2593 modelOptions: {
2694 codex: {
......@@ -44,6 +112,7 @@ describe("getComposerProviderState", () => {
44112 const state = getComposerProviderState({
45113 provider: "codex",
46114 model: "gpt-5.4",
115 models: CODEX_MODELS,
47116 prompt: "",
48117 modelOptions: {
49118 codex: {
......@@ -65,6 +134,7 @@ describe("getComposerProviderState", () => {
65134 const state = getComposerProviderState({
66135 provider: "codex",
67136 model: "gpt-5.4",
137 models: CODEX_MODELS,
68138 prompt: "",
69139 modelOptions: {
70140 codex: {
......@@ -85,6 +155,7 @@ describe("getComposerProviderState", () => {
85155 const state = getComposerProviderState({
86156 provider: "claudeAgent",
87157 model: "claude-sonnet-4-6",
158 models: CLAUDE_MODELS,
88159 prompt: "",
89160 modelOptions: undefined,
90161 });
......@@ -100,6 +171,7 @@ describe("getComposerProviderState", () => {
100171 const state = getComposerProviderState({
101172 provider: "claudeAgent",
102173 model: "claude-sonnet-4-6",
174 models: CLAUDE_MODELS,
103175 prompt: "Ultrathink:\nInvestigate this failure",
104176 modelOptions: {
105177 claudeAgent: {
......@@ -124,6 +196,7 @@ describe("getComposerProviderState", () => {
124196 const state = getComposerProviderState({
125197 provider: "claudeAgent",
126198 model: "claude-haiku-4-5",
199 models: CLAUDE_MODELS,
127200 prompt: "",
128201 modelOptions: {
129202 claudeAgent: {
......@@ -146,6 +219,7 @@ describe("getComposerProviderState", () => {
146219 const state = getComposerProviderState({
147220 provider: "claudeAgent",
148221 model: "claude-opus-4-6",
222 models: CLAUDE_MODELS,
149223 prompt: "",
150224 modelOptions: {
151225 claudeAgent: {
......@@ -167,6 +241,7 @@ describe("getComposerProviderState", () => {
167241 const state = getComposerProviderState({
168242 provider: "claudeAgent",
169243 model: "claude-opus-4-6",
244 models: CLAUDE_MODELS,
170245 prompt: "",
171246 modelOptions: {
172247 claudeAgent: {
apps/web/src/components/chat/composerProviderRegistry.tsx+39-11
......@@ -2,23 +2,27 @@ import {
22 type ModelSlug,
33 type ProviderKind,
44 type ProviderModelOptions,
5 type ServerProviderModel,
56 type ThreadId,
67} from "@t3tools/contracts";
78import {
8 getModelCapabilities,
99 isClaudeUltrathinkPrompt,
10 normalizeClaudeModelOptions,
11 normalizeCodexModelOptions,
1210 trimOrNull,
1311 getDefaultEffort,
1412 hasEffortLevel,
1513} from "@t3tools/shared/model";
1614import type { ReactNode } from "react";
15import {
16 getProviderModelCapabilities,
17 normalizeClaudeModelOptionsWithCapabilities,
18 normalizeCodexModelOptionsWithCapabilities,
19} from "../../providerModels";
1720import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
1821
1922export type ComposerProviderStateInput = {
2023 provider: ProviderKind;
2124 model: ModelSlug;
25 models: ReadonlyArray<ServerProviderModel>;
2226 prompt: string;
2327 modelOptions: ProviderModelOptions | null | undefined;
2428};
......@@ -37,6 +41,7 @@ type ProviderRegistryEntry = {
3741 renderTraitsMenuContent: (input: {
3842 threadId: ThreadId;
3943 model: ModelSlug;
44 models: ReadonlyArray<ServerProviderModel>;
4045 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
4146 prompt: string;
4247 onPromptChange: (prompt: string) => void;
......@@ -44,6 +49,7 @@ type ProviderRegistryEntry = {
4449 renderTraitsPicker: (input: {
4550 threadId: ThreadId;
4651 model: ModelSlug;
52 models: ReadonlyArray<ServerProviderModel>;
4753 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
4854 prompt: string;
4955 onPromptChange: (prompt: string) => void;
......@@ -53,8 +59,8 @@ type ProviderRegistryEntry = {
5359function getProviderStateFromCapabilities(
5460 input: ComposerProviderStateInput,
5561): ComposerProviderState {
56 const { provider, model, prompt, modelOptions } = input;
57 const caps = getModelCapabilities(provider, model);
62 const { provider, model, models, prompt, modelOptions } = input;
63 const caps = getProviderModelCapabilities(models, model, provider);
5864 const providerOptions = modelOptions?.[provider];
5965
6066 // Resolve effort
......@@ -81,8 +87,8 @@ function getProviderStateFromCapabilities(
8187 // Normalize options for dispatch
8288 const normalizedOptions =
8389 provider === "codex"
84 ? normalizeCodexModelOptions(model, providerOptions)
85 : normalizeClaudeModelOptions(model, providerOptions);
90 ? normalizeCodexModelOptionsWithCapabilities(caps, providerOptions)
91 : normalizeClaudeModelOptionsWithCapabilities(caps, providerOptions);
8692
8793 // Ultrathink styling (driven by capabilities data, not provider identity)
8894 const ultrathinkActive =
......@@ -103,9 +109,17 @@ function getProviderStateFromCapabilities(
103109const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {
104110 codex: {
105111 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 }) => (
107120 <TraitsMenuContent
108121 provider="codex"
122 models={models}
109123 threadId={threadId}
110124 model={model}
111125 modelOptions={modelOptions}
......@@ -113,9 +127,10 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {
113127 onPromptChange={onPromptChange}
114128 />
115129 ),
116 renderTraitsPicker: ({ threadId, model, modelOptions, prompt, onPromptChange }) => (
130 renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => (
117131 <TraitsPicker
118132 provider="codex"
133 models={models}
119134 threadId={threadId}
120135 model={model}
121136 modelOptions={modelOptions}
......@@ -126,9 +141,17 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {
126141 },
127142 claudeAgent: {
128143 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 }) => (
130152 <TraitsMenuContent
131153 provider="claudeAgent"
154 models={models}
132155 threadId={threadId}
133156 model={model}
134157 modelOptions={modelOptions}
......@@ -136,9 +159,10 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {
136159 onPromptChange={onPromptChange}
137160 />
138161 ),
139 renderTraitsPicker: ({ threadId, model, modelOptions, prompt, onPromptChange }) => (
162 renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => (
140163 <TraitsPicker
141164 provider="claudeAgent"
165 models={models}
142166 threadId={threadId}
143167 model={model}
144168 modelOptions={modelOptions}
......@@ -157,6 +181,7 @@ export function renderProviderTraitsMenuContent(input: {
157181 provider: ProviderKind;
158182 threadId: ThreadId;
159183 model: ModelSlug;
184 models: ReadonlyArray<ServerProviderModel>;
160185 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
161186 prompt: string;
162187 onPromptChange: (prompt: string) => void;
......@@ -164,6 +189,7 @@ export function renderProviderTraitsMenuContent(input: {
164189 return composerProviderRegistry[input.provider].renderTraitsMenuContent({
165190 threadId: input.threadId,
166191 model: input.model,
192 models: input.models,
167193 modelOptions: input.modelOptions,
168194 prompt: input.prompt,
169195 onPromptChange: input.onPromptChange,
......@@ -174,6 +200,7 @@ export function renderProviderTraitsPicker(input: {
174200 provider: ProviderKind;
175201 threadId: ThreadId;
176202 model: ModelSlug;
203 models: ReadonlyArray<ServerProviderModel>;
177204 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
178205 prompt: string;
179206 onPromptChange: (prompt: string) => void;
......@@ -181,6 +208,7 @@ export function renderProviderTraitsPicker(input: {
181208 return composerProviderRegistry[input.provider].renderTraitsPicker({
182209 threadId: input.threadId,
183210 model: input.model,
211 models: input.models,
184212 modelOptions: input.modelOptions,
185213 prompt: input.prompt,
186214 onPromptChange: input.onPromptChange,
apps/web/src/composerDraftStore.ts+19-16
......@@ -9,16 +9,13 @@ import {
99 ProviderKind,
1010 ProviderModelOptions,
1111 RuntimeMode,
12 type ServerProvider,
1213 ThreadId,
1314} from "@t3tools/contracts";
1415import * as Schema from "effect/Schema";
1516import * as Equal from "effect/Equal";
1617import { DeepMutable } from "effect/Types";
17import {
18 getDefaultModel,
19 normalizeModelSlug,
20 resolveModelSlugForProvider,
21} from "@t3tools/shared/model";
18import { getDefaultModel, normalizeModelSlug } from "@t3tools/shared/model";
2219import { useMemo } from "react";
2320import { getLocalStorageItem } from "./hooks/useLocalStorage";
2421import { resolveAppModelSelection } from "./modelSelection";
......@@ -31,6 +28,8 @@ import {
3128import { create } from "zustand";
3229import { createJSONStorage, persist } from "zustand/middleware";
3330import { createDebouncedStorage, createMemoryStorage } from "./lib/storage";
31import { getDefaultServerModel } from "./providerModels";
32import { UnifiedSettings } from "@t3tools/contracts/settings";
3433
3534export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
3635const COMPOSER_DRAFT_STORAGE_VERSION = 3;
......@@ -613,22 +612,23 @@ export function deriveEffectiveComposerModelState(input: {
613612 | Pick<ComposerThreadDraftState, "modelSelectionByProvider" | "activeProvider">
614613 | null
615614 | undefined;
615 providers: ReadonlyArray<ServerProvider>;
616616 selectedProvider: ProviderKind;
617617 threadModelSelection: ModelSelection | null | undefined;
618618 projectModelSelection: ModelSelection | null | undefined;
619 customModelsByProvider: Record<ProviderKind, readonly string[]>;
619 settings: UnifiedSettings;
620620}): EffectiveComposerModelState {
621 const baseModel = resolveModelSlugForProvider(
622 input.selectedProvider,
623 input.threadModelSelection?.model ??
624 input.projectModelSelection?.model ??
625 getDefaultModel(input.selectedProvider),
626 );
621 const baseModel =
622 normalizeModelSlug(
623 input.threadModelSelection?.model ?? input.projectModelSelection?.model,
624 input.selectedProvider,
625 ) ?? getDefaultServerModel(input.providers, input.selectedProvider);
627626 const activeSelection = input.draft?.modelSelectionByProvider?.[input.selectedProvider];
628627 const selectedModel = activeSelection?.model
629628 ? resolveAppModelSelection(
630629 input.selectedProvider,
631 input.customModelsByProvider,
630 input.settings,
631 input.providers,
632632 activeSelection.model,
633633 )
634634 : baseModel;
......@@ -2157,10 +2157,11 @@ export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftS
21572157
21582158export function useEffectiveComposerModelState(input: {
21592159 threadId: ThreadId;
2160 providers: ReadonlyArray<ServerProvider>;
21602161 selectedProvider: ProviderKind;
21612162 threadModelSelection: ModelSelection | null | undefined;
21622163 projectModelSelection: ModelSelection | null | undefined;
2163 customModelsByProvider: Record<ProviderKind, readonly string[]>;
2164 settings: UnifiedSettings;
21642165}): EffectiveComposerModelState {
21652166 const draft = useComposerThreadDraft(input.threadId);
21662167
......@@ -2168,14 +2169,16 @@ export function useEffectiveComposerModelState(input: {
21682169 () =>
21692170 deriveEffectiveComposerModelState({
21702171 draft,
2172 providers: input.providers,
21712173 selectedProvider: input.selectedProvider,
21722174 threadModelSelection: input.threadModelSelection,
21732175 projectModelSelection: input.projectModelSelection,
2174 customModelsByProvider: input.customModelsByProvider,
2176 settings: input.settings,
21752177 }),
21762178 [
21772179 draft,
2178 input.customModelsByProvider,
2180 input.providers,
2181 input.settings,
21792182 input.projectModelSelection,
21802183 input.selectedProvider,
21812184 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", () => {
3232 const options = gitRunStackedActionMutationOptions({
3333 cwd: "/repo/a",
3434 queryClient,
35 modelSelection: {
36 provider: "codex",
37 model: "gpt-5.4",
38 },
3935 });
4036 expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction("/repo/a"));
4137 });
apps/web/src/lib/gitReactQuery.ts+1-3
......@@ -1,4 +1,4 @@
1import { type GitStackedAction, type ModelSelection } from "@t3tools/contracts";
1import { type GitStackedAction } from "@t3tools/contracts";
22import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";
33import { ensureNativeApi } from "../nativeApi";
44
......@@ -112,7 +112,6 @@ export function gitCheckoutMutationOptions(input: {
112112export function gitRunStackedActionMutationOptions(input: {
113113 cwd: string | null;
114114 queryClient: QueryClient;
115 modelSelection: ModelSelection;
116115}) {
117116 return mutationOptions({
118117 mutationKey: gitMutationKeys.runStackedAction(input.cwd),
......@@ -134,7 +133,6 @@ export function gitRunStackedActionMutationOptions(input: {
134133 return api.git.runStackedAction({
135134 actionId,
136135 cwd: input.cwd,
137 modelSelection: input.modelSelection,
138136 action,
139137 ...(commitMessage ? { commitMessage } : {}),
140138 ...(featureBranch ? { featureBranch } : {}),
apps/web/src/lib/serverReactQuery.ts+7-1
......@@ -6,6 +6,13 @@ export const serverQueryKeys = {
66 config: () => ["server", "config"] as const,
77};
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 */
916export function serverConfigQueryOptions() {
1017 return queryOptions({
1118 queryKey: serverQueryKeys.config(),
......@@ -13,6 +20,5 @@ export function serverConfigQueryOptions() {
1320 const api = ensureNativeApi();
1421 return api.server.getConfig();
1522 },
16 staleTime: Infinity,
1723 });
1824}
apps/web/src/modelSelection.ts+50-78
......@@ -2,27 +2,22 @@ import {
22 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
33 type ModelSelection,
44 type ProviderKind,
5 type ServerProvider,
56} from "@t3tools/contracts";
6import {
7 getDefaultModel,
8 getModelOptions,
9 normalizeModelSlug,
10 resolveSelectableModel,
11} from "@t3tools/shared/model";
7import { normalizeModelSlug, resolveSelectableModel } from "@t3tools/shared/model";
128import { getComposerProviderState } from "./components/chat/composerProviderRegistry";
9import { UnifiedSettings } from "@t3tools/contracts/settings";
10import {
11 getDefaultServerModel,
12 getProviderModels,
13 resolveSelectableProvider,
14} from "./providerModels";
1315
1416const MAX_CUSTOM_MODEL_COUNT = 32;
1517export const MAX_CUSTOM_MODEL_LENGTH = 256;
1618
17export type CustomModelSettings = {
18 customCodexModels: readonly string[];
19 customClaudeModels: readonly string[];
20};
21
2219export type ProviderCustomModelConfig = {
2320 provider: ProviderKind;
24 settingsKey: keyof CustomModelSettings;
25 defaultSettingsKey: keyof CustomModelSettings;
2621 title: string;
2722 description: string;
2823 placeholder: string;
......@@ -38,8 +33,6 @@ export interface AppModelOption {
3833const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConfig> = {
3934 codex: {
4035 provider: "codex",
41 settingsKey: "customCodexModels",
42 defaultSettingsKey: "customCodexModels",
4336 title: "Codex",
4437 description: "Save additional Codex model slugs for the picker and `/model` command.",
4538 placeholder: "your-codex-model-slug",
......@@ -47,8 +40,6 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConf
4740 },
4841 claudeAgent: {
4942 provider: "claudeAgent",
50 settingsKey: "customClaudeModels",
51 defaultSettingsKey: "customClaudeModels",
5243 title: "Claude",
5344 description: "Save additional Claude model slugs for the picker and `/model` command.",
5445 placeholder: "your-claude-model-slug",
......@@ -56,20 +47,15 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConf
5647 },
5748};
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
6450export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG);
6551
6652export function normalizeCustomModelSlugs(
6753 models: Iterable<string | null | undefined>,
54 builtInModelSlugs: ReadonlySet<string>,
6855 provider: ProviderKind = "codex",
6956): string[] {
7057 const normalizedModels: string[] = [];
7158 const seen = new Set<string>();
72 const builtInModelSlugs = BUILT_IN_MODEL_SLUGS_BY_PROVIDER[provider];
7359
7460 for (const candidate of models) {
7561 const normalized = normalizeModelSlug(candidate, provider);
......@@ -92,52 +78,29 @@ export function normalizeCustomModelSlugs(
9278 return normalizedModels;
9379}
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
12781export function getAppModelOptions(
82 settings: UnifiedSettings,
83 providers: ReadonlyArray<ServerProvider>,
12884 provider: ProviderKind,
129 customModels: readonly string[],
13085 selectedModel?: string | null,
13186): AppModelOption[] {
132 const options: AppModelOption[] = getModelOptions(provider).map(({ slug, name }) => ({
133 slug,
134 name,
135 isCustom: false,
136 }));
87 const options: AppModelOption[] = getProviderModels(providers, provider).map(
88 ({ slug, name, isCustom }) => ({
89 slug,
90 name,
91 isCustom,
92 }),
93 );
13794 const seen = new Set(options.map((option) => option.slug));
13895 const trimmedSelectedModel = selectedModel?.trim().toLowerCase();
139
140 for (const slug of normalizeCustomModelSlugs(customModels, provider)) {
96 const builtInModelSlugs = new Set(
97 getProviderModels(providers, provider)
98 .filter((model) => !model.isCustom)
99 .map((model) => model.slug),
100 );
101
102 const customModels = settings.providers[provider].customModels;
103 for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs, provider)) {
141104 if (seen.has(slug)) {
142105 continue;
143106 }
......@@ -171,52 +134,61 @@ export function getAppModelOptions(
171134
172135export function resolveAppModelSelection(
173136 provider: ProviderKind,
174 customModels: Record<ProviderKind, readonly string[]>,
137 settings: UnifiedSettings,
138 providers: ReadonlyArray<ServerProvider>,
175139 selectedModel: string | null | undefined,
176140): string {
177 const customModelsForProvider = customModels[provider];
178 const options = getAppModelOptions(provider, customModelsForProvider, selectedModel);
179 return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider);
141 const resolvedProvider = resolveSelectableProvider(providers, provider);
142 const options = getAppModelOptions(settings, providers, resolvedProvider, selectedModel);
143 return (
144 resolveSelectableModel(resolvedProvider, selectedModel, options) ??
145 getDefaultServerModel(providers, resolvedProvider)
146 );
180147}
181148
182149export function getCustomModelOptionsByProvider(
183 settings: CustomModelSettings,
150 settings: UnifiedSettings,
151 providers: ReadonlyArray<ServerProvider>,
184152 selectedProvider?: ProviderKind | null,
185153 selectedModel?: string | null,
186154): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> {
187 const customModelsByProvider = getCustomModelsByProvider(settings);
188155 return {
189156 codex: getAppModelOptions(
157 settings,
158 providers,
190159 "codex",
191 customModelsByProvider.codex,
192160 selectedProvider === "codex" ? selectedModel : undefined,
193161 ),
194162 claudeAgent: getAppModelOptions(
163 settings,
164 providers,
195165 "claudeAgent",
196 customModelsByProvider.claudeAgent,
197166 selectedProvider === "claudeAgent" ? selectedModel : undefined,
198167 ),
199168 };
200169}
201170
202171export function resolveAppModelSelectionState(
203 settings: CustomModelSettings & {
204 textGenerationModelSelection: ModelSelection | undefined;
205 },
172 settings: UnifiedSettings,
173 providers: ReadonlyArray<ServerProvider>,
206174): ModelSelection {
207175 const selection = settings.textGenerationModelSelection ?? {
208176 provider: "codex" as const,
209177 model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex,
210178 };
211 const provider = selection.provider;
212 const customModelsByProvider = getCustomModelsByProvider(settings);
213 const model = resolveAppModelSelection(provider, customModelsByProvider, selection.model);
179 const provider = resolveSelectableProvider(providers, selection.provider);
180
181 // When the provider changed due to fallback (e.g. selected provider was disabled),
182 // don't carry over the old provider's model — use the fallback provider's default.
183 const selectedModel = provider === selection.provider ? selection.model : null;
184 const model = resolveAppModelSelection(provider, settings, providers, selectedModel);
214185 const { modelOptionsForDispatch } = getComposerProviderState({
215186 provider,
216187 model,
188 models: getProviderModels(providers, provider),
217189 prompt: "",
218190 modelOptions: {
219 [provider]: selection.options,
191 [provider]: provider === selection.provider ? selection.options : undefined,
220192 },
221193 });
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
2020import { useStore } from "../store";
2121import { useTerminalStateStore } from "../terminalStateStore";
2222import { terminalRunningSubprocessFromEvent } from "../terminalActivity";
23import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi";
23import { onServerConfigUpdated, onServerProvidersUpdated, onServerWelcome } from "../wsNativeApi";
24import { migrateLocalSettingsToServer } from "../hooks/useSettings";
2425import { providerQueryKeys } from "../lib/providerReactQuery";
2526import { projectQueryKeys } from "../lib/projectReactQuery";
2627import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup";
......@@ -230,6 +231,8 @@ function EventRouter() {
230231 );
231232 });
232233 const unsubWelcome = onServerWelcome((payload) => {
234 // Migrate old localStorage settings to server on first connect
235 migrateLocalSettingsToServer();
233236 void (async () => {
234237 await syncSnapshot();
235238 if (disposed) {
......@@ -260,8 +263,14 @@ function EventRouter() {
260263 // don't produce duplicate toasts.
261264 let subscribed = false;
262265 const unsubServerConfigUpdated = onServerConfigUpdated((payload) => {
266 // Invalidate the config query so active observers refetch fresh data.
263267 void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() });
268
264269 if (!subscribed) return;
270
271 // Only show keybindings toasts for keybindings changes (no settings in payload)
272 if (payload.settings) return;
273
265274 const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings."));
266275 if (!issue) {
267276 toastManager.add({
......@@ -300,6 +309,9 @@ function EventRouter() {
300309 },
301310 });
302311 });
312 const unsubProvidersUpdated = onServerProvidersUpdated(() => {
313 void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() });
314 });
303315 subscribed = true;
304316 return () => {
305317 disposed = true;
......@@ -309,6 +321,7 @@ function EventRouter() {
309321 unsubTerminalEvent();
310322 unsubWelcome();
311323 unsubServerConfigUpdated();
324 unsubProvidersUpdated();
312325 };
313326 }, [
314327 navigate,
apps/web/src/routes/_chat.settings.tsx+684-379
......@@ -1,16 +1,27 @@
11import { createFileRoute } from "@tanstack/react-router";
2import { useQuery } from "@tanstack/react-query";
3import { ChevronDownIcon, PlusIcon, RotateCcwIcon, Undo2Icon, XIcon } from "lucide-react";
4import { type ReactNode, useCallback, useState } from "react";
5import { type ProviderKind } from "@t3tools/contracts";
6import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
7import { useAppSettings } from "../appSettings";
2import { useQuery, useQueryClient } from "@tanstack/react-query";
3import {
4 ChevronDownIcon,
5 InfoIcon,
6 LoaderIcon,
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";
822import {
923 getCustomModelOptionsByProvider,
10 getCustomModelsForProvider,
1124 MAX_CUSTOM_MODEL_LENGTH,
12 MODEL_PROVIDER_SETTINGS,
13 patchCustomModels,
1425 resolveAppModelSelectionState,
1526} from "../modelSelection";
1627import { APP_VERSION } from "../branding";
......@@ -33,9 +44,12 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"
3344import { resolveAndPersistPreferredEditor } from "../editorPreferences";
3445import { isElectron } from "../env";
3546import { useTheme } from "../hooks/useTheme";
36import { serverConfigQueryOptions } from "../lib/serverReactQuery";
47import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";
3748import { cn } from "../lib/utils";
49import { formatRelativeTime } from "../timestampFormat";
3850import { ensureNativeApi, readNativeApi } from "../nativeApi";
51import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
52import { Equal } from "effect";
3953
4054const THEME_OPTIONS = [
4155 {
......@@ -61,11 +75,11 @@ const TIMESTAMP_FORMAT_LABELS = {
6175 "24-hour": "24-hour",
6276} as const;
6377
64type InstallBinarySettingsKey = "claudeBinaryPath" | "codexBinaryPath";
78const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = [];
79
6580type InstallProviderSettings = {
6681 provider: ProviderKind;
6782 title: string;
68 binaryPathKey: InstallBinarySettingsKey;
6983 binaryPlaceholder: string;
7084 binaryDescription: ReactNode;
7185 homePathKey?: "codexHomePath";
......@@ -73,17 +87,12 @@ type InstallProviderSettings = {
7387 homeDescription?: ReactNode;
7488};
7589
76const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [
90const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [
7791 {
7892 provider: "codex",
7993 title: "Codex",
80 binaryPathKey: "codexBinaryPath",
8194 binaryPlaceholder: "Codex binary path",
82 binaryDescription: (
83 <>
84 Leave blank to use <code>codex</code> from your PATH.
85 </>
86 ),
95 binaryDescription: "Path to the Codex binary",
8796 homePathKey: "codexHomePath",
8897 homePlaceholder: "CODEX_HOME",
8998 homeDescription: "Optional custom Codex home and config directory.",
......@@ -91,22 +100,116 @@ const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [
91100 {
92101 provider: "claudeAgent",
93102 title: "Claude",
94 binaryPathKey: "claudeBinaryPath",
95103 binaryPlaceholder: "Claude binary path",
96 binaryDescription: (
97 <>
98 Leave blank to use <code>claude</code> from your PATH.
99 </>
100 ),
104 binaryDescription: "Path to the Claude binary",
101105 },
102106];
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}) {
105205 return (
106206 <section className="space-y-3">
107 <h2 className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
108 {title}
109 </h2>
207 <div className="flex items-center justify-between">
208 <h2 className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
209 {title}
210 </h2>
211 {headerAction}
212 </div>
110213 <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%)]">
111214 {children}
112215 </div>
......@@ -121,7 +224,6 @@ function SettingsRow({
121224 resetAction,
122225 control,
123226 children,
124 onClick,
125227}: {
126228 title: string;
127229 description: string;
......@@ -129,20 +231,13 @@ function SettingsRow({
129231 resetAction?: ReactNode;
130232 control?: ReactNode;
131233 children?: ReactNode;
132 onClick?: () => void;
133234}) {
134235 return (
135236 <div
136237 className="border-t border-border px-4 py-4 first:border-t-0 sm:px-5"
137238 data-slot="settings-row"
138239 >
139 <div
140 className={cn(
141 "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between",
142 onClick && "cursor-pointer",
143 )}
144 onClick={onClick}
145 >
240 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
146241 <div className="min-w-0 flex-1 space-y-1">
147242 <div className="flex min-h-5 items-center gap-1.5">
148243 <h3 className="text-sm font-medium text-foreground">{title}</h3>
......@@ -190,16 +285,23 @@ function SettingResetButton({ label, onClick }: { label: string; onClick: () =>
190285
191286function SettingsRouteView() {
192287 const { theme, setTheme } = useTheme();
193 const { settings, defaults, updateSettings, resetSettings } = useAppSettings();
288 const settings = useSettings();
289 const { updateSettings, resetSettings } = useUpdateSettings();
194290 const serverConfigQuery = useQuery(serverConfigQueryOptions());
195291 const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);
196292 const [openKeybindingsError, setOpenKeybindingsError] = useState<string | null>(null);
197 const [openInstallProviders, setOpenInstallProviders] = useState<Record<ProviderKind, boolean>>({
198 codex: Boolean(settings.codexBinaryPath || settings.codexHomePath),
199 claudeAgent: Boolean(settings.claudeBinaryPath),
293 const [openProviderDetails, setOpenProviderDetails] = useState<Record<ProviderKind, boolean>>({
294 codex: Boolean(
295 settings.providers.codex.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.binaryPath ||
296 settings.providers.codex.homePath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.homePath ||
297 settings.providers.codex.customModels.length > 0,
298 ),
299 claudeAgent: Boolean(
300 settings.providers.claudeAgent.binaryPath !==
301 DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath ||
302 settings.providers.claudeAgent.customModels.length > 0,
303 ),
200304 });
201 const [selectedCustomModelProvider, setSelectedCustomModelProvider] =
202 useState<ProviderKind>("codex");
203305 const [customModelInputByProvider, setCustomModelInputByProvider] = useState<
204306 Record<ProviderKind, string>
205307 >({
......@@ -209,63 +311,73 @@ function SettingsRouteView() {
209311 const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState<
210312 Partial<Record<ProviderKind, string | null>>
211313 >({});
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;
215 const codexHomePath = settings.codexHomePath;
216 const claudeBinaryPath = settings.claudeBinaryPath;
336 const modelListRefs = useRef<Partial<Record<ProviderKind, HTMLDivElement | null>>>({});
337
338 const codexHomePath = settings.providers.codex.homePath;
217339 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
218340 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);
221344 const textGenProvider = textGenerationModelSelection.provider;
222345 const textGenModel = textGenerationModelSelection.model;
223346 const textGenModelOptions = textGenerationModelSelection.options;
224347 const gitModelOptionsByProvider = getCustomModelOptionsByProvider(
225348 settings,
349 serverProviders,
226350 textGenProvider,
227351 textGenModel,
228352 );
229 const selectedCustomModelProviderSettings = MODEL_PROVIDER_SETTINGS.find(
230 (providerSettings) => providerSettings.provider === selectedCustomModelProvider,
231 )!;
232 const selectedCustomModelInput = customModelInputByProvider[selectedCustomModelProvider];
233 const selectedCustomModelError = customModelErrorByProvider[selectedCustomModelProvider] ?? null;
234 const totalCustomModels = settings.customCodexModels.length + settings.customClaudeModels.length;
235 const savedCustomModelRows = MODEL_PROVIDER_SETTINGS.flatMap((providerSettings) =>
236 getCustomModelsForProvider(settings, providerSettings.provider).map((slug) => ({
237 key: `${providerSettings.provider}:${slug}`,
238 provider: providerSettings.provider,
239 providerTitle: providerSettings.title,
240 slug,
241 })),
353 const areProviderSettingsDirty = PROVIDER_SETTINGS.some((providerSettings) => {
354 const currentSettings = settings.providers[providerSettings.provider];
355 const defaultSettings = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider];
356 return !Equal.equals(currentSettings, defaultSettings);
357 });
358 const isGitWritingModelDirty = !Equal.equals(
359 settings.textGenerationModelSelection ?? null,
360 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
242361 );
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;
250362 const changedSettingLabels = [
251363 ...(theme !== "system" ? ["Theme"] : []),
252 ...(settings.timestampFormat !== defaults.timestampFormat ? ["Time format"] : []),
253 ...(settings.diffWordWrap !== defaults.diffWordWrap ? ["Diff line wrapping"] : []),
254 ...(settings.enableAssistantStreaming !== defaults.enableAssistantStreaming
255 ? ["Assistant output"]
364 ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
365 ? ["Time format"]
256366 : []),
257 ...(settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ["New thread mode"] : []),
258 ...(settings.confirmThreadDelete !== defaults.confirmThreadDelete
259 ? ["Delete confirmation"]
367 ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap
368 ? ["Diff line wrapping"]
369 : []),
370 ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming
371 ? ["Assistant output"]
260372 : []),
261 ...(JSON.stringify(settings.textGenerationModelSelection ?? null) !==
262 JSON.stringify(defaults.textGenerationModelSelection ?? null)
263 ? ["Git writing model"]
373 ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode
374 ? ["New thread mode"]
264375 : []),
265 ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0
266 ? ["Custom models"]
376 ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete
377 ? ["Delete confirmation"]
267378 : []),
268 ...(isInstallSettingsDirty ? ["Provider installs"] : []),
379 ...(isGitWritingModelDirty ? ["Git writing model"] : []),
380 ...(areProviderSettingsDirty ? ["Providers"] : []),
269381 ];
270382
271383 const openKeybindingsFile = useCallback(() => {
......@@ -294,7 +406,7 @@ function SettingsRouteView() {
294406 const addCustomModel = useCallback(
295407 (provider: ProviderKind) => {
296408 const customModelInput = customModelInputByProvider[provider];
297 const customModels = getCustomModelsForProvider(settings, provider);
409 const customModels = settings.providers[provider].customModels;
298410 const normalized = normalizeModelSlug(customModelInput, provider);
299411 if (!normalized) {
300412 setCustomModelErrorByProvider((existing) => ({
......@@ -303,7 +415,11 @@ function SettingsRouteView() {
303415 }));
304416 return;
305417 }
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 ) {
307423 setCustomModelErrorByProvider((existing) => ({
308424 ...existing,
309425 [provider]: "That model is already built in.",
......@@ -325,7 +441,15 @@ function SettingsRouteView() {
325441 return;
326442 }
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 });
329453 setCustomModelInputByProvider((existing) => ({
330454 ...existing,
331455 [provider]: "",
......@@ -334,19 +458,37 @@ function SettingsRouteView() {
334458 ...existing,
335459 [provider]: null,
336460 }));
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 }
337476 },
338 [customModelInputByProvider, settings, updateSettings],
477 [customModelInputByProvider, serverProviders, settings, updateSettings],
339478 );
340479
341480 const removeCustomModel = useCallback(
342481 (provider: ProviderKind, slug: string) => {
343 const customModels = getCustomModelsForProvider(settings, provider);
344 updateSettings(
345 patchCustomModels(
346 provider,
347 customModels.filter((model) => model !== slug),
348 ),
349 );
482 const customModels = settings.providers[provider].customModels;
483 updateSettings({
484 providers: {
485 ...settings.providers,
486 [provider]: {
487 ...settings.providers[provider],
488 customModels: customModels.filter((model) => model !== slug),
489 },
490 },
491 });
350492 setCustomModelErrorByProvider((existing) => ({
351493 ...existing,
352494 [provider]: null,
......@@ -355,6 +497,46 @@ function SettingsRouteView() {
355497 [settings, updateSettings],
356498 );
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
358540 async function restoreDefaults() {
359541 if (changedSettingLabels.length === 0) return;
360542
......@@ -368,11 +550,10 @@ function SettingsRouteView() {
368550
369551 setTheme("system");
370552 resetSettings();
371 setOpenInstallProviders({
553 setOpenProviderDetails({
372554 codex: false,
373555 claudeAgent: false,
374556 });
375 setSelectedCustomModelProvider("codex");
376557 setCustomModelInputByProvider({
377558 codex: "",
378559 claudeAgent: "",
......@@ -461,12 +642,12 @@ function SettingsRouteView() {
461642 title="Time format"
462643 description="System default follows your browser or OS clock preference."
463644 resetAction={
464 settings.timestampFormat !== defaults.timestampFormat ? (
645 settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? (
465646 <SettingResetButton
466647 label="time format"
467648 onClick={() =>
468649 updateSettings({
469 timestampFormat: defaults.timestampFormat,
650 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
470651 })
471652 }
472653 />
......@@ -506,12 +687,12 @@ function SettingsRouteView() {
506687 title="Diff line wrapping"
507688 description="Set the default wrap state when the diff panel opens. The in-panel wrap toggle only affects the current diff session."
508689 resetAction={
509 settings.diffWordWrap !== defaults.diffWordWrap ? (
690 settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap ? (
510691 <SettingResetButton
511692 label="diff line wrapping"
512693 onClick={() =>
513694 updateSettings({
514 diffWordWrap: defaults.diffWordWrap,
695 diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap,
515696 })
516697 }
517698 />
......@@ -534,12 +715,14 @@ function SettingsRouteView() {
534715 title="Assistant output"
535716 description="Show token-by-token output while a response is in progress."
536717 resetAction={
537 settings.enableAssistantStreaming !== defaults.enableAssistantStreaming ? (
718 settings.enableAssistantStreaming !==
719 DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? (
538720 <SettingResetButton
539721 label="assistant output"
540722 onClick={() =>
541723 updateSettings({
542 enableAssistantStreaming: defaults.enableAssistantStreaming,
724 enableAssistantStreaming:
725 DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming,
543726 })
544727 }
545728 />
......@@ -562,12 +745,13 @@ function SettingsRouteView() {
562745 title="New threads"
563746 description="Pick the default workspace mode for newly created draft threads."
564747 resetAction={
565 settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? (
748 settings.defaultThreadEnvMode !==
749 DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? (
566750 <SettingResetButton
567751 label="new threads"
568752 onClick={() =>
569753 updateSettings({
570 defaultThreadEnvMode: defaults.defaultThreadEnvMode,
754 defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode,
571755 })
572756 }
573757 />
......@@ -604,12 +788,12 @@ function SettingsRouteView() {
604788 title="Delete confirmation"
605789 description="Ask before deleting a thread and its chat history."
606790 resetAction={
607 settings.confirmThreadDelete !== defaults.confirmThreadDelete ? (
791 settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? (
608792 <SettingResetButton
609793 label="delete confirmation"
610794 onClick={() =>
611795 updateSettings({
612 confirmThreadDelete: defaults.confirmThreadDelete,
796 confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,
613797 })
614798 }
615799 />
......@@ -627,20 +811,18 @@ function SettingsRouteView() {
627811 />
628812 }
629813 />
630 </SettingsSection>
631
632 <SettingsSection title="Models">
633814 <SettingsRow
634 title="Git writing model"
635 description="Provider and model used for auto-generated git content."
815 title="Text generation model"
816 description="Configure the model used for text generation (commit messages, PR content etc.)"
636817 resetAction={
637818 JSON.stringify(settings.textGenerationModelSelection ?? null) !==
638 JSON.stringify(defaults.textGenerationModelSelection ?? null) ? (
819 JSON.stringify(DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null) ? (
639820 <SettingResetButton
640 label="git writing model"
821 label="text generation model"
641822 onClick={() => {
642823 updateSettings({
643 textGenerationModelSelection: defaults.textGenerationModelSelection,
824 textGenerationModelSelection:
825 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
644826 });
645827 }}
646828 />
......@@ -652,20 +834,28 @@ function SettingsRouteView() {
652834 provider={textGenProvider}
653835 model={textGenModel}
654836 lockedProvider={null}
837 providers={serverProviders}
655838 modelOptionsByProvider={gitModelOptionsByProvider}
656839 triggerVariant="outline"
657840 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
658841 onProviderModelChange={(provider, model) => {
659842 updateSettings({
660 textGenerationModelSelection: resolveAppModelSelectionState({
661 ...settings,
662 textGenerationModelSelection: { provider, model },
663 }),
843 textGenerationModelSelection: resolveAppModelSelectionState(
844 {
845 ...settings,
846 textGenerationModelSelection: { provider, model },
847 },
848 serverProviders,
849 ),
664850 });
665851 }}
666852 />
667853 <TraitsPicker
668854 provider={textGenProvider}
855 models={
856 serverProviders.find((provider) => provider.provider === textGenProvider)
857 ?.models ?? []
858 }
669859 model={textGenModel}
670860 prompt=""
671861 onPromptChange={() => {}}
......@@ -675,292 +865,407 @@ function SettingsRouteView() {
675865 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
676866 onModelOptionsChange={(nextOptions) => {
677867 updateSettings({
678 textGenerationModelSelection: resolveAppModelSelectionState({
679 ...settings,
680 textGenerationModelSelection: {
681 provider: textGenProvider,
682 model: textGenModel,
683 ...(nextOptions ? { options: nextOptions } : {}),
868 textGenerationModelSelection: resolveAppModelSelectionState(
869 {
870 ...settings,
871 textGenerationModelSelection: {
872 provider: textGenProvider,
873 model: textGenModel,
874 ...(nextOptions ? { options: nextOptions } : {}),
875 },
684876 },
685 }),
877 serverProviders,
878 ),
686879 });
687880 }}
688881 />
689882 </div>
690883 }
691884 />
885 </SettingsSection>
692886
693 <SettingsRow
694 title="Custom models"
695 description="Add custom model slugs for supported providers."
696 resetAction={
697 totalCustomModels > 0 ? (
698 <SettingResetButton
699 label="custom models"
700 onClick={() => {
701 updateSettings({
702 customCodexModels: defaults.customCodexModels,
703 customClaudeModels: defaults.customClaudeModels,
704 });
705 setCustomModelErrorByProvider({});
706 setShowAllCustomModels(false);
707 }}
708 />
709 ) : null
710 }
711 >
712 <div className="mt-4 border-t border-border pt-4">
713 <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
714 <Select
715 value={selectedCustomModelProvider}
716 onValueChange={(value) => {
717 if (value !== "codex" && value !== "claudeAgent") {
718 return;
719 }
720 setSelectedCustomModelProvider(value);
721 }}
722 >
723 <SelectTrigger
724 size="sm"
725 className="w-full sm:w-40"
726 aria-label="Custom model provider"
727 >
728 <SelectValue>{selectedCustomModelProviderSettings.title}</SelectValue>
729 </SelectTrigger>
730 <SelectPopup align="start" alignItemWithTrigger={false}>
731 {MODEL_PROVIDER_SETTINGS.map((providerSettings) => (
732 <SelectItem
733 hideIndicator
734 className="min-h-7 text-sm"
735 key={providerSettings.provider}
736 value={providerSettings.provider}
737 >
738 {providerSettings.title}
739 </SelectItem>
740 ))}
741 </SelectPopup>
742 </Select>
743 <Input
744 id="custom-model-slug"
745 value={selectedCustomModelInput}
746 onChange={(event) => {
747 const value = event.target.value;
748 setCustomModelInputByProvider((existing) => ({
749 ...existing,
750 [selectedCustomModelProvider]: value,
751 }));
752 if (selectedCustomModelError) {
753 setCustomModelErrorByProvider((existing) => ({
754 ...existing,
755 [selectedCustomModelProvider]: null,
756 }));
757 }
758 }}
759 onKeyDown={(event) => {
760 if (event.key !== "Enter") return;
761 event.preventDefault();
762 addCustomModel(selectedCustomModelProvider);
763 }}
764 placeholder={selectedCustomModelProviderSettings.example}
765 spellCheck={false}
766 />
767 <Button
768 className="shrink-0"
769 variant="outline"
770 onClick={() => addCustomModel(selectedCustomModelProvider)}
771 >
772 <PlusIcon className="size-3.5" />
773 Add
774 </Button>
775 </div>
776
777 {selectedCustomModelError ? (
778 <p className="mt-2 text-xs text-destructive">{selectedCustomModelError}</p>
887 <SettingsSection
888 title="Providers"
889 headerAction={
890 <div className="flex items-center gap-1.5">
891 {serverProviders.length > 0 ? (
892 <span className="text-[11px] text-muted-foreground/60">
893 {(() => {
894 const rel = formatRelativeTime(
895 serverProviders.reduce(
896 (latest, provider) =>
897 provider.checkedAt > latest ? provider.checkedAt : latest,
898 serverProviders[0]!.checkedAt,
899 ),
900 );
901 return rel.suffix ? (
902 <>
903 Checked <span className="font-mono tabular-nums">{rel.value}</span>{" "}
904 {rel.suffix}
905 </>
906 ) : (
907 <>Checked {rel.value}</>
908 );
909 })()}
910 </span>
779911 ) : 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 ? (
782 <div className="mt-3">
783 <div>
784 {visibleCustomModelRows.map((row) => (
785 <div
786 key={row.key}
787 className="group grid grid-cols-[minmax(5rem,6rem)_minmax(0,1fr)_auto] items-center gap-3 border-t border-border/60 px-4 py-2 first:border-t-0"
788 >
789 <span className="truncate text-xs text-muted-foreground">
790 {row.providerTitle}
942 return (
943 <div
944 key={providerCard.provider}
945 className="border-t border-border first:border-t-0"
946 data-slot="settings-row"
947 >
948 <div className="px-4 py-4 sm:px-5">
949 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
950 <div className="min-w-0 flex-1 space-y-1">
951 <div className="flex min-h-5 items-center gap-1.5">
952 <span
953 className={cn(
954 "size-2 shrink-0 rounded-full",
955 providerCard.statusStyle.dot,
956 )}
957 />
958 <h3 className="text-sm font-medium text-foreground">
959 {providerDisplayName}
960 </h3>
961 {providerCard.versionLabel ? (
962 <code className="text-xs text-muted-foreground">
963 {providerCard.versionLabel}
964 </code>
965 ) : null}
966 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
967 {providerCard.isDirty ? (
968 <SettingResetButton
969 label={`${providerDisplayName} provider settings`}
970 onClick={() => {
971 updateSettings({
972 providers: {
973 ...settings.providers,
974 [providerCard.provider]:
975 DEFAULT_UNIFIED_SETTINGS.providers[providerCard.provider],
976 },
977 });
978 setCustomModelErrorByProvider((existing) => ({
979 ...existing,
980 [providerCard.provider]: null,
981 }));
982 }}
983 />
984 ) : null}
791985 </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>
803986 </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>
8051045 </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}
8181046 </div>
819 ) : null}
820 </div>
821 </SettingsRow>
822 </SettingsSection>
8231047
824 <SettingsSection title="Advanced">
825 <SettingsRow
826 title="Provider installs"
827 description="Override the CLI used for new sessions."
828 resetAction={
829 isInstallSettingsDirty ? (
830 <SettingResetButton
831 label="provider installs"
832 onClick={() => {
833 updateSettings({
834 claudeBinaryPath: defaults.claudeBinaryPath,
835 codexBinaryPath: defaults.codexBinaryPath,
836 codexHomePath: defaults.codexHomePath,
837 });
838 setOpenInstallProviders({
839 codex: false,
840 claudeAgent: false,
841 });
842 }}
843 />
844 ) : null
845 }
846 >
847 <div className="mt-4">
848 <div className="space-y-2">
849 {INSTALL_PROVIDER_SETTINGS.map((providerSettings) => {
850 const isOpen = openInstallProviders[providerSettings.provider];
851 const isDirty =
852 providerSettings.provider === "codex"
853 ? settings.codexBinaryPath !== defaults.codexBinaryPath ||
854 settings.codexHomePath !== defaults.codexHomePath
855 : settings.claudeBinaryPath !== defaults.claudeBinaryPath;
856 const binaryPathValue =
857 providerSettings.binaryPathKey === "claudeBinaryPath"
858 ? claudeBinaryPath
859 : codexBinaryPath;
860
861 return (
862 <Collapsible
863 key={providerSettings.provider}
864 open={isOpen}
865 onOpenChange={(open) =>
866 setOpenInstallProviders((existing) => ({
867 ...existing,
868 [providerSettings.provider]: open,
869 }))
870 }
871 >
872 <div className="overflow-hidden rounded-xl border border-border/70">
873 <button
874 type="button"
875 className="flex w-full items-center gap-3 px-4 py-3 text-left"
876 onClick={() =>
877 setOpenInstallProviders((existing) => ({
878 ...existing,
879 [providerSettings.provider]: !existing[providerSettings.provider],
880 }))
881 }
1048 <Collapsible
1049 open={openProviderDetails[providerCard.provider]}
1050 onOpenChange={(open) =>
1051 setOpenProviderDetails((existing) => ({
1052 ...existing,
1053 [providerCard.provider]: open,
1054 }))
1055 }
1056 >
1057 <CollapsibleContent>
1058 <div className="space-y-0">
1059 {/* Binary path */}
1060 <div className="border-t border-border/60 px-4 py-3 sm:px-5">
1061 <label
1062 htmlFor={`provider-install-${providerCard.provider}-binary-path`}
1063 className="block"
8821064 >
883 <span className="min-w-0 flex-1 text-sm font-medium text-foreground">
884 {providerSettings.title}
1065 <span className="text-xs font-medium text-foreground">
1066 {providerDisplayName} binary path
8851067 </span>
886 {isDirty ? (
887 <span className="text-[11px] text-muted-foreground">Custom</span>
888 ) : null}
889 <ChevronDownIcon
890 className={cn(
891 "size-4 shrink-0 text-muted-foreground transition-transform",
892 isOpen && "rotate-180",
893 )}
1068 <Input
1069 id={`provider-install-${providerCard.provider}-binary-path`}
1070 className="mt-1.5"
1071 value={providerCard.binaryPathValue}
1072 onChange={(event) =>
1073 updateSettings({
1074 providers: {
1075 ...settings.providers,
1076 [providerCard.provider]: {
1077 ...settings.providers[providerCard.provider],
1078 binaryPath: event.target.value,
1079 },
1080 },
1081 })
1082 }
1083 placeholder={providerCard.binaryPlaceholder}
1084 spellCheck={false}
8941085 />
895 </button>
896
897 <CollapsibleContent>
898 <div className="border-t border-border/70 px-4 py-4">
899 <div className="space-y-3">
900 <label
901 htmlFor={`provider-install-${providerSettings.binaryPathKey}`}
902 className="block"
1086 <span className="mt-1 block text-xs text-muted-foreground">
1087 {providerCard.binaryDescription}
1088 </span>
1089 </label>
1090 </div>
1091
1092 {/* Home path (Codex only) */}
1093 {providerCard.homePathKey ? (
1094 <div className="border-t border-border/60 px-4 py-3 sm:px-5">
1095 <label
1096 htmlFor={`provider-install-${providerCard.homePathKey}`}
1097 className="block"
1098 >
1099 <span className="text-xs font-medium text-foreground">
1100 CODEX_HOME path
1101 </span>
1102 <Input
1103 id={`provider-install-${providerCard.homePathKey}`}
1104 className="mt-1.5"
1105 value={codexHomePath}
1106 onChange={(event) =>
1107 updateSettings({
1108 providers: {
1109 ...settings.providers,
1110 codex: {
1111 ...settings.providers.codex,
1112 homePath: event.target.value,
1113 },
1114 },
1115 })
1116 }
1117 placeholder={providerCard.homePlaceholder}
1118 spellCheck={false}
1119 />
1120 {providerCard.homeDescription ? (
1121 <span className="mt-1 block text-xs text-muted-foreground">
1122 {providerCard.homeDescription}
1123 </span>
1124 ) : null}
1125 </label>
1126 </div>
1127 ) : null}
1128
1129 {/* Models */}
1130 <div className="border-t border-border/60 px-4 py-3 sm:px-5">
1131 <div className="text-xs font-medium text-foreground">Models</div>
1132 <div className="mt-1 text-xs text-muted-foreground">
1133 {providerCard.models.length} model
1134 {providerCard.models.length === 1 ? "" : "s"} available.
1135 </div>
1136 <div
1137 ref={(el) => {
1138 modelListRefs.current[providerCard.provider] = el;
1139 }}
1140 className="mt-2 max-h-40 overflow-y-auto pb-1"
1141 >
1142 {providerCard.models.map((model) => {
1143 const caps = model.capabilities;
1144 const capLabels: string[] = [];
1145 if (caps?.supportsFastMode) capLabels.push("Fast mode");
1146 if (caps?.supportsThinkingToggle) capLabels.push("Thinking");
1147 if (
1148 caps?.reasoningEffortLevels &&
1149 caps.reasoningEffortLevels.length > 0
1150 )
1151 capLabels.push("Reasoning");
1152 const hasDetails =
1153 capLabels.length > 0 || model.name !== model.slug;
1154
1155 return (
1156 <div
1157 key={`${providerCard.provider}:${model.slug}`}
1158 className="flex items-center gap-2 py-1"
9031159 >
904 <span className="block text-xs font-medium text-foreground">
905 {providerSettings.title} binary path
1160 <span className="min-w-0 truncate text-xs text-foreground/90">
1161 {model.name}
9061162 </span>
907 <Input
908 id={`provider-install-${providerSettings.binaryPathKey}`}
909 className="mt-1"
910 value={binaryPathValue}
911 onChange={(event) =>
912 updateSettings(
913 providerSettings.binaryPathKey === "claudeBinaryPath"
914 ? { claudeBinaryPath: event.target.value }
915 : { codexBinaryPath: event.target.value },
916 )
917 }
918 placeholder={providerSettings.binaryPlaceholder}
919 spellCheck={false}
920 />
921 <span className="mt-1 block text-xs text-muted-foreground">
922 {providerSettings.binaryDescription}
923 </span>
924 </label>
925
926 {providerSettings.homePathKey ? (
927 <label
928 htmlFor={`provider-install-${providerSettings.homePathKey}`}
929 className="block"
930 >
931 <span className="block text-xs font-medium text-foreground">
932 CODEX_HOME path
933 </span>
934 <Input
935 id={`provider-install-${providerSettings.homePathKey}`}
936 className="mt-1"
937 value={codexHomePath}
938 onChange={(event) =>
939 updateSettings({
940 codexHomePath: event.target.value,
941 })
942 }
943 placeholder={providerSettings.homePlaceholder}
944 spellCheck={false}
945 />
946 {providerSettings.homeDescription ? (
947 <span className="mt-1 block text-xs text-muted-foreground">
948 {providerSettings.homeDescription}
1163 {hasDetails ? (
1164 <Tooltip>
1165 <TooltipTrigger
1166 render={
1167 <button
1168 type="button"
1169 className="shrink-0 text-muted-foreground/40 transition-colors hover:text-muted-foreground"
1170 aria-label={`Details for ${model.name}`}
1171 />
1172 }
1173 >
1174 <InfoIcon className="size-3" />
1175 </TooltipTrigger>
1176 <TooltipPopup side="top" className="max-w-56">
1177 <div className="space-y-1">
1178 <code className="block text-[11px] text-foreground">
1179 {model.slug}
1180 </code>
1181 {capLabels.length > 0 ? (
1182 <div className="flex flex-wrap gap-x-2 gap-y-0.5">
1183 {capLabels.map((label) => (
1184 <span
1185 key={label}
1186 className="text-[10px] text-muted-foreground"
1187 >
1188 {label}
1189 </span>
1190 ))}
1191 </div>
1192 ) : null}
1193 </div>
1194 </TooltipPopup>
1195 </Tooltip>
1196 ) : null}
1197 {model.isCustom ? (
1198 <div className="ml-auto flex shrink-0 items-center gap-1.5">
1199 <span className="text-[10px] text-muted-foreground">
1200 custom
9491201 </span>
950 ) : null}
951 </label>
952 ) : null}
953 </div>
954 </div>
955 </CollapsibleContent>
1202 <button
1203 type="button"
1204 className="text-muted-foreground transition-colors hover:text-foreground"
1205 aria-label={`Remove ${model.slug}`}
1206 onClick={() =>
1207 removeCustomModel(providerCard.provider, model.slug)
1208 }
1209 >
1210 <XIcon className="size-3" />
1211 </button>
1212 </div>
1213 ) : null}
1214 </div>
1215 );
1216 })}
1217 </div>
1218 <div className="mt-3 flex flex-col gap-2 sm:flex-row">
1219 <Input
1220 id={`custom-model-${providerCard.provider}`}
1221 value={customModelInput}
1222 onChange={(event) => {
1223 const value = event.target.value;
1224 setCustomModelInputByProvider((existing) => ({
1225 ...existing,
1226 [providerCard.provider]: value,
1227 }));
1228 if (customModelError) {
1229 setCustomModelErrorByProvider((existing) => ({
1230 ...existing,
1231 [providerCard.provider]: null,
1232 }));
1233 }
1234 }}
1235 onKeyDown={(event) => {
1236 if (event.key !== "Enter") return;
1237 event.preventDefault();
1238 addCustomModel(providerCard.provider);
1239 }}
1240 placeholder={
1241 providerCard.provider === "codex"
1242 ? "gpt-6.7-codex-ultra-preview"
1243 : "claude-sonnet-5-0"
1244 }
1245 spellCheck={false}
1246 />
1247 <Button
1248 className="shrink-0"
1249 variant="outline"
1250 onClick={() => addCustomModel(providerCard.provider)}
1251 >
1252 <PlusIcon className="size-3.5" />
1253 Add
1254 </Button>
1255 </div>
1256 {customModelError ? (
1257 <p className="mt-2 text-xs text-destructive">{customModelError}</p>
1258 ) : null}
9561259 </div>
957 </Collapsible>
958 );
959 })}
1260 </div>
1261 </CollapsibleContent>
1262 </Collapsible>
9601263 </div>
961 </div>
962 </SettingsRow>
1264 );
1265 })}
1266 </SettingsSection>
9631267
1268 <SettingsSection title="Advanced">
9641269 <SettingsRow
9651270 title="Keybindings"
9661271 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";
1111import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
1212import { useThreadSelectionStore } from "../threadSelectionStore";
1313import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic";
14import { useAppSettings } from "~/appSettings";
14import { useSettings } from "~/hooks/useSettings";
1515import { Sidebar, SidebarProvider, SidebarRail } from "~/components/ui/sidebar";
1616
1717const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
......@@ -31,7 +31,7 @@ function ChatRouteGlobalShortcuts() {
3131 ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen
3232 : false,
3333 );
34 const { settings: appSettings } = useAppSettings();
34 const appSettings = useSettings();
3535
3636 useEffect(() => {
3737 const onWindowKeyDown = (event: KeyboardEvent) => {
apps/web/src/timestampFormat.ts+20-1
......@@ -1,4 +1,4 @@
1import { type TimestampFormat } from "./appSettings";
1import { type TimestampFormat } from "@t3tools/contracts/settings";
22
33export function getTimestampFormatOptions(
44 timestampFormat: TimestampFormat,
......@@ -47,3 +47,22 @@ export function formatTimestamp(isoDate: string, timestampFormat: TimestampForma
4747export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string {
4848 return getTimestampFormatter(timestampFormat, false).format(new Date(isoDate));
4949}
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 {
1313 WS_CHANNELS,
1414 WS_METHODS,
1515 type WsPush,
16 type ServerProviderStatus,
16 type ServerProvider,
1717} from "@t3tools/contracts";
1818import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1919
......@@ -92,13 +92,16 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn
9292 return testGlobal.window;
9393}
9494
95const defaultProviders: ReadonlyArray<ServerProviderStatus> = [
95const defaultProviders: ReadonlyArray<ServerProvider> = [
9696 {
9797 provider: "codex",
98 enabled: true,
99 installed: true,
100 version: "0.116.0",
98101 status: "ready",
99 available: true,
100102 authStatus: "authenticated",
101103 checkedAt: "2026-01-01T00:00:00.000Z",
104 models: [],
102105 },
103106];
104107
......@@ -197,7 +200,6 @@ describe("wsNativeApi", () => {
197200 message: "Entry at index 1 is invalid.",
198201 },
199202 ],
200 providers: defaultProviders,
201203 } as const;
202204 emitPush(WS_CHANNELS.serverConfigUpdated, payload);
203205
......@@ -219,20 +221,38 @@ describe("wsNativeApi", () => {
219221
220222 emitPush(WS_CHANNELS.serverConfigUpdated, {
221223 issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
222 providers: defaultProviders,
223224 });
224225 emitPush(WS_CHANNELS.serverConfigUpdated, {
225226 issues: [],
226 providers: defaultProviders,
227227 });
228228
229229 expect(listener).toHaveBeenCalledTimes(2);
230230 expect(listener).toHaveBeenLastCalledWith({
231231 issues: [],
232 providers: defaultProviders,
233232 });
234233 });
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
236256 it("forwards valid terminal and orchestration events", async () => {
237257 const { createWsNativeApi } = await import("./wsNativeApi");
238258
......@@ -357,10 +377,6 @@ describe("wsNativeApi", () => {
357377 actionId: "action-1",
358378 cwd: "/repo",
359379 action: "commit",
360 modelSelection: {
361 provider: "codex",
362 model: "gpt-5.4-mini",
363 },
364380 });
365381
366382 expect(requestMock).toHaveBeenCalledWith(
......@@ -369,10 +385,6 @@ describe("wsNativeApi", () => {
369385 actionId: "action-1",
370386 cwd: "/repo",
371387 action: "commit",
372 modelSelection: {
373 provider: "codex",
374 model: "gpt-5.4-mini",
375 },
376388 },
377389 { timeoutMs: null },
378390 );
apps/web/src/wsNativeApi.ts+35
......@@ -5,6 +5,7 @@ import {
55 type ContextMenuItem,
66 type NativeApi,
77 ServerConfigUpdatedPayload,
8 ServerProviderUpdatedPayload,
89 WS_CHANNELS,
910 WS_METHODS,
1011 type WsWelcomePayload,
......@@ -16,6 +17,7 @@ import { WsTransport } from "./wsTransport";
1617let instance: { api: NativeApi; transport: WsTransport } | null = null;
1718const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>();
1819const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>();
20const providersUpdatedListeners = new Set<(payload: ServerProviderUpdatedPayload) => void>();
1921const gitActionProgressListeners = new Set<(payload: GitActionProgressEvent) => void>();
2022
2123/**
......@@ -64,6 +66,26 @@ export function onServerConfigUpdated(
6466 };
6567}
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
6789export function createWsNativeApi(): NativeApi {
6890 if (instance) return instance.api;
6991
......@@ -89,6 +111,16 @@ export function createWsNativeApi(): NativeApi {
89111 }
90112 }
91113 });
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 });
92124 transport.subscribe(WS_CHANNELS.gitActionProgress, (message) => {
93125 const payload = message.data;
94126 for (const listener of gitActionProgressListeners) {
......@@ -178,7 +210,10 @@ export function createWsNativeApi(): NativeApi {
178210 },
179211 server: {
180212 getConfig: () => transport.request(WS_METHODS.serverGetConfig),
213 refreshProviders: () => transport.request(WS_METHODS.serverRefreshProviders),
181214 upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input),
215 getSettings: () => transport.request(WS_METHODS.serverGetSettings),
216 updateSettings: (patch) => transport.request(WS_METHODS.serverUpdateSettings, { patch }),
182217 },
183218 orchestration: {
184219 getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot),
apps/web/src/wsTransport.test.ts+4-4
......@@ -98,7 +98,7 @@ describe("WsTransport", () => {
9898 type: "push",
9999 sequence: 1,
100100 channel: WS_CHANNELS.serverConfigUpdated,
101 data: { issues: [], providers: [] },
101 data: { issues: [] },
102102 }),
103103 );
104104
......@@ -107,7 +107,7 @@ describe("WsTransport", () => {
107107 type: "push",
108108 sequence: 1,
109109 channel: WS_CHANNELS.serverConfigUpdated,
110 data: { issues: [], providers: [] },
110 data: { issues: [] },
111111 });
112112
113113 transport.dispose();
......@@ -160,7 +160,7 @@ describe("WsTransport", () => {
160160 type: "push",
161161 sequence: 3,
162162 channel: WS_CHANNELS.serverConfigUpdated,
163 data: { issues: [], providers: [] },
163 data: { issues: [] },
164164 }),
165165 );
166166
......@@ -169,7 +169,7 @@ describe("WsTransport", () => {
169169 type: "push",
170170 sequence: 3,
171171 channel: WS_CHANNELS.serverConfigUpdated,
172 data: { issues: [], providers: [] },
172 data: { issues: [] },
173173 });
174174 expect(warnSpy).toHaveBeenCalledTimes(2);
175175 expect(warnSpy).toHaveBeenNthCalledWith(
packages/contracts/package.json+5
......@@ -10,6 +10,11 @@
1010 "module": "./dist/index.mjs",
1111 "types": "./src/index.ts",
1212 "exports": {
13 "./settings": {
14 "types": "./src/settings.ts",
15 "import": "./src/settings.ts",
16 "require": "./src/settings.ts"
17 },
1318 ".": {
1419 "types": "./src/index.ts",
1520 "import": "./src/index.ts",
packages/contracts/src/git.test.ts-15
......@@ -74,19 +74,4 @@ describe("GitRunStackedActionInput", () => {
7474 expect(parsed.actionId).toBe("action-1");
7575 expect(parsed.action).toBe("commit");
7676 });
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 });
9277});
packages/contracts/src/git.ts-2
......@@ -1,6 +1,5 @@
11import { Schema } from "effect";
22import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";
3import { ModelSelection } from "./orchestration";
43
54const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
65
......@@ -80,7 +79,6 @@ export const GitRunStackedActionInput = Schema.Struct({
8079 filePaths: Schema.optional(
8180 Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)),
8281 ),
83 modelSelection: ModelSelection,
8482});
8583export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;
8684
packages/contracts/src/index.ts+1
......@@ -7,6 +7,7 @@ export * from "./model";
77export * from "./ws";
88export * from "./keybindings";
99export * from "./server";
10export * from "./settings";
1011export * from "./git";
1112export * from "./orchestration";
1213export * from "./editor";
packages/contracts/src/ipc.ts+10-2
......@@ -25,7 +25,11 @@ import type {
2525 ProjectWriteFileInput,
2626 ProjectWriteFileResult,
2727} from "./project";
28import type { ServerConfig } from "./server";
28import type {
29 ServerConfig,
30 ServerProviderUpdatedPayload,
31 ServerUpsertKeybindingResult,
32} from "./server";
2933import type {
3034 TerminalClearInput,
3135 TerminalCloseInput,
......@@ -36,7 +40,7 @@ import type {
3640 TerminalSessionSnapshot,
3741 TerminalWriteInput,
3842} from "./terminal";
39import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server";
43import type { ServerUpsertKeybindingInput } from "./server";
4044import type {
4145 ClientOrchestrationCommand,
4246 OrchestrationGetFullThreadDiffInput,
......@@ -47,6 +51,7 @@ import type {
4751 OrchestrationReadModel,
4852} from "./orchestration";
4953import { EditorId } from "./editor";
54import { ServerSettings, ServerSettingsPatch } from "./settings";
5055
5156export interface ContextMenuItem<T extends string = string> {
5257 id: T;
......@@ -160,7 +165,10 @@ export interface NativeApi {
160165 };
161166 server: {
162167 getConfig: () => Promise<ServerConfig>;
168 refreshProviders: () => Promise<ServerProviderUpdatedPayload>;
163169 upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>;
170 getSettings: () => Promise<ServerSettings>;
171 updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>;
164172 };
165173 orchestration: {
166174 getSnapshot: () => Promise<OrchestrationReadModel>;
packages/contracts/src/model.ts+15-173
......@@ -1,4 +1,5 @@
11import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";
23import type { ProviderKind } from "./orchestration";
34
45export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const;
......@@ -26,178 +27,28 @@ export const ProviderModelOptions = Schema.Struct({
2627});
2728export type ProviderModelOptions = typeof ProviderModelOptions.Type;
2829
29export type EffortOption = {
30 readonly value: string;
31 readonly label: string;
32 readonly isDefault?: true;
33};
34
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};
30export const EffortOption = Schema.Struct({
31 value: TrimmedNonEmptyString,
32 label: TrimmedNonEmptyString,
33 isDefault: Schema.optional(Schema.Boolean),
34});
35export type EffortOption = typeof EffortOption.Type;
4736
48/**
49 * TODO: This should not be a static array, each provider
50 * should return its own model list over the WS API.
51 */
52export const MODEL_OPTIONS_BY_PROVIDER = {
53 codex: [
54 {
55 slug: "gpt-5.4",
56 name: "GPT-5.4",
57 capabilities: {
58 reasoningEffortLevels: [
59 { value: "xhigh", label: "Extra High" },
60 { value: "high", label: "High", isDefault: true },
61 { value: "medium", label: "Medium" },
62 { value: "low", label: "Low" },
63 ],
64 supportsFastMode: true,
65 supportsThinkingToggle: false,
66 promptInjectedEffortLevels: [],
67 },
68 },
69 {
70 slug: "gpt-5.4-mini",
71 name: "GPT-5.4 Mini",
72 capabilities: {
73 reasoningEffortLevels: [
74 { value: "xhigh", label: "Extra High" },
75 { value: "high", label: "High", isDefault: true },
76 { value: "medium", label: "Medium" },
77 { value: "low", label: "Low" },
78 ],
79 supportsFastMode: true,
80 supportsThinkingToggle: false,
81 promptInjectedEffortLevels: [],
82 },
83 },
84 {
85 slug: "gpt-5.3-codex",
86 name: "GPT-5.3 Codex",
87 capabilities: {
88 reasoningEffortLevels: [
89 { value: "xhigh", label: "Extra High" },
90 { value: "high", label: "High", isDefault: true },
91 { value: "medium", label: "Medium" },
92 { value: "low", label: "Low" },
93 ],
94 supportsFastMode: true,
95 supportsThinkingToggle: false,
96 promptInjectedEffortLevels: [],
97 },
98 },
99 {
100 slug: "gpt-5.3-codex-spark",
101 name: "GPT-5.3 Codex Spark",
102 capabilities: {
103 reasoningEffortLevels: [
104 { value: "xhigh", label: "Extra High" },
105 { value: "high", label: "High", isDefault: true },
106 { value: "medium", label: "Medium" },
107 { value: "low", label: "Low" },
108 ],
109 supportsFastMode: true,
110 supportsThinkingToggle: false,
111 promptInjectedEffortLevels: [],
112 },
113 },
114 {
115 slug: "gpt-5.2-codex",
116 name: "GPT-5.2 Codex",
117 capabilities: {
118 reasoningEffortLevels: [
119 { value: "xhigh", label: "Extra High" },
120 { value: "high", label: "High", isDefault: true },
121 { value: "medium", label: "Medium" },
122 { value: "low", label: "Low" },
123 ],
124 supportsFastMode: true,
125 supportsThinkingToggle: false,
126 promptInjectedEffortLevels: [],
127 },
128 },
129 {
130 slug: "gpt-5.2",
131 name: "GPT-5.2",
132 capabilities: {
133 reasoningEffortLevels: [
134 { value: "xhigh", label: "Extra High" },
135 { value: "high", label: "High", isDefault: true },
136 { value: "medium", label: "Medium" },
137 { value: "low", label: "Low" },
138 ],
139 supportsFastMode: true,
140 supportsThinkingToggle: false,
141 promptInjectedEffortLevels: [],
142 },
143 },
144 ],
145 claudeAgent: [
146 {
147 slug: "claude-opus-4-6",
148 name: "Claude Opus 4.6",
149 capabilities: {
150 reasoningEffortLevels: [
151 { value: "low", label: "Low" },
152 { value: "medium", label: "Medium" },
153 { value: "high", label: "High", isDefault: true },
154 { value: "max", label: "Max" },
155 { value: "ultrathink", label: "Ultrathink" },
156 ],
157 supportsFastMode: true,
158 supportsThinkingToggle: false,
159 promptInjectedEffortLevels: ["ultrathink"],
160 },
161 },
162 {
163 slug: "claude-sonnet-4-6",
164 name: "Claude Sonnet 4.6",
165 capabilities: {
166 reasoningEffortLevels: [
167 { value: "low", label: "Low" },
168 { value: "medium", label: "Medium" },
169 { value: "high", label: "High", isDefault: true },
170 { value: "ultrathink", label: "Ultrathink" },
171 ],
172 supportsFastMode: false,
173 supportsThinkingToggle: false,
174 promptInjectedEffortLevels: ["ultrathink"],
175 },
176 },
177 {
178 slug: "claude-haiku-4-5",
179 name: "Claude Haiku 4.5",
180 capabilities: {
181 reasoningEffortLevels: [],
182 supportsFastMode: false,
183 supportsThinkingToggle: true,
184 promptInjectedEffortLevels: [],
185 },
186 },
187 ],
188} as const satisfies Record<ProviderKind, readonly ModelDefinition[]>;
189export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER;
37export const ModelCapabilities = Schema.Struct({
38 reasoningEffortLevels: Schema.Array(EffortOption),
39 supportsFastMode: Schema.Boolean,
40 supportsThinkingToggle: Schema.Boolean,
41 promptInjectedEffortLevels: Schema.Array(TrimmedNonEmptyString),
42});
43export type ModelCapabilities = typeof ModelCapabilities.Type;
19044
191type BuiltInModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"];
192export type ModelSlug = BuiltInModelSlug | (string & {});
45export type ModelSlug = string & {};
19346
19447export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, ModelSlug> = {
19548 codex: "gpt-5.4",
19649 claudeAgent: "claude-sonnet-4-6",
19750};
19851
199// Backward compatibility for existing Codex-only call sites.
200export const MODEL_OPTIONS = MODEL_OPTIONS_BY_PROVIDER.codex;
20152export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex;
20253
20354/** Per-provider text generation model defaults. */
......@@ -230,15 +81,6 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string,
23081 },
23182};
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
24284// ── Provider display names ────────────────────────────────────────────
24385
24486export 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";
4747export const CodexModelSelection = Schema.Struct({
4848 provider: Schema.Literal("codex"),
4949 model: TrimmedNonEmptyString,
50 options: Schema.optional(CodexModelOptions),
50 options: Schema.optionalKey(CodexModelOptions),
5151});
5252export type CodexModelSelection = typeof CodexModelSelection.Type;
5353
5454export const ClaudeModelSelection = Schema.Struct({
5555 provider: Schema.Literal("claudeAgent"),
5656 model: TrimmedNonEmptyString,
57 options: Schema.optional(ClaudeModelOptions),
57 options: Schema.optionalKey(ClaudeModelOptions),
5858});
5959export type ClaudeModelSelection = typeof ClaudeModelSelection.Type;
6060
6161export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]);
6262export 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
8164export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]);
8265export type RuntimeMode = typeof RuntimeMode.Type;
8366export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
......@@ -402,8 +385,6 @@ export const ThreadTurnStartCommand = Schema.Struct({
402385 attachments: Schema.Array(ChatAttachment),
403386 }),
404387 modelSelection: Schema.optional(ModelSelection),
405 providerOptions: Schema.optional(ProviderStartOptions),
406 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
407388 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
408389 interactionMode: ProviderInteractionMode.pipe(
409390 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
......@@ -423,8 +404,6 @@ const ClientThreadTurnStartCommand = Schema.Struct({
423404 attachments: Schema.Array(UploadChatAttachment),
424405 }),
425406 modelSelection: Schema.optional(ModelSelection),
426 providerOptions: Schema.optional(ProviderStartOptions),
427 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
428407 runtimeMode: RuntimeMode,
429408 interactionMode: ProviderInteractionMode,
430409 sourceProposedPlan: Schema.optional(SourceProposedPlanReference),
......@@ -702,7 +681,6 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({
702681 threadId: ThreadId,
703682 messageId: MessageId,
704683 modelSelection: Schema.optional(ModelSelection),
705 providerOptions: Schema.optional(ProviderStartOptions),
706684 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
707685 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
708686 interactionMode: ProviderInteractionMode.pipe(
packages/contracts/src/provider.test.ts-18
......@@ -21,12 +21,6 @@ describe("ProviderSessionStartInput", () => {
2121 },
2222 },
2323 runtimeMode: "full-access",
24 providerOptions: {
25 codex: {
26 binaryPath: "/usr/local/bin/codex",
27 homePath: "/tmp/.codex",
28 },
29 },
3024 });
3125 expect(parsed.runtimeMode).toBe("full-access");
3226 expect(parsed.modelSelection?.provider).toBe("codex");
......@@ -36,8 +30,6 @@ describe("ProviderSessionStartInput", () => {
3630 }
3731 expect(parsed.modelSelection.options?.reasoningEffort).toBe("high");
3832 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");
4133 });
4234
4335 it("rejects payloads without runtime mode", () => {
......@@ -63,13 +55,6 @@ describe("ProviderSessionStartInput", () => {
6355 fastMode: true,
6456 },
6557 },
66 providerOptions: {
67 claudeAgent: {
68 binaryPath: "/usr/local/bin/claude",
69 permissionMode: "plan",
70 maxThinkingTokens: 12_000,
71 },
72 },
7358 runtimeMode: "full-access",
7459 });
7560 expect(parsed.provider).toBe("claudeAgent");
......@@ -81,9 +66,6 @@ describe("ProviderSessionStartInput", () => {
8166 expect(parsed.modelSelection.options?.thinking).toBe(true);
8267 expect(parsed.modelSelection.options?.effort).toBe("max");
8368 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);
8769 expect(parsed.runtimeMode).toBe("full-access");
8870 });
8971});
packages/contracts/src/provider.ts-2
......@@ -19,7 +19,6 @@ import {
1919 ProviderKind,
2020 ProviderRequestKind,
2121 ProviderSandboxMode,
22 ProviderStartOptions,
2322 ProviderUserInputAnswers,
2423 RuntimeMode,
2524} from "./orchestration";
......@@ -55,7 +54,6 @@ export const ProviderSessionStartInput = Schema.Struct({
5554 resumeCursor: Schema.optional(Schema.Unknown),
5655 approvalPolicy: Schema.optional(ProviderApprovalPolicy),
5756 sandboxMode: Schema.optional(ProviderSandboxMode),
58 providerOptions: Schema.optional(ProviderStartOptions),
5957 runtimeMode: RuntimeMode,
6058});
6159export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type;
packages/contracts/src/server.ts+28-9
......@@ -2,7 +2,9 @@ import { Schema } from "effect";
22import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas";
33import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";
44import { EditorId } from "./editor";
5import { ModelCapabilities } from "./model";
56import { ProviderKind } from "./orchestration";
7import { ServerSettings } from "./settings";
68
79const KeybindingsMalformedConfigIssue = Schema.Struct({
810 kind: Schema.Literal("keybindings.malformed-config"),
......@@ -23,8 +25,8 @@ export type ServerConfigIssue = typeof ServerConfigIssue.Type;
2325
2426const ServerConfigIssues = Schema.Array(ServerConfigIssue);
2527
26export const ServerProviderStatusState = Schema.Literals(["ready", "warning", "error"]);
27export type ServerProviderStatusState = typeof ServerProviderStatusState.Type;
28export const ServerProviderState = Schema.Literals(["ready", "warning", "error", "disabled"]);
29export type ServerProviderState = typeof ServerProviderState.Type;
2830
2931export const ServerProviderAuthStatus = Schema.Literals([
3032 "authenticated",
......@@ -33,25 +35,37 @@ export const ServerProviderAuthStatus = Schema.Literals([
3335]);
3436export 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({
3747 provider: ProviderKind,
38 status: ServerProviderStatusState,
39 available: Schema.Boolean,
48 enabled: Schema.Boolean,
49 installed: Schema.Boolean,
50 version: Schema.NullOr(TrimmedNonEmptyString),
51 status: ServerProviderState,
4052 authStatus: ServerProviderAuthStatus,
4153 checkedAt: IsoDateTime,
4254 message: Schema.optional(TrimmedNonEmptyString),
55 models: Schema.Array(ServerProviderModel),
4356});
44export type ServerProviderStatus = typeof ServerProviderStatus.Type;
57export type ServerProvider = typeof ServerProvider.Type;
4558
46const ServerProviderStatuses = Schema.Array(ServerProviderStatus);
59const ServerProviders = Schema.Array(ServerProvider);
4760
4861export const ServerConfig = Schema.Struct({
4962 cwd: TrimmedNonEmptyString,
5063 keybindingsConfigPath: TrimmedNonEmptyString,
5164 keybindings: ResolvedKeybindingsConfig,
5265 issues: ServerConfigIssues,
53 providers: ServerProviderStatuses,
66 providers: ServerProviders,
5467 availableEditors: Schema.Array(EditorId),
68 settings: ServerSettings,
5569});
5670export type ServerConfig = typeof ServerConfig.Type;
5771
......@@ -66,6 +80,11 @@ export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.T
6680
6781export const ServerConfigUpdatedPayload = Schema.Struct({
6882 issues: ServerConfigIssues,
69 providers: ServerProviderStatuses,
83 settings: Schema.optional(ServerSettings),
7084});
7185export 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", () =>
119119 }),
120120);
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
122141it.effect("rejects push envelopes when channel payload does not match the channel schema", () =>
123142 Effect.gen(function* () {
124143 const result = yield* Effect.exit(
packages/contracts/src/ws.ts+16-1
......@@ -37,7 +37,8 @@ import {
3737import { KeybindingRule } from "./keybindings";
3838import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project";
3939import { OpenInEditorInput } from "./editor";
40import { ServerConfigUpdatedPayload } from "./server";
40import { ServerConfigUpdatedPayload, ServerProviderUpdatedPayload } from "./server";
41import { ServerSettingsPatch } from "./settings";
4142
4243// ── WebSocket RPC Method Names ───────────────────────────────────────
4344
......@@ -75,7 +76,10 @@ export const WS_METHODS = {
7576
7677 // Server meta
7778 serverGetConfig: "server.getConfig",
79 serverRefreshProviders: "server.refreshProviders",
7880 serverUpsertKeybinding: "server.upsertKeybinding",
81 serverGetSettings: "server.getSettings",
82 serverUpdateSettings: "server.updateSettings",
7983} as const;
8084
8185// ── Push Event Channels ──────────────────────────────────────────────
......@@ -85,6 +89,7 @@ export const WS_CHANNELS = {
8589 terminalEvent: "terminal.event",
8690 serverWelcome: "server.welcome",
8791 serverConfigUpdated: "server.configUpdated",
92 serverProvidersUpdated: "server.providersUpdated",
8893} as const;
8994
9095// -- Tagged Union of all request body schemas ─────────────────────────
......@@ -140,7 +145,10 @@ const WebSocketRequestBody = Schema.Union([
140145
141146 // Server meta
142147 tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})),
148 tagRequestBody(WS_METHODS.serverRefreshProviders, Schema.Struct({})),
143149 tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule),
150 tagRequestBody(WS_METHODS.serverGetSettings, Schema.Struct({})),
151 tagRequestBody(WS_METHODS.serverUpdateSettings, Schema.Struct({ patch: ServerSettingsPatch })),
144152]);
145153
146154export const WebSocketRequest = Schema.Struct({
......@@ -174,6 +182,7 @@ export type WsWelcomePayload = typeof WsWelcomePayload.Type;
174182export interface WsPushPayloadByChannel {
175183 readonly [WS_CHANNELS.serverWelcome]: WsWelcomePayload;
176184 readonly [WS_CHANNELS.serverConfigUpdated]: typeof ServerConfigUpdatedPayload.Type;
185 readonly [WS_CHANNELS.serverProvidersUpdated]: typeof ServerProviderUpdatedPayload.Type;
177186 readonly [WS_CHANNELS.gitActionProgress]: typeof GitActionProgressEvent.Type;
178187 readonly [WS_CHANNELS.terminalEvent]: typeof TerminalEvent.Type;
179188 readonly [ORCHESTRATION_WS_CHANNELS.domainEvent]: OrchestrationEvent;
......@@ -198,6 +207,10 @@ export const WsPushServerConfigUpdated = makeWsPushSchema(
198207 WS_CHANNELS.serverConfigUpdated,
199208 ServerConfigUpdatedPayload,
200209);
210export const WsPushServerProvidersUpdated = makeWsPushSchema(
211 WS_CHANNELS.serverProvidersUpdated,
212 ServerProviderUpdatedPayload,
213);
201214export const WsPushGitActionProgress = makeWsPushSchema(
202215 WS_CHANNELS.gitActionProgress,
203216 GitActionProgressEvent,
......@@ -212,6 +225,7 @@ export const WsPushChannelSchema = Schema.Literals([
212225 WS_CHANNELS.gitActionProgress,
213226 WS_CHANNELS.serverWelcome,
214227 WS_CHANNELS.serverConfigUpdated,
228 WS_CHANNELS.serverProvidersUpdated,
215229 WS_CHANNELS.terminalEvent,
216230 ORCHESTRATION_WS_CHANNELS.domainEvent,
217231]);
......@@ -220,6 +234,7 @@ export type WsPushChannelSchema = typeof WsPushChannelSchema.Type;
220234export const WsPush = Schema.Union([
221235 WsPushServerWelcome,
222236 WsPushServerConfigUpdated,
237 WsPushServerProvidersUpdated,
223238 WsPushGitActionProgress,
224239 WsPushTerminalEvent,
225240 WsPushOrchestrationDomainEvent,
packages/shared/package.json+4
......@@ -31,6 +31,10 @@
3131 "./schemaJson": {
3232 "types": "./src/schemaJson.ts",
3333 "import": "./src/schemaJson.ts"
34 },
35 "./Struct": {
36 "types": "./src/Struct.ts",
37 "import": "./src/Struct.ts"
3438 }
3539 },
3640 "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";
22import {
33 DEFAULT_MODEL,
44 DEFAULT_MODEL_BY_PROVIDER,
5 MODEL_OPTIONS,
6 MODEL_OPTIONS_BY_PROVIDER,
7 CODEX_REASONING_EFFORT_OPTIONS,
5 type ModelCapabilities,
86} from "@t3tools/contracts";
97
108import {
119 applyClaudePromptEffortPrefix,
12 getDefaultModel,
13 getModelCapabilities,
14 getModelOptions,
10 getDefaultEffort,
11 hasEffortLevel,
1512 isClaudeUltrathinkPrompt,
16 normalizeClaudeModelOptions,
17 normalizeCodexModelOptions,
1813 normalizeModelSlug,
19 resolveSelectableModel,
2014 resolveModelSlug,
2115 resolveModelSlugForProvider,
22 getDefaultEffort,
23 hasEffortLevel,
16 resolveSelectableModel,
17 trimOrNull,
2418} 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
2641describe("normalizeModelSlug", () => {
2742 it("maps known aliases to canonical slugs", () => {
2843 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");
3045 });
3146
3247 it("returns null for empty or missing values", () => {
......@@ -35,290 +50,62 @@ describe("normalizeModelSlug", () => {
3550 expect(normalizeModelSlug(null)).toBeNull();
3651 expect(normalizeModelSlug(undefined)).toBeNull();
3752 });
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 });
5453});
5554
5655describe("resolveModelSlug", () => {
57 it("returns default only when the model is missing", () => {
56 it("returns defaults when the model is missing", () => {
5857 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", () => {
7458 expect(resolveModelSlugForProvider("claudeAgent", undefined)).toBe(
7559 DEFAULT_MODEL_BY_PROVIDER.claudeAgent,
7660 );
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 );
8161 });
8262
83 it("keeps codex defaults for backward compatibility", () => {
84 expect(getDefaultModel()).toBe(DEFAULT_MODEL);
85 expect(getModelOptions()).toEqual(MODEL_OPTIONS);
86 expect(getModelOptions("claudeAgent")).toEqual(MODEL_OPTIONS_BY_PROVIDER.claudeAgent);
63 it("preserves normalized unknown models", () => {
64 expect(resolveModelSlug("custom/internal-model")).toBe("custom/internal-model");
8765 });
8866});
8967
9068describe("resolveSelectableModel", () => {
91 it("resolves exact slug matches", () => {
92 expect(
93 resolveSelectableModel("codex", "gpt-5.3-codex", [
94 { slug: "gpt-5.4", name: "GPT-5.4" },
95 { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
96 ]),
97 ).toBe("gpt-5.3-codex");
98 });
99
100 it("resolves case-insensitive display-name matches", () => {
101 expect(
102 resolveSelectableModel("codex", "gpt-5.3 codex", [
103 { slug: "gpt-5.4", name: "GPT-5.4" },
104 { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
105 ]),
106 ).toBe("gpt-5.3-codex");
107 });
108
109 it("resolves provider-specific aliases after normalization", () => {
110 expect(
111 resolveSelectableModel("claudeAgent", "sonnet", [
112 { slug: "claude-opus-4-6", name: "Claude Opus 4.6" },
113 { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
114 ]),
115 ).toBe("claude-sonnet-4-6");
116 });
117
118 it("returns null for empty input", () => {
119 expect(resolveSelectableModel("codex", "", [{ slug: "gpt-5.4", name: "GPT-5.4" }])).toBeNull();
120 expect(
121 resolveSelectableModel("codex", " ", [{ slug: "gpt-5.4", name: "GPT-5.4" }]),
122 ).toBeNull();
123 expect(
124 resolveSelectableModel("codex", null, [{ slug: "gpt-5.4", name: "GPT-5.4" }]),
125 ).toBeNull();
126 });
127
128 it("returns null for unknown values that are not present in options", () => {
129 expect(
130 resolveSelectableModel("codex", "gpt-4.1", [{ slug: "gpt-5.4", name: "GPT-5.4" }]),
131 ).toBeNull();
132 });
133
134 it("does not accept normalized custom-looking slugs unless they exist in options", () => {
135 expect(
136 resolveSelectableModel("codex", "custom/internal-model", [
137 { slug: "gpt-5.4", name: "GPT-5.4" },
138 ]),
139 ).toBeNull();
140 });
141
142 it("respects provider boundaries", () => {
143 expect(
144 resolveSelectableModel("codex", "sonnet", [{ slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }]),
145 ).toBeNull();
146 expect(
147 resolveSelectableModel("claudeAgent", "5.3", [
148 { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
149 ]),
150 ).toBeNull();
151 });
152});
153
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" });
69 it("resolves exact slugs, labels, and aliases", () => {
70 const options = [
71 { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
72 { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
73 ];
74 expect(resolveSelectableModel("codex", "gpt-5.3-codex", options)).toBe("gpt-5.3-codex");
75 expect(resolveSelectableModel("codex", "gpt-5.3 codex", options)).toBe("gpt-5.3-codex");
76 expect(resolveSelectableModel("claudeAgent", "sonnet", options)).toBe("claude-sonnet-4-6");
19377 });
19478});
19579
196describe("getDefaultEffort", () => {
197 it("returns the default effort from capabilities", () => {
198 expect(getDefaultEffort(getModelCapabilities("codex", "gpt-5.4"))).toBe("high");
199 expect(getDefaultEffort(getModelCapabilities("claudeAgent", "claude-opus-4-6"))).toBe("high");
200 expect(getDefaultEffort(getModelCapabilities("claudeAgent", "claude-haiku-4-5"))).toBeNull();
80describe("capability helpers", () => {
81 it("reads default efforts", () => {
82 expect(getDefaultEffort(codexCaps)).toBe("high");
83 expect(getDefaultEffort(claudeCaps)).toBe("high");
20184 });
202});
20385
204describe("hasEffortLevel", () => {
205 it("validates effort against model capabilities", () => {
206 const opusCaps = getModelCapabilities("claudeAgent", "claude-opus-4-6");
207 expect(hasEffortLevel(opusCaps, "max")).toBe(true);
208 expect(hasEffortLevel(opusCaps, "xhigh")).toBe(false);
209
210 const codexCaps = getModelCapabilities("codex", "gpt-5.4");
86 it("checks effort support", () => {
21187 expect(hasEffortLevel(codexCaps, "xhigh")).toBe(true);
21288 expect(hasEffortLevel(codexCaps, "max")).toBe(false);
21389 });
21490});
21591
216describe("applyClaudePromptEffortPrefix", () => {
217 it("prefixes ultrathink prompts exactly once", () => {
218 expect(applyClaudePromptEffortPrefix("Investigate this", "ultrathink")).toBe(
219 "Ultrathink:\nInvestigate this",
220 );
221 expect(applyClaudePromptEffortPrefix("Ultrathink:\nInvestigate this", "ultrathink")).toBe(
222 "Ultrathink:\nInvestigate this",
223 );
224 });
225
226 it("leaves non-ultrathink prompts unchanged", () => {
227 expect(applyClaudePromptEffortPrefix("Investigate this", "high")).toBe("Investigate this");
228 });
229});
230
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);
92describe("misc helpers", () => {
93 it("detects ultrathink prompts", () => {
94 expect(isClaudeUltrathinkPrompt("Ultrathink:\nInvestigate")).toBe(true);
95 expect(isClaudeUltrathinkPrompt("Investigate")).toBe(false);
30496 });
30597
306 it("only enables the Claude thinking toggle for Haiku 4.5", () => {
307 const has = (m: string | undefined) =>
308 getModelCapabilities("claudeAgent", m).supportsThinkingToggle;
309 expect(has("claude-opus-4-6")).toBe(false);
310 expect(has("claude-sonnet-4-6")).toBe(false);
311 expect(has("claude-haiku-4-5")).toBe(true);
312 expect(has("haiku")).toBe(true);
313 expect(has(undefined)).toBe(false);
98 it("prefixes ultrathink prompts once", () => {
99 expect(applyClaudePromptEffortPrefix("Investigate", "ultrathink")).toBe(
100 "Ultrathink:\nInvestigate",
101 );
102 expect(applyClaudePromptEffortPrefix("Ultrathink:\nInvestigate", "ultrathink")).toBe(
103 "Ultrathink:\nInvestigate",
104 );
314105 });
315});
316106
317describe("isClaudeUltrathinkPrompt", () => {
318 it("detects ultrathink prompts case-insensitively", () => {
319 expect(isClaudeUltrathinkPrompt("Please ultrathink about this")).toBe(true);
320 expect(isClaudeUltrathinkPrompt("Ultrathink:\nInvestigate")).toBe(true);
321 expect(isClaudeUltrathinkPrompt("Think hard about this")).toBe(false);
322 expect(isClaudeUltrathinkPrompt(undefined)).toBe(false);
107 it("trims strings to null", () => {
108 expect(trimOrNull(" hi ")).toBe("hi");
109 expect(trimOrNull(" ")).toBeNull();
323110 });
324111});
packages/shared/src/model.ts+1-77
......@@ -1,31 +1,17 @@
11import {
22 DEFAULT_MODEL_BY_PROVIDER,
3 MODEL_CAPABILITIES_INDEX,
4 MODEL_OPTIONS_BY_PROVIDER,
53 MODEL_SLUG_ALIASES_BY_PROVIDER,
6 type ClaudeModelOptions,
74 type ClaudeCodeEffort,
8 type CodexModelOptions,
95 type ModelCapabilities,
106 type ModelSlug,
117 type ProviderKind,
12 CodexReasoningEffort,
138} 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
2010export interface SelectableModelOption {
2111 slug: string;
2212 name: string;
2313}
2414
25export function getModelOptions(provider: ProviderKind = "codex") {
26 return MODEL_OPTIONS_BY_PROVIDER[provider];
27}
28
2915export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug {
3016 return DEFAULT_MODEL_BY_PROVIDER[provider];
3117}
......@@ -42,24 +28,6 @@ export function getDefaultEffort(caps: ModelCapabilities): string | null {
4228 return caps.reasoningEffortLevels.find((l) => l.isDefault)?.value ?? null;
4329}
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
6331export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean {
6432 return typeof text === "string" && /\bultrathink\b/i.test(text);
6533}
......@@ -125,10 +93,7 @@ export function resolveModelSlug(
12593 if (!normalized) {
12694 return DEFAULT_MODEL_BY_PROVIDER[provider];
12795 }
128
129 return MODEL_SLUG_SET_BY_PROVIDER[provider].has(normalized)
130 ? normalized
131 : DEFAULT_MODEL_BY_PROVIDER[provider];
96 return normalized;
13297}
13398
13499export function resolveModelSlugForProvider(
......@@ -145,47 +110,6 @@ export function trimOrNull<T extends string>(value: T | null | undefined): T | n
145110 return trimmed || null;
146111}
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
189113export function applyClaudePromptEffortPrefix(
190114 text: string,
191115 effort: ClaudeCodeEffort | null | undefined,
packages/shared/src/schemaJson.ts+63-1
......@@ -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
313export const decodeJsonResult = <S extends Schema.Codec<unknown, unknown, never, never>>(
414 schema: S,
......@@ -32,3 +42,55 @@ export const formatSchemaError = (cause: Cause.Cause<Schema.SchemaError>) => {
3242 ? SchemaIssue.makeFormatterDefault()(squashed.issue)
3343 : Cause.pretty(cause);
3444};
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));