1import { ProviderInstanceId, TextGenerationError } from "@t3tools/contracts";
2import { OpenCodeSettings } from "../settings.ts";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { it } from "@effect/vitest";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Schema from "effect/Schema";
9import * as TestClock from "effect/testing/TestClock";
10import * as NetService from "@t3tools/shared/Net";
11import { beforeEach, expect } from "vite-plus/test";
12
13import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
14import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
15import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
16import * as OpenCodeTextGeneration from "./textGeneration.ts";
17import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration";
18
19const runtimeMock = {
20 state: {
21 startCalls: [] as string[],
22 promptUrls: [] as string[],
23 promptParts: [] as ReadonlyArray<unknown>[],
24 authHeaders: [] as Array<string | null>,
25 closeCalls: [] as string[],
26 sessionCreateCalls: 0,
27 connectionError: undefined as Error | undefined,
28 sessionCreateError: undefined as unknown,
29 sessionResult: undefined as { data?: { id: string } } | undefined,
30 promptRequestError: undefined as unknown,
31 promptResult: undefined as
32 | { data?: { info?: { error?: unknown }; parts?: Array<unknown> } }
33 | undefined,
34 },
35 reset() {
36 this.state.startCalls.length = 0;
37 this.state.promptUrls.length = 0;
38 this.state.promptParts.length = 0;
39 this.state.authHeaders.length = 0;
40 this.state.closeCalls.length = 0;
41 this.state.sessionCreateCalls = 0;
42 this.state.connectionError = undefined;
43 this.state.sessionCreateError = undefined;
44 this.state.sessionResult = undefined;
45 this.state.promptRequestError = undefined;
46 this.state.promptResult = undefined;
47 },
48};
49
50const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = {
51 startOpenCodeServerProcess: ({ binaryPath, serverPassword, environment }) =>
52 Effect.gen(function* () {
53 const index = runtimeMock.state.startCalls.length + 1;
54 const url = `http://127.0.0.1:${4_300 + index}`;
55 runtimeMock.state.startCalls.push(binaryPath);
56 // The production runtime binds server lifetime to the caller's scope.
57 // Mirror that here so the closeCalls probe observes scope close.
58 yield* Effect.addFinalizer(() =>
59 Effect.sync(() => {
60 runtimeMock.state.closeCalls.push(url);
61 }),
62 );
63 const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({
64 external: false,
65 ...(serverPassword !== undefined ? { serverPassword } : {}),
66 ...(environment !== undefined ? { environment } : {}),
67 });
68 return {
69 url,
70 ...(effectiveServerPassword !== undefined
71 ? { serverPassword: effectiveServerPassword }
72 : {}),
73 version: "1.14.19",
74 isRunning: Effect.succeed(true),
75 exitCode: Effect.never,
76 };
77 }),
78 connectToOpenCodeServer: ({ serverUrl, serverPassword }) =>
79 runtimeMock.state.connectionError
80 ? Effect.fail(
81 new OpenCodeRuntime.OpenCodeRuntimeError({
82 operation: "global.health",
83 detail: runtimeMock.state.connectionError.message,
84 cause: runtimeMock.state.connectionError,
85 }),
86 )
87 : Effect.succeed({
88 url: serverUrl ?? "http://127.0.0.1:4301",
89 ...(serverPassword ? { serverPassword } : {}),
90 version: "1.14.19",
91 exitCode: null,
92 external: Boolean(serverUrl),
93 }),
94 runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }),
95 createOpenCodeSdkClient: ({ baseUrl, serverPassword }) =>
96 ({
97 session: {
98 create: async () => {
99 runtimeMock.state.sessionCreateCalls += 1;
100 if (runtimeMock.state.sessionCreateError !== undefined) {
101 throw runtimeMock.state.sessionCreateError;
102 }
103 return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } };
104 },
105 prompt: async (input: { readonly parts: ReadonlyArray<unknown> }) => {
106 runtimeMock.state.promptUrls.push(baseUrl);
107 runtimeMock.state.promptParts.push(input.parts);
108 runtimeMock.state.authHeaders.push(
109 serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,
110 );
111 if (runtimeMock.state.promptRequestError !== undefined) {
112 throw runtimeMock.state.promptRequestError;
113 }
114 return (
115 runtimeMock.state.promptResult ?? {
116 data: {
117 parts: [
118 {
119 type: "text",
120 text: JSON.stringify({
121 subject: "Improve OpenCode reuse",
122 body: "Reuse one server for the full action.",
123 }),
124 },
125 ],
126 },
127 }
128 );
129 },
130 },
131 }) as unknown as ReturnType<
132 OpenCodeRuntime.OpenCodeRuntime["Service"]["createOpenCodeSdkClient"]
133 >,
134 loadOpenCodeInventory: () =>
135 Effect.fail(
136 new OpenCodeRuntime.OpenCodeRuntimeError({
137 operation: "loadOpenCodeInventory",
138 detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test",
139 cause: null,
140 }),
141 ),
142 loadOpenCodeSkills: () => Effect.succeed([]),
143 loadInventoryFromCli: () =>
144 Effect.fail(
145 new OpenCodeRuntime.OpenCodeRuntimeError({
146 operation: "loadInventoryFromCli",
147 detail: "OpenCodeRuntimeTestDouble.loadInventoryFromCli not used in this test",
148 cause: null,
149 }),
150 ),
151 loadSkillsFromCli: () => Effect.succeed([]),
152};
153
154const DEFAULT_TEST_MODEL_SELECTION = {
155 instanceId: ProviderInstanceId.make("opencode"),
156 model: "openai/gpt-5",
157};
158const DEFAULT_COMMIT_MESSAGE_INPUT = {
159 cwd: process.cwd(),
160 branch: "feature/opencode-reuse",
161 stagedSummary: "M README.md",
162 stagedPatch: "diff --git a/README.md b/README.md",
163 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
164};
165
166const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;
167
168const layerOpenCodeTextGenerationTest = Layer.succeed(
169 OpenCodeRuntime.OpenCodeRuntime,
170 OpenCodeRuntimeTestDouble,
171).pipe(
172 Layer.provideMerge(TestProviderHost.layer()),
173 Layer.provideMerge(NetService.layer),
174 Layer.provideMerge(NodeServices.layer),
175);
176
177const layerOpenCodeTextGenerationExistingServerTest = Layer.succeed(
178 OpenCodeRuntime.OpenCodeRuntime,
179 OpenCodeRuntimeTestDouble,
180).pipe(
181 Layer.provideMerge(TestProviderHost.layer()),
182 Layer.provideMerge(NetService.layer),
183 Layer.provideMerge(NodeServices.layer),
184);
185
186const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
187 binaryPath: "fake-opencode",
188});
189const LOCAL_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
190 binaryPath: "fake-opencode",
191 serverPassword: "secret-password",
192});
193const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
194 binaryPath: "fake-opencode",
195 serverUrl: "http://127.0.0.1:9999",
196 serverPassword: "secret-password",
197});
198const EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
199 binaryPath: "fake-opencode",
200 serverUrl: "http://127.0.0.1:9999",
201});
202
203function withOpenCodeTextGeneration<A, E, R>(
204 settings: OpenCodeSettings,
205 effectFn: (textGeneration: ProviderTextGeneration) => Effect.Effect<A, E, R>,
206 environment?: NodeJS.ProcessEnv,
207) {
208 return Effect.gen(function* () {
209 const serverOwner = yield* OpenCodeServerOwner.make({
210 binaryPath: settings.binaryPath,
211 directory: process.cwd(),
212 ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}),
213 ...(environment ? { environment } : {}),
214 });
215 const textGeneration = yield* OpenCodeTextGeneration.makeOpenCodeTextGeneration(settings).pipe(
216 Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
217 );
218 return yield* effectFn(textGeneration);
219 }).pipe(Effect.scoped);
220}
221
222beforeEach(() => {
223 runtimeMock.reset();
224});
225
226const advanceIdleClock = Effect.gen(function* () {
227 yield* Effect.yieldNow;
228 yield* TestClock.adjust(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS + 1));
229 yield* Effect.yieldNow;
230});
231
232it.layer(layerOpenCodeTextGenerationTest)("OpenCodeTextGeneration", (it) => {
233 it.effect("excludes generic files from thread title generation", () =>
234 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
235 Effect.gen(function* () {
236 runtimeMock.state.promptResult = {
237 data: {
238 parts: [{ type: "text", text: '{"title":"Review uploaded report"}' }],
239 },
240 };
241
242 yield* textGeneration.generateThreadTitle({
243 cwd: process.cwd(),
244 message: "Review these attachments.",
245 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
246 attachments: [
247 {
248 type: "image",
249 id: "thread-image-attachment",
250 name: "screenshot.png",
251 mimeType: "image/png",
252 sizeBytes: 3,
253 },
254 {
255 type: "file",
256 id: "thread-report-attachment-pdf",
257 name: "report.pdf",
258 mimeType: "application/pdf",
259 sizeBytes: 42,
260 },
261 ],
262 });
263
264 expect(runtimeMock.state.promptParts[0]).toEqual([
265 expect.objectContaining({ type: "text" }),
266 expect.objectContaining({ type: "file", filename: "screenshot.png" }),
267 ]);
268 }),
269 ),
270 );
271
272 it.effect("passes configured authentication to a locally spawned server", () =>
273 withOpenCodeTextGeneration(LOCAL_AUTH_OPENCODE_SETTINGS, (textGeneration) =>
274 Effect.gen(function* () {
275 yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT);
276
277 expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]);
278 expect(runtimeMock.state.authHeaders).toEqual([
279 `Basic ${btoa("opencode:secret-password")}`,
280 ]);
281 }),
282 ),
283 );
284
285 it.effect("uses an environment-only password for a locally spawned server", () =>
286 withOpenCodeTextGeneration(
287 DEFAULT_OPENCODE_SETTINGS,
288 (textGeneration) =>
289 Effect.gen(function* () {
290 yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT);
291
292 expect(runtimeMock.state.authHeaders).toEqual([
293 `Basic ${btoa("opencode:environment-password")}`,
294 ]);
295 }),
296 { OPENCODE_SERVER_PASSWORD: "environment-password" },
297 ),
298 );
299
300 it.effect("uses settings auth when the local environment password differs", () =>
301 withOpenCodeTextGeneration(
302 LOCAL_AUTH_OPENCODE_SETTINGS,
303 (textGeneration) =>
304 Effect.gen(function* () {
305 yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT);
306
307 expect(runtimeMock.state.authHeaders).toEqual([
308 `Basic ${btoa("opencode:secret-password")}`,
309 ]);
310 }),
311 { OPENCODE_SERVER_PASSWORD: "environment-password" },
312 ),
313 );
314
315 it.effect("reuses a warm server across back-to-back requests and closes it after idling", () =>
316 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
317 Effect.gen(function* () {
318 yield* textGeneration.generateCommitMessage({
319 cwd: process.cwd(),
320 branch: "feature/opencode-reuse",
321 stagedSummary: "M README.md",
322 stagedPatch: "diff --git a/README.md b/README.md",
323 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
324 });
325 yield* textGeneration.generateCommitMessage({
326 cwd: process.cwd(),
327 branch: "feature/opencode-reuse",
328 stagedSummary: "M README.md",
329 stagedPatch: "diff --git a/README.md b/README.md",
330 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
331 });
332
333 expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]);
334 expect(runtimeMock.state.promptUrls).toEqual([
335 "http://127.0.0.1:4301",
336 "http://127.0.0.1:4301",
337 ]);
338 expect(runtimeMock.state.closeCalls).toEqual([]);
339
340 yield* advanceIdleClock;
341
342 expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]);
343 }),
344 ).pipe(Effect.provide(TestClock.layer())),
345 );
346
347 it.effect("starts a new server after the warm server idles out", () =>
348 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
349 Effect.gen(function* () {
350 yield* textGeneration.generateCommitMessage({
351 cwd: process.cwd(),
352 branch: "feature/opencode-reuse",
353 stagedSummary: "M README.md",
354 stagedPatch: "diff --git a/README.md b/README.md",
355 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
356 });
357
358 yield* advanceIdleClock;
359
360 yield* textGeneration.generateCommitMessage({
361 cwd: process.cwd(),
362 branch: "feature/opencode-reuse",
363 stagedSummary: "M README.md",
364 stagedPatch: "diff --git a/README.md b/README.md",
365 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
366 });
367
368 expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]);
369 expect(runtimeMock.state.promptUrls).toEqual([
370 "http://127.0.0.1:4301",
371 "http://127.0.0.1:4302",
372 ]);
373 expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]);
374 }),
375 ).pipe(Effect.provide(TestClock.layer())),
376 );
377
378 it.effect("preserves the SDK cause when session creation fails", () =>
379 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
380 Effect.gen(function* () {
381 const sdkCause = new Error("session endpoint unavailable");
382 runtimeMock.state.sessionCreateError = sdkCause;
383
384 const error = yield* textGeneration
385 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
386 .pipe(Effect.flip);
387
388 expect(error).toBeInstanceOf(TextGenerationError);
389 expect(error.message).toContain("OpenCode session.create request failed.");
390 expect(error.cause).toMatchObject({
391 _tag: "OpenCodeTextGenerationSessionRequestError",
392 operation: "generateCommitMessage",
393 cwd: process.cwd(),
394 cause: sdkCause,
395 });
396 expect((error.cause as { cause: unknown }).cause).toBe(sdkCause);
397 }),
398 ),
399 );
400
401 it.effect("reports a missing session payload without manufacturing a cause", () =>
402 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
403 Effect.gen(function* () {
404 runtimeMock.state.sessionResult = {};
405
406 const error = yield* textGeneration
407 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
408 .pipe(Effect.flip);
409
410 expect(error.message).toContain("OpenCode session.create returned no session payload.");
411 expect(error.cause).toMatchObject({
412 _tag: "OpenCodeTextGenerationSessionPayloadError",
413 operation: "generateCommitMessage",
414 cwd: process.cwd(),
415 });
416 expect(error.cause).not.toHaveProperty("cause");
417 }),
418 ),
419 );
420
421 it.effect("preserves the SDK cause and request context when prompting fails", () =>
422 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
423 Effect.gen(function* () {
424 const sdkCause = new Error("prompt endpoint unavailable");
425 runtimeMock.state.promptRequestError = sdkCause;
426
427 const error = yield* textGeneration
428 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
429 .pipe(Effect.flip);
430
431 expect(error.message).toContain("OpenCode session.prompt request failed.");
432 expect(error.cause).toMatchObject({
433 _tag: "OpenCodeTextGenerationPromptRequestError",
434 operation: "generateCommitMessage",
435 cwd: process.cwd(),
436 sessionId: "http://127.0.0.1:4301/session",
437 providerId: "openai",
438 modelId: "gpt-5",
439 cause: sdkCause,
440 });
441 expect((error.cause as { cause: unknown }).cause).toBe(sdkCause);
442 }),
443 ),
444 );
445
446 it.effect("returns a typed empty-output error for malformed and blank response parts", () =>
447 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
448 Effect.gen(function* () {
449 runtimeMock.state.promptResult = {
450 data: {
451 parts: [null, { type: "tool" }, { type: "text", text: " " }],
452 },
453 };
454
455 const error = yield* textGeneration
456 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
457 .pipe(Effect.flip);
458
459 expect(error.message).toContain("OpenCode returned empty output.");
460 expect(error.cause).toMatchObject({
461 _tag: "OpenCodeTextGenerationEmptyOutputError",
462 operation: "generateCommitMessage",
463 cwd: process.cwd(),
464 sessionId: "http://127.0.0.1:4301/session",
465 providerId: "openai",
466 modelId: "gpt-5",
467 responsePartCount: 3,
468 textPartCount: 1,
469 });
470 expect(error.cause).not.toHaveProperty("cause");
471 }),
472 ),
473 );
474
475 it.effect("parses JSON returned as plain text output", () =>
476 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
477 Effect.gen(function* () {
478 runtimeMock.state.promptResult = {
479 data: {
480 parts: [
481 {
482 type: "text",
483 text: 'Here is the result:\n{"subject":"Tighten OpenCode parsing","body":"Handle JSON text output locally."}',
484 },
485 ],
486 },
487 };
488
489 const result = yield* textGeneration.generateCommitMessage({
490 cwd: process.cwd(),
491 branch: "feature/opencode-reuse",
492 stagedSummary: "M README.md",
493 stagedPatch: "diff --git a/README.md b/README.md",
494 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
495 });
496
497 expect(result).toEqual({
498 subject: "Tighten OpenCode parsing",
499 body: "Handle JSON text output locally.",
500 });
501 }),
502 ),
503 );
504
505 it.effect("surfaces the upstream OpenCode structured-output error message", () =>
506 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
507 Effect.gen(function* () {
508 runtimeMock.state.promptResult = {
509 data: {
510 info: {
511 error: {
512 name: "StructuredOutputError",
513 data: {
514 message: "Model did not produce structured output",
515 retries: 2,
516 },
517 },
518 },
519 },
520 };
521
522 const error = yield* textGeneration
523 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
524 .pipe(Effect.flip);
525
526 expect(error.message).toContain("Model did not produce structured output");
527 expect(error.cause).toMatchObject({
528 _tag: "OpenCodeTextGenerationPromptResponseError",
529 operation: "generateCommitMessage",
530 cwd: process.cwd(),
531 sessionId: "http://127.0.0.1:4301/session",
532 providerId: "openai",
533 modelId: "gpt-5",
534 providerErrorName: "StructuredOutputError",
535 providerMessage: "Model did not produce structured output",
536 });
537 expect(error.cause).not.toHaveProperty("cause");
538 }),
539 ),
540 );
541});
542
543it.layer(layerOpenCodeTextGenerationExistingServerTest)(
544 "OpenCodeTextGeneration with configured server URL",
545 (it) => {
546 it.effect("does not send a local environment password to a configured server", () =>
547 withOpenCodeTextGeneration(
548 EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS,
549 (textGeneration) =>
550 Effect.gen(function* () {
551 yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT);
552 expect(runtimeMock.state.authHeaders).toEqual([null]);
553 }),
554 { OPENCODE_SERVER_PASSWORD: "local-secret" },
555 ),
556 );
557
558 it.effect("does not create a session when the server version is unsupported", () =>
559 withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) =>
560 Effect.gen(function* () {
561 runtimeMock.state.connectionError = new Error(
562 "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.",
563 );
564
565 const error = yield* textGeneration
566 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
567 .pipe(Effect.flip);
568
569 expect(error).toBeInstanceOf(TextGenerationError);
570 expect(error.message).toContain("v1.14.18 is too old");
571 expect(runtimeMock.state.sessionCreateCalls).toBe(0);
572 }),
573 ),
574 );
575
576 it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () =>
577 withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) =>
578 Effect.gen(function* () {
579 yield* textGeneration.generateCommitMessage({
580 cwd: process.cwd(),
581 branch: "feature/opencode-reuse",
582 stagedSummary: "M README.md",
583 stagedPatch: "diff --git a/README.md b/README.md",
584 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
585 });
586 yield* textGeneration.generateCommitMessage({
587 cwd: process.cwd(),
588 branch: "feature/opencode-reuse",
589 stagedSummary: "M README.md",
590 stagedPatch: "diff --git a/README.md b/README.md",
591 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
592 });
593
594 expect(runtimeMock.state.startCalls).toEqual([]);
595 expect(runtimeMock.state.promptUrls).toEqual([
596 "http://127.0.0.1:9999",
597 "http://127.0.0.1:9999",
598 ]);
599 expect(runtimeMock.state.authHeaders).toEqual([
600 `Basic ${btoa("opencode:secret-password")}`,
601 `Basic ${btoa("opencode:secret-password")}`,
602 ]);
603
604 yield* advanceIdleClock;
605
606 expect(runtimeMock.state.closeCalls).toEqual([]);
607 }),
608 ).pipe(Effect.provide(TestClock.layer())),
609 );
610 },
611);