From 2a12331c2e131160e5babceca77c0d705962c9a8 Mon Sep 17 00:00:00 2001 From: Abdul Azeez Date: Sun, 19 Apr 2026 19:27:15 +0530 Subject: [PATCH] fix(server): handle OpenCode text response format in commit message generation (#2199) --- .../src/git/Layers/CursorTextGeneration.ts | 55 ++----------- .../git/Layers/OpenCodeTextGeneration.test.ts | 81 +++++++++++++++++-- .../src/git/Layers/OpenCodeTextGeneration.ts | 68 +++++++++++++--- apps/server/src/git/Utils.ts | 48 +++++++++++ 4 files changed, 184 insertions(+), 68 deletions(-) diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts index 754f3737eb5..24f066059c7 100644 --- a/apps/server/src/git/Layers/CursorTextGeneration.ts +++ b/apps/server/src/git/Layers/CursorTextGeneration.ts @@ -16,7 +16,12 @@ import { buildPrContentPrompt, buildThreadTitlePrompt, } from "../Prompts.ts"; -import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle } from "../Utils.ts"; +import { + extractJsonObject, + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "../Utils.ts"; import { applyCursorAcpModelSelection, makeCursorAcpRuntime, @@ -25,54 +30,6 @@ import { ServerSettingsService } from "../../serverSettings.ts"; const CURSOR_TIMEOUT_MS = 180_000; -function extractJsonObject(raw: string): string { - const trimmed = raw.trim(); - if (trimmed.length === 0) { - return trimmed; - } - - const start = trimmed.indexOf("{"); - if (start < 0) { - return trimmed; - } - - let depth = 0; - let inString = false; - let escaping = false; - for (let index = start; index < trimmed.length; index += 1) { - const char = trimmed[index]; - if (inString) { - if (escaping) { - escaping = false; - } else if (char === "\\") { - escaping = true; - } else if (char === '"') { - inString = false; - } - continue; - } - - if (char === '"') { - inString = true; - continue; - } - - if (char === "{") { - depth += 1; - continue; - } - - if (char === "}") { - depth -= 1; - if (depth === 0) { - return trimmed.slice(start, index + 1); - } - } - } - - return trimmed.slice(start); -} - function mapCursorAcpError( operation: | "generateCommitMessage" diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts index 4cf25c9468d..ab98024567b 100644 --- a/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts @@ -17,7 +17,9 @@ const runtimeMock = vi.hoisted(() => { promptUrls: [] as string[], authHeaders: [] as Array, closeCalls: [] as string[], - promptResult: undefined as { data?: { info?: { structured?: unknown } } } | undefined, + promptResult: undefined as + | { data?: { info?: { error?: unknown }; parts?: Array<{ type: string; text?: string }> } } + | undefined, }; return { @@ -63,12 +65,15 @@ vi.mock("../../provider/opencodeRuntime.ts", async () => { return ( runtimeMock.state.promptResult ?? { data: { - info: { - structured: { - subject: "Improve OpenCode reuse", - body: "Reuse one server for the full action.", + parts: [ + { + type: "text", + text: JSON.stringify({ + subject: "Improve OpenCode reuse", + body: "Reuse one server for the full action.", + }), }, - }, + ], }, } ); @@ -198,7 +203,7 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => }).pipe(Effect.provide(TestClock.layer())), ); - it.effect("returns a typed missing-output error when OpenCode omits info.structured", () => + it.effect("returns a typed empty-output error when OpenCode returns no text parts", () => Effect.gen(function* () { runtimeMock.state.promptResult = { data: {} }; const textGeneration = yield* TextGeneration; @@ -213,7 +218,67 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => }) .pipe(Effect.flip); - expect(error.message).toContain("OpenCode returned no structured output."); + expect(error.message).toContain("OpenCode returned empty output."); + }), + ); + + it.effect("parses JSON returned as plain text output", () => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + parts: [ + { + type: "text", + text: 'Here is the result:\n{"subject":"Tighten OpenCode parsing","body":"Handle JSON text output locally."}', + }, + ], + }, + }; + const textGeneration = yield* TextGeneration; + + const result = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(result).toEqual({ + subject: "Tighten OpenCode parsing", + body: "Handle JSON text output locally.", + }); + }), + ); + + it.effect("surfaces the upstream OpenCode structured-output error message", () => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + info: { + error: { + name: "StructuredOutputError", + data: { + message: "Model did not produce structured output", + retries: 2, + }, + }, + }, + }, + }; + const textGeneration = yield* TextGeneration; + + const error = yield* textGeneration + .generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }) + .pipe(Effect.flip); + + expect(error.message).toContain("Model did not produce structured output"); }), ); }); diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts index 7721354e4da..d206e59e8d2 100644 --- a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts +++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts @@ -19,10 +19,10 @@ import { } from "../Prompts.ts"; import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; import { + extractJsonObject, sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, - toJsonSchemaObject, } from "../Utils.ts"; import { createOpenCodeSdkClient, @@ -35,6 +35,49 @@ import { const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; +function getOpenCodePromptErrorMessage(error: unknown): string | null { + if (!error || typeof error !== "object") { + return null; + } + + const message = + "data" in error && + error.data && + typeof error.data === "object" && + "message" in error.data && + typeof error.data.message === "string" + ? error.data.message.trim() + : ""; + if (message.length > 0) { + return message; + } + + if ("name" in error && typeof error.name === "string") { + const name = error.name.trim(); + return name.length > 0 ? name : null; + } + + return null; +} + +function getOpenCodeTextResponse(parts: ReadonlyArray | undefined): string { + return (parts ?? []) + .flatMap((part) => { + if (!part || typeof part !== "object") { + return []; + } + if (!("type" in part) || part.type !== "text") { + return []; + } + if (!("text" in part) || typeof part.text !== "string") { + return []; + } + return [part.text]; + }) + .join("") + .trim(); +} + interface SharedOpenCodeTextGenerationServerState { server: OpenCodeServerProcess | null; binaryPath: string | null; @@ -245,17 +288,18 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { ...(input.modelSelection.options?.variant ? { variant: input.modelSelection.options.variant } : {}), - format: { - type: "json_schema", - schema: toJsonSchemaObject(input.outputSchemaJson) as Record, - }, parts: [{ type: "text", text: input.prompt }, ...fileParts], }); - const structured = result.data?.info?.structured; - if (structured === undefined) { - throw new Error("OpenCode returned no structured output."); + const info = result.data?.info; + const errorMessage = getOpenCodePromptErrorMessage(info?.error); + if (errorMessage) { + throw new Error(errorMessage); + } + const rawText = getOpenCodeTextResponse(result.data?.parts); + if (rawText.length === 0) { + throw new Error("OpenCode returned empty output."); } - return structured; + return rawText; }, catch: (cause) => new TextGenerationError({ @@ -266,7 +310,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { }), }); - const structuredOutput = + const rawOutput = settings.serverUrl.length > 0 ? yield* runAgainstServer({ url: settings.serverUrl }) : yield* Effect.acquireUseRelease( @@ -278,7 +322,9 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { releaseSharedServer, ); - return yield* Schema.decodeUnknownEffect(input.outputSchemaJson)(structuredOutput).pipe( + return yield* Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson))( + extractJsonObject(rawOutput), + ).pipe( Effect.catchTag("SchemaError", (cause) => Effect.fail( new TextGenerationError({ diff --git a/apps/server/src/git/Utils.ts b/apps/server/src/git/Utils.ts index 4a7931c74b2..15015e8cda5 100644 --- a/apps/server/src/git/Utils.ts +++ b/apps/server/src/git/Utils.ts @@ -30,6 +30,54 @@ export function limitSection(value: string, maxChars: number): string { return `${truncated}\n\n[truncated]`; } +export function extractJsonObject(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return trimmed; + } + + const start = trimmed.indexOf("{"); + if (start < 0) { + return trimmed; + } + + let depth = 0; + let inString = false; + let escaping = false; + for (let index = start; index < trimmed.length; index += 1) { + const char = trimmed[index]; + if (inString) { + if (escaping) { + escaping = false; + } else if (char === "\\") { + escaping = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + continue; + } + + if (char === "{") { + depth += 1; + continue; + } + + if (char === "}") { + depth -= 1; + if (depth === 0) { + return trimmed.slice(start, index + 1); + } + } + } + + return trimmed.slice(start); +} + /** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */ export function sanitizeCommitSubject(raw: string): string { const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";