@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,1120 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import type { ChildProcess } from "node:child_process";
4
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { normalizeRuntimeConfig, resolveRuntimeConfig } from "../../config/runtimeConfig.js";
8
+ import { runCommand, type CommandResult } from "../process/CommandRunner.js";
9
+ import { buildClaudeSpawnSpec, resetClaudeExecutableCacheForTests } from "../executables/claudeExecutable.js";
10
+ import {
11
+ ANTHROPIC_ROUTE_SETUP_MESSAGE,
12
+ anthropicRuntime,
13
+ buildClaudeCodeArgs,
14
+ buildClaudeCodePlainTextArgs,
15
+ ensureClaudeStreamJsonVerbose,
16
+ mapModelIdToClaudeArg,
17
+ mapReasoningToEffort,
18
+ parseClaudeAuthStatus,
19
+ resetAnthropicRouteValidationCacheForTests,
20
+ runClaudeCodeWithRunner,
21
+ tryParseStreamJsonDelta,
22
+ validateAnthropicRoute,
23
+ } from "./anthropic.js";
24
+ import { discoverClaudeCodeCapabilities } from "./claudeCodeDiscovery.js";
25
+ import type { ProviderChatRequest } from "./types.js";
26
+
27
+ function commandResult(overrides: Partial<CommandResult>): CommandResult {
28
+ return {
29
+ status: "completed",
30
+ exitCode: 0,
31
+ signal: null,
32
+ stdout: "",
33
+ stderr: "",
34
+ startedAt: 0,
35
+ endedAt: 0,
36
+ durationMs: 0,
37
+ userMessage: "Command completed.",
38
+ ...overrides,
39
+ };
40
+ }
41
+
42
+ function mockRunCommand(
43
+ resultOrMap: CommandResult | ((executable: string, args: string[]) => CommandResult),
44
+ onCall?: (spec: Parameters<typeof runCommand>[0]) => void,
45
+ ): typeof runCommand {
46
+ return ((spec) => {
47
+ onCall?.(spec);
48
+ const result = typeof resultOrMap === "function"
49
+ ? resultOrMap(spec.executable, spec.args)
50
+ : resultOrMap;
51
+ return {
52
+ child: null as unknown as ChildProcess,
53
+ result: Promise.resolve(result),
54
+ cancel: () => undefined,
55
+ };
56
+ }) as typeof runCommand;
57
+ }
58
+
59
+ function buildRequest(overrides: Partial<ProviderChatRequest> = {}): ProviderChatRequest {
60
+ return {
61
+ prompt: "Say hi.",
62
+ route: {
63
+ providerId: "anthropic",
64
+ modelId: "claude-sonnet-4-20250514",
65
+ backendKind: "anthropic-api-key",
66
+ reasoning: "high",
67
+ },
68
+ runtime: resolveRuntimeConfig(normalizeRuntimeConfig({})),
69
+ workspaceRoot: process.cwd(),
70
+ projectInstructions: {
71
+ path: "AGENTS.md",
72
+ content: "Be brief.",
73
+ },
74
+ ...overrides,
75
+ };
76
+ }
77
+
78
+ async function withAnthropicEnv<T>(
79
+ env: Partial<NodeJS.ProcessEnv>,
80
+ callback: () => T | Promise<T>,
81
+ ): Promise<T> {
82
+ const originalApiKey = process.env.ANTHROPIC_API_KEY;
83
+ const originalClaudeExe = process.env.CLAUDE_EXECUTABLE;
84
+ try {
85
+ if ("ANTHROPIC_API_KEY" in env) {
86
+ process.env.ANTHROPIC_API_KEY = env.ANTHROPIC_API_KEY;
87
+ } else {
88
+ delete process.env.ANTHROPIC_API_KEY;
89
+ }
90
+ if ("CLAUDE_EXECUTABLE" in env) {
91
+ process.env.CLAUDE_EXECUTABLE = env.CLAUDE_EXECUTABLE;
92
+ } else {
93
+ delete process.env.CLAUDE_EXECUTABLE;
94
+ }
95
+ resetAnthropicRouteValidationCacheForTests();
96
+ return await callback();
97
+ } finally {
98
+ if (originalApiKey === undefined) {
99
+ delete process.env.ANTHROPIC_API_KEY;
100
+ } else {
101
+ process.env.ANTHROPIC_API_KEY = originalApiKey;
102
+ }
103
+ if (originalClaudeExe === undefined) {
104
+ delete process.env.CLAUDE_EXECUTABLE;
105
+ } else {
106
+ process.env.CLAUDE_EXECUTABLE = originalClaudeExe;
107
+ }
108
+ resetAnthropicRouteValidationCacheForTests();
109
+ }
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // API key route
114
+ // ---------------------------------------------------------------------------
115
+
116
+ test("Anthropic runtime sends prompts through the Messages API", async () => {
117
+ await withAnthropicEnv({ ANTHROPIC_API_KEY: "test-anthropic-key" }, async () => {
118
+ const originalFetch = globalThis.fetch;
119
+ let capturedUrl = "";
120
+ let capturedInit: RequestInit | undefined;
121
+
122
+ try {
123
+ globalThis.fetch = (async (input, init) => {
124
+ capturedUrl = String(input);
125
+ capturedInit = init;
126
+ return new Response(JSON.stringify({
127
+ content: [{ type: "text", text: "Hi from Claude." }],
128
+ }), { status: 200 });
129
+ }) as typeof fetch;
130
+
131
+ const response = await new Promise<string>((resolve, reject) => {
132
+ anthropicRuntime.run?.(buildRequest(), {
133
+ onResponse: resolve,
134
+ onError: reject,
135
+ });
136
+ });
137
+
138
+ assert.equal(response, "Hi from Claude.");
139
+ assert.equal(capturedUrl, "https://api.anthropic.com/v1/messages");
140
+ assert.equal(capturedInit?.method, "POST");
141
+ assert.equal((capturedInit?.headers as Record<string, string>)["x-api-key"], "test-anthropic-key");
142
+ assert.equal((capturedInit?.headers as Record<string, string>)["anthropic-version"], "2023-06-01");
143
+ } finally {
144
+ globalThis.fetch = originalFetch;
145
+ }
146
+ });
147
+ });
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Windows executable resolution
151
+ // ---------------------------------------------------------------------------
152
+
153
+ test("resolver: where.exe returns .exe path → validation uses that path", async () => {
154
+ await withAnthropicEnv({}, async () => {
155
+ const resolvedPaths: string[] = [];
156
+
157
+ const validation = await validateAnthropicRoute({
158
+ cwd: process.cwd(),
159
+ runCommandImpl: mockRunCommand((executable, args) => {
160
+ if (executable === "where.exe") {
161
+ return commandResult({ exitCode: 0, stdout: "C:\\Users\\Example\\.local\\bin\\claude.exe\n" });
162
+ }
163
+ // Track which executable is used for auth/version
164
+ resolvedPaths.push(executable);
165
+ if (executable === "C:\\Users\\Example\\.local\\bin\\claude.exe" && args[0] === "auth") {
166
+ return commandResult({ exitCode: 0, stdout: JSON.stringify({ loggedIn: true, authMethod: "claude.ai" }) });
167
+ }
168
+ return commandResult({ exitCode: 0 });
169
+ }),
170
+ });
171
+
172
+ assert.equal(validation.status, "ready");
173
+ assert.equal(validation.backendKind, "claude-code-auth");
174
+ assert.equal(validation.diagnostics?.["resolvedCommand"], "C:\\Users\\Example\\.local\\bin\\claude.exe");
175
+ // The auth command should have used the resolved path, not bare "claude"
176
+ assert.ok(
177
+ resolvedPaths.some((p) => p === "C:\\Users\\Example\\.local\\bin\\claude.exe"),
178
+ `Expected resolved path to be used for auth; got: ${resolvedPaths.join(", ")}`,
179
+ );
180
+ });
181
+ });
182
+
183
+ test("resolver: where.exe returns .cmd path → auth uses that path", async () => {
184
+ await withAnthropicEnv({}, async () => {
185
+ let authExecutable = "";
186
+
187
+ await validateAnthropicRoute({
188
+ cwd: process.cwd(),
189
+ runCommandImpl: mockRunCommand((executable, args) => {
190
+ if (executable === "where.exe") {
191
+ return commandResult({ exitCode: 0, stdout: "C:\\npm\\node_modules\\.bin\\claude.cmd\n" });
192
+ }
193
+ if (args.includes("auth")) authExecutable = executable;
194
+ return commandResult({ exitCode: 0, stdout: JSON.stringify({ loggedIn: true }) });
195
+ }),
196
+ });
197
+
198
+ // .cmd files get wrapped: cmd.exe /d /s /c <path>
199
+ // The auth runner should use cmd.exe (from buildClaudeSpawnSpec)
200
+ assert.ok(
201
+ authExecutable === "cmd.exe" || authExecutable === "C:\\npm\\node_modules\\.bin\\claude.cmd",
202
+ `Expected cmd.exe or .cmd path for auth; got: ${authExecutable}`,
203
+ );
204
+ });
205
+ });
206
+
207
+ test("resolver: CLAUDE_EXECUTABLE env var is used without calling where.exe", async () => {
208
+ // Use a bare (non-absolute) name so the resolver returns it without an existsSync check.
209
+ await withAnthropicEnv({ CLAUDE_EXECUTABLE: "my-claude" }, async () => {
210
+ let whereExeCalled = false;
211
+
212
+ // We pass a custom runCommandImpl, so the resolver won't cache.
213
+ // But CLAUDE_EXECUTABLE is checked BEFORE where.exe, so where.exe shouldn't be called.
214
+ const mockImpl = mockRunCommand((executable, args) => {
215
+ if (executable === "where.exe") whereExeCalled = true;
216
+ if (args[0] === "auth") {
217
+ return commandResult({ exitCode: 0, stdout: JSON.stringify({ loggedIn: true }) });
218
+ }
219
+ return commandResult({ exitCode: 0 });
220
+ });
221
+
222
+ // Manually test the resolver (bypass the cache since we're injecting mockImpl)
223
+ const { resolveClaudeExecutable } = await import("../executables/claudeExecutable.js");
224
+ const resolved = await resolveClaudeExecutable({ runCommandImpl: mockImpl, cwd: process.cwd() });
225
+
226
+ assert.equal(resolved, "my-claude", "Should use CLAUDE_EXECUTABLE directly");
227
+ assert.equal(whereExeCalled, false, "Should not call where.exe when CLAUDE_EXECUTABLE is set");
228
+ });
229
+ });
230
+
231
+ test("resolver: CLAUDE_EXECUTABLE set to nonexistent absolute path → validation returns not-configured", async () => {
232
+ await withAnthropicEnv({ CLAUDE_EXECUTABLE: "C:\\nonexistent\\claude.exe" }, async () => {
233
+ const validation = await validateAnthropicRoute({
234
+ cwd: process.cwd(),
235
+ runCommandImpl: mockRunCommand(commandResult({ exitCode: 0 })),
236
+ });
237
+
238
+ assert.equal(validation.status, "not-configured");
239
+ assert.match(validation.message!, /CLAUDE_EXECUTABLE/);
240
+ assert.match(validation.message!, /does not exist/);
241
+ });
242
+ });
243
+
244
+ test("resolver: where.exe fails → uses known path or bare 'claude' fallback", async () => {
245
+ await withAnthropicEnv({}, async () => {
246
+ let usedExecutable = "";
247
+
248
+ await validateAnthropicRoute({
249
+ cwd: process.cwd(),
250
+ runCommandImpl: mockRunCommand((executable, args) => {
251
+ if (executable === "where.exe") {
252
+ return commandResult({ status: "failed", exitCode: 1, stdout: "" });
253
+ }
254
+ if (args[0] === "auth") usedExecutable = executable;
255
+ return commandResult({ exitCode: 0, stdout: JSON.stringify({ loggedIn: true }) });
256
+ }),
257
+ });
258
+
259
+ // After where.exe fails, the resolver checks known Windows paths (e.g. %USERPROFILE%\bin\claude.exe).
260
+ // On machines where a known path exists it is returned; otherwise bare "claude" is the final fallback.
261
+ assert.ok(
262
+ usedExecutable === "claude" ||
263
+ usedExecutable.toLowerCase().endsWith("claude.exe") ||
264
+ usedExecutable.toLowerCase().endsWith("claude.cmd") ||
265
+ usedExecutable.toLowerCase().endsWith("claude.bat"),
266
+ `Expected resolved executable to be 'claude' or a known path, got: "${usedExecutable}"`,
267
+ );
268
+ });
269
+ });
270
+
271
+ test("buildClaudeSpawnSpec wraps .bat files in cmd.exe on Windows", () => {
272
+ if (process.platform !== "win32") return;
273
+ const spec = buildClaudeSpawnSpec("C:\\some\\path\\claude.bat", ["-p", "hello"]);
274
+ assert.equal(spec.executable, "cmd.exe");
275
+ assert.ok(spec.args.includes("C:\\some\\path\\claude.bat"), "bat path should appear in args");
276
+ assert.ok(spec.args.includes("call"), "cmd.exe should use call for batch files");
277
+ assert.ok(spec.args.includes("/c"), "cmd.exe /c flag should be present");
278
+ });
279
+
280
+ test("buildClaudeSpawnSpec does not wrap .exe on Windows", () => {
281
+ if (process.platform !== "win32") return;
282
+ const spec = buildClaudeSpawnSpec("C:\\some\\claude.exe", ["-p", "hello"]);
283
+ assert.equal(spec.executable, "C:\\some\\claude.exe");
284
+ assert.deepEqual(spec.args, ["-p", "hello"]);
285
+ });
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // Auth JSON parsing
289
+ // ---------------------------------------------------------------------------
290
+
291
+ test("parseClaudeAuthStatus: loggedIn true with all fields", () => {
292
+ const result = parseClaudeAuthStatus(JSON.stringify({
293
+ loggedIn: true,
294
+ authMethod: "claude.ai",
295
+ apiProvider: "firstParty",
296
+ subscriptionType: "pro",
297
+ }));
298
+
299
+ assert.ok(result !== null);
300
+ assert.equal(result?.loggedIn, true);
301
+ assert.equal(result?.authMethod, "claude.ai");
302
+ assert.equal(result?.apiProvider, "firstParty");
303
+ assert.equal(result?.subscriptionType, "pro");
304
+ });
305
+
306
+ test("parseClaudeAuthStatus: loggedIn false", () => {
307
+ const result = parseClaudeAuthStatus(JSON.stringify({ loggedIn: false }));
308
+ assert.ok(result !== null);
309
+ assert.equal(result?.loggedIn, false);
310
+ });
311
+
312
+ test("parseClaudeAuthStatus: malformed JSON returns null", () => {
313
+ assert.equal(parseClaudeAuthStatus("{bad json}"), null);
314
+ assert.equal(parseClaudeAuthStatus("plain text"), null);
315
+ assert.equal(parseClaudeAuthStatus(""), null);
316
+ });
317
+
318
+ test("parseClaudeAuthStatus: valid JSON but not an object returns null", () => {
319
+ assert.equal(parseClaudeAuthStatus('"string"'), null);
320
+ assert.equal(parseClaudeAuthStatus("42"), null);
321
+ assert.equal(parseClaudeAuthStatus("null"), null);
322
+ });
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // Route validation with auth JSON
326
+ // ---------------------------------------------------------------------------
327
+
328
+ test("validateAnthropicRoute: exit 0 + loggedIn true → ready with auth diagnostics", async () => {
329
+ await withAnthropicEnv({}, async () => {
330
+ const validation = await validateAnthropicRoute({
331
+ cwd: process.cwd(),
332
+ runCommandImpl: mockRunCommand((executable, args) => {
333
+ if (executable === "where.exe") return commandResult({ exitCode: 0, stdout: "C:\\bin\\claude.exe\n" });
334
+ if (args[0] === "auth") {
335
+ return commandResult({
336
+ exitCode: 0,
337
+ stdout: JSON.stringify({ loggedIn: true, authMethod: "claude.ai", apiProvider: "firstParty", subscriptionType: "pro" }),
338
+ });
339
+ }
340
+ return commandResult({ exitCode: 0 });
341
+ }),
342
+ });
343
+
344
+ assert.equal(validation.status, "ready");
345
+ assert.equal(validation.backendKind, "claude-code-auth");
346
+ assert.match(validation.message!, /claude\.ai/);
347
+ assert.equal(validation.diagnostics?.["loggedIn"], true);
348
+ assert.equal(validation.diagnostics?.["authMethod"], "claude.ai");
349
+ assert.equal(validation.diagnostics?.["subscriptionType"], "pro");
350
+ });
351
+ });
352
+
353
+ test("validateAnthropicRoute: exit 0 + loggedIn false → not-configured with login hint", async () => {
354
+ await withAnthropicEnv({}, async () => {
355
+ const validation = await validateAnthropicRoute({
356
+ cwd: process.cwd(),
357
+ runCommandImpl: mockRunCommand((executable, args) => {
358
+ if (executable === "where.exe") return commandResult({ exitCode: 0, stdout: "C:\\bin\\claude.exe\n" });
359
+ if (args[0] === "auth") {
360
+ return commandResult({ exitCode: 0, stdout: JSON.stringify({ loggedIn: false }) });
361
+ }
362
+ return commandResult({ exitCode: 0 });
363
+ }),
364
+ });
365
+
366
+ assert.equal(validation.status, "not-configured");
367
+ assert.match(validation.message!, /not signed in/i);
368
+ assert.match(validation.message!, /auth login/);
369
+ assert.equal(validation.diagnostics?.["loggedIn"], false);
370
+ });
371
+ });
372
+
373
+ test("validateAnthropicRoute: exit 0 + malformed JSON → not configured", async () => {
374
+ await withAnthropicEnv({}, async () => {
375
+ const validation = await validateAnthropicRoute({
376
+ cwd: process.cwd(),
377
+ runCommandImpl: mockRunCommand((executable, args) => {
378
+ if (executable === "where.exe") return commandResult({ exitCode: 0, stdout: "C:\\bin\\claude.exe\n" });
379
+ if (args[0] === "auth") {
380
+ return commandResult({ exitCode: 0, stdout: "authenticated\n" }); // non-JSON output
381
+ }
382
+ return commandResult({ exitCode: 0 });
383
+ }),
384
+ });
385
+
386
+ assert.equal(validation.status, "not-configured");
387
+ assert.match(validation.message!, /valid JSON/i);
388
+ assert.equal(validation.diagnostics?.["authJsonParsed"], false);
389
+ });
390
+ });
391
+
392
+ test("validateAnthropicRoute: exit 0 prefers claude-code-auth over API key", async () => {
393
+ await withAnthropicEnv({ ANTHROPIC_API_KEY: "anthropic-key" }, async () => {
394
+ const validation = await validateAnthropicRoute({
395
+ cwd: process.cwd(),
396
+ runCommandImpl: mockRunCommand((executable, args) => {
397
+ if (executable === "where.exe") return commandResult({ exitCode: 0, stdout: "claude\n" });
398
+ if (args[0] === "auth") {
399
+ return commandResult({ exitCode: 0, stdout: JSON.stringify({ loggedIn: true }) });
400
+ }
401
+ return commandResult({ exitCode: 0 });
402
+ }),
403
+ });
404
+
405
+ assert.equal(validation.status, "ready");
406
+ assert.equal(validation.backendKind, "claude-code-auth");
407
+ });
408
+ });
409
+
410
+ test("validateAnthropicRoute: exit 1 falls back to ANTHROPIC_API_KEY", async () => {
411
+ await withAnthropicEnv({ ANTHROPIC_API_KEY: "anthropic-key" }, async () => {
412
+ const validation = await validateAnthropicRoute({
413
+ cwd: process.cwd(),
414
+ runCommandImpl: mockRunCommand((executable, args) => {
415
+ if (executable === "where.exe") return commandResult({ exitCode: 0, stdout: "claude\n" });
416
+ if (args[0] === "auth") return commandResult({ status: "failed", exitCode: 1 });
417
+ return commandResult({ exitCode: 0 });
418
+ }),
419
+ });
420
+
421
+ assert.equal(validation.status, "ready");
422
+ assert.equal(validation.backendKind, "anthropic-api-key");
423
+ });
424
+ });
425
+
426
+ test("validateAnthropicRoute: exit 1 + no API key → not signed in message", async () => {
427
+ await withAnthropicEnv({}, async () => {
428
+ const validation = await validateAnthropicRoute({
429
+ cwd: process.cwd(),
430
+ runCommandImpl: mockRunCommand((executable, args) => {
431
+ if (executable === "where.exe") return commandResult({ exitCode: 0, stdout: "claude\n" });
432
+ if (args[0] === "auth") return commandResult({ status: "failed", exitCode: 1 });
433
+ return commandResult({ exitCode: 0 });
434
+ }),
435
+ });
436
+
437
+ assert.equal(validation.status, "not-configured");
438
+ assert.match(validation.message!, /not signed in/i);
439
+ assert.match(validation.message!, /auth login/);
440
+ });
441
+ });
442
+
443
+ test("validateAnthropicRoute: ENOENT + no API key → command not found message", async () => {
444
+ await withAnthropicEnv({}, async () => {
445
+ const validation = await validateAnthropicRoute({
446
+ cwd: process.cwd(),
447
+ runCommandImpl: mockRunCommand((executable, args) => {
448
+ if (executable === "where.exe") return commandResult({ status: "spawn_error", exitCode: null, errorCode: "ENOENT" });
449
+ return commandResult({ status: "spawn_error", exitCode: null, errorCode: "ENOENT" });
450
+ }),
451
+ });
452
+
453
+ assert.equal(validation.status, "not-configured");
454
+ assert.match(validation.message!, /not found|Install Claude Code/i);
455
+ });
456
+ });
457
+
458
+ test("validateAnthropicRoute: timeout → timeout message", async () => {
459
+ await withAnthropicEnv({}, async () => {
460
+ const validation = await validateAnthropicRoute({
461
+ cwd: process.cwd(),
462
+ runCommandImpl: mockRunCommand((executable, args) => {
463
+ if (executable === "where.exe") return commandResult({ exitCode: 0, stdout: "claude\n" });
464
+ if (args[0] === "auth") return commandResult({ status: "timeout", exitCode: null });
465
+ return commandResult({ exitCode: 0 });
466
+ }),
467
+ });
468
+
469
+ assert.equal(validation.status, "not-configured");
470
+ assert.match(validation.message!, /timed out/i);
471
+ assert.match(validation.message!, /auth status/);
472
+ });
473
+ });
474
+
475
+ test("validateAnthropicRoute: diagnostics include resolvedCommand", async () => {
476
+ await withAnthropicEnv({}, async () => {
477
+ const validation = await validateAnthropicRoute({
478
+ cwd: process.cwd(),
479
+ runCommandImpl: mockRunCommand((executable, args) => {
480
+ if (executable === "where.exe") return commandResult({ exitCode: 0, stdout: "C:\\Users\\Example\\.local\\bin\\claude.exe\n" });
481
+ if (args[0] === "auth") return commandResult({ exitCode: 0, stdout: JSON.stringify({ loggedIn: true, authMethod: "claude.ai" }) });
482
+ return commandResult({ exitCode: 0 });
483
+ }),
484
+ });
485
+
486
+ assert.equal(validation.diagnostics?.["resolvedCommand"], "C:\\Users\\Example\\.local\\bin\\claude.exe");
487
+ assert.equal(validation.diagnostics?.["authCommand"], "C:\\Users\\Example\\.local\\bin\\claude.exe auth status");
488
+ });
489
+ });
490
+
491
+ // ---------------------------------------------------------------------------
492
+ // buildClaudeSpawnSpec
493
+ // ---------------------------------------------------------------------------
494
+
495
+ test("buildClaudeSpawnSpec: .exe path returns as-is", () => {
496
+ const spec = buildClaudeSpawnSpec("C:\\bin\\claude.exe", ["auth", "status"]);
497
+ assert.equal(spec.executable, "C:\\bin\\claude.exe");
498
+ assert.deepEqual(spec.args, ["auth", "status"]);
499
+ });
500
+
501
+ test("buildClaudeSpawnSpec: bare name returns as-is", () => {
502
+ const spec = buildClaudeSpawnSpec("claude", ["-p", "hello"]);
503
+ assert.equal(spec.executable, "claude");
504
+ assert.deepEqual(spec.args, ["-p", "hello"]);
505
+ });
506
+
507
+ // ---------------------------------------------------------------------------
508
+ // Argument building
509
+ // ---------------------------------------------------------------------------
510
+
511
+ test("mapModelIdToClaudeArg: all model IDs pass through unchanged (short aliases and versioned)", () => {
512
+ // Short aliases — Claude Code CLI accepts these directly
513
+ assert.equal(mapModelIdToClaudeArg("sonnet"), "sonnet");
514
+ assert.equal(mapModelIdToClaudeArg("opus"), "opus");
515
+ assert.equal(mapModelIdToClaudeArg("haiku"), "haiku");
516
+ // Full versioned IDs also pass through unchanged
517
+ assert.equal(mapModelIdToClaudeArg("claude-sonnet-4-20250514"), "claude-sonnet-4-20250514");
518
+ assert.equal(mapModelIdToClaudeArg("claude-opus-4-5"), "claude-opus-4-5");
519
+ assert.equal(mapModelIdToClaudeArg("claude-haiku-4-5"), "claude-haiku-4-5");
520
+ // Unknown IDs pass through too
521
+ assert.equal(mapModelIdToClaudeArg("my-custom-model"), "my-custom-model");
522
+ });
523
+
524
+ test("mapReasoningToEffort: maps low/medium/high correctly", () => {
525
+ assert.equal(mapReasoningToEffort("low"), "low");
526
+ assert.equal(mapReasoningToEffort("medium"), "medium");
527
+ assert.equal(mapReasoningToEffort("high"), "high");
528
+ assert.equal(mapReasoningToEffort("xhigh"), "xhigh");
529
+ assert.equal(mapReasoningToEffort("max"), "max");
530
+ });
531
+
532
+ test("mapReasoningToEffort: returns null for unknown or missing values", () => {
533
+ assert.equal(mapReasoningToEffort(null), null);
534
+ assert.equal(mapReasoningToEffort(undefined), null);
535
+ assert.equal(mapReasoningToEffort("ultra"), null);
536
+ });
537
+
538
+ test("buildClaudeCodeArgs: includes -p, model, effort, permission-mode, and prompt", () => {
539
+ const request = buildRequest({
540
+ route: {
541
+ providerId: "anthropic",
542
+ modelId: "claude-sonnet-4-20250514",
543
+ backendKind: "claude-code-auth",
544
+ reasoning: "high",
545
+ },
546
+ prompt: "Hello world",
547
+ });
548
+
549
+ const args = buildClaudeCodeArgs(request);
550
+
551
+ assert.ok(args.includes("-p"), "must include -p");
552
+ assert.ok(args.includes("--verbose"), "stream-json mode must include --verbose");
553
+ assert.ok(args.includes("--output-format"), "must include --output-format");
554
+ assert.ok(args.includes("stream-json"), "must include stream-json value");
555
+ assert.ok(args.includes("--include-partial-messages"), "must include --include-partial-messages");
556
+ assert.ok(args.includes("--model"), "must include --model flag");
557
+ assert.ok(args.includes("claude-sonnet-4-20250514"), "must pass versioned model ID through unchanged");
558
+ assert.ok(args.includes("--effort"), "must include --effort");
559
+ assert.ok(args.includes("high"), "must include high effort");
560
+ assert.ok(args.includes("--permission-mode"), "must include --permission-mode");
561
+ assert.ok(args.includes("default"), "must default to safe permission mode");
562
+ assert.ok(args.includes("Hello world"), "must include the prompt");
563
+ });
564
+
565
+ for (const effort of ["low", "medium", "high", "xhigh", "max"] as const) {
566
+ test(`buildClaudeCodeArgs: includes --effort ${effort}`, () => {
567
+ const request = buildRequest({
568
+ route: {
569
+ providerId: "anthropic",
570
+ modelId: effort === "xhigh" ? "opus" : "sonnet",
571
+ backendKind: "claude-code-auth",
572
+ reasoning: effort,
573
+ },
574
+ prompt: `Hello ${effort}`,
575
+ });
576
+
577
+ const args = buildClaudeCodeArgs(request);
578
+ const effortIndex = args.indexOf("--effort");
579
+ assert.notEqual(effortIndex, -1, `missing --effort for ${effort}`);
580
+ assert.equal(args[effortIndex + 1], effort);
581
+ });
582
+ }
583
+
584
+ test("buildClaudeCodeArgs: stream-json command includes both --verbose and --effort", () => {
585
+ const args = buildClaudeCodeArgs(buildRequest({
586
+ route: {
587
+ providerId: "anthropic",
588
+ modelId: "opus",
589
+ backendKind: "claude-code-auth",
590
+ reasoning: "xhigh",
591
+ },
592
+ }));
593
+
594
+ assert.ok(args.includes("-p"), "must use print mode");
595
+ assert.ok(args.includes("--output-format"), "must include output format");
596
+ assert.ok(args.includes("stream-json"), "must use stream-json");
597
+ assert.ok(args.includes("--verbose"), "stream-json print mode must include --verbose");
598
+ assert.deepEqual(args.slice(args.indexOf("--effort"), args.indexOf("--effort") + 2), ["--effort", "xhigh"]);
599
+ });
600
+
601
+ test("buildClaudeCodeArgs: omits --effort when reasoning is null", () => {
602
+ const request = buildRequest({
603
+ route: {
604
+ providerId: "anthropic",
605
+ modelId: "claude-sonnet-4-20250514",
606
+ backendKind: "claude-code-auth",
607
+ reasoning: undefined,
608
+ },
609
+ });
610
+
611
+ const args = buildClaudeCodeArgs(request);
612
+ assert.ok(!args.includes("--effort"), "--effort must not appear when reasoning is undefined");
613
+ });
614
+
615
+ test("buildClaudeCodePlainTextArgs: plain text command omits --verbose and stream-json flags", () => {
616
+ const request = buildRequest({
617
+ route: {
618
+ providerId: "anthropic",
619
+ modelId: "sonnet",
620
+ backendKind: "claude-code-auth",
621
+ reasoning: "low",
622
+ },
623
+ prompt: "Hello plain text",
624
+ });
625
+
626
+ const args = buildClaudeCodePlainTextArgs(request);
627
+ assert.ok(args.includes("-p"), "plain text mode must still print");
628
+ assert.ok(!args.includes("--verbose"), "plain text mode should not require --verbose");
629
+ assert.ok(!args.includes("--output-format"), "plain text mode should not request stream-json");
630
+ assert.ok(!args.includes("stream-json"), "plain text mode should not include stream-json value");
631
+ assert.ok(!args.includes("--include-partial-messages"), "plain text mode should not request partial stream messages");
632
+ assert.deepEqual(args.slice(args.indexOf("--model"), args.indexOf("--model") + 2), ["--model", "sonnet"]);
633
+ });
634
+
635
+ test("ensureClaudeStreamJsonVerbose: inserts --verbose whenever print mode uses stream-json", () => {
636
+ assert.deepEqual(
637
+ ensureClaudeStreamJsonVerbose(["-p", "--output-format", "stream-json", "--model", "haiku", "Hi"]),
638
+ ["-p", "--verbose", "--output-format", "stream-json", "--model", "haiku", "Hi"],
639
+ );
640
+ assert.deepEqual(
641
+ ensureClaudeStreamJsonVerbose(["--print", "--output-format", "stream-json", "--model", "opus", "Hi"]),
642
+ ["--print", "--verbose", "--output-format", "stream-json", "--model", "opus", "Hi"],
643
+ );
644
+ assert.deepEqual(
645
+ ensureClaudeStreamJsonVerbose(["-p", "--verbose", "--output-format", "stream-json", "Hi"]),
646
+ ["-p", "--verbose", "--output-format", "stream-json", "Hi"],
647
+ );
648
+ assert.deepEqual(
649
+ ensureClaudeStreamJsonVerbose(["-p", "--model", "sonnet", "Hi"]),
650
+ ["-p", "--model", "sonnet", "Hi"],
651
+ );
652
+ });
653
+
654
+ test("Claude arg builders keep --model selectedModel", () => {
655
+ for (const modelId of ["sonnet", "opus", "haiku", "claude-sonnet-4-20250514"]) {
656
+ const request = buildRequest({
657
+ route: {
658
+ providerId: "anthropic",
659
+ modelId,
660
+ backendKind: "claude-code-auth",
661
+ },
662
+ prompt: "Hi",
663
+ });
664
+ for (const args of [buildClaudeCodeArgs(request), buildClaudeCodePlainTextArgs(request)]) {
665
+ const modelIndex = args.indexOf("--model");
666
+ assert.notEqual(modelIndex, -1, `missing --model for ${modelId}`);
667
+ assert.equal(args[modelIndex + 1], modelId);
668
+ }
669
+ }
670
+ });
671
+
672
+ test("runClaudeCodeWithRunner: known stream-json verbose error falls back once to plain text", async () => {
673
+ const calls: string[][] = [];
674
+ const progress: string[] = [];
675
+ const response = await new Promise<string>((resolve, reject) => {
676
+ runClaudeCodeWithRunner(
677
+ buildRequest({
678
+ route: {
679
+ providerId: "anthropic",
680
+ modelId: "haiku",
681
+ backendKind: "claude-code-auth",
682
+ },
683
+ prompt: "Hi",
684
+ }),
685
+ {
686
+ onResponse: resolve,
687
+ onError: reject,
688
+ onProgress: (update) => progress.push(update.text),
689
+ },
690
+ mockRunCommand((executable, args) => {
691
+ calls.push(args);
692
+ if (args.includes("stream-json")) {
693
+ return commandResult({
694
+ status: "failed",
695
+ exitCode: 1,
696
+ stderr: "Error: When using --print, --output-format=stream-json requires --verbose",
697
+ userMessage: "Error: When using --print, --output-format=stream-json requires --verbose",
698
+ });
699
+ }
700
+ return commandResult({ stdout: "Hi from Claude.\n" });
701
+ }),
702
+ "claude",
703
+ );
704
+ });
705
+
706
+ assert.equal(response, "Hi from Claude.");
707
+ assert.equal(calls.length, 2, "must not retry forever");
708
+ assert.ok(calls[0]?.includes("--verbose"), "first stream-json attempt should include --verbose");
709
+ assert.ok(calls[0]?.includes("stream-json"), "first attempt should use stream-json");
710
+ assert.ok(!calls[1]?.includes("stream-json"), "fallback attempt should use plain text");
711
+ assert.ok(!calls[1]?.includes("--verbose"), "plain text fallback should not require --verbose");
712
+ assert.ok(progress.some((line) => line.includes("--verbose")), "diagnostic should show whether --verbose was included");
713
+ });
714
+
715
+ test("runClaudeCodeWithRunner: invalid Claude effort falls back to model default once", async () => {
716
+ const calls: string[][] = [];
717
+ const progress: string[] = [];
718
+ const response = await new Promise<string>((resolve, reject) => {
719
+ runClaudeCodeWithRunner(
720
+ buildRequest({
721
+ route: {
722
+ providerId: "anthropic",
723
+ modelId: "opus",
724
+ backendKind: "claude-code-auth",
725
+ reasoning: "max",
726
+ },
727
+ prompt: "Hi",
728
+ }),
729
+ {
730
+ onResponse: resolve,
731
+ onError: reject,
732
+ onProgress: (update) => progress.push(update.text),
733
+ },
734
+ mockRunCommand((executable, args) => {
735
+ calls.push(args);
736
+ if (args.includes("--effort") && args[args.indexOf("--effort") + 1] === "max") {
737
+ return commandResult({
738
+ status: "failed",
739
+ exitCode: 2,
740
+ stderr: "Invalid effort max for selected model. Valid efforts: low, medium, high, xhigh.",
741
+ userMessage: "Invalid effort max for selected model.",
742
+ });
743
+ }
744
+ return commandResult({ stdout: "Hi from xhigh.\n" });
745
+ }),
746
+ "claude",
747
+ );
748
+ });
749
+
750
+ assert.equal(response, "Hi from xhigh.");
751
+ assert.equal(calls.length, 2, "must retry only once");
752
+ assert.deepEqual(calls[0]?.slice(calls[0].indexOf("--effort"), calls[0].indexOf("--effort") + 2), ["--effort", "max"]);
753
+ assert.deepEqual(calls[1]?.slice(calls[1].indexOf("--effort"), calls[1].indexOf("--effort") + 2), ["--effort", "xhigh"]);
754
+ assert.ok(progress.some((line) => /retrying once with --effort xhigh/i.test(line)));
755
+ });
756
+
757
+ test("runClaudeCodeWithRunner: invalid medium effort reports error without looping", async () => {
758
+ const calls: string[][] = [];
759
+ const error = await new Promise<{ message: string; rawOutput?: string }>((resolve) => {
760
+ runClaudeCodeWithRunner(
761
+ buildRequest({
762
+ route: {
763
+ providerId: "anthropic",
764
+ modelId: "haiku",
765
+ backendKind: "claude-code-auth",
766
+ reasoning: "medium",
767
+ },
768
+ }),
769
+ {
770
+ onResponse: () => assert.fail("unexpected response"),
771
+ onError: (message, rawOutput) => resolve({ message, rawOutput }),
772
+ },
773
+ mockRunCommand((executable, args) => {
774
+ calls.push(args);
775
+ return commandResult({
776
+ status: "failed",
777
+ exitCode: 2,
778
+ stderr: "Invalid effort medium for selected model.",
779
+ userMessage: "Invalid effort medium for selected model.",
780
+ });
781
+ }),
782
+ "claude",
783
+ );
784
+ });
785
+
786
+ assert.equal(calls.length, 1);
787
+ assert.match(error.message, /Invalid effort medium/);
788
+ assert.match(error.rawOutput ?? "", /Claude Code command args/);
789
+ });
790
+
791
+ test("runClaudeCodeWithRunner: non-retryable CLI argument error reports safe command args once", async () => {
792
+ const calls: string[][] = [];
793
+ const error = await new Promise<{ message: string; rawOutput?: string }>((resolve) => {
794
+ runClaudeCodeWithRunner(
795
+ buildRequest({ prompt: "Sensitive prompt body" }),
796
+ {
797
+ onResponse: () => assert.fail("unexpected response"),
798
+ onError: (message, rawOutput) => resolve({ message, rawOutput }),
799
+ },
800
+ mockRunCommand((executable, args) => {
801
+ calls.push(args);
802
+ return commandResult({
803
+ status: "failed",
804
+ exitCode: 2,
805
+ stderr: "unknown option: --bad",
806
+ userMessage: "unknown option: --bad",
807
+ });
808
+ }),
809
+ "claude",
810
+ );
811
+ });
812
+
813
+ assert.equal(calls.length, 1);
814
+ assert.match(error.message, /unknown option/);
815
+ assert.match(error.rawOutput ?? "", /Claude Code command args/);
816
+ assert.match(error.rawOutput ?? "", /--verbose/);
817
+ assert.match(error.rawOutput ?? "", /<prompt redacted: 21 chars>/);
818
+ assert.doesNotMatch(error.rawOutput ?? "", /Sensitive prompt body/);
819
+ });
820
+
821
+ // ---------------------------------------------------------------------------
822
+ // Stream-JSON parsing
823
+ // ---------------------------------------------------------------------------
824
+
825
+ test("tryParseStreamJsonDelta: extracts text from valid assistant event", () => {
826
+ const line = JSON.stringify({
827
+ type: "assistant",
828
+ message: {
829
+ content: [{ type: "text", text: "Hello there" }],
830
+ },
831
+ });
832
+
833
+ assert.equal(tryParseStreamJsonDelta(line), "Hello there");
834
+ });
835
+
836
+ test("tryParseStreamJsonDelta: returns null for non-assistant event types", () => {
837
+ const line = JSON.stringify({ type: "system", data: {} });
838
+ assert.equal(tryParseStreamJsonDelta(line), null);
839
+ });
840
+
841
+ test("tryParseStreamJsonDelta: returns null for assistant event with no text content", () => {
842
+ const line = JSON.stringify({
843
+ type: "assistant",
844
+ message: {
845
+ content: [{ type: "tool_use", id: "tool1" }],
846
+ },
847
+ });
848
+ assert.equal(tryParseStreamJsonDelta(line), null);
849
+ });
850
+
851
+ test("tryParseStreamJsonDelta: returns false for malformed JSON — does not throw", () => {
852
+ assert.equal(tryParseStreamJsonDelta("{not valid json}"), false);
853
+ assert.equal(tryParseStreamJsonDelta("plain text line"), false);
854
+ assert.equal(tryParseStreamJsonDelta(""), null);
855
+ });
856
+
857
+ test("tryParseStreamJsonDelta: returns false for partial/truncated JSON", () => {
858
+ assert.equal(tryParseStreamJsonDelta('{"type":"assistant"'), false);
859
+ });
860
+
861
+ test("tryParseStreamJsonDelta: concatenates multiple text parts", () => {
862
+ const line = JSON.stringify({
863
+ type: "assistant",
864
+ message: {
865
+ content: [
866
+ { type: "text", text: "Hello" },
867
+ { type: "text", text: " world" },
868
+ ],
869
+ },
870
+ });
871
+ assert.equal(tryParseStreamJsonDelta(line), "Hello world");
872
+ });
873
+
874
+ // ---------------------------------------------------------------------------
875
+ // Integration: arg building with resolved exe
876
+ // ---------------------------------------------------------------------------
877
+
878
+ test("integration: validation stores resolved exe used by subsequent execution", async () => {
879
+ await withAnthropicEnv({}, async () => {
880
+ await validateAnthropicRoute({
881
+ cwd: process.cwd(),
882
+ runCommandImpl: mockRunCommand((executable, args) => {
883
+ if (executable === "where.exe") return commandResult({ exitCode: 0, stdout: "C:\\bin\\claude.exe\n" });
884
+ if (args[0] === "auth") return commandResult({ exitCode: 0, stdout: JSON.stringify({ loggedIn: true }) });
885
+ return commandResult({ exitCode: 0 });
886
+ }),
887
+ });
888
+
889
+ // buildClaudeCodeArgs does not depend on the resolved exe — just verifies args shape
890
+ const request = buildRequest({
891
+ route: {
892
+ providerId: "anthropic",
893
+ modelId: "claude-sonnet-4-20250514",
894
+ backendKind: "claude-code-auth",
895
+ reasoning: "medium",
896
+ },
897
+ prompt: "Hello",
898
+ });
899
+ const args = buildClaudeCodeArgs(request);
900
+ assert.ok(args.includes("-p"));
901
+ assert.ok(args.includes("claude-sonnet-4-20250514"));
902
+ assert.ok(args.includes("medium"));
903
+ });
904
+ });
905
+
906
+ // ---------------------------------------------------------------------------
907
+ // Claude Code model discovery
908
+ // ---------------------------------------------------------------------------
909
+
910
+ test("discoverModels returns ANTHROPIC_FALLBACK_MODELS before any validation", () => {
911
+ resetAnthropicRouteValidationCacheForTests();
912
+ const { discoverModels } = anthropicRuntime;
913
+ const result = discoverModels();
914
+ assert.equal(result.status, "ready");
915
+ assert.ok(result.models.length > 0, "Should have models");
916
+ const ids = result.models.map((m) => m.modelId);
917
+ assert.ok(ids.includes("sonnet"), "Should include sonnet alias");
918
+ assert.ok(ids.includes("opus"), "Should include opus alias");
919
+ assert.ok(ids.includes("haiku"), "Should include haiku alias");
920
+ for (const m of result.models) {
921
+ assert.ok(!m.modelId.startsWith("gpt-"), "Must not include OpenAI models");
922
+ }
923
+ assert.deepEqual(result.models.find((m) => m.modelId === "opus")?.supportedReasoningLevels?.map((level) => level.id), ["low", "medium", "high", "xhigh", "max"]);
924
+ assert.deepEqual(result.models.find((m) => m.modelId === "sonnet")?.supportedReasoningLevels?.map((level) => level.id), ["low", "medium", "high", "max"]);
925
+ assert.deepEqual(result.models.find((m) => m.modelId === "haiku")?.supportedReasoningLevels?.map((level) => level.id), ["low", "medium", "high"]);
926
+ });
927
+
928
+ test("discoverModels uses Claude Code model-list result when available", async () => {
929
+ await withAnthropicEnv({}, async () => {
930
+ const mockImpl = mockRunCommand((executable, args) => {
931
+ if (executable === "where.exe") return commandResult({ exitCode: 0, stdout: "C:\\bin\\claude.exe\n" });
932
+ if (args[0] === "auth") return commandResult({ exitCode: 0, stdout: JSON.stringify({ loggedIn: true }) });
933
+ if (args[0] === "--help") return commandResult({ exitCode: 0, stdout: "Commands:\n model list --json\n" });
934
+ if (args[0] === "model" && args[1] === "--help") return commandResult({ exitCode: 0, stdout: "model list --json\n" });
935
+ if (args[0] === "model" && args[1] === "list" && args[2] === "--json") {
936
+ return commandResult({ exitCode: 0, stdout: JSON.stringify({
937
+ models: [
938
+ { value: "sonnet", label: "Sonnet 4.6", family: "sonnet", canonicalId: "claude-sonnet-4-6", effortLevels: ["low", "medium", "high", "max"], defaultEffort: "high" },
939
+ { value: "opus", label: "Opus 4.7", family: "opus", canonicalId: "claude-opus-4-7", effortLevels: ["low", "medium", "high", "xhigh", "max"], defaultEffort: "xhigh" },
940
+ { value: "haiku", label: "Haiku 4.5", family: "haiku", canonicalId: "claude-haiku-4-5", effortLevels: ["low", "medium", "high"], defaultEffort: "medium" },
941
+ ],
942
+ }) });
943
+ }
944
+ return commandResult({ exitCode: 0 });
945
+ });
946
+ await validateAnthropicRoute({ cwd: process.cwd(), runCommandImpl: mockImpl });
947
+
948
+ const result = anthropicRuntime.discoverModels();
949
+ assert.equal(result.status, "ready");
950
+ assert.ok(result.models.length === 3, "Should have 3 discovered models");
951
+
952
+ const sonnet = result.models.find((m) => m.modelId === "sonnet");
953
+ const opus = result.models.find((m) => m.modelId === "opus");
954
+ const haiku = result.models.find((m) => m.modelId === "haiku");
955
+ assert.ok(sonnet, "Should include sonnet");
956
+ assert.ok(opus, "Should include opus");
957
+ assert.ok(haiku, "Should include haiku");
958
+
959
+ assert.equal(sonnet?.source, "claude-code", "Sonnet should be marked as Claude Code discovered");
960
+ assert.equal(opus?.source, "claude-code", "Opus should be marked as Claude Code discovered");
961
+ assert.equal(haiku?.source, "claude-code", "Haiku should be marked as Claude Code discovered");
962
+
963
+ assert.equal(sonnet?.label, "Sonnet 4.6");
964
+ assert.equal(opus?.label, "Opus 4.7");
965
+ assert.equal(haiku?.label, "Haiku 4.5");
966
+ });
967
+ });
968
+
969
+ test("Claude capability discovery uses settings availableModels when CLI model list is unavailable", async () => {
970
+ const tempRoot = mkdtempSync(join(tmpdir(), "codexa-claude-settings-"));
971
+ try {
972
+ const settingsPath = join(tempRoot, "settings.json");
973
+ writeFileSync(settingsPath, JSON.stringify({
974
+ availableModels: ["sonnet"],
975
+ effortLevel: "max",
976
+ }), "utf-8");
977
+
978
+ const discovery = await discoverClaudeCodeCapabilities({
979
+ cwd: process.cwd(),
980
+ settingsPath,
981
+ runCommandImpl: mockRunCommand((executable, args) => {
982
+ if (executable === "where.exe") return commandResult({ exitCode: 0, stdout: "C:\\bin\\claude.exe\n" });
983
+ if (args[0] === "auth") return commandResult({ exitCode: 0, stdout: JSON.stringify({ loggedIn: true }) });
984
+ if (args.includes("--help")) return commandResult({ exitCode: 0, stdout: "no model json command here" });
985
+ return commandResult({ exitCode: 0, stdout: "" });
986
+ }),
987
+ });
988
+
989
+ assert.equal(discovery.modelSource, "settings");
990
+ assert.equal(discovery.models.length, 1);
991
+ assert.equal(discovery.models[0]?.value, "sonnet");
992
+ assert.equal(discovery.models[0]?.source, "settings");
993
+ assert.equal(discovery.settings?.effortLevel, "max");
994
+ } finally {
995
+ rmSync(tempRoot, { recursive: true, force: true });
996
+ }
997
+ });
998
+
999
+ test("discoverModels returns fallback-source models when version check fails", async () => {
1000
+ await withAnthropicEnv({}, async () => {
1001
+ const mockImpl = mockRunCommand((executable, args) => {
1002
+ if (executable === "where.exe") return commandResult({ exitCode: 0, stdout: "C:\\bin\\claude.exe\n" });
1003
+ if (args[0] === "--version") return commandResult({ exitCode: 1, stdout: "" });
1004
+ if (args[0] === "auth") return commandResult({ exitCode: 0, stdout: JSON.stringify({ loggedIn: true }) });
1005
+ return commandResult({ exitCode: 0 });
1006
+ });
1007
+ await validateAnthropicRoute({ cwd: process.cwd(), runCommandImpl: mockImpl });
1008
+
1009
+ const result = anthropicRuntime.discoverModels();
1010
+ assert.ok(result.models.length > 0);
1011
+ for (const m of result.models) {
1012
+ assert.equal(m.source, "fallback", `Model ${m.modelId} should be fallback when version fails`);
1013
+ }
1014
+ });
1015
+ });
1016
+
1017
+ test("refreshModels returns correct structure with sonnet/opus/haiku aliases", async () => {
1018
+ // refreshModels uses the real claude executable; just verify structural correctness.
1019
+ if (!anthropicRuntime.refreshModels) {
1020
+ assert.fail("anthropicRuntime.refreshModels should be defined");
1021
+ }
1022
+ const result = await anthropicRuntime.refreshModels({ cwd: process.cwd() });
1023
+ assert.equal(result.status, "ready");
1024
+ assert.equal(result.providerId, "anthropic");
1025
+ assert.ok(result.models.length === 3, "Should return exactly 3 Claude models");
1026
+
1027
+ const ids = result.models.map((m) => m.modelId);
1028
+ assert.ok(ids.includes("sonnet"), "Must include sonnet alias");
1029
+ assert.ok(ids.includes("opus"), "Must include opus alias");
1030
+ assert.ok(ids.includes("haiku"), "Must include haiku alias");
1031
+
1032
+ // Source must be explicit and provider-owned.
1033
+ for (const m of result.models) {
1034
+ assert.ok(["claude-code", "settings", "config", "fallback"].includes(m.source ?? ""), `Unexpected source: ${m.source}`);
1035
+ }
1036
+ });
1037
+
1038
+ test("refreshModels keeps previous good capability data on failure", async () => {
1039
+ await withAnthropicEnv({}, async () => {
1040
+ await validateAnthropicRoute({
1041
+ cwd: process.cwd(),
1042
+ runCommandImpl: mockRunCommand((executable, args) => {
1043
+ if (executable === "where.exe") return commandResult({ exitCode: 0, stdout: "C:\\bin\\claude.exe\n" });
1044
+ if (args[0] === "auth") return commandResult({ exitCode: 0, stdout: JSON.stringify({ loggedIn: true }) });
1045
+ if (args[0] === "--help") return commandResult({ exitCode: 0, stdout: "model list --json" });
1046
+ if (args[0] === "model" && args[1] === "--help") return commandResult({ exitCode: 0, stdout: "model list --json" });
1047
+ if (args[0] === "model" && args[1] === "list" && args[2] === "--json") {
1048
+ return commandResult({ exitCode: 0, stdout: JSON.stringify({
1049
+ models: [{ value: "sonnet", label: "Sonnet 4.6", family: "sonnet", effortLevels: ["low", "medium", "high", "max"], defaultEffort: "high" }],
1050
+ }) });
1051
+ }
1052
+ return commandResult({ exitCode: 0 });
1053
+ }),
1054
+ });
1055
+
1056
+ const before = anthropicRuntime.discoverModels();
1057
+ assert.ok(before.models.some((model) => model.source === "claude-code"));
1058
+ process.env.CLAUDE_EXECUTABLE = "C:\\definitely-missing\\claude.exe";
1059
+
1060
+ const refreshed = await anthropicRuntime.refreshModels?.({ cwd: process.cwd() });
1061
+ assert.equal(refreshed?.status, "ready");
1062
+ assert.match(refreshed?.message ?? "", /keeping previous Claude capability data/i);
1063
+ assert.ok(refreshed?.models.some((model) => model.source === "claude-code"));
1064
+ assert.equal(refreshed?.diagnostics?.["refreshFailed"], true);
1065
+ });
1066
+ });
1067
+
1068
+ test("buildClaudeCodeArgs uses short alias 'sonnet' directly as --model arg", () => {
1069
+ const request = buildRequest({
1070
+ route: {
1071
+ providerId: "anthropic",
1072
+ modelId: "sonnet",
1073
+ backendKind: "claude-code-auth",
1074
+ reasoning: "high",
1075
+ },
1076
+ prompt: "Hello",
1077
+ });
1078
+ const args = buildClaudeCodeArgs(request);
1079
+ assert.ok(args.includes("sonnet"), "Short alias 'sonnet' must be passed through to --model");
1080
+ assert.ok(!args.some((a) => a.startsWith("claude-sonnet")), "Must not remap to a versioned ID");
1081
+ });
1082
+
1083
+ test("buildClaudeCodeArgs uses short alias 'opus' directly as --model arg", () => {
1084
+ const request = buildRequest({
1085
+ route: {
1086
+ providerId: "anthropic",
1087
+ modelId: "opus",
1088
+ backendKind: "claude-code-auth",
1089
+ },
1090
+ prompt: "Hello",
1091
+ });
1092
+ const args = buildClaudeCodeArgs(request);
1093
+ assert.ok(args.includes("opus"), "Short alias 'opus' must be passed through to --model");
1094
+ assert.ok(!args.some((a) => a.startsWith("claude-opus")), "Must not remap to a versioned ID");
1095
+ });
1096
+
1097
+ test("buildClaudeCodeArgs uses short alias 'haiku' directly as --model arg", () => {
1098
+ const request = buildRequest({
1099
+ route: {
1100
+ providerId: "anthropic",
1101
+ modelId: "haiku",
1102
+ backendKind: "claude-code-auth",
1103
+ },
1104
+ prompt: "Hello",
1105
+ });
1106
+ const args = buildClaudeCodeArgs(request);
1107
+ assert.ok(args.includes("haiku"), "Short alias 'haiku' must be passed through to --model");
1108
+ assert.ok(!args.some((a) => a.startsWith("claude-haiku")), "Must not remap to a versioned ID");
1109
+ });
1110
+
1111
+ test("malformed stream-json lines do not crash (no throw)", () => {
1112
+ const malformedLines = ["{bad json}", "some plain text", '{"type":"assistant"'];
1113
+ for (const line of malformedLines) {
1114
+ const result = tryParseStreamJsonDelta(line);
1115
+ assert.equal(result, false, `Line "${line}" should return false`);
1116
+ }
1117
+
1118
+ assert.equal(tryParseStreamJsonDelta(" "), null);
1119
+ assert.equal(tryParseStreamJsonDelta("\n"), null);
1120
+ });