History

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

Abdul Azeez committed 6 months ago

Commit 37965da1 parent 57b59b54 files · +184 −68Browse files

Unified · Split

4 files
  1. apps/server/src/git/Layers/CursorTextGeneration.ts +6 −49
  2. apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts +73 −8
  3. apps/server/src/git/Layers/OpenCodeTextGeneration.ts +57 −11
  4. apps/server/src/git/Utils.ts +48 −0
apps/server/src/git/Layers/CursorTextGeneration.ts+6−49
@@ -16,7 +16,12 @@ import { · 15 unmodified lines
1616 buildPrContentPrompt,
1717 buildThreadTitlePrompt,
1818 } from "../Prompts.ts";
19−import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle } from "../Utils.ts";
19+import {
20+ extractJsonObject,
21+ sanitizeCommitSubject,
22+ sanitizePrTitle,
23+ sanitizeThreadTitle,
24+} from "../Utils.ts";
2025 import {
2126 applyCursorAcpModelSelection,
2227 makeCursorAcpRuntime,
@@ -25,54 +30,6 @@ import { ServerSettingsService } from "../../serverSettings.ts"; · 2 unmodified lines
2530
2631 const CURSOR_TIMEOUT_MS = 180_000;
2732
28−function 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−
7633 function mapCursorAcpError(
7734 operation:
7835 | "generateCommitMessage"
apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts+73−8
@@ -17,7 +17,9 @@ const runtimeMock = vi.hoisted(() => { · 16 unmodified lines
1717 promptUrls: [] as string[],
1818 authHeaders: [] as Array<string | null>,
1919 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,
2123 };
2224
2325 return {
@@ -63,12 +65,15 @@ vi.mock("../../provider/opencodeRuntime.ts", async () => { · 39 unmodified lines
6365 return (
6466 runtimeMock.state.promptResult ?? {
6567 data: {
66− info: {
67− structured: {
68− subject: "Improve OpenCode reuse",
69− body: "Reuse one server for the full action.",
68+ parts: [
69+ {
70+ type: "text",
71+ text: JSON.stringify({
72+ subject: "Improve OpenCode reuse",
73+ body: "Reuse one server for the full action.",
74+ }),
7075 },
71− },
76+ ],
7277 },
7378 }
7479 );
@@ -198,7 +203,7 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => · 123 unmodified lines
198203 }).pipe(Effect.provide(TestClock.layer())),
199204 );
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", () =>
202207 Effect.gen(function* () {
203208 runtimeMock.state.promptResult = { data: {} };
204209 const textGeneration = yield* TextGeneration;
@@ -213,7 +218,67 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => · 8 unmodified lines
213218 })
214219 .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");
217282 }),
218283 );
219284 });
apps/server/src/git/Layers/OpenCodeTextGeneration.ts+57−11
@@ -19,10 +19,10 @@ import { · 18 unmodified lines
1919 } from "../Prompts.ts";
2020 import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";
2121 import {
22+ extractJsonObject,
2223 sanitizeCommitSubject,
2324 sanitizePrTitle,
2425 sanitizeThreadTitle,
25− toJsonSchemaObject,
2626 } from "../Utils.ts";
2727 import {
2828 createOpenCodeSdkClient,
@@ -35,6 +35,49 @@ import { · 6 unmodified lines
3535
3636 const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;
3737
38+function 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+
63+function 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+
3881 interface SharedOpenCodeTextGenerationServerState {
3982 server: OpenCodeServerProcess | null;
4083 binaryPath: string | null;
@@ -245,17 +288,18 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { · 204 unmodified lines
245288 ...(input.modelSelection.options?.variant
246289 ? { variant: input.modelSelection.options.variant }
247290 : {}),
248− format: {
249− type: "json_schema",
250− schema: toJsonSchemaObject(input.outputSchemaJson) as Record<string, unknown>,
251− },
252291 parts: [{ type: "text", text: input.prompt }, ...fileParts],
253292 });
254− const structured = result.data?.info?.structured;
255− if (structured === undefined) {
256− throw new Error("OpenCode returned no structured output.");
293+ const info = result.data?.info;
294+ const errorMessage = getOpenCodePromptErrorMessage(info?.error);
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.");
257301 }
258− return structured;
302+ return rawText;
259303 },
260304 catch: (cause) =>
261305 new TextGenerationError({
@@ -266,7 +310,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { · 4 unmodified lines
266310 }),
267311 });
268312
269− const structuredOutput =
313+ const rawOutput =
270314 settings.serverUrl.length > 0
271315 ? yield* runAgainstServer({ url: settings.serverUrl })
272316 : yield* Effect.acquireUseRelease(
@@ -278,7 +322,9 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { · 5 unmodified lines
278322 releaseSharedServer,
279323 );
280324
281− return yield* Schema.decodeUnknownEffect(input.outputSchemaJson)(structuredOutput).pipe(
325+ return yield* Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson))(
326+ extractJsonObject(rawOutput),
327+ ).pipe(
282328 Effect.catchTag("SchemaError", (cause) =>
283329 Effect.fail(
284330 new TextGenerationError({
apps/server/src/git/Utils.ts+48−0
@@ -30,6 +30,54 @@ export function limitSection(value: string, maxChars: number): string { · 29 unmodified lines
3030 return `${truncated}\n\n[truncated]`;
3131 }
3232
33+export 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+
3381 /** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */
3482 export function sanitizeCommitSubject(raw: string): string {
3583 const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";