| author | |
| committer | |
| log | 37965da01d34e50a67f92cd7fb978b74a6cf8fcf |
| tree | 25166f7eab8403a660d95c9935630e9cf5f212db |
| parent | 57b59b5bf209837db3ae911f2ca181f8f1dc2df7 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 184 insertions(+), 68 deletions(-)
apps/server/src/git/Layers/CursorTextGeneration.ts+6-49| ... | ... | @@ -16,7 +16,12 @@ import { |
| 16 | 16 | buildPrContentPrompt, |
| 17 | 17 | buildThreadTitlePrompt, |
| 18 | 18 | } 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"; | |
| 20 | 25 | import { |
| 21 | 26 | applyCursorAcpModelSelection, |
| 22 | 27 | makeCursorAcpRuntime, |
| ... | ... | @@ -25,54 +30,6 @@ import { ServerSettingsService } from "../../serverSettings.ts"; |
| 25 | 30 | |
| 26 | 31 | const CURSOR_TIMEOUT_MS = 180_000; |
| 27 | 32 | |
| 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 | ||
| 76 | 33 | function mapCursorAcpError( |
| 77 | 34 | operation: |
| 78 | 35 | | "generateCommitMessage" |
apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts+73-8| ... | ... | @@ -17,7 +17,9 @@ const runtimeMock = vi.hoisted(() => { |
| 17 | 17 | promptUrls: [] as string[], |
| 18 | 18 | authHeaders: [] as Array<string | null>, |
| 19 | 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 | }; |
| 22 | 24 | |
| 23 | 25 | return { |
| ... | ... | @@ -63,12 +65,15 @@ vi.mock("../../provider/opencodeRuntime.ts", async () => { |
| 63 | 65 | return ( |
| 64 | 66 | runtimeMock.state.promptResult ?? { |
| 65 | 67 | 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 | }), | |
| 70 | 75 | }, |
| 71 | }, | |
| 76 | ], | |
| 72 | 77 | }, |
| 73 | 78 | } |
| 74 | 79 | ); |
| ... | ... | @@ -198,7 +203,7 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => |
| 198 | 203 | }).pipe(Effect.provide(TestClock.layer())), |
| 199 | 204 | ); |
| 200 | 205 | |
| 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 | 207 | Effect.gen(function* () { |
| 203 | 208 | runtimeMock.state.promptResult = { data: {} }; |
| 204 | 209 | const textGeneration = yield* TextGeneration; |
| ... | ... | @@ -213,7 +218,67 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => |
| 213 | 218 | }) |
| 214 | 219 | .pipe(Effect.flip); |
| 215 | 220 | |
| 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 | 19 | } from "../Prompts.ts"; |
| 20 | 20 | import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; |
| 21 | 21 | import { |
| 22 | extractJsonObject, | |
| 22 | 23 | sanitizeCommitSubject, |
| 23 | 24 | sanitizePrTitle, |
| 24 | 25 | sanitizeThreadTitle, |
| 25 | toJsonSchemaObject, | |
| 26 | 26 | } from "../Utils.ts"; |
| 27 | 27 | import { |
| 28 | 28 | createOpenCodeSdkClient, |
| ... | ... | @@ -35,6 +35,49 @@ import { |
| 35 | 35 | |
| 36 | 36 | const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; |
| 37 | 37 | |
| 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 | ||
| 38 | 81 | interface SharedOpenCodeTextGenerationServerState { |
| 39 | 82 | server: OpenCodeServerProcess | null; |
| 40 | 83 | binaryPath: string | null; |
| ... | ... | @@ -245,17 +288,18 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { |
| 245 | 288 | ...(input.modelSelection.options?.variant |
| 246 | 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 | 291 | parts: [{ type: "text", text: input.prompt }, ...fileParts], |
| 253 | 292 | }); |
| 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."); | |
| 257 | 301 | } |
| 258 | return structured; | |
| 302 | return rawText; | |
| 259 | 303 | }, |
| 260 | 304 | catch: (cause) => |
| 261 | 305 | new TextGenerationError({ |
| ... | ... | @@ -266,7 +310,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { |
| 266 | 310 | }), |
| 267 | 311 | }); |
| 268 | 312 | |
| 269 | const structuredOutput = | |
| 313 | const rawOutput = | |
| 270 | 314 | settings.serverUrl.length > 0 |
| 271 | 315 | ? yield* runAgainstServer({ url: settings.serverUrl }) |
| 272 | 316 | : yield* Effect.acquireUseRelease( |
| ... | ... | @@ -278,7 +322,9 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { |
| 278 | 322 | releaseSharedServer, |
| 279 | 323 | ); |
| 280 | 324 | |
| 281 | return yield* Schema.decodeUnknownEffect(input.outputSchemaJson)(structuredOutput).pipe( | |
| 325 | return yield* Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson))( | |
| 326 | extractJsonObject(rawOutput), | |
| 327 | ).pipe( | |
| 282 | 328 | Effect.catchTag("SchemaError", (cause) => |
| 283 | 329 | Effect.fail( |
| 284 | 330 | new TextGenerationError({ |
apps/server/src/git/Utils.ts+48| ... | ... | @@ -30,6 +30,54 @@ export function limitSection(value: string, maxChars: number): string { |
| 30 | 30 | return `${truncated}\n\n[truncated]`; |
| 31 | 31 | } |
| 32 | 32 | |
| 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 | ||
| 33 | 81 | /** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */ |
| 34 | 82 | export function sanitizeCommitSubject(raw: string): string { |
| 35 | 83 | const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; |