From c0f26a88bab76cda07c1bd450dd7eed3b0b4f324 Mon Sep 17 00:00:00 2001 From: Kevin Rajan Date: Mon, 28 Sep 2026 02:24:28 -0500 Subject: [PATCH 1/9] [muse] fix(server): bound OpenCode text generation with 180s timeout A wedged local OpenCode server could park session.create / session.prompt forever: runOpenCodeJson had no timeout while every other text-generation provider (Claude, Codex, Cursor, Grok, Antigravity) bounds its call at 180s. The single sequential title-regeneration worker would then stall and stop all future title regeneration. Wrap the network portion of runOpenCodeJson in Effect.timeoutOption(OPENCODE_TIMEOUT_MS) and map expiry to TextGenerationError("OpenCode request timed out."), mirroring the Claude provider's timeout shape. Authored with AI assistance (Muse, Meta's Muse Spark) under the contributor's direction. The contributor reviewed the diff and ran the focused tests before opening this PR. --- .../OpenCodeTextGeneration.test.ts | 26 +++++++++++++++++++ .../textGeneration/OpenCodeTextGeneration.ts | 18 +++++++++++++ 2 files changed, 44 insertions(+) diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index 688bf4bca352..c30e3c7a75dd 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; @@ -612,5 +613,30 @@ it.layer(OpenCodeTextGenerationExistingServerTestLayer)( }), ).pipe(Effect.provide(TestClock.layer())), ); + it.effect("fails with a timeout error when the OpenCode server never answers", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + // The SDK promise never settles: the server accepted the session + // but the prompt call is wedged. + runtimeMock.state.promptResult = new Promise(() => {}) as unknown as NonNullable< + typeof runtimeMock.state.promptResult + >; + + const child = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.forkChild); + // Spin until the wedged prompt call is in flight, so the clock + // adjustment below cannot run before the timeout is armed. + while (runtimeMock.state.promptUrls.length === 0) { + yield* Effect.yieldNow; + } + yield* TestClock.adjust(181_000); + const error = yield* Fiber.join(child).pipe(Effect.flip); + expect(error).toBeInstanceOf(TextGenerationError); + expect(error.operation).toBe("generateCommitMessage"); + expect(error.detail).toContain("timed out"); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); }, ); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 3ab0b4966dad..5d52841ce2fe 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -1,4 +1,5 @@ import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { @@ -29,6 +30,10 @@ import { import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; import * as OpenCodeServerOwner from "../provider/OpenCodeServerOwner.ts"; +// Matches the other text-generation providers: a wedged OpenCode server +// must not hold the caller forever. +const OPENCODE_TIMEOUT_MS = 180_000; + const OpenCodeTextGenerationOperation = Schema.Literals([ "generateCommitMessage", "generatePrContent", @@ -332,6 +337,19 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" .pipe(Effect.flatMap(runAgainstServer), Effect.scoped) : serverOwner.withServer(runAgainstServer); const rawOutput = yield* serverOutput.pipe( + Effect.timeoutOption(OPENCODE_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode request timed out.", + }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), Effect.catchTags({ OpenCodeRuntimeError: (cause) => Effect.fail( From 7bdb51e142dd64aecb369ce237a082942f685638 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:07:09 -0500 Subject: [PATCH 2/9] fix(opencode): forward abort signals to timed text requests --- .../textGeneration/OpenCodeTextGeneration.ts | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 5d52841ce2fe..e05d2eab881a 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -217,11 +217,14 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), }); const session = yield* Effect.tryPromise({ - try: () => - client.session.create({ - title: `T3 Code ${input.operation}`, - permission: [{ permission: "*", pattern: "*", action: "deny" }], - }), + try: (signal) => + client.session.create( + { + title: `T3 Code ${input.operation}`, + permission: [{ permission: "*", pattern: "*", action: "deny" }], + }, + { signal }, + ), catch: (cause) => new OpenCodeTextGenerationSessionRequestError({ operation: input.operation, @@ -246,14 +249,17 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }; const result = yield* Effect.tryPromise({ - try: () => - client.session.prompt({ - sessionID: session.data.id, - model: parsedModel, - ...(selectedAgent ? { agent: selectedAgent } : {}), - ...(selectedVariant ? { variant: selectedVariant } : {}), - parts: [{ type: "text", text: input.prompt }, ...fileParts], - }), + try: (signal) => + client.session.prompt( + { + sessionID: session.data.id, + model: parsedModel, + ...(selectedAgent ? { agent: selectedAgent } : {}), + ...(selectedVariant ? { variant: selectedVariant } : {}), + parts: [{ type: "text", text: input.prompt }, ...fileParts], + }, + { signal }, + ), catch: (cause) => new OpenCodeTextGenerationPromptRequestError({ ...promptContext, From a0229de77e8bda2a178ae57ad96518054c87ad5b Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:07:12 -0500 Subject: [PATCH 3/9] test(opencode): require session and prompt cancellation on timeout --- .../OpenCodeTextGeneration.test.ts | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index c30e3c7a75dd..856e993a1bd5 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -24,6 +24,8 @@ const runtimeMock = { authHeaders: [] as Array, closeCalls: [] as string[], sessionCreateCalls: 0, + sessionCreateSignals: [] as Array, + promptSignals: [] as Array, connectionError: undefined as Error | undefined, sessionCreateError: undefined as unknown, sessionResult: undefined as { data?: { id: string } } | undefined, @@ -39,6 +41,8 @@ const runtimeMock = { this.state.authHeaders.length = 0; this.state.closeCalls.length = 0; this.state.sessionCreateCalls = 0; + this.state.sessionCreateSignals.length = 0; + this.state.promptSignals.length = 0; this.state.connectionError = undefined; this.state.sessionCreateError = undefined; this.state.sessionResult = undefined; @@ -95,14 +99,19 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => ({ session: { - create: async () => { + create: async (_input: unknown, options?: { readonly signal?: AbortSignal }) => { runtimeMock.state.sessionCreateCalls += 1; + runtimeMock.state.sessionCreateSignals.push(options?.signal); if (runtimeMock.state.sessionCreateError !== undefined) { throw runtimeMock.state.sessionCreateError; } return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } }; }, - prompt: async (input: { readonly parts: ReadonlyArray }) => { + prompt: async ( + input: { readonly parts: ReadonlyArray }, + options?: { readonly signal?: AbortSignal }, + ) => { + runtimeMock.state.promptSignals.push(options?.signal); runtimeMock.state.promptUrls.push(baseUrl); runtimeMock.state.promptParts.push(input.parts); runtimeMock.state.authHeaders.push( @@ -635,6 +644,27 @@ it.layer(OpenCodeTextGenerationExistingServerTestLayer)( expect(error).toBeInstanceOf(TextGenerationError); expect(error.operation).toBe("generateCommitMessage"); expect(error.detail).toContain("timed out"); + expect(runtimeMock.state.promptSignals.at(-1)?.aborted).toBe(true); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("aborts a stalled OpenCode session creation after the timeout", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.sessionResult = new Promise(() => {}) as unknown as NonNullable< + typeof runtimeMock.state.sessionResult + >; + const child = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.forkChild); + while (runtimeMock.state.sessionCreateCalls === 0) { + yield* Effect.yieldNow; + } + yield* TestClock.adjust(181_000); + const error = yield* Fiber.join(child).pipe(Effect.flip); + expect(error).toBeInstanceOf(TextGenerationError); + expect(error.detail).toContain("timed out"); + expect(runtimeMock.state.sessionCreateSignals.at(-1)?.aborted).toBe(true); }), ).pipe(Effect.provide(TestClock.layer())), ); From 590ed719f2d1a39a474a7aa8fef360195d44e87d Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:30:30 -0500 Subject: [PATCH 4/9] style(opencode): normalize test-group closing whitespace --- packages/provider-opencode/src/server/textGeneration.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/provider-opencode/src/server/textGeneration.test.ts b/packages/provider-opencode/src/server/textGeneration.test.ts index bdce2f8f8146..9ef3de13911c 100644 --- a/packages/provider-opencode/src/server/textGeneration.test.ts +++ b/packages/provider-opencode/src/server/textGeneration.test.ts @@ -592,7 +592,6 @@ it.layer(layerOpenCodeTextGenerationTest)("OpenCodeTextGeneration", (it) => { }), ).pipe(Effect.provide(TestClock.layer())), ); - }); it.layer(layerOpenCodeTextGenerationExistingServerTest)( From 9fdbd80d9567a48467b23697ab0ba60ba7b7e520 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:06:36 -0500 Subject: [PATCH 5/9] fix(opencode): stop timed-out v1 generation sessions On prompt interruption, send a session-specific abort with a bounded 10-second cleanup that preserves the original timeout outcome. Design adapted from yashranaway's existing #10606; CodeRabbit identified this remaining server-side cancellation gap on #14137. Original timeout and SDK signal forwarding directed by Kevin / Muse Spark; follow-up adapted by GPT-6 via ChatGPT, preserving credit. --- .../src/server/textGeneration.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/provider-opencode/src/server/textGeneration.ts b/packages/provider-opencode/src/server/textGeneration.ts index f608e4102753..871493f2c1c9 100644 --- a/packages/provider-opencode/src/server/textGeneration.ts +++ b/packages/provider-opencode/src/server/textGeneration.ts @@ -238,7 +238,22 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" ...promptContext, cause, }), - }); + }).pipe( + // Request cancellation alone does not stop generation on the OpenCode server. + // This session-specific cleanup follows the approach in #10606 (yashranaway). + Effect.onInterrupt(() => + OpenCodeRuntime.runOpenCodeSdk("session.abort", (signal) => + client.session.abort({ sessionID: session.data.id }, { signal }), + ).pipe( + Effect.interruptible, + Effect.timeout("10 seconds"), + Effect.catch(() => + Effect.logWarning("Failed to stop cancelled OpenCode text generation.", promptContext), + ), + Effect.asVoid, + ), + ), + ); const promptFailure = getOpenCodePromptFailure(result.data?.info?.error); if (promptFailure) { return yield* new OpenCodeTextGenerationPromptResponseError({ From 190c9194573cfa8de63dc169faa28eff9c8f9899 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:06:38 -0500 Subject: [PATCH 6/9] test(opencode): cover session abort on timeout and cleanup failure Keep session-create-only cancellation separate from established session cleanup, and assert a failed abort cannot mask the text-generation timeout. Credit yashranaway (#10606) for the original session-abort approach. --- .../src/server/textGeneration.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/provider-opencode/src/server/textGeneration.test.ts b/packages/provider-opencode/src/server/textGeneration.test.ts index 9ef3de13911c..1e95fa6e84a0 100644 --- a/packages/provider-opencode/src/server/textGeneration.test.ts +++ b/packages/provider-opencode/src/server/textGeneration.test.ts @@ -27,6 +27,8 @@ const runtimeMock = { sessionCreateCalls: 0, sessionCreateSignals: [] as Array, promptSignals: [] as Array, + sessionAbortIds: [] as string[], + abortFailure: undefined as Error | undefined, connectionError: undefined as Error | undefined, sessionCreateError: undefined as unknown, sessionResult: undefined as { data?: { id: string } } | undefined, @@ -44,6 +46,8 @@ const runtimeMock = { this.state.sessionCreateCalls = 0; this.state.sessionCreateSignals.length = 0; this.state.promptSignals.length = 0; + this.state.sessionAbortIds.length = 0; + this.state.abortFailure = undefined; this.state.connectionError = undefined; this.state.sessionCreateError = undefined; this.state.sessionResult = undefined; @@ -108,6 +112,11 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = { } return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } }; }, + abort: async (input: { readonly sessionID: string }) => { + runtimeMock.state.sessionAbortIds.push(input.sessionID); + if (runtimeMock.state.abortFailure) throw runtimeMock.state.abortFailure; + return { data: true }; + }, prompt: async ( input: { readonly parts: ReadonlyArray }, options?: { readonly signal?: AbortSignal }, @@ -567,6 +576,7 @@ it.layer(layerOpenCodeTextGenerationTest)("OpenCodeTextGeneration", (it) => { expect(error.detail).toContain("timed out"); expect(runtimeMock.state.sessionCreateSignals.at(-1)?.aborted).toBe(true); expect(runtimeMock.state.promptUrls).toEqual([]); + expect(runtimeMock.state.sessionAbortIds).toEqual([]); }), ).pipe(Effect.provide(TestClock.layer())), ); @@ -589,6 +599,30 @@ it.layer(layerOpenCodeTextGenerationTest)("OpenCodeTextGeneration", (it) => { expect(error).toBeInstanceOf(TextGenerationError); expect(error.detail).toContain("timed out"); expect(runtimeMock.state.promptSignals.at(-1)?.aborted).toBe(true); + expect(runtimeMock.state.sessionAbortIds).toEqual(["http://127.0.0.1:4301/session"]); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("does not mask a timed-out prompt when its session abort fails", () => + withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.abortFailure = new Error("synthetic session-abort failure"); + runtimeMock.state.promptResult = new Promise(() => {}) as unknown as NonNullable< + typeof runtimeMock.state.promptResult + >; + + const child = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.forkChild); + while (runtimeMock.state.promptUrls.length === 0) { + yield* Effect.yieldNow; + } + yield* TestClock.adjust(Duration.seconds(181)); + const error = yield* Fiber.join(child).pipe(Effect.flip); + expect(error).toBeInstanceOf(TextGenerationError); + expect(error.detail).toContain("timed out"); + expect(runtimeMock.state.sessionAbortIds).toEqual(["http://127.0.0.1:9999/session"]); }), ).pipe(Effect.provide(TestClock.layer())), ); From e9060ddb4d1a49229c71c5ef006a62d68235b303 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:08:31 -0500 Subject: [PATCH 7/9] style(opencode): format bounded session-abort warning --- packages/provider-opencode/src/server/textGeneration.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/provider-opencode/src/server/textGeneration.ts b/packages/provider-opencode/src/server/textGeneration.ts index 871493f2c1c9..ebb3d47d217c 100644 --- a/packages/provider-opencode/src/server/textGeneration.ts +++ b/packages/provider-opencode/src/server/textGeneration.ts @@ -248,7 +248,10 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" Effect.interruptible, Effect.timeout("10 seconds"), Effect.catch(() => - Effect.logWarning("Failed to stop cancelled OpenCode text generation.", promptContext), + Effect.logWarning( + "Failed to stop cancelled OpenCode text generation.", + promptContext, + ), ), Effect.asVoid, ), From 1abe4c73037c43bafea251c36008d7c6e4562a65 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:21:22 -0500 Subject: [PATCH 8/9] fix(opencode): run bounded session abort in child cleanup fiber Avoid re-enabling interruption in onInterrupt's already-interrupted parent. Fork an interruptible child, await its ten-second capped abort, and preserve the original timeout result. Identified by CodeRabbit. Retain credit to yashranaway's existing session-abort design (#10606). --- packages/provider-opencode/src/server/textGeneration.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/provider-opencode/src/server/textGeneration.ts b/packages/provider-opencode/src/server/textGeneration.ts index ebb3d47d217c..220419de9c5e 100644 --- a/packages/provider-opencode/src/server/textGeneration.ts +++ b/packages/provider-opencode/src/server/textGeneration.ts @@ -1,4 +1,5 @@ import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -245,7 +246,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" OpenCodeRuntime.runOpenCodeSdk("session.abort", (signal) => client.session.abort({ sessionID: session.data.id }, { signal }), ).pipe( - Effect.interruptible, + // The onInterrupt finalizer is masked. Run the bounded abort + // in a fresh, interruptible child; wait for it before returning. Effect.timeout("10 seconds"), Effect.catch(() => Effect.logWarning( @@ -254,6 +256,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" ), ), Effect.asVoid, + Effect.forkChild({ startImmediately: true, uninterruptible: false }), + Effect.flatMap(Fiber.join), ), ), ); From 63cbd152c1bd9b63d48464bb5b49a547f76536eb Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:21:26 -0500 Subject: [PATCH 9/9] test(opencode): bound stalled session abort in interrupted prompt Verify abort starts despite pending interruption, is cancelled at the cleanup deadline, and never changes the caller's prompt timeout outcome. The case is a mocked SDK, not live OpenCode transport. --- .../src/server/textGeneration.test.ts | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/provider-opencode/src/server/textGeneration.test.ts b/packages/provider-opencode/src/server/textGeneration.test.ts index 1e95fa6e84a0..0f52f5c0b9c5 100644 --- a/packages/provider-opencode/src/server/textGeneration.test.ts +++ b/packages/provider-opencode/src/server/textGeneration.test.ts @@ -28,6 +28,8 @@ const runtimeMock = { sessionCreateSignals: [] as Array, promptSignals: [] as Array, sessionAbortIds: [] as string[], + sessionAbortSignals: [] as Array, + abortStall: false, abortFailure: undefined as Error | undefined, connectionError: undefined as Error | undefined, sessionCreateError: undefined as unknown, @@ -47,6 +49,8 @@ const runtimeMock = { this.state.sessionCreateSignals.length = 0; this.state.promptSignals.length = 0; this.state.sessionAbortIds.length = 0; + this.state.sessionAbortSignals.length = 0; + this.state.abortStall = false; this.state.abortFailure = undefined; this.state.connectionError = undefined; this.state.sessionCreateError = undefined; @@ -112,8 +116,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = { } return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } }; }, - abort: async (input: { readonly sessionID: string }) => { + abort: async ( + input: { readonly sessionID: string }, + options?: { readonly signal?: AbortSignal }, + ) => { runtimeMock.state.sessionAbortIds.push(input.sessionID); + runtimeMock.state.sessionAbortSignals.push(options?.signal); + if (runtimeMock.state.abortStall) return new Promise(() => {}); if (runtimeMock.state.abortFailure) throw runtimeMock.state.abortFailure; return { data: true }; }, @@ -604,6 +613,38 @@ it.layer(layerOpenCodeTextGenerationTest)("OpenCodeTextGeneration", (it) => { ).pipe(Effect.provide(TestClock.layer())), ); + it.effect("bounds a stalled server-side abort without masking the prompt timeout", () => + withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.abortStall = true; + runtimeMock.state.promptResult = new Promise(() => {}) as unknown as NonNullable< + typeof runtimeMock.state.promptResult + >; + + const child = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.forkChild); + while (runtimeMock.state.promptUrls.length === 0) { + yield* Effect.yieldNow; + } + yield* TestClock.adjust(Duration.seconds(181)); + // An already-interrupted prompt must still enter session-abort cleanup. + while (runtimeMock.state.sessionAbortIds.length === 0) { + yield* Effect.yieldNow; + } + expect(runtimeMock.state.sessionAbortIds).toEqual(["http://127.0.0.1:9999/session"]); + expect(runtimeMock.state.sessionAbortSignals.at(-1)?.aborted).toBe(false); + yield* TestClock.adjust(Duration.seconds(10)); + + const error = yield* Fiber.join(child).pipe(Effect.flip); + expect(error).toBeInstanceOf(TextGenerationError); + expect(error.detail).toContain("timed out"); + expect(runtimeMock.state.sessionAbortSignals.at(-1)?.aborted).toBe(true); + expect(runtimeMock.state.sessionAbortIds).toHaveLength(1); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("does not mask a timed-out prompt when its session abort fails", () => withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () {