| 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import type { ModelCapabilities } from "@t3tools/contracts"; |
| 3 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 4 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as PlatformError from "effect/PlatformError"; |
| 8 | import * as Sink from "effect/Sink"; |
| 9 | import * as Stream from "effect/Stream"; |
| 10 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 11 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 12 | |
| 13 | import { |
| 14 | isCommandMissingCause, |
| 15 | parseGenericCliVersion, |
| 16 | providerModelsFromSettings, |
| 17 | spawnAndCollect, |
| 18 | } from "./snapshotProbe.ts"; |
| 19 | |
| 20 | const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ |
| 21 | optionDescriptors: [ |
| 22 | { |
| 23 | id: "variant", |
| 24 | label: "Reasoning", |
| 25 | type: "select", |
| 26 | options: [{ id: "medium", label: "Medium", isDefault: true }], |
| 27 | currentValue: "medium", |
| 28 | }, |
| 29 | { |
| 30 | id: "agent", |
| 31 | label: "Agent", |
| 32 | type: "select", |
| 33 | options: [{ id: "build", label: "Build", isDefault: true }], |
| 34 | currentValue: "build", |
| 35 | }, |
| 36 | ], |
| 37 | }); |
| 38 | |
| 39 | describe("providerModelsFromSettings", () => { |
| 40 | it("applies the provided capabilities to custom models", () => { |
| 41 | const models = providerModelsFromSettings( |
| 42 | [], |
| 43 | ["openai/gpt-5"], |
| 44 | OPENCODE_CUSTOM_MODEL_CAPABILITIES, |
| 45 | ); |
| 46 | |
| 47 | expect(models).toEqual([ |
| 48 | { |
| 49 | slug: "openai/gpt-5", |
| 50 | name: "openai/gpt-5", |
| 51 | isCustom: true, |
| 52 | capabilities: OPENCODE_CUSTOM_MODEL_CAPABILITIES, |
| 53 | }, |
| 54 | ]); |
| 55 | }); |
| 56 | |
| 57 | it("keeps an entry's own name and capabilities over the driver default", () => { |
| 58 | const capabilities = createModelCapabilities({ |
| 59 | optionDescriptors: [{ id: "fastMode", label: "Fast Mode", type: "boolean" }], |
| 60 | }); |
| 61 | const models = providerModelsFromSettings( |
| 62 | [], |
| 63 | ["bare", { slug: "named", name: "Named", capabilities }], |
| 64 | OPENCODE_CUSTOM_MODEL_CAPABILITIES, |
| 65 | ); |
| 66 | |
| 67 | expect(models).toEqual([ |
| 68 | { |
| 69 | slug: "bare", |
| 70 | name: "bare", |
| 71 | isCustom: true, |
| 72 | capabilities: OPENCODE_CUSTOM_MODEL_CAPABILITIES, |
| 73 | }, |
| 74 | { slug: "named", name: "Named", isCustom: true, capabilities }, |
| 75 | ]); |
| 76 | }); |
| 77 | |
| 78 | it("renames a built-in model when a named entry uses its slug", () => { |
| 79 | const capabilities = createModelCapabilities({ optionDescriptors: [] }); |
| 80 | const models = providerModelsFromSettings( |
| 81 | [ |
| 82 | { |
| 83 | slug: "gpt-5.5", |
| 84 | name: "GPT-5.5", |
| 85 | shortName: "5.5", |
| 86 | isCustom: false, |
| 87 | capabilities, |
| 88 | }, |
| 89 | ], |
| 90 | [{ slug: "gpt-5.5", name: "Team GPT" }], |
| 91 | capabilities, |
| 92 | ); |
| 93 | |
| 94 | expect(models).toEqual([{ slug: "gpt-5.5", name: "Team GPT", isCustom: false, capabilities }]); |
| 95 | }); |
| 96 | |
| 97 | it("preserves a custom slug that collides with a provider alias", () => { |
| 98 | const capabilities = createModelCapabilities({ optionDescriptors: [] }); |
| 99 | const models = providerModelsFromSettings( |
| 100 | [ |
| 101 | { |
| 102 | slug: "claude-opus-4-8", |
| 103 | name: "Claude Opus 4.8", |
| 104 | isCustom: false, |
| 105 | capabilities, |
| 106 | }, |
| 107 | ], |
| 108 | [" opus "], |
| 109 | capabilities, |
| 110 | ); |
| 111 | |
| 112 | expect(models.map((model) => model.slug)).toEqual(["claude-opus-4-8", "opus"]); |
| 113 | expect(models[1]?.isCustom).toBe(true); |
| 114 | }); |
| 115 | }); |
| 116 | |
| 117 | describe("parseGenericCliVersion", () => { |
| 118 | it("parses a bare version", () => { |
| 119 | expect(parseGenericCliVersion("1.14.19")).toBe("1.14.19"); |
| 120 | }); |
| 121 | |
| 122 | it("parses a v-prefixed version", () => { |
| 123 | expect(parseGenericCliVersion("opencode v2.0.3")).toBe("2.0.3"); |
| 124 | expect(parseGenericCliVersion("v22.19.0")).toBe("22.19.0"); |
| 125 | }); |
| 126 | |
| 127 | it("parses a version embedded in other output", () => { |
| 128 | expect(parseGenericCliVersion("codex-cli 0.53.0 (build abc)")).toBe("0.53.0"); |
| 129 | }); |
| 130 | |
| 131 | it("returns null when no version is present", () => { |
| 132 | expect(parseGenericCliVersion("no version here")).toBeNull(); |
| 133 | }); |
| 134 | |
| 135 | it("ignores versions glued to other word characters", () => { |
| 136 | expect(parseGenericCliVersion("build2.0.3artifact")).toBeNull(); |
| 137 | }); |
| 138 | }); |
| 139 | |
| 140 | describe("ProviderCommandNotFoundError", () => { |
| 141 | it("classifies normalized platform failures without parsing messages", () => { |
| 142 | expect( |
| 143 | isCommandMissingCause( |
| 144 | PlatformError.systemError({ |
| 145 | _tag: "NotFound", |
| 146 | module: "ChildProcess", |
| 147 | method: "spawn", |
| 148 | description: "arbitrary host detail", |
| 149 | }), |
| 150 | ), |
| 151 | ).toBe(true); |
| 152 | expect(isCommandMissingCause(new Error("spawn provider ENOENT"))).toBe(false); |
| 153 | }); |
| 154 | |
| 155 | it.effect("retains safe failed-command diagnostics without process output", () => { |
| 156 | const stderr = "'codex' is not recognized: secret-token-value"; |
| 157 | const spawner = ChildProcessSpawner.make(() => |
| 158 | Effect.succeed( |
| 159 | ChildProcessSpawner.makeHandle({ |
| 160 | pid: ChildProcessSpawner.ProcessId(1), |
| 161 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(9009)), |
| 162 | isRunning: Effect.succeed(false), |
| 163 | kill: () => Effect.void, |
| 164 | unref: Effect.succeed(Effect.void), |
| 165 | stdin: Sink.drain, |
| 166 | stdout: Stream.empty, |
| 167 | stderr: Stream.encodeText(Stream.make(stderr)), |
| 168 | all: Stream.empty, |
| 169 | getInputFd: () => Sink.drain, |
| 170 | getOutputFd: () => Stream.empty, |
| 171 | }), |
| 172 | ), |
| 173 | ); |
| 174 | return Effect.gen(function* () { |
| 175 | const error = yield* spawnAndCollect( |
| 176 | "C:\\tools\\codex.cmd", |
| 177 | ChildProcess.make("codex", ["--version"]), |
| 178 | ).pipe( |
| 179 | Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), |
| 180 | Effect.provideService(HostProcess.Platform, "win32"), |
| 181 | Effect.flip, |
| 182 | ); |
| 183 | |
| 184 | if (error._tag !== "ProviderCommandNotFoundError") { |
| 185 | throw new Error(`Unexpected error: ${error._tag}`); |
| 186 | } |
| 187 | |
| 188 | expect(error.binaryPath).toBe("C:\\tools\\codex.cmd"); |
| 189 | expect(error.exitCode).toBe(9009); |
| 190 | expect(error.stdoutLength).toBe(0); |
| 191 | expect(error.stderrLength).toBe(stderr.length); |
| 192 | expect(error.message).toBe( |
| 193 | "Provider command C:\\tools\\codex.cmd was not found (exit code 9009).", |
| 194 | ); |
| 195 | expect(isCommandMissingCause(error)).toBe(true); |
| 196 | expect(error).not.toHaveProperty("stdout"); |
| 197 | expect(error).not.toHaveProperty("stderr"); |
| 198 | expect(error.message).not.toContain("secret-token-value"); |
| 199 | }); |
| 200 | }); |
| 201 | }); |