authorgravatar for me@imabdulazeez.comAbdul Azeez <me@imabdulazeez.com> 2026-04-20 05:42:35+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-20 00:12:35+00:00
log37965da01d34e50a67f92cd7fb978b74a6cf8fcf
tree25166f7eab8403a660d95c9935630e9cf5f212db
parent57b59b5bf209837db3ae911f2ca181f8f1dc2df7
signature Signed by PGP key B5690EEEBB952194

fix(server): handle OpenCode text response format in commit message g… (#2202)


4 files changed, 184 insertions(+), 68 deletions(-)

apps/server/src/git/Layers/CursorTextGeneration.ts+6-49
...@@ -16,7 +16,12 @@ import {...@@ -16,7 +16,12 @@ import {
16 buildPrContentPrompt,16 buildPrContentPrompt,
17 buildThreadTitlePrompt,17 buildThreadTitlePrompt,
18} from "../Prompts.ts";18} from "../Prompts.ts";
19import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle } from "../Utils.ts";19import {
20 extractJsonObject,
21 sanitizeCommitSubject,
22 sanitizePrTitle,
23 sanitizeThreadTitle,
24} from "../Utils.ts";
20import {25import {
21 applyCursorAcpModelSelection,26 applyCursorAcpModelSelection,
22 makeCursorAcpRuntime,27 makeCursorAcpRuntime,
...@@ -25,54 +30,6 @@ import { ServerSettingsService } from "../../serverSettings.ts";...@@ -25,54 +30,6 @@ import { ServerSettingsService } from "../../serverSettings.ts";
2530
26const CURSOR_TIMEOUT_MS = 180_000;31const CURSOR_TIMEOUT_MS = 180_000;
2732
28function extractJsonObject(raw: string): string {
29 const trimmed = raw.trim();
30 if (trimmed.length === 0) {
31 return trimmed;
32 }
33
34 const start = trimmed.indexOf("{");
35 if (start < 0) {
36 return trimmed;
37 }
38
39 let depth = 0;
40 let inString = false;
41 let escaping = false;
42 for (let index = start; index < trimmed.length; index += 1) {
43 const char = trimmed[index];
44 if (inString) {
45 if (escaping) {
46 escaping = false;
47 } else if (char === "\\") {
48 escaping = true;
49 } else if (char === '"') {
50 inString = false;
51 }
52 continue;
53 }
54
55 if (char === '"') {
56 inString = true;
57 continue;
58 }
59
60 if (char === "{") {
61 depth += 1;
62 continue;
63 }
64
65 if (char === "}") {
66 depth -= 1;
67 if (depth === 0) {
68 return trimmed.slice(start, index + 1);
69 }
70 }
71 }
72
73 return trimmed.slice(start);
74}
75
76function mapCursorAcpError(33function mapCursorAcpError(
77 operation:34 operation:
78 | "generateCommitMessage"35 | "generateCommitMessage"
apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts+73-8
...@@ -17,7 +17,9 @@ const runtimeMock = vi.hoisted(() => {...@@ -17,7 +17,9 @@ const runtimeMock = vi.hoisted(() => {
17 promptUrls: [] as string[],17 promptUrls: [] as string[],
18 authHeaders: [] as Array<string | null>,18 authHeaders: [] as Array<string | null>,
19 closeCalls: [] as string[],19 closeCalls: [] as string[],
20 promptResult: undefined as { data?: { info?: { structured?: unknown } } } | undefined,20 promptResult: undefined as
21 | { data?: { info?: { error?: unknown }; parts?: Array<{ type: string; text?: string }> } }
22 | undefined,
21 };23 };
2224
23 return {25 return {
...@@ -63,12 +65,15 @@ vi.mock("../../provider/opencodeRuntime.ts", async () => {...@@ -63,12 +65,15 @@ vi.mock("../../provider/opencodeRuntime.ts", async () => {
63 return (65 return (
64 runtimeMock.state.promptResult ?? {66 runtimeMock.state.promptResult ?? {
65 data: {67 data: {
66 info: {68 parts: [
67 structured: {69 {
68 subject: "Improve OpenCode reuse",70 type: "text",
69 body: "Reuse one server for the full action.",71 text: JSON.stringify({
72 subject: "Improve OpenCode reuse",
73 body: "Reuse one server for the full action.",
74 }),
70 },75 },
71 },76 ],
72 },77 },
73 }78 }
74 );79 );
...@@ -198,7 +203,7 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) =>...@@ -198,7 +203,7 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) =>
198 }).pipe(Effect.provide(TestClock.layer())),203 }).pipe(Effect.provide(TestClock.layer())),
199 );204 );
200205
201 it.effect("returns a typed missing-output error when OpenCode omits info.structured", () =>206 it.effect("returns a typed empty-output error when OpenCode returns no text parts", () =>
202 Effect.gen(function* () {207 Effect.gen(function* () {
203 runtimeMock.state.promptResult = { data: {} };208 runtimeMock.state.promptResult = { data: {} };
204 const textGeneration = yield* TextGeneration;209 const textGeneration = yield* TextGeneration;
...@@ -213,7 +218,67 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) =>...@@ -213,7 +218,67 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) =>
213 })218 })
214 .pipe(Effect.flip);219 .pipe(Effect.flip);
215220
216 expect(error.message).toContain("OpenCode returned no structured output.");221 expect(error.message).toContain("OpenCode returned empty output.");
222 }),
223 );
224
225 it.effect("parses JSON returned as plain text output", () =>
226 Effect.gen(function* () {
227 runtimeMock.state.promptResult = {
228 data: {
229 parts: [
230 {
231 type: "text",
232 text: 'Here is the result:\n{"subject":"Tighten OpenCode parsing","body":"Handle JSON text output locally."}',
233 },
234 ],
235 },
236 };
237 const textGeneration = yield* TextGeneration;
238
239 const result = yield* textGeneration.generateCommitMessage({
240 cwd: process.cwd(),
241 branch: "feature/opencode-reuse",
242 stagedSummary: "M README.md",
243 stagedPatch: "diff --git a/README.md b/README.md",
244 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
245 });
246
247 expect(result).toEqual({
248 subject: "Tighten OpenCode parsing",
249 body: "Handle JSON text output locally.",
250 });
251 }),
252 );
253
254 it.effect("surfaces the upstream OpenCode structured-output error message", () =>
255 Effect.gen(function* () {
256 runtimeMock.state.promptResult = {
257 data: {
258 info: {
259 error: {
260 name: "StructuredOutputError",
261 data: {
262 message: "Model did not produce structured output",
263 retries: 2,
264 },
265 },
266 },
267 },
268 };
269 const textGeneration = yield* TextGeneration;
270
271 const error = yield* textGeneration
272 .generateCommitMessage({
273 cwd: process.cwd(),
274 branch: "feature/opencode-reuse",
275 stagedSummary: "M README.md",
276 stagedPatch: "diff --git a/README.md b/README.md",
277 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
278 })
279 .pipe(Effect.flip);
280
281 expect(error.message).toContain("Model did not produce structured output");
217 }),282 }),
218 );283 );
219});284});
apps/server/src/git/Layers/OpenCodeTextGeneration.ts+57-11
...@@ -19,10 +19,10 @@ import {...@@ -19,10 +19,10 @@ import {
19} from "../Prompts.ts";19} from "../Prompts.ts";
20import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";20import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";
21import {21import {
22 extractJsonObject,
22 sanitizeCommitSubject,23 sanitizeCommitSubject,
23 sanitizePrTitle,24 sanitizePrTitle,
24 sanitizeThreadTitle,25 sanitizeThreadTitle,
25 toJsonSchemaObject,
26} from "../Utils.ts";26} from "../Utils.ts";
27import {27import {
28 createOpenCodeSdkClient,28 createOpenCodeSdkClient,
...@@ -35,6 +35,49 @@ import {...@@ -35,6 +35,49 @@ import {
3535
36const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;36const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;
3737
38function getOpenCodePromptErrorMessage(error: unknown): string | null {
39 if (!error || typeof error !== "object") {
40 return null;
41 }
42
43 const message =
44 "data" in error &&
45 error.data &&
46 typeof error.data === "object" &&
47 "message" in error.data &&
48 typeof error.data.message === "string"
49 ? error.data.message.trim()
50 : "";
51 if (message.length > 0) {
52 return message;
53 }
54
55 if ("name" in error && typeof error.name === "string") {
56 const name = error.name.trim();
57 return name.length > 0 ? name : null;
58 }
59
60 return null;
61}
62
63function getOpenCodeTextResponse(parts: ReadonlyArray<unknown> | undefined): string {
64 return (parts ?? [])
65 .flatMap((part) => {
66 if (!part || typeof part !== "object") {
67 return [];
68 }
69 if (!("type" in part) || part.type !== "text") {
70 return [];
71 }
72 if (!("text" in part) || typeof part.text !== "string") {
73 return [];
74 }
75 return [part.text];
76 })
77 .join("")
78 .trim();
79}
80
38interface SharedOpenCodeTextGenerationServerState {81interface SharedOpenCodeTextGenerationServerState {
39 server: OpenCodeServerProcess | null;82 server: OpenCodeServerProcess | null;
40 binaryPath: string | null;83 binaryPath: string | null;
...@@ -245,17 +288,18 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {...@@ -245,17 +288,18 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
245 ...(input.modelSelection.options?.variant288 ...(input.modelSelection.options?.variant
246 ? { variant: input.modelSelection.options.variant }289 ? { variant: input.modelSelection.options.variant }
247 : {}),290 : {}),
248 format: {
249 type: "json_schema",
250 schema: toJsonSchemaObject(input.outputSchemaJson) as Record<string, unknown>,
251 },
252 parts: [{ type: "text", text: input.prompt }, ...fileParts],291 parts: [{ type: "text", text: input.prompt }, ...fileParts],
253 });292 });
254 const structured = result.data?.info?.structured;293 const info = result.data?.info;
255 if (structured === undefined) {294 const errorMessage = getOpenCodePromptErrorMessage(info?.error);
256 throw new Error("OpenCode returned no structured output.");295 if (errorMessage) {
296 throw new Error(errorMessage);
297 }
298 const rawText = getOpenCodeTextResponse(result.data?.parts);
299 if (rawText.length === 0) {
300 throw new Error("OpenCode returned empty output.");
257 }301 }
258 return structured;302 return rawText;
259 },303 },
260 catch: (cause) =>304 catch: (cause) =>
261 new TextGenerationError({305 new TextGenerationError({
...@@ -266,7 +310,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {...@@ -266,7 +310,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
266 }),310 }),
267 });311 });
268312
269 const structuredOutput =313 const rawOutput =
270 settings.serverUrl.length > 0314 settings.serverUrl.length > 0
271 ? yield* runAgainstServer({ url: settings.serverUrl })315 ? yield* runAgainstServer({ url: settings.serverUrl })
272 : yield* Effect.acquireUseRelease(316 : yield* Effect.acquireUseRelease(
...@@ -278,7 +322,9 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {...@@ -278,7 +322,9 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
278 releaseSharedServer,322 releaseSharedServer,
279 );323 );
280324
281 return yield* Schema.decodeUnknownEffect(input.outputSchemaJson)(structuredOutput).pipe(325 return yield* Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson))(
326 extractJsonObject(rawOutput),
327 ).pipe(
282 Effect.catchTag("SchemaError", (cause) =>328 Effect.catchTag("SchemaError", (cause) =>
283 Effect.fail(329 Effect.fail(
284 new TextGenerationError({330 new TextGenerationError({
apps/server/src/git/Utils.ts+48
...@@ -30,6 +30,54 @@ export function limitSection(value: string, maxChars: number): string {...@@ -30,6 +30,54 @@ export function limitSection(value: string, maxChars: number): string {
30 return `${truncated}\n\n[truncated]`;30 return `${truncated}\n\n[truncated]`;
31}31}
3232
33export function extractJsonObject(raw: string): string {
34 const trimmed = raw.trim();
35 if (trimmed.length === 0) {
36 return trimmed;
37 }
38
39 const start = trimmed.indexOf("{");
40 if (start < 0) {
41 return trimmed;
42 }
43
44 let depth = 0;
45 let inString = false;
46 let escaping = false;
47 for (let index = start; index < trimmed.length; index += 1) {
48 const char = trimmed[index];
49 if (inString) {
50 if (escaping) {
51 escaping = false;
52 } else if (char === "\\") {
53 escaping = true;
54 } else if (char === '"') {
55 inString = false;
56 }
57 continue;
58 }
59
60 if (char === '"') {
61 inString = true;
62 continue;
63 }
64
65 if (char === "{") {
66 depth += 1;
67 continue;
68 }
69
70 if (char === "}") {
71 depth -= 1;
72 if (depth === 0) {
73 return trimmed.slice(start, index + 1);
74 }
75 }
76 }
77
78 return trimmed.slice(start);
79}
80
33/** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */81/** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */
34export function sanitizeCommitSubject(raw: string): string {82export function sanitizeCommitSubject(raw: string): string {
35 const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";83 const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";