| ... | @@ -1,10 +1,15 @@ | ... | @@ -1,10 +1,15 @@ |
| 1 | import assert from "node:assert/strict"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { it } from "@effect/vitest"; | 2 | import { describe, it, assert } from "@effect/vitest"; |
| 3 | import { Effect, Layer, Sink, Stream } from "effect"; | 3 | import { Effect, FileSystem, Layer, Path, Sink, Stream } from "effect"; |
| 4 | import * as PlatformError from "effect/PlatformError"; | 4 | import * as PlatformError from "effect/PlatformError"; |
| 5 | import { ChildProcessSpawner } from "effect/unstable/process"; | 5 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 6 | | 6 | |
| 7 | import { checkCodexProviderStatus, parseAuthStatusFromOutput } from "./ProviderHealth"; | 7 | import { |
| | 8 | checkCodexProviderStatus, |
| | 9 | hasCustomModelProvider, |
| | 10 | parseAuthStatusFromOutput, |
| | 11 | readCodexConfigModelProvider, |
| | 12 | } from "./ProviderHealth"; |
| 8 | | 13 | |
| 9 | // ── Test helpers ──────────────────────────────────────────────────── | 14 | // ── Test helpers ──────────────────────────────────────────────────── |
| 10 | | 15 | |
| ... | @@ -53,157 +58,410 @@ function failingSpawnerLayer(description: string) { | ... | @@ -53,157 +58,410 @@ function failingSpawnerLayer(description: string) { |
| 53 | ); | 58 | ); |
| 54 | } | 59 | } |
| 55 | | 60 | |
| 56 | // ── Tests ─────────────────────────────────────────────────────────── | 61 | /** |
| 57 | | 62 | * Create a temporary CODEX_HOME scoped to the current Effect test. |
| 58 | it.effect("returns ready when codex is installed and authenticated", () => | 63 | * Cleanup is registered in the test scope rather than via Vitest hooks. |
| 59 | Effect.gen(function* () { | 64 | */ |
| 60 | const status = yield* checkCodexProviderStatus; | 65 | function withTempCodexHome(configContent?: string) { |
| 61 | assert.strictEqual(status.provider, "codex"); | 66 | return Effect.gen(function* () { |
| 62 | assert.strictEqual(status.status, "ready"); | 67 | const fileSystem = yield* FileSystem.FileSystem; |
| 63 | assert.strictEqual(status.available, true); | 68 | const path = yield* Path.Path; |
| 64 | assert.strictEqual(status.authStatus, "authenticated"); | 69 | const tmpDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-test-codex-" }); |
| 65 | }).pipe( | 70 | |
| 66 | Effect.provide( | 71 | yield* Effect.acquireRelease( |
| 67 | mockSpawnerLayer((args) => { | 72 | Effect.sync(() => { |
| 68 | const joined = args.join(" "); | 73 | const originalCodexHome = process.env.CODEX_HOME; |
| 69 | if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; | 74 | process.env.CODEX_HOME = tmpDir; |
| 70 | if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; | 75 | return originalCodexHome; |
| 71 | throw new Error(`Unexpected args: ${joined}`); | | |
| 72 | }), | 76 | }), |
| 73 | ), | 77 | (originalCodexHome) => |
| 74 | ), | 78 | Effect.sync(() => { |
| 75 | ); | 79 | if (originalCodexHome !== undefined) { |
| 76 | | 80 | process.env.CODEX_HOME = originalCodexHome; |
| 77 | it.effect("returns unavailable when codex is missing", () => | 81 | } else { |
| 78 | Effect.gen(function* () { | 82 | delete process.env.CODEX_HOME; |
| 79 | const status = yield* checkCodexProviderStatus; | 83 | } |
| 80 | assert.strictEqual(status.provider, "codex"); | 84 | }), |
| 81 | assert.strictEqual(status.status, "error"); | 85 | ); |
| 82 | assert.strictEqual(status.available, false); | 86 | |
| 83 | assert.strictEqual(status.authStatus, "unknown"); | 87 | if (configContent !== undefined) { |
| 84 | assert.strictEqual(status.message, "Codex CLI (`codex`) is not installed or not on PATH."); | 88 | yield* fileSystem.writeFileString(path.join(tmpDir, "config.toml"), configContent); |
| 85 | }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), | 89 | } |
| 86 | ); | 90 | |
| 87 | | 91 | return { tmpDir } as const; |
| 88 | it.effect("returns unavailable when codex is below the minimum supported version", () => | 92 | }); |
| 89 | Effect.gen(function* () { | 93 | } |
| 90 | const status = yield* checkCodexProviderStatus; | 94 | |
| 91 | assert.strictEqual(status.provider, "codex"); | 95 | it.layer(NodeServices.layer)("ProviderHealth", (it) => { |
| 92 | assert.strictEqual(status.status, "error"); | 96 | // ── checkCodexProviderStatus tests ──────────────────────────────── |
| 93 | assert.strictEqual(status.available, false); | 97 | // |
| 94 | assert.strictEqual(status.authStatus, "unknown"); | 98 | // These tests control CODEX_HOME to ensure the custom-provider detection |
| 95 | assert.strictEqual( | 99 | // in hasCustomModelProvider() does not interfere with the auth-probe |
| 96 | status.message, | 100 | // path being tested. |
| 97 | "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", | 101 | |
| 98 | ); | 102 | describe("checkCodexProviderStatus", () => { |
| 99 | }).pipe( | 103 | it.effect("returns ready when codex is installed and authenticated", () => |
| 100 | Effect.provide( | 104 | Effect.gen(function* () { |
| 101 | mockSpawnerLayer((args) => { | 105 | // Point CODEX_HOME at an empty tmp dir (no config.toml) so the |
| 102 | const joined = args.join(" "); | 106 | // default code path (OpenAI provider, auth probe runs) is exercised. |
| 103 | if (joined === "--version") return { stdout: "codex 0.36.0\n", stderr: "", code: 0 }; | 107 | yield* withTempCodexHome(); |
| 104 | throw new Error(`Unexpected args: ${joined}`); | 108 | const status = yield* checkCodexProviderStatus; |
| | 109 | assert.strictEqual(status.provider, "codex"); |
| | 110 | assert.strictEqual(status.status, "ready"); |
| | 111 | assert.strictEqual(status.available, true); |
| | 112 | assert.strictEqual(status.authStatus, "authenticated"); |
| | 113 | }).pipe( |
| | 114 | Effect.provide( |
| | 115 | mockSpawnerLayer((args) => { |
| | 116 | const joined = args.join(" "); |
| | 117 | if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; |
| | 118 | if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; |
| | 119 | throw new Error(`Unexpected args: ${joined}`); |
| | 120 | }), |
| | 121 | ), |
| | 122 | ), |
| | 123 | ); |
| | 124 | |
| | 125 | it.effect("returns unavailable when codex is missing", () => |
| | 126 | Effect.gen(function* () { |
| | 127 | yield* withTempCodexHome(); |
| | 128 | const status = yield* checkCodexProviderStatus; |
| | 129 | assert.strictEqual(status.provider, "codex"); |
| | 130 | assert.strictEqual(status.status, "error"); |
| | 131 | assert.strictEqual(status.available, false); |
| | 132 | assert.strictEqual(status.authStatus, "unknown"); |
| | 133 | assert.strictEqual(status.message, "Codex CLI (`codex`) is not installed or not on PATH."); |
| | 134 | }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), |
| | 135 | ); |
| | 136 | |
| | 137 | it.effect("returns unavailable when codex is below the minimum supported version", () => |
| | 138 | Effect.gen(function* () { |
| | 139 | yield* withTempCodexHome(); |
| | 140 | const status = yield* checkCodexProviderStatus; |
| | 141 | assert.strictEqual(status.provider, "codex"); |
| | 142 | assert.strictEqual(status.status, "error"); |
| | 143 | assert.strictEqual(status.available, false); |
| | 144 | assert.strictEqual(status.authStatus, "unknown"); |
| | 145 | assert.strictEqual( |
| | 146 | status.message, |
| | 147 | "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", |
| | 148 | ); |
| | 149 | }).pipe( |
| | 150 | Effect.provide( |
| | 151 | mockSpawnerLayer((args) => { |
| | 152 | const joined = args.join(" "); |
| | 153 | if (joined === "--version") return { stdout: "codex 0.36.0\n", stderr: "", code: 0 }; |
| | 154 | throw new Error(`Unexpected args: ${joined}`); |
| | 155 | }), |
| | 156 | ), |
| | 157 | ), |
| | 158 | ); |
| | 159 | |
| | 160 | it.effect("returns unauthenticated when auth probe reports login required", () => |
| | 161 | Effect.gen(function* () { |
| | 162 | yield* withTempCodexHome(); |
| | 163 | const status = yield* checkCodexProviderStatus; |
| | 164 | assert.strictEqual(status.provider, "codex"); |
| | 165 | assert.strictEqual(status.status, "error"); |
| | 166 | assert.strictEqual(status.available, true); |
| | 167 | assert.strictEqual(status.authStatus, "unauthenticated"); |
| | 168 | assert.strictEqual( |
| | 169 | status.message, |
| | 170 | "Codex CLI is not authenticated. Run `codex login` and try again.", |
| | 171 | ); |
| | 172 | }).pipe( |
| | 173 | Effect.provide( |
| | 174 | mockSpawnerLayer((args) => { |
| | 175 | const joined = args.join(" "); |
| | 176 | if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; |
| | 177 | if (joined === "login status") { |
| | 178 | return { stdout: "", stderr: "Not logged in. Run codex login.", code: 1 }; |
| | 179 | } |
| | 180 | throw new Error(`Unexpected args: ${joined}`); |
| | 181 | }), |
| | 182 | ), |
| | 183 | ), |
| | 184 | ); |
| | 185 | |
| | 186 | it.effect("returns unauthenticated when login status output includes 'not logged in'", () => |
| | 187 | Effect.gen(function* () { |
| | 188 | yield* withTempCodexHome(); |
| | 189 | const status = yield* checkCodexProviderStatus; |
| | 190 | assert.strictEqual(status.provider, "codex"); |
| | 191 | assert.strictEqual(status.status, "error"); |
| | 192 | assert.strictEqual(status.available, true); |
| | 193 | assert.strictEqual(status.authStatus, "unauthenticated"); |
| | 194 | assert.strictEqual( |
| | 195 | status.message, |
| | 196 | "Codex CLI is not authenticated. Run `codex login` and try again.", |
| | 197 | ); |
| | 198 | }).pipe( |
| | 199 | Effect.provide( |
| | 200 | mockSpawnerLayer((args) => { |
| | 201 | const joined = args.join(" "); |
| | 202 | if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; |
| | 203 | if (joined === "login status") |
| | 204 | return { stdout: "Not logged in\n", stderr: "", code: 1 }; |
| | 205 | throw new Error(`Unexpected args: ${joined}`); |
| | 206 | }), |
| | 207 | ), |
| | 208 | ), |
| | 209 | ); |
| | 210 | |
| | 211 | it.effect("returns warning when login status command is unsupported", () => |
| | 212 | Effect.gen(function* () { |
| | 213 | yield* withTempCodexHome(); |
| | 214 | const status = yield* checkCodexProviderStatus; |
| | 215 | assert.strictEqual(status.provider, "codex"); |
| | 216 | assert.strictEqual(status.status, "warning"); |
| | 217 | assert.strictEqual(status.available, true); |
| | 218 | assert.strictEqual(status.authStatus, "unknown"); |
| | 219 | assert.strictEqual( |
| | 220 | status.message, |
| | 221 | "Codex CLI authentication status command is unavailable in this Codex version.", |
| | 222 | ); |
| | 223 | }).pipe( |
| | 224 | Effect.provide( |
| | 225 | mockSpawnerLayer((args) => { |
| | 226 | const joined = args.join(" "); |
| | 227 | if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; |
| | 228 | if (joined === "login status") { |
| | 229 | return { stdout: "", stderr: "error: unknown command 'login'", code: 2 }; |
| | 230 | } |
| | 231 | throw new Error(`Unexpected args: ${joined}`); |
| | 232 | }), |
| | 233 | ), |
| | 234 | ), |
| | 235 | ); |
| | 236 | }); |
| | 237 | |
| | 238 | // ── Custom model provider: checkCodexProviderStatus integration ─── |
| | 239 | |
| | 240 | describe("checkCodexProviderStatus with custom model provider", () => { |
| | 241 | it.effect("skips auth probe and returns ready when a custom model provider is configured", () => |
| | 242 | Effect.gen(function* () { |
| | 243 | yield* withTempCodexHome( |
| | 244 | [ |
| | 245 | 'model_provider = "portkey"', |
| | 246 | "", |
| | 247 | "[model_providers.portkey]", |
| | 248 | 'base_url = "https://api.portkey.ai/v1"', |
| | 249 | 'env_key = "PORTKEY_API_KEY"', |
| | 250 | ].join("\n"), |
| | 251 | ); |
| | 252 | const status = yield* checkCodexProviderStatus; |
| | 253 | assert.strictEqual(status.provider, "codex"); |
| | 254 | assert.strictEqual(status.status, "ready"); |
| | 255 | assert.strictEqual(status.available, true); |
| | 256 | assert.strictEqual(status.authStatus, "unknown"); |
| | 257 | assert.strictEqual( |
| | 258 | status.message, |
| | 259 | "Using a custom Codex model provider; OpenAI login check skipped.", |
| | 260 | ); |
| | 261 | }).pipe( |
| | 262 | Effect.provide( |
| | 263 | // The spawner only handles --version; if the test attempts |
| | 264 | // "login status" the throw proves the auth probe was NOT skipped. |
| | 265 | mockSpawnerLayer((args) => { |
| | 266 | const joined = args.join(" "); |
| | 267 | if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; |
| | 268 | throw new Error(`Auth probe should have been skipped but got args: ${joined}`); |
| | 269 | }), |
| | 270 | ), |
| | 271 | ), |
| | 272 | ); |
| | 273 | |
| | 274 | it.effect("still reports error when codex CLI is missing even with custom provider", () => |
| | 275 | Effect.gen(function* () { |
| | 276 | yield* withTempCodexHome( |
| | 277 | [ |
| | 278 | 'model_provider = "portkey"', |
| | 279 | "", |
| | 280 | "[model_providers.portkey]", |
| | 281 | 'base_url = "https://api.portkey.ai/v1"', |
| | 282 | 'env_key = "PORTKEY_API_KEY"', |
| | 283 | ].join("\n"), |
| | 284 | ); |
| | 285 | const status = yield* checkCodexProviderStatus; |
| | 286 | assert.strictEqual(status.status, "error"); |
| | 287 | assert.strictEqual(status.available, false); |
| | 288 | }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), |
| | 289 | ); |
| | 290 | }); |
| | 291 | |
| | 292 | describe("checkCodexProviderStatus with openai model provider", () => { |
| | 293 | it.effect("still runs auth probe when model_provider is openai", () => |
| | 294 | Effect.gen(function* () { |
| | 295 | yield* withTempCodexHome('model_provider = "openai"\n'); |
| | 296 | const status = yield* checkCodexProviderStatus; |
| | 297 | // The auth probe runs and sees "not logged in" → error |
| | 298 | assert.strictEqual(status.status, "error"); |
| | 299 | assert.strictEqual(status.authStatus, "unauthenticated"); |
| | 300 | }).pipe( |
| | 301 | Effect.provide( |
| | 302 | mockSpawnerLayer((args) => { |
| | 303 | const joined = args.join(" "); |
| | 304 | if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; |
| | 305 | if (joined === "login status") |
| | 306 | return { stdout: "Not logged in\n", stderr: "", code: 1 }; |
| | 307 | throw new Error(`Unexpected args: ${joined}`); |
| | 308 | }), |
| | 309 | ), |
| | 310 | ), |
| | 311 | ); |
| | 312 | }); |
| | 313 | |
| | 314 | // ── parseAuthStatusFromOutput pure tests ────────────────────────── |
| | 315 | |
| | 316 | describe("parseAuthStatusFromOutput", () => { |
| | 317 | it("exit code 0 with no auth markers is ready", () => { |
| | 318 | const parsed = parseAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); |
| | 319 | assert.strictEqual(parsed.status, "ready"); |
| | 320 | assert.strictEqual(parsed.authStatus, "authenticated"); |
| | 321 | }); |
| | 322 | |
| | 323 | it("JSON with authenticated=false is unauthenticated", () => { |
| | 324 | const parsed = parseAuthStatusFromOutput({ |
| | 325 | stdout: '[{"authenticated":false}]\n', |
| | 326 | stderr: "", |
| | 327 | code: 0, |
| | 328 | }); |
| | 329 | assert.strictEqual(parsed.status, "error"); |
| | 330 | assert.strictEqual(parsed.authStatus, "unauthenticated"); |
| | 331 | }); |
| | 332 | |
| | 333 | it("JSON without auth marker is warning", () => { |
| | 334 | const parsed = parseAuthStatusFromOutput({ |
| | 335 | stdout: '[{"ok":true}]\n', |
| | 336 | stderr: "", |
| | 337 | code: 0, |
| | 338 | }); |
| | 339 | assert.strictEqual(parsed.status, "warning"); |
| | 340 | assert.strictEqual(parsed.authStatus, "unknown"); |
| | 341 | }); |
| | 342 | }); |
| | 343 | |
| | 344 | // ── readCodexConfigModelProvider tests ───────────────────────────── |
| | 345 | |
| | 346 | describe("readCodexConfigModelProvider", () => { |
| | 347 | it.effect("returns undefined when config file does not exist", () => |
| | 348 | Effect.gen(function* () { |
| | 349 | yield* withTempCodexHome(); |
| | 350 | assert.strictEqual(yield* readCodexConfigModelProvider, undefined); |
| 105 | }), | 351 | }), |
| 106 | ), | 352 | ); |
| 107 | ), | 353 | |
| 108 | ); | 354 | it.effect("returns undefined when config has no model_provider key", () => |
| 109 | | 355 | Effect.gen(function* () { |
| 110 | it.effect("returns unauthenticated when auth probe reports login required", () => | 356 | yield* withTempCodexHome('model = "gpt-5-codex"\n'); |
| 111 | Effect.gen(function* () { | 357 | assert.strictEqual(yield* readCodexConfigModelProvider, undefined); |
| 112 | const status = yield* checkCodexProviderStatus; | | |
| 113 | assert.strictEqual(status.provider, "codex"); | | |
| 114 | assert.strictEqual(status.status, "error"); | | |
| 115 | assert.strictEqual(status.available, true); | | |
| 116 | assert.strictEqual(status.authStatus, "unauthenticated"); | | |
| 117 | assert.strictEqual( | | |
| 118 | status.message, | | |
| 119 | "Codex CLI is not authenticated. Run `codex login` and try again.", | | |
| 120 | ); | | |
| 121 | }).pipe( | | |
| 122 | Effect.provide( | | |
| 123 | mockSpawnerLayer((args) => { | | |
| 124 | const joined = args.join(" "); | | |
| 125 | if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; | | |
| 126 | if (joined === "login status") { | | |
| 127 | return { stdout: "", stderr: "Not logged in. Run codex login.", code: 1 }; | | |
| 128 | } | | |
| 129 | throw new Error(`Unexpected args: ${joined}`); | | |
| 130 | }), | 358 | }), |
| 131 | ), | 359 | ); |
| 132 | ), | 360 | |
| 133 | ); | 361 | it.effect("returns the provider when model_provider is set at top level", () => |
| 134 | | 362 | Effect.gen(function* () { |
| 135 | it.effect("returns unauthenticated when login status output includes 'not logged in'", () => | 363 | yield* withTempCodexHome('model = "gpt-5-codex"\nmodel_provider = "portkey"\n'); |
| 136 | Effect.gen(function* () { | 364 | assert.strictEqual(yield* readCodexConfigModelProvider, "portkey"); |
| 137 | const status = yield* checkCodexProviderStatus; | | |
| 138 | assert.strictEqual(status.provider, "codex"); | | |
| 139 | assert.strictEqual(status.status, "error"); | | |
| 140 | assert.strictEqual(status.available, true); | | |
| 141 | assert.strictEqual(status.authStatus, "unauthenticated"); | | |
| 142 | assert.strictEqual( | | |
| 143 | status.message, | | |
| 144 | "Codex CLI is not authenticated. Run `codex login` and try again.", | | |
| 145 | ); | | |
| 146 | }).pipe( | | |
| 147 | Effect.provide( | | |
| 148 | mockSpawnerLayer((args) => { | | |
| 149 | const joined = args.join(" "); | | |
| 150 | if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; | | |
| 151 | if (joined === "login status") return { stdout: "Not logged in\n", stderr: "", code: 1 }; | | |
| 152 | throw new Error(`Unexpected args: ${joined}`); | | |
| 153 | }), | 365 | }), |
| 154 | ), | 366 | ); |
| 155 | ), | 367 | |
| 156 | ); | 368 | it.effect("returns openai when model_provider is openai", () => |
| 157 | | 369 | Effect.gen(function* () { |
| 158 | it.effect("returns warning when login status command is unsupported", () => | 370 | yield* withTempCodexHome('model_provider = "openai"\n'); |
| 159 | Effect.gen(function* () { | 371 | assert.strictEqual(yield* readCodexConfigModelProvider, "openai"); |
| 160 | const status = yield* checkCodexProviderStatus; | | |
| 161 | assert.strictEqual(status.provider, "codex"); | | |
| 162 | assert.strictEqual(status.status, "warning"); | | |
| 163 | assert.strictEqual(status.available, true); | | |
| 164 | assert.strictEqual(status.authStatus, "unknown"); | | |
| 165 | assert.strictEqual( | | |
| 166 | status.message, | | |
| 167 | "Codex CLI authentication status command is unavailable in this Codex version.", | | |
| 168 | ); | | |
| 169 | }).pipe( | | |
| 170 | Effect.provide( | | |
| 171 | mockSpawnerLayer((args) => { | | |
| 172 | const joined = args.join(" "); | | |
| 173 | if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; | | |
| 174 | if (joined === "login status") { | | |
| 175 | return { stdout: "", stderr: "error: unknown command 'login'", code: 2 }; | | |
| 176 | } | | |
| 177 | throw new Error(`Unexpected args: ${joined}`); | | |
| 178 | }), | 372 | }), |
| 179 | ), | 373 | ); |
| 180 | ), | | |
| 181 | ); | | |
| 182 | | 374 | |
| 183 | // ── Pure function tests ───────────────────────────────────────────── | 375 | it.effect("ignores model_provider inside section headers", () => |
| | 376 | Effect.gen(function* () { |
| | 377 | yield* withTempCodexHome( |
| | 378 | [ |
| | 379 | 'model = "gpt-5-codex"', |
| | 380 | "", |
| | 381 | "[model_providers.portkey]", |
| | 382 | 'base_url = "https://api.portkey.ai/v1"', |
| | 383 | 'model_provider = "should-be-ignored"', |
| | 384 | "", |
| | 385 | ].join("\n"), |
| | 386 | ); |
| | 387 | assert.strictEqual(yield* readCodexConfigModelProvider, undefined); |
| | 388 | }), |
| | 389 | ); |
| 184 | | 390 | |
| 185 | it("parseAuthStatusFromOutput: exit code 0 with no auth markers is ready", () => { | 391 | it.effect("handles comments and whitespace", () => |
| 186 | const parsed = parseAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); | 392 | Effect.gen(function* () { |
| 187 | assert.strictEqual(parsed.status, "ready"); | 393 | yield* withTempCodexHome( |
| 188 | assert.strictEqual(parsed.authStatus, "authenticated"); | 394 | [ |
| 189 | }); | 395 | "# This is a comment", |
| | 396 | "", |
| | 397 | ' model_provider = "azure" ', |
| | 398 | "", |
| | 399 | "[profiles.deep-review]", |
| | 400 | 'model = "gpt-5-pro"', |
| | 401 | ].join("\n"), |
| | 402 | ); |
| | 403 | assert.strictEqual(yield* readCodexConfigModelProvider, "azure"); |
| | 404 | }), |
| | 405 | ); |
| 190 | | 406 | |
| 191 | it("parseAuthStatusFromOutput: JSON with authenticated=false is unauthenticated", () => { | 407 | it.effect("handles single-quoted values in TOML", () => |
| 192 | const parsed = parseAuthStatusFromOutput({ | 408 | Effect.gen(function* () { |
| 193 | stdout: '[{"authenticated":false}]\n', | 409 | yield* withTempCodexHome("model_provider = 'mistral'\n"); |
| 194 | stderr: "", | 410 | assert.strictEqual(yield* readCodexConfigModelProvider, "mistral"); |
| 195 | code: 0, | 411 | }), |
| | 412 | ); |
| 196 | }); | 413 | }); |
| 197 | assert.strictEqual(parsed.status, "error"); | | |
| 198 | assert.strictEqual(parsed.authStatus, "unauthenticated"); | | |
| 199 | }); | | |
| 200 | | 414 | |
| 201 | it("parseAuthStatusFromOutput: JSON without auth marker is warning", () => { | 415 | // ── hasCustomModelProvider tests ─────────────────────────────────── |
| 202 | const parsed = parseAuthStatusFromOutput({ | 416 | |
| 203 | stdout: '[{"ok":true}]\n', | 417 | describe("hasCustomModelProvider", () => { |
| 204 | stderr: "", | 418 | it.effect("returns false when no config file exists", () => |
| 205 | code: 0, | 419 | Effect.gen(function* () { |
| | 420 | yield* withTempCodexHome(); |
| | 421 | assert.strictEqual(yield* hasCustomModelProvider, false); |
| | 422 | }), |
| | 423 | ); |
| | 424 | |
| | 425 | it.effect("returns false when model_provider is not set", () => |
| | 426 | Effect.gen(function* () { |
| | 427 | yield* withTempCodexHome('model = "gpt-5-codex"\n'); |
| | 428 | assert.strictEqual(yield* hasCustomModelProvider, false); |
| | 429 | }), |
| | 430 | ); |
| | 431 | |
| | 432 | it.effect("returns false when model_provider is openai", () => |
| | 433 | Effect.gen(function* () { |
| | 434 | yield* withTempCodexHome('model_provider = "openai"\n'); |
| | 435 | assert.strictEqual(yield* hasCustomModelProvider, false); |
| | 436 | }), |
| | 437 | ); |
| | 438 | |
| | 439 | it.effect("returns true when model_provider is portkey", () => |
| | 440 | Effect.gen(function* () { |
| | 441 | yield* withTempCodexHome('model_provider = "portkey"\n'); |
| | 442 | assert.strictEqual(yield* hasCustomModelProvider, true); |
| | 443 | }), |
| | 444 | ); |
| | 445 | |
| | 446 | it.effect("returns true when model_provider is azure", () => |
| | 447 | Effect.gen(function* () { |
| | 448 | yield* withTempCodexHome('model_provider = "azure"\n'); |
| | 449 | assert.strictEqual(yield* hasCustomModelProvider, true); |
| | 450 | }), |
| | 451 | ); |
| | 452 | |
| | 453 | it.effect("returns true when model_provider is ollama", () => |
| | 454 | Effect.gen(function* () { |
| | 455 | yield* withTempCodexHome('model_provider = "ollama"\n'); |
| | 456 | assert.strictEqual(yield* hasCustomModelProvider, true); |
| | 457 | }), |
| | 458 | ); |
| | 459 | |
| | 460 | it.effect("returns true when model_provider is a custom proxy", () => |
| | 461 | Effect.gen(function* () { |
| | 462 | yield* withTempCodexHome('model_provider = "my-company-proxy"\n'); |
| | 463 | assert.strictEqual(yield* hasCustomModelProvider, true); |
| | 464 | }), |
| | 465 | ); |
| 206 | }); | 466 | }); |
| 207 | assert.strictEqual(parsed.status, "warning"); | | |
| 208 | assert.strictEqual(parsed.authStatus, "unknown"); | | |
| 209 | }); | 467 | }); |