From e076fe89ad791fa952d539070e85e5a31a4f4f40 Mon Sep 17 00:00:00 2001 From: Astra Date: Wed, 9 Sep 2026 13:13:48 +0100 Subject: [PATCH 1/4] fix(server): await OpenCode text generation --- .../OpenCodeTextGeneration.test.ts | 70 ++++++++++++++-- .../textGeneration/OpenCodeTextGeneration.ts | 79 +++++++++++++++++-- 2 files changed, 138 insertions(+), 11 deletions(-) diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index 688bf4bca352..d74198c6dfa1 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"; @@ -23,6 +24,9 @@ const runtimeMock = { authHeaders: [] as Array, closeCalls: [] as string[], sessionCreateCalls: 0, + promptAsyncCalls: 0, + messagesCalls: 0, + emptyMessageResponses: 0, connectionError: undefined as Error | undefined, sessionCreateError: undefined as unknown, sessionResult: undefined as { data?: { id: string } } | undefined, @@ -38,6 +42,9 @@ const runtimeMock = { this.state.authHeaders.length = 0; this.state.closeCalls.length = 0; this.state.sessionCreateCalls = 0; + this.state.promptAsyncCalls = 0; + this.state.messagesCalls = 0; + this.state.emptyMessageResponses = 0; this.state.connectionError = undefined; this.state.sessionCreateError = undefined; this.state.sessionResult = undefined; @@ -101,7 +108,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { } return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } }; }, - prompt: async (input: { readonly parts: ReadonlyArray }) => { + promptAsync: async (input: { readonly parts: ReadonlyArray }) => { + runtimeMock.state.promptAsyncCalls += 1; runtimeMock.state.promptUrls.push(baseUrl); runtimeMock.state.promptParts.push(input.parts); runtimeMock.state.authHeaders.push( @@ -110,7 +118,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { if (runtimeMock.state.promptRequestError !== undefined) { throw runtimeMock.state.promptRequestError; } - return ( + }, + messages: async () => { + runtimeMock.state.messagesCalls += 1; + if (runtimeMock.state.emptyMessageResponses > 0) { + runtimeMock.state.emptyMessageResponses -= 1; + return { data: [] }; + } + const result = runtimeMock.state.promptResult ?? { data: { parts: [ @@ -123,9 +138,23 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { }, ], }, - } - ); + }; + return { + data: [ + { + info: { + role: "assistant", + time: { completed: 1 }, + ...(result.data?.info?.error !== undefined + ? { error: result.data.info.error } + : {}), + }, + parts: result.data?.parts ?? [], + }, + ], + }; }, + status: async () => ({ data: {} }), }, }) as unknown as ReturnType, loadOpenCodeInventory: () => @@ -287,6 +316,31 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { ), ); + it.effect("submits asynchronously and waits for the completed assistant message", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.emptyMessageResponses = 1; + + const fiber = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.fork); + yield* Effect.yieldNow; + + expect(runtimeMock.state.promptAsyncCalls).toBe(1); + expect(runtimeMock.state.messagesCalls).toBe(1); + + yield* TestClock.adjust("50 millis"); + const generated = yield* Fiber.join(fiber); + + expect(runtimeMock.state.messagesCalls).toBe(2); + expect(generated).toEqual({ + subject: "Improve OpenCode reuse", + body: "Reuse one server for the full action.", + }); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("uses an environment-only password for a locally spawned server", () => withOpenCodeTextGeneration( DEFAULT_OPENCODE_SETTINGS, @@ -426,14 +480,18 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { it.effect("preserves the SDK cause and request context when prompting fails", () => withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { - const sdkCause = new Error("prompt endpoint unavailable"); + const sdkCause = { + response: { status: 503 }, + error: { message: "upstream provider unavailable" }, + }; runtimeMock.state.promptRequestError = sdkCause; const error = yield* textGeneration .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) .pipe(Effect.flip); - expect(error.message).toContain("OpenCode session.prompt request failed."); + expect(error.message).toContain("status=503"); + expect(error.message).toContain("upstream provider unavailable"); expect(error.cause).toMatchObject({ _tag: "OpenCodeTextGenerationPromptRequestError", operation: "generateCommitMessage", diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 7ed86aeec916..52e94ffe68ec 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -120,6 +120,15 @@ interface OpenCodeTextPart { readonly text: string; } +interface OpenCodeAssistantMessage { + readonly info: { + readonly role: "assistant"; + readonly time?: { readonly completed?: unknown } | undefined; + readonly error?: unknown; + }; + readonly parts?: ReadonlyArray | undefined; +} + function getOpenCodePromptFailure(error: unknown): OpenCodePromptFailure | null { if (!error || typeof error !== "object") { return null; @@ -170,6 +179,21 @@ function getOpenCodeTextResponse(parts: ReadonlyArray | undefined): str .trim(); } +function getOpenCodeAssistantMessage( + messages: ReadonlyArray | undefined, +): OpenCodeAssistantMessage | undefined { + return messages?.findLast( + (message): message is OpenCodeAssistantMessage => + message !== null && + typeof message === "object" && + "info" in message && + message.info !== null && + typeof message.info === "object" && + "role" in message.info && + message.info.role === "assistant", + ); +} + export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( openCodeSettings: OpenCodeSettings, ) { @@ -240,9 +264,9 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" modelId: parsedModel.modelID, }; - const result = yield* Effect.tryPromise({ + yield* Effect.tryPromise({ try: () => - client.session.prompt({ + client.session.promptAsync({ sessionID: session.data.id, model: parsedModel, ...(selectedAgent ? { agent: selectedAgent } : {}), @@ -255,7 +279,52 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" cause, }), }); - const promptFailure = getOpenCodePromptFailure(result.data?.info?.error); + + const result = yield* Effect.gen(function* () { + let idleStatusConfirmations = 0; + while (true) { + const messages = yield* Effect.tryPromise({ + try: () => client.session.messages({ sessionID: session.data.id }), + catch: (cause) => + new OpenCodeTextGenerationPromptRequestError({ + ...promptContext, + cause, + }), + }); + const assistantMessage = getOpenCodeAssistantMessage(messages.data); + if (assistantMessage) { + if ( + assistantMessage.info.error !== undefined || + assistantMessage.info.time?.completed !== undefined + ) { + return assistantMessage; + } + + const statuses = yield* Effect.tryPromise({ + try: () => client.session.status(), + catch: (cause) => + new OpenCodeTextGenerationPromptRequestError({ + ...promptContext, + cause, + }), + }); + const status = statuses.data?.[session.data.id]; + const isIdle = + statuses.data !== undefined && (status === undefined || status.type === "idle"); + if (isIdle) { + idleStatusConfirmations += 1; + if (idleStatusConfirmations >= 2) { + return assistantMessage; + } + } else { + idleStatusConfirmations = 0; + } + } + + yield* Effect.sleep("50 millis"); + } + }); + const promptFailure = getOpenCodePromptFailure(result.info.error); if (promptFailure) { return yield* new OpenCodeTextGenerationPromptResponseError({ ...promptContext, @@ -263,7 +332,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" providerMessage: promptFailure.message, }); } - const responseParts = result.data?.parts ?? []; + const responseParts = result.parts ?? []; const rawText = getOpenCodeTextResponse(responseParts); if (rawText.length === 0) { return yield* new OpenCodeTextGenerationEmptyOutputError({ @@ -295,7 +364,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" Effect.fail( new TextGenerationError({ operation: cause.operation, - detail: "OpenCode session.prompt request failed.", + detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause.cause), cause, }), ), From bd166256d7a3996cb8be2ed7223bc0e4e21a3671 Mon Sep 17 00:00:00 2001 From: Astra Date: Wed, 9 Sep 2026 13:24:09 +0100 Subject: [PATCH 2/4] fix(server): bound OpenCode text generation wait --- .../OpenCodeTextGeneration.test.ts | 29 +++++++++++++++++++ .../textGeneration/OpenCodeTextGeneration.ts | 15 +++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index d74198c6dfa1..144f762bbe0d 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -27,6 +27,7 @@ const runtimeMock = { promptAsyncCalls: 0, messagesCalls: 0, emptyMessageResponses: 0, + alwaysEmptyMessages: false, connectionError: undefined as Error | undefined, sessionCreateError: undefined as unknown, sessionResult: undefined as { data?: { id: string } } | undefined, @@ -45,6 +46,7 @@ const runtimeMock = { this.state.promptAsyncCalls = 0; this.state.messagesCalls = 0; this.state.emptyMessageResponses = 0; + this.state.alwaysEmptyMessages = false; this.state.connectionError = undefined; this.state.sessionCreateError = undefined; this.state.sessionResult = undefined; @@ -121,6 +123,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { }, messages: async () => { runtimeMock.state.messagesCalls += 1; + if (runtimeMock.state.alwaysEmptyMessages) { + return { data: [] }; + } if (runtimeMock.state.emptyMessageResponses > 0) { runtimeMock.state.emptyMessageResponses -= 1; return { data: [] }; @@ -341,6 +346,30 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { ).pipe(Effect.provide(TestClock.layer())), ); + it.effect("returns an empty-output error when no assistant message arrives", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.alwaysEmptyMessages = true; + + const fiber = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip, Effect.fork); + yield* Effect.yieldNow; + + yield* TestClock.adjust("30 seconds"); + const error = yield* Fiber.join(fiber); + + expect(error.message).toContain("OpenCode returned empty output."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationEmptyOutputError", + operation: "generateCommitMessage", + responsePartCount: 0, + textPartCount: 0, + }); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("uses an environment-only password for a locally spawned server", () => withOpenCodeTextGeneration( DEFAULT_OPENCODE_SETTINGS, diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 52e94ffe68ec..4d4f7b26cfec 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -38,6 +38,8 @@ const OpenCodeTextGenerationOperation = Schema.Literals([ type OpenCodeTextGenerationOperation = typeof OpenCodeTextGenerationOperation.Type; +const OPEN_CODE_TEXT_GENERATION_WAIT_TIMEOUT = "30 seconds"; + const openCodeTextGenerationErrorContext = { operation: OpenCodeTextGenerationOperation, cwd: Schema.String, @@ -323,7 +325,18 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" yield* Effect.sleep("50 millis"); } - }); + }).pipe( + Effect.timeout(OPEN_CODE_TEXT_GENERATION_WAIT_TIMEOUT), + Effect.catchTag("TimeoutError", () => + Effect.fail( + new OpenCodeTextGenerationEmptyOutputError({ + ...promptContext, + responsePartCount: 0, + textPartCount: 0, + }), + ), + ), + ); const promptFailure = getOpenCodePromptFailure(result.info.error); if (promptFailure) { return yield* new OpenCodeTextGenerationPromptResponseError({ From b9003eb07c80cf2d0c9347aed89d01ee9e5b35e1 Mon Sep 17 00:00:00 2001 From: Astra Date: Wed, 9 Sep 2026 13:32:04 +0100 Subject: [PATCH 3/4] test(server): cover OpenCode idle completion fallback --- .../OpenCodeTextGeneration.test.ts | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index 144f762bbe0d..34014bd1a39b 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -26,8 +26,10 @@ const runtimeMock = { sessionCreateCalls: 0, promptAsyncCalls: 0, messagesCalls: 0, + statusCalls: 0, emptyMessageResponses: 0, alwaysEmptyMessages: false, + assistantCompleted: true, connectionError: undefined as Error | undefined, sessionCreateError: undefined as unknown, sessionResult: undefined as { data?: { id: string } } | undefined, @@ -45,8 +47,10 @@ const runtimeMock = { this.state.sessionCreateCalls = 0; this.state.promptAsyncCalls = 0; this.state.messagesCalls = 0; + this.state.statusCalls = 0; this.state.emptyMessageResponses = 0; this.state.alwaysEmptyMessages = false; + this.state.assistantCompleted = true; this.state.connectionError = undefined; this.state.sessionCreateError = undefined; this.state.sessionResult = undefined; @@ -149,7 +153,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { { info: { role: "assistant", - time: { completed: 1 }, + ...(runtimeMock.state.assistantCompleted ? { time: { completed: 1 } } : {}), ...(result.data?.info?.error !== undefined ? { error: result.data.info.error } : {}), @@ -159,7 +163,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { ], }; }, - status: async () => ({ data: {} }), + status: async () => { + runtimeMock.state.statusCalls += 1; + return { data: {} }; + }, }, }) as unknown as ReturnType, loadOpenCodeInventory: () => @@ -346,6 +353,24 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { ).pipe(Effect.provide(TestClock.layer())), ); + it.effect("accepts an unfinished assistant after two idle status polls", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.assistantCompleted = false; + + const generated = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); + + expect(runtimeMock.state.messagesCalls).toBe(2); + expect(runtimeMock.state.statusCalls).toBe(2); + expect(generated).toEqual({ + subject: "Improve OpenCode reuse", + body: "Reuse one server for the full action.", + }); + }), + ), + ); + it.effect("returns an empty-output error when no assistant message arrives", () => withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { From 5ffa905114b23933ad9f1bdfb83987bf69b0d52c Mon Sep 17 00:00:00 2001 From: Astra Date: Wed, 9 Sep 2026 14:24:50 +0100 Subject: [PATCH 4/4] test(server): model OpenCode idle session status --- apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index 34014bd1a39b..c9a9aee7724f 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -165,7 +165,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { }, status: async () => { runtimeMock.state.statusCalls += 1; - return { data: {} }; + return { data: { [`${baseUrl}/session`]: { type: "idle" } } }; }, }, }) as unknown as ReturnType,