1import * as NodeAssert from "node:assert/strict";
2
3import { describe, it } from "vite-plus/test";
4
5import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
6
7describe("parseModelsCliOutput", () => {
8 it("parses a single model from a single provider", () => {
9 const stdout = [
10 "anthropic/claude-sonnet-4-5",
11 JSON.stringify({
12 id: "claude-sonnet-4-5",
13 providerID: "anthropic",
14 name: "Claude Sonnet 4.5",
15 capabilities: { temperature: true, reasoning: true, toolcall: true },
16 cost: { input: 3, output: 15 },
17 limit: { context: 200000, output: 8192 },
18 status: "active",
19 options: {},
20 headers: {},
21 release_date: "2025-01-01",
22 }),
23 ].join("\n");
24
25 const result = OpenCodeRuntime.parseModelsCliOutput(stdout);
26 NodeAssert.equal(result.providers.size, 1);
27 NodeAssert.equal(result.connected.length, 1);
28 NodeAssert.equal(result.connected[0], "anthropic");
29
30 const provider = result.providers.get("anthropic")!;
31 NodeAssert.ok(provider);
32 NodeAssert.equal(provider.id, "anthropic");
33 NodeAssert.equal(provider.name, "anthropic");
34 NodeAssert.equal(Object.keys(provider.models).length, 1);
35
36 const model = provider.models["claude-sonnet-4-5"]!;
37 NodeAssert.ok(model);
38 NodeAssert.equal(model.id, "claude-sonnet-4-5");
39 NodeAssert.equal(model.providerID, "anthropic");
40 NodeAssert.equal(model.name, "Claude Sonnet 4.5");
41 });
42
43 it("parses multiple models from multiple providers", () => {
44 const stdout = [
45 "anthropic/claude-sonnet-4-5",
46 JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet 4.5" }),
47 "anthropic/claude-haiku-4-5",
48 JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }),
49 "openai/gpt-4o",
50 JSON.stringify({ id: "gpt-4o", providerID: "openai", name: "GPT-4o" }),
51 ].join("\n");
52
53 const result = OpenCodeRuntime.parseModelsCliOutput(stdout);
54 NodeAssert.equal(result.providers.size, 2);
55 NodeAssert.equal(result.connected.length, 2);
56 NodeAssert.equal([...result.connected].sort().join(","), "anthropic,openai");
57 NodeAssert.equal(Object.keys(result.providers.get("anthropic")!.models).length, 2);
58 NodeAssert.equal(Object.keys(result.providers.get("openai")!.models).length, 1);
59 });
60
61 it("handles empty input", () => {
62 const result = OpenCodeRuntime.parseModelsCliOutput("");
63 NodeAssert.equal(result.providers.size, 0);
64 NodeAssert.equal(result.connected.length, 0);
65 });
66
67 it("skips unparseable JSON blocks", () => {
68 const stdout = [
69 "anthropic/claude-sonnet-4-5",
70 "this is not valid json {{{",
71 "anthropic/claude-haiku-4-5",
72 JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }),
73 ].join("\n");
74
75 const result = OpenCodeRuntime.parseModelsCliOutput(stdout);
76 NodeAssert.equal(result.providers.size, 1);
77 const provider = result.providers.get("anthropic")!;
78 NodeAssert.equal(Object.keys(provider.models).length, 1);
79 NodeAssert.ok(provider.models["claude-haiku-4-5"]);
80 });
81
82 it("handles Windows-style CRLF line endings", () => {
83 const stdout =
84 "anthropic/claude-sonnet-4-5\r\n" +
85 JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet" }) +
86 "\r\n";
87
88 const result = OpenCodeRuntime.parseModelsCliOutput(stdout);
89 NodeAssert.equal(result.providers.size, 1);
90 NodeAssert.ok(result.providers.get("anthropic")!.models["claude-sonnet-4-5"]);
91 });
92
93 it("handles model JSON with variants and nested fields", () => {
94 const stdout = [
95 "opencode/gpt-5.4",
96 JSON.stringify({
97 id: "gpt-5.4",
98 providerID: "opencode",
99 name: "GPT-5.4",
100 family: "gpt",
101 capabilities: {
102 temperature: true,
103 reasoning: true,
104 attachment: false,
105 toolcall: true,
106 input: { text: true, audio: false, image: false, video: false, pdf: false },
107 output: { text: true, audio: false, image: false, video: false, pdf: false },
108 interleaved: false,
109 },
110 cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
111 limit: { context: 200000, input: 160000, output: 32000 },
112 status: "active",
113 options: {},
114 headers: {},
115 release_date: "2025-01-01",
116 variants: { none: {}, low: {}, medium: {}, high: {} },
117 }),
118 ].join("\n");
119
120 const result = OpenCodeRuntime.parseModelsCliOutput(stdout);
121 const model = result.providers.get("opencode")!.models["gpt-5.4"]!;
122 NodeAssert.ok(model);
123 NodeAssert.ok(model.capabilities);
124 NodeAssert.equal(model.capabilities!.reasoning, true);
125 NodeAssert.ok(model.variants);
126 NodeAssert.equal(model.variants!["medium"] !== undefined, true);
127 });
128
129 it("keeps a model whose JSON body has a slash and no interior whitespace", () => {
130 // OpenRouter-style: the model id contains a `/` and no string value has a
131 // space, so the JSON body line itself matches the slug regex. It must still
132 // be treated as the body of the preceding slug, not a new slug.
133 const stdout = [
134 "openrouter/qwen/qwen3-coder",
135 JSON.stringify({
136 id: "qwen/qwen3-coder",
137 providerID: "openrouter",
138 name: "qwen3-coder",
139 status: "active",
140 }),
141 ].join("\n");
142
143 const result = OpenCodeRuntime.parseModelsCliOutput(stdout);
144 NodeAssert.equal(result.providers.size, 1);
145 NodeAssert.deepEqual([...result.connected], ["openrouter"]);
146 const provider = result.providers.get("openrouter")!;
147 NodeAssert.ok(provider);
148 const model = provider.models["qwen/qwen3-coder"]!;
149 NodeAssert.ok(model);
150 NodeAssert.equal(model.id, "qwen/qwen3-coder");
151 NodeAssert.equal(model.providerID, "openrouter");
152 });
153});
154
155describe("parseAgentListCliOutput", () => {
156 it("parses a single agent", () => {
157 const stdout = [
158 "build (primary)",
159 " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]),
160 ].join("\n");
161
162 const result = OpenCodeRuntime.parseAgentListCliOutput(stdout);
163 NodeAssert.equal(result.length, 1);
164 NodeAssert.equal(result[0]!.name, "build");
165 NodeAssert.equal(result[0]!.mode, "primary");
166 NodeAssert.equal(result[0]!.permission.length, 1);
167 });
168
169 it("parses multiple agents", () => {
170 const stdout = [
171 "build (primary)",
172 " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]),
173 "explore (subagent)",
174 " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]),
175 "plan (primary)",
176 " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.md" }]),
177 ].join("\n");
178
179 const result = OpenCodeRuntime.parseAgentListCliOutput(stdout);
180 NodeAssert.equal(result.length, 3);
181 NodeAssert.equal(result[0]!.name, "build");
182 NodeAssert.equal(result[0]!.mode, "primary");
183 NodeAssert.equal(result[1]!.name, "explore");
184 NodeAssert.equal(result[1]!.mode, "subagent");
185 NodeAssert.equal(result[2]!.name, "plan");
186 NodeAssert.equal(result[2]!.mode, "primary");
187 });
188
189 it("handles empty input", () => {
190 const result = OpenCodeRuntime.parseAgentListCliOutput("");
191 NodeAssert.equal(result.length, 0);
192 });
193
194 it("skips agents with unparseable permission JSON", () => {
195 const stdout = [
196 "build (primary)",
197 " not valid json {",
198 "explore (subagent)",
199 " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]),
200 ].join("\n");
201
202 const result = OpenCodeRuntime.parseAgentListCliOutput(stdout);
203 NodeAssert.equal(result.length, 1);
204 NodeAssert.equal(result[0]!.name, "explore");
205 });
206
207 it("handles real-world permission blocks with nested paths", () => {
208 const permissions = [
209 { permission: "*", action: "allow", pattern: "*" },
210 {
211 permission: "external_directory",
212 pattern: "C:\\Users\\test\\.local\\*",
213 action: "allow",
214 },
215 { permission: "read", pattern: "*.env", action: "ask" },
216 ];
217 const stdout = ["build (primary)", " " + JSON.stringify(permissions)].join("\n");
218
219 const result = OpenCodeRuntime.parseAgentListCliOutput(stdout);
220 NodeAssert.equal(result.length, 1);
221 NodeAssert.equal(result[0]!.permission.length, 3);
222 NodeAssert.equal(result[0]!.permission[0]!.action, "allow");
223 NodeAssert.equal(result[0]!.permission[2]!.action, "ask");
224 });
225
226 it("handles agent names with spaces", () => {
227 const stdout = [
228 "code reviewer (subagent)",
229 " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]),
230 "my custom agent (primary)",
231 " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.ts" }]),
232 ].join("\n");
233
234 const result = OpenCodeRuntime.parseAgentListCliOutput(stdout);
235 NodeAssert.equal(result.length, 2);
236 NodeAssert.equal(result[0]!.name, "code reviewer");
237 NodeAssert.equal(result[0]!.mode, "subagent");
238 NodeAssert.equal(result[1]!.name, "my custom agent");
239 NodeAssert.equal(result[1]!.mode, "primary");
240 });
241
242 it("marks known hidden agents", () => {
243 const stdout = [
244 "compaction (primary)",
245 " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]),
246 "build (primary)",
247 " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]),
248 ].join("\n");
249
250 const result = OpenCodeRuntime.parseAgentListCliOutput(stdout);
251 NodeAssert.equal(result[0]!.hidden, true);
252 NodeAssert.equal(result[1]!.hidden, false);
253 });
254});
255
256describe("parseSkillsCliOutput", () => {
257 it("parses only skill metadata from the CLI JSON output", () => {
258 const result = OpenCodeRuntime.parseSkillsCliOutput(
259 JSON.stringify([
260 {
261 name: "review-pr",
262 description: "Review a pull request.",
263 location: "/tmp/review-pr/SKILL.md",
264 content: "---\nname: review-pr\n---\n",
265 },
266 ]),
267 );
268
269 NodeAssert.deepEqual(result, [
270 {
271 name: "review-pr",
272 description: "Review a pull request.",
273 location: "/tmp/review-pr/SKILL.md",
274 },
275 ]);
276 });
277
278 it("degrades malformed output to an empty skill list", () => {
279 NodeAssert.deepEqual(OpenCodeRuntime.parseSkillsCliOutput("not json"), []);
280 });
281});
282
283describe("toOpenCodeFileParts", () => {
284 const attachment = (mimeType: string, sizeBytes = 12) => ({
285 type: "file" as const,
286 id: "thread-1-00000000-0000-4000-8000-000000000001-bin",
287 name: "attachment",
288 mimeType,
289 sizeBytes,
290 });
291
292 it("sends supported images, text, and PDFs natively and skips what models reject", () => {
293 const parts = OpenCodeRuntime.toOpenCodeFileParts({
294 attachments: [
295 attachment("application/pdf"),
296 attachment("text/plain"),
297 attachment("image/png"),
298 // A ZIP file part makes OpenCode's Anthropic path throw before the
299 // turn starts; it must ride only as the prompt's file path line.
300 attachment("application/zip"),
301 // OpenCode inlines only text/plain itself. Other text types reach the
302 // model API as raw file parts, which Anthropic and OpenAI reject.
303 attachment("text/html"),
304 attachment("text/markdown"),
305 attachment("application/octet-stream"),
306 // Image formats the model APIs reject stay on the fallback path too.
307 attachment("image/bmp"),
308 attachment("image/svg+xml"),
309 // Over the direct-attachment limit: path fallback even for a PDF.
310 attachment("application/pdf", 21 * 1024 * 1024),
311 ],
312 resolveAttachmentPath: () => "/tmp/attachment",
313 });
314
315 NodeAssert.deepEqual(
316 parts.map((part) => part.mime),
317 ["application/pdf", "text/plain", "image/png"],
318 );
319 });
320
321 it("keeps folded clipboard text on the lazy path fallback", () => {
322 const parts = OpenCodeRuntime.toOpenCodeFileParts({
323 attachments: [
324 {
325 ...attachment("text/plain"),
326 source: { _tag: "pasted-text" as const },
327 },
328 ],
329 resolveAttachmentPath: () => "/tmp/pasted-text.txt",
330 });
331
332 NodeAssert.deepEqual(parts, []);
333 });
334});