1import { describe, expect, it } from "@effect/vitest";
2import type { ModelCapabilities } from "@t3tools/contracts";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import { createModelCapabilities } from "@t3tools/shared/model";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as PlatformError from "effect/PlatformError";
8import * as Sink from "effect/Sink";
9import * as Stream from "effect/Stream";
10import * as ChildProcess from "effect/process/ChildProcess";
11import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
12
13import {
14 isCommandMissingCause,
15 parseGenericCliVersion,
16 providerModelsFromSettings,
17 spawnAndCollect,
18} from "./snapshotProbe.ts";
19
20const 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
39describe("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
117describe("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
140describe("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});