@golba98/codexa 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +320 -0
  2. package/bin/codexa.js +445 -0
  3. package/package.json +45 -0
  4. package/scripts/audit-codexa-capabilities.mjs +466 -0
  5. package/scripts/smoke-terminal-bench.mjs +35 -0
  6. package/src/app.tsx +4561 -0
  7. package/src/appRenderStability.test.ts +131 -0
  8. package/src/commands/handler.test.ts +643 -0
  9. package/src/commands/handler.ts +875 -0
  10. package/src/config/launchArgs.test.ts +158 -0
  11. package/src/config/launchArgs.ts +186 -0
  12. package/src/config/layeredConfig.test.ts +143 -0
  13. package/src/config/layeredConfig.ts +836 -0
  14. package/src/config/persistence.test.ts +110 -0
  15. package/src/config/persistence.ts +311 -0
  16. package/src/config/runtimeConfig.test.ts +218 -0
  17. package/src/config/runtimeConfig.ts +554 -0
  18. package/src/config/settings.test.ts +155 -0
  19. package/src/config/settings.ts +401 -0
  20. package/src/config/toml-serialize.ts +98 -0
  21. package/src/config/trustStore.test.ts +29 -0
  22. package/src/config/trustStore.ts +68 -0
  23. package/src/core/attachments.test.ts +155 -0
  24. package/src/core/attachments.ts +71 -0
  25. package/src/core/auth/codexAuth.test.ts +68 -0
  26. package/src/core/auth/codexAuth.ts +359 -0
  27. package/src/core/cleanupFastFail.test.ts +76 -0
  28. package/src/core/cleanupFastFail.ts +67 -0
  29. package/src/core/clipboard.ts +24 -0
  30. package/src/core/codex.ts +124 -0
  31. package/src/core/codexExecArgs.test.ts +195 -0
  32. package/src/core/codexExecArgs.ts +152 -0
  33. package/src/core/codexLaunch.test.ts +205 -0
  34. package/src/core/codexLaunch.ts +162 -0
  35. package/src/core/codexPrompt.test.ts +252 -0
  36. package/src/core/codexPrompt.ts +428 -0
  37. package/src/core/executables/claudeExecutable.ts +63 -0
  38. package/src/core/executables/codexExecutable.test.ts +212 -0
  39. package/src/core/executables/codexExecutable.ts +159 -0
  40. package/src/core/executables/executableResolver.test.ts +129 -0
  41. package/src/core/executables/executableResolver.ts +138 -0
  42. package/src/core/executables/geminiExecutable.test.ts +116 -0
  43. package/src/core/executables/geminiExecutable.ts +78 -0
  44. package/src/core/executables/pathSanityScan.test.ts +47 -0
  45. package/src/core/githubDiagnostics.test.ts +92 -0
  46. package/src/core/githubDiagnostics.ts +222 -0
  47. package/src/core/hollowResponseFormat.test.ts +58 -0
  48. package/src/core/hollowResponseFormat.ts +39 -0
  49. package/src/core/inputDebug.ts +51 -0
  50. package/src/core/launchContext.test.ts +157 -0
  51. package/src/core/launchContext.ts +266 -0
  52. package/src/core/models/codexCapabilities.test.ts +45 -0
  53. package/src/core/models/codexCapabilities.ts +95 -0
  54. package/src/core/models/codexModelCapabilities.test.ts +246 -0
  55. package/src/core/models/codexModelCapabilities.ts +571 -0
  56. package/src/core/models/modelSpecs.test.ts +283 -0
  57. package/src/core/models/modelSpecs.ts +300 -0
  58. package/src/core/perf/profiler.ts +125 -0
  59. package/src/core/perf/renderDebug.test.ts +230 -0
  60. package/src/core/perf/renderDebug.ts +373 -0
  61. package/src/core/planStorage.test.ts +143 -0
  62. package/src/core/planStorage.ts +141 -0
  63. package/src/core/process/CommandRunner.test.ts +105 -0
  64. package/src/core/process/CommandRunner.ts +269 -0
  65. package/src/core/process/processValidation.ts +101 -0
  66. package/src/core/projectInstructions.test.ts +50 -0
  67. package/src/core/projectInstructions.ts +54 -0
  68. package/src/core/providerLauncher/launcher.test.ts +238 -0
  69. package/src/core/providerLauncher/launcher.ts +203 -0
  70. package/src/core/providerLauncher/registry.test.ts +324 -0
  71. package/src/core/providerLauncher/registry.ts +253 -0
  72. package/src/core/providerLauncher/types.ts +84 -0
  73. package/src/core/providerLauncher/workspaceConfig.test.ts +638 -0
  74. package/src/core/providerLauncher/workspaceConfig.ts +407 -0
  75. package/src/core/providerRuntime/anthropic.test.ts +1120 -0
  76. package/src/core/providerRuntime/anthropic.ts +576 -0
  77. package/src/core/providerRuntime/capabilityProfile.test.ts +311 -0
  78. package/src/core/providerRuntime/capabilityProfile.ts +288 -0
  79. package/src/core/providerRuntime/claudeCodeDiscovery.ts +446 -0
  80. package/src/core/providerRuntime/contextMetadata.test.ts +468 -0
  81. package/src/core/providerRuntime/contextMetadata.ts +409 -0
  82. package/src/core/providerRuntime/gemini.test.ts +437 -0
  83. package/src/core/providerRuntime/gemini.ts +784 -0
  84. package/src/core/providerRuntime/lmstudio.test.ts +168 -0
  85. package/src/core/providerRuntime/lmstudio.ts +118 -0
  86. package/src/core/providerRuntime/local.test.ts +787 -0
  87. package/src/core/providerRuntime/local.ts +754 -0
  88. package/src/core/providerRuntime/models.ts +150 -0
  89. package/src/core/providerRuntime/reasoning.ts +17 -0
  90. package/src/core/providerRuntime/registry.test.ts +233 -0
  91. package/src/core/providerRuntime/registry.ts +203 -0
  92. package/src/core/providerRuntime/types.ts +103 -0
  93. package/src/core/providers/codexJsonStream.test.ts +148 -0
  94. package/src/core/providers/codexJsonStream.ts +305 -0
  95. package/src/core/providers/codexSubprocess.test.ts +68 -0
  96. package/src/core/providers/codexSubprocess.ts +372 -0
  97. package/src/core/providers/codexTranscript.test.ts +284 -0
  98. package/src/core/providers/codexTranscript.ts +695 -0
  99. package/src/core/providers/openaiNative.ts +13 -0
  100. package/src/core/providers/registry.ts +21 -0
  101. package/src/core/providers/types.ts +59 -0
  102. package/src/core/terminal/terminalCapabilities.test.ts +93 -0
  103. package/src/core/terminal/terminalCapabilities.ts +100 -0
  104. package/src/core/terminal/terminalControl.test.ts +75 -0
  105. package/src/core/terminal/terminalControl.ts +147 -0
  106. package/src/core/terminal/terminalSanitize.test.ts +22 -0
  107. package/src/core/terminal/terminalSanitize.ts +147 -0
  108. package/src/core/terminal/terminalSelection.test.ts +42 -0
  109. package/src/core/terminal/terminalSelection.ts +66 -0
  110. package/src/core/terminal/terminalTitle.test.ts +328 -0
  111. package/src/core/terminal/terminalTitle.ts +483 -0
  112. package/src/core/workspaceActivity.test.ts +163 -0
  113. package/src/core/workspaceActivity.ts +380 -0
  114. package/src/core/workspaceGuard.test.ts +151 -0
  115. package/src/core/workspaceGuard.ts +288 -0
  116. package/src/core/workspaceRoot.test.ts +23 -0
  117. package/src/core/workspaceRoot.ts +47 -0
  118. package/src/exec.test.ts +13 -0
  119. package/src/exec.ts +72 -0
  120. package/src/headless/execArgs.test.ts +147 -0
  121. package/src/headless/execArgs.ts +294 -0
  122. package/src/headless/execRunner.test.ts +434 -0
  123. package/src/headless/execRunner.ts +304 -0
  124. package/src/index.test.tsx +618 -0
  125. package/src/index.tsx +296 -0
  126. package/src/session/appSession.test.ts +897 -0
  127. package/src/session/appSession.ts +761 -0
  128. package/src/session/chatLifecycle.test.ts +64 -0
  129. package/src/session/chatLifecycle.ts +951 -0
  130. package/src/session/liveRenderScheduler.test.ts +201 -0
  131. package/src/session/liveRenderScheduler.ts +214 -0
  132. package/src/session/planFlow.test.ts +103 -0
  133. package/src/session/planFlow.ts +149 -0
  134. package/src/session/planTranscript.test.ts +65 -0
  135. package/src/session/planTranscript.ts +15 -0
  136. package/src/session/promptRunSchedule.test.ts +36 -0
  137. package/src/session/promptRunSchedule.ts +26 -0
  138. package/src/session/types.ts +228 -0
  139. package/src/test/runtimeTestUtils.ts +14 -0
  140. package/src/types/react-dom.d.ts +3 -0
  141. package/src/ui/ActionRequiredBlock.tsx +38 -0
  142. package/src/ui/ActivityBars.tsx +68 -0
  143. package/src/ui/ActivityIndicator.test.tsx +58 -0
  144. package/src/ui/ActivityIndicator.tsx +58 -0
  145. package/src/ui/AgentBlock.test.ts +6 -0
  146. package/src/ui/AgentBlock.tsx +130 -0
  147. package/src/ui/AnimatedStatusText.test.ts +16 -0
  148. package/src/ui/AnimatedStatusText.tsx +69 -0
  149. package/src/ui/AppShell.test.tsx +1739 -0
  150. package/src/ui/AppShell.tsx +698 -0
  151. package/src/ui/AttachmentImportPanel.test.tsx +204 -0
  152. package/src/ui/AttachmentImportPanel.tsx +98 -0
  153. package/src/ui/AuthPanel.tsx +113 -0
  154. package/src/ui/BackendPicker.tsx +28 -0
  155. package/src/ui/BottomComposer.test.ts +674 -0
  156. package/src/ui/BottomComposer.tsx +1028 -0
  157. package/src/ui/CodexLogo.tsx +55 -0
  158. package/src/ui/DashCard.tsx +82 -0
  159. package/src/ui/Markdown.test.ts +157 -0
  160. package/src/ui/Markdown.tsx +310 -0
  161. package/src/ui/ModePicker.tsx +27 -0
  162. package/src/ui/ModelPicker.tsx +31 -0
  163. package/src/ui/ModelPickerProviderScope.test.tsx +411 -0
  164. package/src/ui/ModelPickerScreen.test.tsx +99 -0
  165. package/src/ui/ModelPickerScreen.tsx +416 -0
  166. package/src/ui/ModelPickerState.test.tsx +151 -0
  167. package/src/ui/ModelReasoningPicker.test.tsx +447 -0
  168. package/src/ui/ModelReasoningPicker.tsx +458 -0
  169. package/src/ui/Panel.tsx +51 -0
  170. package/src/ui/PermissionsPanel.tsx +78 -0
  171. package/src/ui/PlanActionPicker.tsx +119 -0
  172. package/src/ui/PlanReviewPanel.test.tsx +267 -0
  173. package/src/ui/PlanReviewPanel.tsx +212 -0
  174. package/src/ui/PromptCardBorder.test.tsx +161 -0
  175. package/src/ui/ProviderPicker.test.tsx +289 -0
  176. package/src/ui/ProviderPicker.tsx +321 -0
  177. package/src/ui/ProviderShortcut.test.tsx +143 -0
  178. package/src/ui/ReasoningPicker.tsx +46 -0
  179. package/src/ui/RunFooter.tsx +65 -0
  180. package/src/ui/SelectionPanel.tsx +67 -0
  181. package/src/ui/SettingsPanel.test.tsx +233 -0
  182. package/src/ui/SettingsPanel.tsx +156 -0
  183. package/src/ui/Spinner.tsx +25 -0
  184. package/src/ui/StaticIntroItem.tsx +54 -0
  185. package/src/ui/StaticTranscriptItem.tsx +56 -0
  186. package/src/ui/TextEntryPanel.tsx +139 -0
  187. package/src/ui/ThemePicker.tsx +31 -0
  188. package/src/ui/ThinkingBlock.tsx +100 -0
  189. package/src/ui/Timeline.test.ts +2067 -0
  190. package/src/ui/Timeline.tsx +1472 -0
  191. package/src/ui/TimelineNavigation.test.tsx +201 -0
  192. package/src/ui/TopHeader.test.tsx +239 -0
  193. package/src/ui/TopHeader.tsx +257 -0
  194. package/src/ui/TurnGroup.test.tsx +365 -0
  195. package/src/ui/TurnGroup.tsx +657 -0
  196. package/src/ui/busyStatusAnimation.test.ts +30 -0
  197. package/src/ui/busyStatusAnimation.ts +11 -0
  198. package/src/ui/commandNormalize.test.ts +142 -0
  199. package/src/ui/commandNormalize.ts +66 -0
  200. package/src/ui/diffRenderer.test.ts +102 -0
  201. package/src/ui/diffRenderer.ts +116 -0
  202. package/src/ui/focus.ts +61 -0
  203. package/src/ui/focusFlow.test.tsx +1098 -0
  204. package/src/ui/inputBuffer.test.ts +151 -0
  205. package/src/ui/inputBuffer.ts +203 -0
  206. package/src/ui/layout.test.ts +145 -0
  207. package/src/ui/layout.ts +287 -0
  208. package/src/ui/modeDisplay.test.ts +42 -0
  209. package/src/ui/modeDisplay.ts +52 -0
  210. package/src/ui/outputPipeline.ts +64 -0
  211. package/src/ui/progressEntries.ts +156 -0
  212. package/src/ui/runActivityView.test.ts +89 -0
  213. package/src/ui/runActivityView.ts +37 -0
  214. package/src/ui/runLifecycleView.test.tsx +237 -0
  215. package/src/ui/slashCommands.ts +41 -0
  216. package/src/ui/statusRenderIsolation.test.tsx +654 -0
  217. package/src/ui/terminalAnswerFormat.test.ts +19 -0
  218. package/src/ui/terminalAnswerFormat.ts +128 -0
  219. package/src/ui/textLayout.test.ts +18 -0
  220. package/src/ui/textLayout.ts +338 -0
  221. package/src/ui/theme.tsx +395 -0
  222. package/src/ui/themeFlow.test.ts +53 -0
  223. package/src/ui/themeFlow.ts +41 -0
  224. package/src/ui/timelineMeasure.ts +3088 -0
  225. package/src/ui/timelineMeasureCache.test.ts +986 -0
  226. package/src/ui/useThrottledValue.ts +31 -0
