| 1 | import { ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; |
| 2 | import { OpenCodeSettings } from "../settings.ts"; |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import { it } from "@effect/vitest"; |
| 5 | import * as Duration from "effect/Duration"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Schema from "effect/Schema"; |
| 9 | import * as TestClock from "effect/testing/TestClock"; |
| 10 | import * as NetService from "@t3tools/shared/Net"; |
| 11 | import { beforeEach, expect } from "vite-plus/test"; |
| 12 | |
| 13 | import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; |
| 14 | import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; |
| 15 | import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; |
| 16 | import * as OpenCodeTextGeneration from "./textGeneration.ts"; |
| 17 | import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; |
| 18 | |
| 19 | const 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 | |
| 50 | const 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 | |
| 154 | const DEFAULT_TEST_MODEL_SELECTION = { |
| 155 | instanceId: ProviderInstanceId.make("opencode"), |
| 156 | model: "openai/gpt-5", |
| 157 | }; |
| 158 | const 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 | |
| 166 | const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; |
| 167 | |
| 168 | const 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 | |
| 177 | const 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 | |
| 186 | const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ |
| 187 | binaryPath: "fake-opencode", |
| 188 | }); |
| 189 | const LOCAL_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ |
| 190 | binaryPath: "fake-opencode", |
| 191 | serverPassword: "secret-password", |
| 192 | }); |
| 193 | const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ |
| 194 | binaryPath: "fake-opencode", |
| 195 | serverUrl: "http://127.0.0.1:9999", |
| 196 | serverPassword: "secret-password", |
| 197 | }); |
| 198 | const EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ |
| 199 | binaryPath: "fake-opencode", |
| 200 | serverUrl: "http://127.0.0.1:9999", |
| 201 | }); |
| 202 | |
| 203 | function 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 | |
| 222 | beforeEach(() => { |
| 223 | runtimeMock.reset(); |
| 224 | }); |
| 225 | |
| 226 | const 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 | |
| 232 | it.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 | |
| 543 | it.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 | ); |