Skip to content
Source
Sign inSign up
Open History to see the last commit

textGeneration.test.ts

675 lines25.4 KB

1import { ProviderInstanceId, settingsSecretLiteral, TextGenerationError } from "@t3tools/contracts";
2import { OpenCodeSettings } from "../settings.ts";
3import type { OpencodeClient } from "@opencode-ai/sdk/v2";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { it } from "@effect/vitest";
6import * as Cause from "effect/Cause";
7import * as Deferred from "effect/Deferred";
8import * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";
10import * as Exit from "effect/Exit";
11import * as Fiber from "effect/Fiber";
12import * as Layer from "effect/Layer";
13import * as Schema from "effect/Schema";
14import * as TestClock from "effect/testing/TestClock";
15import * as NetService from "@t3tools/shared/Net";
16import { beforeEach, expect } from "vite-plus/test";
17​
18import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
19import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
20import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
21import * as OpenCodeTextGeneration from "./textGeneration.ts";
22import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration";
23​
24const runtimeMock = {
25 state: {
26 startCalls: [] as string[],
27 promptUrls: [] as string[],
28 promptParts: [] as ReadonlyArray<unknown>[],
29 promptInputs: [] as Array<Parameters<OpencodeClient["session"]["prompt"]>[0]>,
30 authHeaders: [] as Array<string | null>,
31 closeCalls: [] as string[],
32 sessionCreateCalls: 0,
33 sessionCalls: [] as string[],
34 onSessionCreate: undefined as ((signal?: AbortSignal) => Promise<never>) | undefined,
35 onPrompt: undefined as
36 | ((signal?: AbortSignal) => Promise<{ data: { parts: ReadonlyArray<unknown> } }>)
37 | undefined,
38 onAbort: undefined as ((signal?: AbortSignal) => Promise<never>) | undefined,
39 connectionError: undefined as Error | undefined,
40 sessionCreateError: undefined as unknown,
41 sessionResult: undefined as { data?: { id: string } } | undefined,
42 promptRequestError: undefined as unknown,
43 promptResult: undefined as
44 | { data?: { info?: { error?: unknown }; parts?: Array<unknown> } }
45 | undefined,
46 },
47 reset() {
48 this.state.startCalls.length = 0;
49 this.state.promptUrls.length = 0;
50 this.state.promptParts.length = 0;
51 this.state.promptInputs.length = 0;
52 this.state.authHeaders.length = 0;
53 this.state.closeCalls.length = 0;
54 this.state.sessionCreateCalls = 0;
55 this.state.sessionCalls.length = 0;
56 this.state.onSessionCreate = undefined;
57 this.state.onPrompt = undefined;
58 this.state.onAbort = undefined;
59 this.state.connectionError = undefined;
60 this.state.sessionCreateError = undefined;
61 this.state.sessionResult = undefined;
62 this.state.promptRequestError = undefined;
63 this.state.promptResult = undefined;
64 },
65};
66​
67const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = {
68 startOpenCodeServerProcess: ({ binaryPath, serverPassword, environment }) =>
69 Effect.gen(function* () {
70 const index = runtimeMock.state.startCalls.length + 1;
71 const url = `http://127.0.0.1:${4_300 + index}`;
72 runtimeMock.state.startCalls.push(binaryPath);
73 yield* Effect.addFinalizer(() =>
74 Effect.sync(() => {
75 runtimeMock.state.closeCalls.push(url);
76 }),
77 );
78 const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({
79 external: false,
80 ...(serverPassword !== undefined ? { serverPassword } : {}),
81 ...(environment !== undefined ? { environment } : {}),
82 });
83 return {
84 url,
85 ...(effectiveServerPassword !== undefined
86 ? { serverPassword: effectiveServerPassword }
87 : {}),
88 version: "1.14.19",
89 isRunning: Effect.succeed(true),
90 exitCode: Effect.never,
91 };
92 }),
93 connectToOpenCodeServer: ({ serverUrl, serverPassword }) =>
94 runtimeMock.state.connectionError
95 ? Effect.fail(
96 new OpenCodeRuntime.OpenCodeRuntimeError({
97 operation: "global.health",
98 detail: runtimeMock.state.connectionError.message,
99 cause: runtimeMock.state.connectionError,
100 }),
101 )
102 : Effect.succeed({
103 url: serverUrl ?? "http://127.0.0.1:4301",
104 ...(serverPassword ? { serverPassword } : {}),
105 version: "1.14.19",
106 exitCode: null,
107 external: Boolean(serverUrl),
108 }),
109 runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }),
110 createOpenCodeSdkClient: ({ baseUrl, serverPassword }) =>
111 ({
112 provider: {
113 list: async () => ({
114 data: {
115 all: [{ id: "openai", models: { "gpt-5": { id: "gpt-5" } } }],
116 },
117 }),
118 },
119 session: {
120 create: async (_input: unknown, options?: { readonly signal?: AbortSignal }) => {
121 runtimeMock.state.sessionCreateCalls += 1;
122 if (runtimeMock.state.onSessionCreate) {
123 return runtimeMock.state.onSessionCreate(options?.signal);
124 }
125 if (runtimeMock.state.sessionCreateError !== undefined) {
126 throw runtimeMock.state.sessionCreateError;
127 }
128 return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } };
129 },
130 abort: async (
131 input: { readonly sessionID: string },
132 options?: { readonly signal?: AbortSignal },
133 ) => {
134 runtimeMock.state.sessionCalls.push(`abort ${input.sessionID}`);
135 if (runtimeMock.state.onAbort) return runtimeMock.state.onAbort(options?.signal);
136 return { data: true };
137 },
138 delete: async (input: { readonly sessionID: string }) => {
139 runtimeMock.state.sessionCalls.push(`delete ${input.sessionID}`);
140 return { data: true };
141 },
142 prompt: async (
143 input: Parameters<OpencodeClient["session"]["prompt"]>[0],
144 options?: { readonly signal?: AbortSignal },
145 ) => {
146 runtimeMock.state.promptInputs.push(input);
147 runtimeMock.state.promptUrls.push(baseUrl);
148 runtimeMock.state.promptParts.push(input.parts ?? []);
149 runtimeMock.state.authHeaders.push(
150 serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,
151 );
152 if (runtimeMock.state.onPrompt) return runtimeMock.state.onPrompt(options?.signal);
153 if (runtimeMock.state.promptRequestError !== undefined) {
154 throw runtimeMock.state.promptRequestError;
155 }
156 return (
157 runtimeMock.state.promptResult ?? {
158 data: {
159 parts: [
160 {
161 type: "text",
162 text: JSON.stringify({
163 subject: "Improve OpenCode reuse",
164 body: "Reuse one server for the full action.",
165 }),
166 },
167 ],
168 },
169 }
170 );
171 },
172 },
173 }) as unknown as ReturnType<
174 OpenCodeRuntime.OpenCodeRuntime["Service"]["createOpenCodeSdkClient"]
175 >,
176 loadOpenCodeInventory: () =>
177 Effect.fail(
178 new OpenCodeRuntime.OpenCodeRuntimeError({
179 operation: "loadOpenCodeInventory",
180 detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test",
181 cause: null,
182 }),
183 ),
184 loadOpenCodeSkills: () => Effect.succeed([]),
185};
186​
187const DEFAULT_TEST_MODEL_SELECTION = {
188 instanceId: ProviderInstanceId.make("opencode"),
189 model: "openai/gpt-5",
190};
191const DEFAULT_COMMIT_MESSAGE_INPUT = {
192 cwd: process.cwd(),
193 branch: "feature/opencode-reuse",
194 stagedSummary: "M README.md",
195 stagedPatch: "diff --git a/README.md b/README.md",
196 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
197};
198​
199const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;
200​
201const layerOpenCodeTextGenerationTest = Layer.succeed(
202 OpenCodeRuntime.OpenCodeRuntime,
203 OpenCodeRuntimeTestDouble,
204).pipe(
205 Layer.provideMerge(TestProviderHost.layer()),
206 Layer.provideMerge(NetService.layer),
207 Layer.provideMerge(NodeServices.layer),
208);
209​
210const layerOpenCodeTextGenerationExistingServerTest = Layer.succeed(
211 OpenCodeRuntime.OpenCodeRuntime,
212 OpenCodeRuntimeTestDouble,
213).pipe(
214 Layer.provideMerge(TestProviderHost.layer()),
215 Layer.provideMerge(NetService.layer),
216 Layer.provideMerge(NodeServices.layer),
217);
218​
219const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
220 binaryPath: "fake-opencode",
221});
222const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
223 binaryPath: "fake-opencode",
224 serverUrl: "http://127.0.0.1:9999",
225 serverPassword: "secret-password",
226});
227const EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
228 binaryPath: "fake-opencode",
229 serverUrl: "http://127.0.0.1:9999",
230});
231​
232function withOpenCodeTextGeneration<A, E, R>(
233 settings: OpenCodeSettings,
234 effectFn: (textGeneration: ProviderTextGeneration) => Effect.Effect<A, E, R>,
235 environment?: NodeJS.ProcessEnv,
236) {
237 return Effect.gen(function* () {
238 const serverOwner = yield* OpenCodeServerOwner.make({
239 binaryPath: settings.binaryPath,
240 directory: process.cwd(),
241 ...(settingsSecretLiteral(settings.serverPassword)
242 ? { serverPassword: settingsSecretLiteral(settings.serverPassword) }
243 : {}),
244 ...(environment ? { environment } : {}),
245 });
246 const textGeneration = yield* OpenCodeTextGeneration.makeOpenCodeTextGeneration(settings).pipe(
247 Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
248 );
249 return yield* effectFn(textGeneration);
250 }).pipe(Effect.scoped);
252​
253beforeEach(() => {
254 runtimeMock.reset();
255});
256​
257const advanceIdleClock = Effect.gen(function* () {
258 yield* Effect.yieldNow;
259 yield* TestClock.adjust(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS + 1));
260 yield* Effect.yieldNow;
261});
262​
263it.layer(layerOpenCodeTextGenerationTest)("OpenCodeTextGeneration", (it) => {
264 it.effect("omits stale reasoning variants when generating commit messages", () =>
265 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
266 Effect.gen(function* () {
267 yield* textGeneration.generateCommitMessage({
268 ...DEFAULT_COMMIT_MESSAGE_INPUT,
269 modelSelection: {
270 ...DEFAULT_TEST_MODEL_SELECTION,
271 options: [
272 { id: "variant", value: "medium" },
273 { id: "agent", value: "plan" },
274 ],
275 },
276 });
277​
278 expect(runtimeMock.state.promptInputs).toHaveLength(1);
279 expect(runtimeMock.state.promptInputs[0]).toMatchObject({
280 model: { providerID: "openai", modelID: "gpt-5" },
281 agent: "plan",
282 });
283 expect(runtimeMock.state.promptInputs[0]).not.toHaveProperty("variant");
284 }),
285 ),
286 );
287​
288 it.effect("cancels or times out an in-flight session creation before prompting", () =>
289 withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) =>
290 Effect.gen(function* () {
291 const timedOut = yield* Deferred.make<AbortSignal | undefined>();
292 runtimeMock.state.onSessionCreate = (signal) => {
293 Deferred.doneUnsafe(timedOut, Effect.succeed(signal));
294 return new Promise<never>(() => {});
295 };
296 const timedOutFiber = yield* textGeneration
297 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
298 .pipe(Effect.forkChild);
299 const timedOutSignal = yield* Deferred.await(timedOut);
300 yield* TestClock.adjust(Duration.minutes(3));
301 const timeoutError = yield* Fiber.join(timedOutFiber).pipe(Effect.flip);
302 expect(timeoutError).toBeInstanceOf(TextGenerationError);
303 expect(timeoutError.detail).toBe("OpenCode did not finish generating in time.");
304 expect(timedOutSignal?.aborted).toBe(true);
305​
306 const started = yield* Deferred.make<AbortSignal | undefined>();
307 runtimeMock.state.onSessionCreate = (signal) => {
308 Deferred.doneUnsafe(started, Effect.succeed(signal));
309 return new Promise<never>(() => {});
310 };
311 const fiber = yield* textGeneration
312 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
313 .pipe(Effect.forkChild);
314 const signal = yield* Deferred.await(started);
315 yield* Fiber.interrupt(fiber);
316​
317 const exit = yield* Fiber.await(fiber);
318 expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true);
319 expect(signal?.aborted).toBe(true);
320 expect(runtimeMock.state.promptUrls).toEqual([]);
321 expect(runtimeMock.state.sessionCalls).toEqual([]);
322 }),
323 ),
324 );
325​
326 it.effect("cancels only its prompt session while another generation uses the shared server", () =>
327 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
328 Effect.gen(function* () {
329 const started = yield* Deferred.make<AbortSignal | undefined>();
330 runtimeMock.state.sessionResult = { data: { id: "cancelled-session" } };
331 runtimeMock.state.onPrompt = (signal) => {
332 Deferred.doneUnsafe(started, Effect.succeed(signal));
333 return new Promise<never>(() => {});
334 };
335 const fiber = yield* textGeneration
336 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
337 .pipe(Effect.forkChild);
338 const signal = yield* Deferred.await(started);
339​
340 const otherStarted = yield* Deferred.make<AbortSignal | undefined>();
341 const otherReply = Promise.withResolvers<{ data: { parts: ReadonlyArray<unknown> } }>();
342 runtimeMock.state.sessionResult = { data: { id: "other-session" } };
343 runtimeMock.state.onPrompt = (otherSignal) => {
344 Deferred.doneUnsafe(otherStarted, Effect.succeed(otherSignal));
345 return otherReply.promise;
346 };
347 const otherFiber = yield* textGeneration
348 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
349 .pipe(Effect.forkChild);
350 const otherSignal = yield* Deferred.await(otherStarted);
351 yield* Fiber.interrupt(fiber);
352​
353 const exit = yield* Fiber.await(fiber);
354 expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true);
355 expect(signal?.aborted).toBe(true);
356 expect(otherSignal?.aborted).toBe(false);
357 expect(runtimeMock.state.sessionCalls).toEqual([
358 "abort cancelled-session",
359 "delete cancelled-session",
360 ]);
361 expect(runtimeMock.state.closeCalls).toEqual([]);
362​
363 otherReply.resolve({
364 data: {
365 parts: [{ type: "text", text: '{"subject":"Other generation completed","body":""}' }],
366 },
367 });
368 expect((yield* Fiber.join(otherFiber)).subject).toBe("Other generation completed");
369 expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]);
370 expect(runtimeMock.state.promptUrls).toEqual([
371 "http://127.0.0.1:4301",
372 "http://127.0.0.1:4301",
373 ]);
374 expect(runtimeMock.state.sessionCalls).toEqual([
375 "abort cancelled-session",
376 "delete cancelled-session",
377 "delete other-session",
378 ]);
379 expect(runtimeMock.state.closeCalls).toEqual([]);
380 }),
381 ),
382 );
383​
384 it.effect("excludes generic files from thread title generation", () =>
385 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
386 Effect.gen(function* () {
387 runtimeMock.state.promptResult = {
388 data: {
389 parts: [{ type: "text", text: '{"title":"Review uploaded report"}' }],
390 },
391 };
392​
393 yield* textGeneration.generateThreadTitle({
394 cwd: process.cwd(),
395 message: "Review these attachments.",
396 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
397 attachments: [
398 {
399 type: "image",
400 id: "thread-image-attachment",
401 name: "screenshot.png",
402 mimeType: "image/png",
403 sizeBytes: 3,
404 },
405 {
406 type: "file",
407 id: "thread-report-attachment-pdf",
408 name: "report.pdf",
409 mimeType: "application/pdf",
410 sizeBytes: 42,
411 },
412 ],
413 });
414​
415 expect(runtimeMock.state.promptParts[0]).toEqual([
416 expect.objectContaining({ type: "text" }),
417 expect.objectContaining({ type: "file", filename: "screenshot.png" }),
418 ]);
419 }),
420 ),
421 );
422​
423 it.effect("gives up and aborts the session when a prompt never settles", () =>
424 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
425 Effect.gen(function* () {
426 const started = yield* Deferred.make<AbortSignal | undefined>();
427 let sessionAborts = 0;
428 runtimeMock.state.onPrompt = (signal) => {
429 Deferred.doneUnsafe(started, Effect.succeed(signal));
430 return new Promise<never>(() => {});
431 };
432 runtimeMock.state.onAbort = () => {
433 sessionAborts += 1;
434 return Promise.reject(new Error("abort observed"));
435 };
436 const fiber = yield* textGeneration
437 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
438 .pipe(Effect.forkChild);
439 const signal = yield* Deferred.await(started);
440 yield* TestClock.adjust(Duration.minutes(3));
441​
442 const error = yield* Fiber.join(fiber).pipe(Effect.flip);
443 expect(error).toBeInstanceOf(TextGenerationError);
444 expect(error.detail).toBe("OpenCode did not finish generating in time.");
445 expect(signal?.aborted).toBe(true);
446 expect(sessionAborts).toBe(1);
447 }),
448 ),
449 );
450​
451 it.effect("uses an environment-only password for a locally spawned server", () =>
452 withOpenCodeTextGeneration(
453 DEFAULT_OPENCODE_SETTINGS,
454 (textGeneration) =>
455 Effect.gen(function* () {
456 yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT);
457​
458 expect(runtimeMock.state.authHeaders).toEqual([
459 `Basic ${btoa("opencode:environment-password")}`,
460 ]);
461 }),
462 { OPENCODE_SERVER_PASSWORD: "environment-password" },
463 ),
464 );
465​
466 it.effect("returns a typed empty-output error for malformed and blank response parts", () =>
467 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
468 Effect.gen(function* () {
469 runtimeMock.state.promptResult = {
470 data: {
471 parts: [null, { type: "tool" }, { type: "text", text: " " }],
472 },
473 };
474​
475 const error = yield* textGeneration
476 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
477 .pipe(Effect.flip);
478​
479 expect(error.message).toContain("OpenCode returned empty output.");
480 expect(error.cause).toMatchObject({
481 _tag: "OpenCodeTextGenerationEmptyOutputError",
482 operation: "generateCommitMessage",
483 cwd: process.cwd(),
484 sessionId: "http://127.0.0.1:4301/session",
485 providerId: "openai",
486 modelId: "gpt-5",
487 responsePartCount: 3,
488 textPartCount: 1,
489 });
490 expect(error.cause).not.toHaveProperty("cause");
491 expect(runtimeMock.state.sessionCalls).toEqual(["delete http://127.0.0.1:4301/session"]);
492 }),
493 ),
494 );
495​
496 it.effect(
497 "maps OpenCode prompt errors to fixed details and keeps provider text in the cause",
498 () =>
499 Effect.forEach(
500 [
501 {
502 error: {
503 name: "APIError",
504 data: {
505 message: "Org quota secret-org exceeded",
506 statusCode: 429,
507 isRetryable: true,
508 },
509 },
510 rawText: "secret-org",
511 detail: "The model provider's usage or rate limit was reached. Try again later.",
512 },
513 {
514 error: {
515 name: "APIError",
516 data: {
517 message: "<none>",
518 statusCode: 503,
519 isRetryable: true,
520 responseBody: "<title>Upstream gateway secret-host</title>",
521 },
522 },
523 rawText: "secret-host",
524 detail: "The model provider failed the request (HTTP 503).",
525 },
526 {
527 error: { name: "UnknownError", data: { message: "Provider stack trace secret-token" } },
528 rawText: "secret-token",
529 detail: "OpenCode could not generate the text.",
530 },
531 ],
532 (testCase) =>
533 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
534 Effect.gen(function* () {
535 runtimeMock.state.promptResult = {
536 data: { info: { error: testCase.error }, parts: [] },
537 };
538​
539 const error = yield* textGeneration
540 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
541 .pipe(Effect.flip);
542​
543 expect(error.detail).toBe(testCase.detail);
544 expect(error.message).not.toContain(testCase.rawText);
545 expect(error.cause).toMatchObject({
546 _tag: "OpenCodeTextGenerationPromptResponseError",
547 providerErrorName: testCase.error.name,
548 cause: testCase.error,
549 });
550 expect((error.cause as Error).message).not.toContain(testCase.rawText);
551 }),
552 ),
553 { discard: true },
554 ),
555 );
556​
557 it.effect("deletes the session when the prompt request rejects", () =>
558 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
559 Effect.gen(function* () {
560 runtimeMock.state.promptRequestError = new Error("prompt transport failed");
561​
562 const error = yield* textGeneration
563 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
564 .pipe(Effect.flip);
565​
566 expect(error.detail).toBe("OpenCode session.prompt request failed.");
567 expect(runtimeMock.state.sessionCalls).toEqual(["delete http://127.0.0.1:4301/session"]);
568 }),
569 ),
570 );
571​
572 it.effect("parses JSON returned as plain text output", () =>
573 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
574 Effect.gen(function* () {
575 runtimeMock.state.promptResult = {
576 data: {
577 parts: [
578 {
579 type: "text",
580 text: 'Here is the result:\n{"subject":"Tighten OpenCode parsing","body":"Handle JSON text output locally."}',
581 },
582 ],
583 },
584 };
585​
586 const result = 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(result).toEqual({
595 subject: "Tighten OpenCode parsing",
596 body: "Handle JSON text output locally.",
597 });
598 }),
599 ),
600 );
601});
602​
603it.layer(layerOpenCodeTextGenerationExistingServerTest)(
604 "OpenCodeTextGeneration with configured server URL",
605 (it) => {
606 it.effect("does not send a local environment password to a configured server", () =>
607 withOpenCodeTextGeneration(
608 EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS,
609 (textGeneration) =>
610 Effect.gen(function* () {
611 yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT);
612 expect(runtimeMock.state.authHeaders).toEqual([null]);
613 }),
614 { OPENCODE_SERVER_PASSWORD: "local-secret" },
615 ),
616 );
617​
618 it.effect("does not create a session when the server version is unsupported", () =>
619 withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) =>
620 Effect.gen(function* () {
621 runtimeMock.state.connectionError = new Error(
622 "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.",
623 );
624​
625 const error = yield* textGeneration
626 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
627 .pipe(Effect.flip);
628​
629 expect(error).toBeInstanceOf(TextGenerationError);
630 expect(error.message).toContain("v1.14.18 is too old");
631 expect(runtimeMock.state.sessionCreateCalls).toBe(0);
632 }),
633 ),
634 );
635​
636 it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () =>
637 withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) =>
638 Effect.gen(function* () {
639 yield* textGeneration.generateCommitMessage({
640 cwd: process.cwd(),
641 branch: "feature/opencode-reuse",
642 stagedSummary: "M README.md",
643 stagedPatch: "diff --git a/README.md b/README.md",
644 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
645 });
646 yield* textGeneration.generateCommitMessage({
647 cwd: process.cwd(),
648 branch: "feature/opencode-reuse",
649 stagedSummary: "M README.md",
650 stagedPatch: "diff --git a/README.md b/README.md",
651 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
652 });
653​
654 expect(runtimeMock.state.startCalls).toEqual([]);
655 expect(runtimeMock.state.sessionCalls).toEqual([
656 "delete http://127.0.0.1:9999/session",
657 "delete http://127.0.0.1:9999/session",
658 ]);
659 expect(runtimeMock.state.promptUrls).toEqual([
660 "http://127.0.0.1:9999",
661 "http://127.0.0.1:9999",
662 ]);
663 expect(runtimeMock.state.authHeaders).toEqual([
664 `Basic ${btoa("opencode:secret-password")}`,
665 `Basic ${btoa("opencode:secret-password")}`,
666 ]);
667​
668 yield* advanceIdleClock;
669​
670 expect(runtimeMock.state.closeCalls).toEqual([]);
671 }),
672 ).pipe(Effect.provide(TestClock.layer())),
673 );
674 },
675);