diff --git a/packages/provider-opencode/src/server/textGeneration.test.ts b/packages/provider-opencode/src/server/textGeneration.test.ts index 9b9c2525190c..ebb14350c9ec 100644 --- a/packages/provider-opencode/src/server/textGeneration.test.ts +++ b/packages/provider-opencode/src/server/textGeneration.test.ts @@ -244,9 +244,24 @@ const advanceIdleClock = Effect.gen(function* () { }); it.layer(layerOpenCodeTextGenerationTest)("OpenCodeTextGeneration", (it) => { - it.effect("cancels an in-flight session creation before prompting", () => + it.effect("cancels or times out an in-flight session creation before prompting", () => withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { + const timedOut = yield* Deferred.make(); + runtimeMock.state.onSessionCreate = (signal) => { + Deferred.doneUnsafe(timedOut, Effect.succeed(signal)); + return new Promise(() => {}); + }; + const timedOutFiber = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.forkChild); + const timedOutSignal = yield* Deferred.await(timedOut); + yield* TestClock.adjust(Duration.minutes(3)); + const timeoutError = yield* Fiber.join(timedOutFiber).pipe(Effect.flip); + expect(timeoutError).toBeInstanceOf(TextGenerationError); + expect(timeoutError.detail).toBe("OpenCode did not finish generating in time."); + expect(timedOutSignal?.aborted).toBe(true); + const started = yield* Deferred.make(); runtimeMock.state.onSessionCreate = (signal) => { Deferred.doneUnsafe(started, Effect.succeed(signal)); @@ -357,6 +372,34 @@ it.layer(layerOpenCodeTextGenerationTest)("OpenCodeTextGeneration", (it) => { ), ); + it.effect("gives up and aborts the session when a prompt never settles", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + let sessionAborts = 0; + runtimeMock.state.onPrompt = (signal) => { + Deferred.doneUnsafe(started, Effect.succeed(signal)); + return new Promise(() => {}); + }; + runtimeMock.state.onAbort = () => { + sessionAborts += 1; + return Promise.reject(new Error("abort observed")); + }; + const fiber = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.forkChild); + const signal = yield* Deferred.await(started); + yield* TestClock.adjust(Duration.minutes(3)); + + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + expect(error).toBeInstanceOf(TextGenerationError); + expect(error.detail).toBe("OpenCode did not finish generating in time."); + expect(signal?.aborted).toBe(true); + expect(sessionAborts).toBe(1); + }), + ), + ); + it.effect("uses an environment-only password for a locally spawned server", () => withOpenCodeTextGeneration( DEFAULT_OPENCODE_SETTINGS, diff --git a/packages/provider-opencode/src/server/textGeneration.ts b/packages/provider-opencode/src/server/textGeneration.ts index eed819e44d62..9b0251c3a51e 100644 --- a/packages/provider-opencode/src/server/textGeneration.ts +++ b/packages/provider-opencode/src/server/textGeneration.ts @@ -10,6 +10,8 @@ import * as TextGenerationOperations from "@t3tools/provider-core/server/textGen import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; +const GENERATION_TIMEOUT = "3 minutes"; + const OpenCodeTextGenerationOperation = Schema.Literals([ "generateCommitMessage", "generatePrContent", @@ -333,6 +335,16 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" .pipe(Effect.flatMap(runAgainstServer), Effect.scoped) : serverOwner.withServer(runAgainstServer); const rawOutput = yield* serverOutput.pipe( + Effect.timeoutOrElse({ + duration: GENERATION_TIMEOUT, + orElse: () => + Effect.fail( + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode did not finish generating in time.", + }), + ), + }), Effect.catchTags({ OpenCodeRuntimeError: (cause) => Effect.fail(