@@ -0,0 +1,638 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, rmSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import test from "node:test";
6
+ import { buildProviderRegistry } from "./registry.js";
7
+ import { resetGeminiRouteValidationCacheForTests } from "../providerRuntime/gemini.js";
8
+ import { checkLocalProvider, resetLocalProviderStateForTests } from "../providerRuntime/local.js";
9
+ import {
10
+ getProviderWorkspaceConfigFile,
11
+ loadProviderWorkspaceConfig,
12
+ parseProviderWorkspaceConfig,
13
+ saveProviderWorkspaceConfig,
14
+ serializeProviderWorkspaceConfig,
15
+ setProviderActiveRoute,
16
+ setProviderDefaultReasoning,
17
+ setProviderDefaultModel,
18
+ setProviderWorkspaceDefault,
19
+ } from "./workspaceConfig.js";
20
+
21
+ function withGeminiEnv<T>(
22
+ env: Partial<NodeJS.ProcessEnv>,
23
+ callback: () => T,
24
+ ): T {
25
+ const originalGemini = process.env.GEMINI_API_KEY;
26
+ const originalGoogle = process.env.GOOGLE_API_KEY;
27
+
28
+ try {
29
+ if ("GEMINI_API_KEY" in env) {
30
+ process.env.GEMINI_API_KEY = env.GEMINI_API_KEY;
31
+ } else {
32
+ delete process.env.GEMINI_API_KEY;
33
+ }
34
+ if ("GOOGLE_API_KEY" in env) {
35
+ process.env.GOOGLE_API_KEY = env.GOOGLE_API_KEY;
36
+ } else {
37
+ delete process.env.GOOGLE_API_KEY;
38
+ }
39
+ resetGeminiRouteValidationCacheForTests();
40
+ return callback();
41
+ } finally {
42
+ if (originalGemini === undefined) {
43
+ delete process.env.GEMINI_API_KEY;
44
+ } else {
45
+ process.env.GEMINI_API_KEY = originalGemini;
46
+ }
47
+ if (originalGoogle === undefined) {
48
+ delete process.env.GOOGLE_API_KEY;
49
+ } else {
50
+ process.env.GOOGLE_API_KEY = originalGoogle;
51
+ }
52
+ resetGeminiRouteValidationCacheForTests();
53
+ }
54
+ }
55
+
56
+ test("parses provider workspace config from Codexa-owned JSON", () => {
57
+ const config = parseProviderWorkspaceConfig({
58
+ default_provider_id: "google",
59
+ activeRoute: {
60
+ providerId: "openai",
61
+ modelId: "gpt-5.5",
62
+ reasoning: "high",
63
+ },
64
+ providers: {
65
+ local: {
66
+ current_model: "llama",
67
+ current_reasoning: "medium",
68
+ type: "openai-compatible",
69
+ base_url: "http://localhost:1234/v1",
70
+ api_key: "lm-studio",
71
+ default_model: "llama",
72
+ command: "ollama",
73
+ },
74
+ unknown: {
75
+ command: "ignored",
76
+ },
77
+ },
78
+ });
79
+
80
+ assert.equal(config.workspaceDefaultProviderId, "google");
81
+ assert.deepEqual(config.activeRoute, {
82
+ providerId: "openai",
83
+ modelId: "gpt-5.5",
84
+ backendKind: "codex-cli-auth",
85
+ reasoning: "high",
86
+ });
87
+ assert.deepEqual(config.providers?.local, {
88
+ currentModel: "llama",
89
+ currentReasoning: "medium",
90
+ type: "openai-compatible",
91
+ baseUrl: "http://localhost:1234/v1",
92
+ apiKey: "lm-studio",
93
+ defaultModel: "llama",
94
+ command: "ollama",
95
+ });
96
+ assert.equal("unknown" in (config.providers ?? {}), false);
97
+ });
98
+
99
+ test("legacy Antigravity active/default config falls back to OpenAI and drops provider override", () => {
100
+ const config = parseProviderWorkspaceConfig({
101
+ workspaceDefaultProviderId: "antigravity",
102
+ activeRoute: {
103
+ providerId: "antigravity",
104
+ modelId: "external-antigravity-default",
105
+ backendKind: "antigravity-cli-auth",
106
+ reasoning: "medium",
107
+ },
108
+ providers: {
109
+ antigravity: {
110
+ current_model: "external-antigravity-default",
111
+ current_reasoning: "medium",
112
+ },
113
+ openai: {
114
+ current_model: "gpt-5.4-mini",
115
+ current_reasoning: "low",
116
+ },
117
+ },
118
+ });
119
+
120
+ assert.deepEqual(config.activeRoute, {
121
+ providerId: "openai",
122
+ modelId: "gpt-5.4-mini",
123
+ backendKind: "codex-cli-auth",
124
+ reasoning: "low",
125
+ });
126
+ assert.equal(config.workspaceDefaultProviderId, "openai");
127
+ assert.equal(config.providers?.openai?.currentModel, "gpt-5.4-mini");
128
+ assert.equal((config.providers as Record<string, unknown> | undefined)?.antigravity, undefined);
129
+ assert.deepEqual(config.migrationNotice, {
130
+ deprecatedProviderId: "antigravity",
131
+ revertedProviderId: "openai",
132
+ });
133
+ assert.doesNotMatch(JSON.stringify(serializeProviderWorkspaceConfig(config)), /antigravity/i);
134
+ });
135
+
136
+ test("legacy Antigravity backend aliases are treated as deprecated routes", () => {
137
+ const config = parseProviderWorkspaceConfig({
138
+ active_route: {
139
+ provider_id: "openai",
140
+ model_id: "external-antigravity-default",
141
+ backend_kind: "agy",
142
+ },
143
+ });
144
+
145
+ assert.equal(config.activeRoute?.providerId, "openai");
146
+ assert.equal(config.activeRoute?.backendKind, "codex-cli-auth");
147
+ assert.equal(config.migrationNotice?.revertedProviderId, "openai");
148
+ });
149
+
150
+ test("serializes and persists provider workspace defaults", () => {
151
+ const tempRoot = mkdtempSync(join(tmpdir(), "codexa-provider-config-"));
152
+ try {
153
+ const config = setProviderWorkspaceDefault({}, "anthropic");
154
+ saveProviderWorkspaceConfig(tempRoot, config);
155
+
156
+ assert.equal(getProviderWorkspaceConfigFile(tempRoot), join(tempRoot, ".codexa", "providers.json"));
157
+ assert.deepEqual(loadProviderWorkspaceConfig(tempRoot), {
158
+ workspaceDefaultProviderId: "anthropic",
159
+ });
160
+ assert.deepEqual(serializeProviderWorkspaceConfig(config), {
161
+ workspaceDefaultProviderId: "anthropic",
162
+ });
163
+ } finally {
164
+ rmSync(tempRoot, { recursive: true, force: true });
165
+ }
166
+ });
167
+
168
+ test("loaded workspace default is applied to a fresh provider registry", () => {
169
+ const tempRoot = mkdtempSync(join(tmpdir(), "codexa-provider-restart-"));
170
+ try {
171
+ saveProviderWorkspaceConfig(tempRoot, setProviderWorkspaceDefault({}, "google"));
172
+
173
+ const loadedConfig = loadProviderWorkspaceConfig(tempRoot);
174
+ const providers = buildProviderRegistry({
175
+ activeModel: "gpt-5.4",
176
+ workspaceConfig: loadedConfig,
177
+ });
178
+
179
+ assert.equal(providers.find((provider) => provider.id === "google")?.isDefault, true);
180
+ assert.equal(providers.find((provider) => provider.id === "openai")?.isDefault, false);
181
+ } finally {
182
+ rmSync(tempRoot, { recursive: true, force: true });
183
+ }
184
+ });
185
+
186
+ test("workspace provider config reload preserves default and active route separately", () => {
187
+ const tempRoot = mkdtempSync(join(tmpdir(), "codexa-provider-route-"));
188
+ try {
189
+ saveProviderWorkspaceConfig(tempRoot, {
190
+ workspaceDefaultProviderId: "anthropic",
191
+ activeRoute: {
192
+ providerId: "openai",
193
+ modelId: "gpt-5.5",
194
+ backendKind: "codex-cli-auth",
195
+ reasoning: "high",
196
+ },
197
+ });
198
+
199
+ assert.deepEqual(loadProviderWorkspaceConfig(tempRoot), {
200
+ workspaceDefaultProviderId: "anthropic",
201
+ activeRoute: {
202
+ providerId: "openai",
203
+ modelId: "gpt-5.5",
204
+ backendKind: "codex-cli-auth",
205
+ reasoning: "high",
206
+ },
207
+ });
208
+ } finally {
209
+ rmSync(tempRoot, { recursive: true, force: true });
210
+ }
211
+ });
212
+
213
+ test("active Anthropic route persists without secrets", () => {
214
+ const tempRoot = mkdtempSync(join(tmpdir(), "codexa-provider-anthropic-route-"));
215
+ const original = process.env.ANTHROPIC_API_KEY;
216
+
217
+ try {
218
+ process.env.ANTHROPIC_API_KEY = "secret-test-key";
219
+ saveProviderWorkspaceConfig(tempRoot, {
220
+ workspaceDefaultProviderId: "anthropic",
221
+ activeRoute: {
222
+ providerId: "anthropic",
223
+ modelId: "claude-sonnet-4-20250514",
224
+ backendKind: "claude-code-auth",
225
+ reasoning: "high",
226
+ },
227
+ });
228
+
229
+ const loaded = loadProviderWorkspaceConfig(tempRoot);
230
+ assert.deepEqual(loaded, {
231
+ workspaceDefaultProviderId: "anthropic",
232
+ activeRoute: {
233
+ providerId: "anthropic",
234
+ modelId: "claude-sonnet-4-20250514",
235
+ backendKind: "claude-code-auth",
236
+ reasoning: "high",
237
+ },
238
+ });
239
+ assert.doesNotMatch(JSON.stringify(serializeProviderWorkspaceConfig(loaded)), /secret-test-key/);
240
+ } finally {
241
+ if (original === undefined) {
242
+ delete process.env.ANTHROPIC_API_KEY;
243
+ } else {
244
+ process.env.ANTHROPIC_API_KEY = original;
245
+ }
246
+ rmSync(tempRoot, { recursive: true, force: true });
247
+ }
248
+ });
249
+
250
+ test("setProviderActiveRoute rejects unconfigured Gemini routes", () => {
251
+ withGeminiEnv({}, () => {
252
+ const config = setProviderActiveRoute({
253
+ activeRoute: {
254
+ providerId: "openai",
255
+ modelId: "gpt-5.5",
256
+ backendKind: "codex-cli-auth",
257
+ reasoning: "high",
258
+ },
259
+ }, {
260
+ providerId: "google",
261
+ modelId: "gemini-2.5-flash",
262
+ backendKind: "gemini-cli-auth",
263
+ reasoning: "high",
264
+ });
265
+
266
+ assert.deepEqual(config.activeRoute, {
267
+ providerId: "openai",
268
+ modelId: "gpt-5.5",
269
+ backendKind: "codex-cli-auth",
270
+ reasoning: "high",
271
+ });
272
+ });
273
+ });
274
+
275
+ test("setProviderActiveRoute persists Gemini routes when GEMINI_API_KEY is configured", () => {
276
+ withGeminiEnv({ GEMINI_API_KEY: "test-gemini-key" }, () => {
277
+ const config = setProviderActiveRoute({}, {
278
+ providerId: "google",
279
+ modelId: "gemini-2.5-flash",
280
+ backendKind: "gemini-api-key",
281
+ reasoning: "high",
282
+ });
283
+
284
+ assert.deepEqual(config.activeRoute, {
285
+ providerId: "google",
286
+ modelId: "gemini-2.5-flash",
287
+ backendKind: "gemini-api-key",
288
+ reasoning: "high",
289
+ });
290
+ assert.doesNotMatch(JSON.stringify(serializeProviderWorkspaceConfig(config)), /test-gemini-key/);
291
+ });
292
+ });
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // setProviderDefaultModel
296
+ // ---------------------------------------------------------------------------
297
+
298
+ test("setProviderDefaultModel saves model without overwriting other provider fields", () => {
299
+ const initial = {
300
+ providers: {
301
+ anthropic: { currentModel: "opus", currentReasoning: "high", enabled: true },
302
+ google: { currentModel: "gemini-2.5-pro" },
303
+ },
304
+ };
305
+ const updated = setProviderDefaultModel(initial, "anthropic", "sonnet");
306
+ assert.equal(updated.providers?.["anthropic"]?.currentModel, "sonnet");
307
+ assert.equal(updated.providers?.["anthropic"]?.currentReasoning, "high", "reasoning must be preserved");
308
+ assert.equal(updated.providers?.["anthropic"]?.enabled, true, "enabled flag must be preserved");
309
+ assert.equal(updated.providers?.["google"]?.currentModel, "gemini-2.5-pro", "other provider unchanged");
310
+ });
311
+
312
+ test("setProviderDefaultModel creates providers entry when none exists", () => {
313
+ const config = setProviderDefaultModel({}, "anthropic", "haiku");
314
+ assert.equal(config.providers?.["anthropic"]?.currentModel, "haiku");
315
+ });
316
+
317
+ test("setProviderDefaultModel round-trips through serialize/parse", () => {
318
+ const config = setProviderDefaultModel({}, "anthropic", "sonnet");
319
+ const serialized = serializeProviderWorkspaceConfig(config);
320
+ const reparsed = parseProviderWorkspaceConfig(serialized);
321
+ assert.equal(reparsed.providers?.["anthropic"]?.currentModel, "sonnet");
322
+ });
323
+
324
+ test("setProviderDefaultReasoning saves provider-scoped reasoning without touching other providers", () => {
325
+ const initial = {
326
+ providers: {
327
+ openai: { currentReasoning: "high" },
328
+ anthropic: { currentModel: "sonnet", currentReasoning: "medium" },
329
+ },
330
+ };
331
+ const updated = setProviderDefaultReasoning(initial, "anthropic", "max");
332
+ assert.equal(updated.providers?.anthropic?.currentModel, "sonnet");
333
+ assert.equal(updated.providers?.anthropic?.currentReasoning, "max");
334
+ assert.equal(updated.providers?.openai?.currentReasoning, "high");
335
+ });
336
+
337
+ test("provider default reasoning round-trips through serialize/parse", () => {
338
+ const config = setProviderDefaultReasoning(
339
+ setProviderDefaultModel({}, "anthropic", "sonnet"),
340
+ "anthropic",
341
+ "xhigh",
342
+ );
343
+ const serialized = serializeProviderWorkspaceConfig(config);
344
+ assert.deepEqual(serialized.providers, {
345
+ anthropic: {
346
+ current_model: "sonnet",
347
+ current_reasoning: "xhigh",
348
+ },
349
+ });
350
+ const reparsed = parseProviderWorkspaceConfig(serialized);
351
+ assert.equal(reparsed.providers?.anthropic?.currentModel, "sonnet");
352
+ assert.equal(reparsed.providers?.anthropic?.currentReasoning, "xhigh");
353
+ });
354
+
355
+ test("Anthropic claudeCommandPath round-trips through serialize/parse", () => {
356
+ const config = parseProviderWorkspaceConfig({
357
+ providers: {
358
+ anthropic: {
359
+ current_model: "sonnet",
360
+ claude_command_path: "C:\\Users\\Example\\.local\\bin\\claude.exe",
361
+ },
362
+ },
363
+ });
364
+
365
+ assert.equal(config.providers?.anthropic?.claudeCommandPath, "C:\\Users\\Example\\.local\\bin\\claude.exe");
366
+ const serialized = serializeProviderWorkspaceConfig(config);
367
+ assert.deepEqual(serialized.providers, {
368
+ anthropic: {
369
+ current_model: "sonnet",
370
+ claude_command_path: "C:\\Users\\Example\\.local\\bin\\claude.exe",
371
+ },
372
+ });
373
+ });
374
+
375
+ test("Gemini geminiCommandPath round-trips through serialize/parse", () => {
376
+ const config = parseProviderWorkspaceConfig({
377
+ providers: {
378
+ google: {
379
+ current_model: "gemini-2.5-flash",
380
+ gemini_command_path: "C:\\Users\\Example\\AppData\\Roaming\\npm\\gemini.cmd",
381
+ },
382
+ },
383
+ });
384
+
385
+ assert.equal(config.providers?.google?.geminiCommandPath, "C:\\Users\\Example\\AppData\\Roaming\\npm\\gemini.cmd");
386
+ const serialized = serializeProviderWorkspaceConfig(config);
387
+ assert.deepEqual(serialized.providers, {
388
+ google: {
389
+ current_model: "gemini-2.5-flash",
390
+ gemini_command_path: "C:\\Users\\Example\\AppData\\Roaming\\npm\\gemini.cmd",
391
+ },
392
+ });
393
+ });
394
+
395
+ test("Codex codexCommandPath round-trips through serialize/parse", () => {
396
+ const config = parseProviderWorkspaceConfig({
397
+ providers: {
398
+ openai: {
399
+ current_model: "gpt-5.4",
400
+ codex_command_path: "C:\\Users\\Example\\AppData\\Roaming\\npm\\codex.cmd",
401
+ },
402
+ },
403
+ });
404
+
405
+ assert.equal(config.providers?.openai?.codexCommandPath, "C:\\Users\\Example\\AppData\\Roaming\\npm\\codex.cmd");
406
+ const serialized = serializeProviderWorkspaceConfig(config);
407
+ assert.deepEqual(serialized.providers, {
408
+ openai: {
409
+ current_model: "gpt-5.4",
410
+ codex_command_path: "C:\\Users\\Example\\AppData\\Roaming\\npm\\codex.cmd",
411
+ },
412
+ });
413
+ });
414
+
415
+ test("Local OpenAI-compatible config round-trips through serialize/parse", () => {
416
+ const config = parseProviderWorkspaceConfig({
417
+ providers: {
418
+ local: {
419
+ enabled: true,
420
+ type: "openai-compatible",
421
+ base_url: "http://localhost:1234/v1",
422
+ api_key: "lm-studio",
423
+ pinned_model: "qwen/qwen3.6-27b",
424
+ default_model: "google/gemma-4-26b-a4b",
425
+ models: {
426
+ "google/gemma-4-26b-a4b": {
427
+ contextLength: 8192,
428
+ },
429
+ },
430
+ },
431
+ },
432
+ });
433
+
434
+ assert.deepEqual(config.providers?.local, {
435
+ enabled: true,
436
+ type: "openai-compatible",
437
+ baseUrl: "http://localhost:1234/v1",
438
+ apiKey: "lm-studio",
439
+ pinnedModel: "qwen/qwen3.6-27b",
440
+ defaultModel: "google/gemma-4-26b-a4b",
441
+ models: {
442
+ "google/gemma-4-26b-a4b": {
443
+ contextLength: 8192,
444
+ },
445
+ },
446
+ });
447
+ assert.deepEqual(serializeProviderWorkspaceConfig(config).providers, {
448
+ local: {
449
+ enabled: true,
450
+ type: "openai-compatible",
451
+ base_url: "http://localhost:1234/v1",
452
+ api_key: "lm-studio",
453
+ pinned_model: "qwen/qwen3.6-27b",
454
+ default_model: "google/gemma-4-26b-a4b",
455
+ models: {
456
+ "google/gemma-4-26b-a4b": {
457
+ contextLength: 8192,
458
+ },
459
+ },
460
+ },
461
+ });
462
+ });
463
+
464
+ test("provider model context length config rejects invalid values", () => {
465
+ const config = parseProviderWorkspaceConfig({
466
+ providers: {
467
+ local: {
468
+ models: {
469
+ zero: { contextLength: 0 },
470
+ negative: { contextLength: -1 },
471
+ decimal: { contextLength: 8192.5 },
472
+ text: { contextLength: "8192" },
473
+ valid: { context_length: 32768 },
474
+ },
475
+ },
476
+ },
477
+ });
478
+
479
+ assert.deepEqual(config.providers?.local?.models, {
480
+ valid: {
481
+ contextLength: 32768,
482
+ },
483
+ });
484
+ });
485
+
486
+ test("setProviderActiveRoute persists Local routes after endpoint discovery", async () => {
487
+ resetLocalProviderStateForTests();
488
+ try {
489
+ await checkLocalProvider({
490
+ fetchImpl: (async (input) => {
491
+ if (String(input).includes("/api/v0/")) {
492
+ return new Response(null, { status: 404 });
493
+ }
494
+ return new Response(JSON.stringify({
495
+ data: [{ id: "google/gemma-4-26b-a4b" }],
496
+ }), { status: 200 });
497
+ }) as typeof fetch,
498
+ });
499
+ const config = setProviderActiveRoute({}, {
500
+ providerId: "local",
501
+ modelId: "google/gemma-4-26b-a4b",
502
+ backendKind: "local-openai-compatible",
503
+ });
504
+
505
+ assert.deepEqual(config.activeRoute, {
506
+ providerId: "local",
507
+ modelId: "google/gemma-4-26b-a4b",
508
+ backendKind: "local-openai-compatible",
509
+ });
510
+ } finally {
511
+ resetLocalProviderStateForTests();
512
+ }
513
+ });
514
+
515
+ test("Gemini workspace config normalizes legacy flash model IDs to preview", () => {
516
+ const config = parseProviderWorkspaceConfig({
517
+ activeRoute: {
518
+ providerId: "google",
519
+ modelId: "gemini-3-flash",
520
+ backendKind: "gemini-cli-auth",
521
+ modelSelection: {
522
+ kind: "manual",
523
+ modelId: "gemini-3-flash",
524
+ },
525
+ },
526
+ providers: {
527
+ google: {
528
+ current_model: "gemini-3-flash",
529
+ },
530
+ },
531
+ });
532
+
533
+ assert.equal(config.activeRoute?.modelId, "gemini-3-flash-preview");
534
+ assert.deepEqual(config.activeRoute?.modelSelection, {
535
+ kind: "manual",
536
+ modelId: "gemini-3-flash-preview",
537
+ });
538
+ assert.equal(config.providers?.google?.currentModel, "gemini-3-flash-preview");
539
+ });
540
+
541
+ test("Local model capability fields round-trip through serialize/parse", () => {
542
+ const config = parseProviderWorkspaceConfig({
543
+ providers: {
544
+ local: {
545
+ enabled: true,
546
+ type: "openai-compatible",
547
+ base_url: "http://localhost:1234/v1",
548
+ api_key: "lm-studio",
549
+ default_model: "test-model",
550
+ models: {
551
+ "test-model": {
552
+ contextLength: 8192,
553
+ supportsToolCalls: false,
554
+ supportsStreaming: true,
555
+ supportsSystemPrompt: true,
556
+ maxOutputTokens: 4096,
557
+ },
558
+ },
559
+ },
560
+ },
561
+ });
562
+
563
+ assert.deepEqual(config.providers?.local?.models?.["test-model"], {
564
+ contextLength: 8192,
565
+ supportsToolCalls: false,
566
+ supportsStreaming: true,
567
+ supportsSystemPrompt: true,
568
+ maxOutputTokens: 4096,
569
+ });
570
+
571
+ const serialized = serializeProviderWorkspaceConfig(config);
572
+ const reparsed = parseProviderWorkspaceConfig(serialized);
573
+ assert.deepEqual(reparsed.providers?.local?.models?.["test-model"], {
574
+ contextLength: 8192,
575
+ supportsToolCalls: false,
576
+ supportsStreaming: true,
577
+ supportsSystemPrompt: true,
578
+ maxOutputTokens: 4096,
579
+ });
580
+ });
581
+
582
+ test("Local model capability boolean string values are rejected", () => {
583
+ const config = parseProviderWorkspaceConfig({
584
+ providers: {
585
+ local: {
586
+ models: {
587
+ "bad-model": {
588
+ supportsStreaming: "true",
589
+ supportsToolCalls: 1,
590
+ supportsSystemPrompt: null,
591
+ },
592
+ },
593
+ },
594
+ },
595
+ });
596
+
597
+ // None of the invalid values should create a model entry
598
+ assert.equal(config.providers?.local?.models?.["bad-model"], undefined);
599
+ });
600
+
601
+ test("Local model maxOutputTokens: 4096 round-trips; invalid values are rejected", () => {
602
+ const config = parseProviderWorkspaceConfig({
603
+ providers: {
604
+ local: {
605
+ models: {
606
+ valid: { max_output_tokens: 4096 },
607
+ zero: { maxOutputTokens: 0 },
608
+ negative: { max_output_tokens: -512 },
609
+ decimal: { maxOutputTokens: 1024.5 },
610
+ },
611
+ },
612
+ },
613
+ });
614
+
615
+ assert.deepEqual(config.providers?.local?.models?.valid, { maxOutputTokens: 4096 });
616
+ assert.equal(config.providers?.local?.models?.zero, undefined);
617
+ assert.equal(config.providers?.local?.models?.negative, undefined);
618
+ assert.equal(config.providers?.local?.models?.decimal, undefined);
619
+ });
620
+
621
+ test("setProviderActiveRoute persists Gemini routes when GOOGLE_API_KEY is configured", () => {
622
+ withGeminiEnv({ GOOGLE_API_KEY: "test-google-key" }, () => {
623
+ const config = setProviderActiveRoute({}, {
624
+ providerId: "google",
625
+ modelId: "gemini-2.5-flash",
626
+ backendKind: "gemini-api-key",
627
+ reasoning: "high",
628
+ });
629
+
630
+ assert.deepEqual(config.activeRoute, {
631
+ providerId: "google",
632
+ modelId: "gemini-2.5-flash",
633
+ backendKind: "gemini-api-key",
634
+ reasoning: "high",
635
+ });
636
+ assert.doesNotMatch(JSON.stringify(serializeProviderWorkspaceConfig(config)), /test-google-key/);
637
+ });
638
+ });