| ... | ... | @@ -20,35 +20,6 @@ const CODEX_MODEL = "gpt-5.3-codex"; |
| 20 | 20 | const CODEX_REASONING_EFFORT = "low"; |
| 21 | 21 | const CODEX_TIMEOUT_MS = 180_000; |
| 22 | 22 | |
| 23 | | const COMMIT_OUTPUT_SCHEMA_JSON = { |
| 24 | | type: "object", |
| 25 | | properties: { |
| 26 | | subject: { type: "string" }, |
| 27 | | body: { type: "string" }, |
| 28 | | }, |
| 29 | | required: ["subject", "body"], |
| 30 | | additionalProperties: false, |
| 31 | | } as const; |
| 32 | | |
| 33 | | const PR_OUTPUT_SCHEMA_JSON = { |
| 34 | | type: "object", |
| 35 | | properties: { |
| 36 | | title: { type: "string" }, |
| 37 | | body: { type: "string" }, |
| 38 | | }, |
| 39 | | required: ["title", "body"], |
| 40 | | additionalProperties: false, |
| 41 | | } as const; |
| 42 | | |
| 43 | | const BRANCH_NAME_OUTPUT_SCHEMA_JSON = { |
| 44 | | type: "object", |
| 45 | | properties: { |
| 46 | | branch: { type: "string" }, |
| 47 | | }, |
| 48 | | required: ["branch"], |
| 49 | | additionalProperties: false, |
| 50 | | } as const; |
| 51 | | |
| 52 | 23 | function normalizeCodexError( |
| 53 | 24 | operation: string, |
| 54 | 25 | error: unknown, |
| ... | ... | @@ -85,44 +56,6 @@ function normalizeCodexError( |
| 85 | 56 | }); |
| 86 | 57 | } |
| 87 | 58 | |
| 88 | | function parseCommitOutput(raw: unknown): { subject: string; body: string } { |
| 89 | | if (!raw || typeof raw !== "object") { |
| 90 | | throw new Error("Codex returned a non-object commit message payload."); |
| 91 | | } |
| 92 | | const record = raw as Record<string, unknown>; |
| 93 | | const subject = typeof record.subject === "string" ? record.subject.trim() : ""; |
| 94 | | const body = typeof record.body === "string" ? record.body : ""; |
| 95 | | if (subject.length === 0) { |
| 96 | | throw new Error("Codex returned an empty commit subject."); |
| 97 | | } |
| 98 | | return { subject, body }; |
| 99 | | } |
| 100 | | |
| 101 | | function parsePrOutput(raw: unknown): { title: string; body: string } { |
| 102 | | if (!raw || typeof raw !== "object") { |
| 103 | | throw new Error("Codex returned a non-object PR payload."); |
| 104 | | } |
| 105 | | const record = raw as Record<string, unknown>; |
| 106 | | const title = typeof record.title === "string" ? record.title.trim() : ""; |
| 107 | | const body = typeof record.body === "string" ? record.body.trim() : ""; |
| 108 | | if (title.length === 0 || body.length === 0) { |
| 109 | | throw new Error("Codex returned an invalid PR title/body payload."); |
| 110 | | } |
| 111 | | return { title, body }; |
| 112 | | } |
| 113 | | |
| 114 | | function parseBranchNameOutput(raw: unknown): { branch: string } { |
| 115 | | if (!raw || typeof raw !== "object") { |
| 116 | | throw new Error("Codex returned a non-object branch payload."); |
| 117 | | } |
| 118 | | const record = raw as Record<string, unknown>; |
| 119 | | const branch = typeof record.branch === "string" ? record.branch.trim() : ""; |
| 120 | | if (branch.length === 0) { |
| 121 | | throw new Error("Codex returned an empty branch name payload."); |
| 122 | | } |
| 123 | | return { branch }; |
| 124 | | } |
| 125 | | |
| 126 | 59 | function limitSection(value: string, maxChars: number): string { |
| 127 | 60 | if (value.length <= maxChars) return value; |
| 128 | 61 | const truncated = value.slice(0, maxChars); |
| ... | ... | @@ -276,20 +209,19 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 276 | 209 | continue; |
| 277 | 210 | } |
| 278 | 211 | |
| 279 | | const persistedPath = |
| 280 | | Option.isSome(serverConfig) |
| 281 | | ? resolveAttachmentRoutePath({ |
| 282 | | stateDir: serverConfig.value.stateDir, |
| 283 | | dataUrl: attachment.dataUrl, |
| 284 | | }) |
| 285 | | : null; |
| 212 | const persistedPath = Option.isSome(serverConfig) |
| 213 | ? resolveAttachmentRoutePath({ |
| 214 | stateDir: serverConfig.value.stateDir, |
| 215 | dataUrl: attachment.dataUrl, |
| 216 | }) |
| 217 | : null; |
| 286 | 218 | const localPath = persistedPath ?? attachment.dataUrl; |
| 287 | 219 | if (!path.isAbsolute(localPath)) { |
| 288 | 220 | continue; |
| 289 | 221 | } |
| 290 | | const fileInfo = yield* fileSystem.stat(localPath).pipe( |
| 291 | | Effect.catch(() => Effect.succeed(null)), |
| 292 | | ); |
| 222 | const fileInfo = yield* fileSystem |
| 223 | .stat(localPath) |
| 224 | .pipe(Effect.catch(() => Effect.succeed(null))); |
| 293 | 225 | if (!fileInfo || fileInfo.type !== "File") { |
| 294 | 226 | continue; |
| 295 | 227 | } |
| ... | ... | @@ -298,26 +230,24 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 298 | 230 | return imagePaths; |
| 299 | 231 | }); |
| 300 | 232 | |
| 301 | | const runCodexJson = <T>({ |
| 233 | const runCodexJson = <S extends Schema.Top>({ |
| 302 | 234 | operation, |
| 303 | 235 | cwd, |
| 304 | 236 | prompt, |
| 305 | 237 | outputSchemaJson, |
| 306 | 238 | imagePaths = [], |
| 307 | | parse, |
| 308 | 239 | }: { |
| 309 | 240 | operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; |
| 310 | 241 | cwd: string; |
| 311 | 242 | prompt: string; |
| 312 | | outputSchemaJson: object; |
| 243 | outputSchemaJson: S; |
| 313 | 244 | imagePaths?: ReadonlyArray<string>; |
| 314 | | parse: (raw: unknown) => T; |
| 315 | | }): Effect.Effect<T, TextGenerationError> => |
| 245 | }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> => |
| 316 | 246 | Effect.gen(function* () { |
| 317 | 247 | const schemaPath = yield* writeTempFile( |
| 318 | 248 | operation, |
| 319 | 249 | "codex-schema", |
| 320 | | JSON.stringify(outputSchemaJson), |
| 250 | JSON.stringify(Schema.toJsonSchemaDocument(outputSchemaJson).schema), |
| 321 | 251 | ); |
| 322 | 252 | const outputPath = yield* writeTempFile(operation, "codex-output", ""); |
| 323 | 253 | |
| ... | ... | @@ -406,7 +336,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 406 | 336 | ), |
| 407 | 337 | ); |
| 408 | 338 | |
| 409 | | const rawOutput = yield* fileSystem.readFileString(outputPath).pipe( |
| 339 | return yield* fileSystem.readFileString(outputPath).pipe( |
| 410 | 340 | Effect.mapError( |
| 411 | 341 | (cause) => |
| 412 | 342 | new TextGenerationError({ |
| ... | ... | @@ -415,30 +345,17 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 415 | 345 | cause, |
| 416 | 346 | }), |
| 417 | 347 | ), |
| 348 | Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))), |
| 349 | Effect.catchTag("SchemaError", (cause) => |
| 350 | Effect.fail( |
| 351 | new TextGenerationError({ |
| 352 | operation, |
| 353 | detail: "Codex returned invalid structured output.", |
| 354 | cause, |
| 355 | }), |
| 356 | ), |
| 357 | ), |
| 418 | 358 | ); |
| 419 | | const trimmed = rawOutput.trim(); |
| 420 | | if (trimmed.length === 0) { |
| 421 | | return yield* new TextGenerationError({ |
| 422 | | operation, |
| 423 | | detail: "Codex returned an empty response.", |
| 424 | | }); |
| 425 | | } |
| 426 | | |
| 427 | | const parsedJson = yield* Effect.try({ |
| 428 | | try: () => JSON.parse(trimmed) as unknown, |
| 429 | | catch: (cause) => |
| 430 | | new TextGenerationError({ |
| 431 | | operation, |
| 432 | | detail: "Codex returned invalid JSON output.", |
| 433 | | cause, |
| 434 | | }), |
| 435 | | }); |
| 436 | | |
| 437 | | return yield* Effect.try({ |
| 438 | | try: () => parse(parsedJson), |
| 439 | | catch: (cause) => |
| 440 | | normalizeCodexError(operation, cause, "Codex returned invalid structured output"), |
| 441 | | }); |
| 442 | 359 | }).pipe(Effect.ensuring(cleanup)); |
| 443 | 360 | }); |
| 444 | 361 | |
| ... | ... | @@ -464,8 +381,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 464 | 381 | operation: "generateCommitMessage", |
| 465 | 382 | cwd: input.cwd, |
| 466 | 383 | prompt, |
| 467 | | outputSchemaJson: COMMIT_OUTPUT_SCHEMA_JSON, |
| 468 | | parse: (raw) => parseCommitOutput(raw), |
| 384 | outputSchemaJson: Schema.Struct({ |
| 385 | subject: Schema.String, |
| 386 | body: Schema.String, |
| 387 | }), |
| 469 | 388 | }).pipe( |
| 470 | 389 | Effect.map( |
| 471 | 390 | (generated) => |
| ... | ... | @@ -504,8 +423,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 504 | 423 | operation: "generatePrContent", |
| 505 | 424 | cwd: input.cwd, |
| 506 | 425 | prompt, |
| 507 | | outputSchemaJson: PR_OUTPUT_SCHEMA_JSON, |
| 508 | | parse: (raw) => parsePrOutput(raw), |
| 426 | outputSchemaJson: Schema.Struct({ |
| 427 | title: Schema.String, |
| 428 | body: Schema.String, |
| 429 | }), |
| 509 | 430 | }).pipe( |
| 510 | 431 | Effect.map( |
| 511 | 432 | (generated) => |
| ... | ... | @@ -519,9 +440,13 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 519 | 440 | |
| 520 | 441 | const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => { |
| 521 | 442 | return Effect.gen(function* () { |
| 522 | | const imagePaths = yield* materializeImageAttachments("generateBranchName", input.attachments); |
| 443 | const imagePaths = yield* materializeImageAttachments( |
| 444 | "generateBranchName", |
| 445 | input.attachments, |
| 446 | ); |
| 523 | 447 | const attachmentLines = (input.attachments ?? []).map( |
| 524 | | (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, |
| 448 | (attachment) => |
| 449 | `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, |
| 525 | 450 | ); |
| 526 | 451 | |
| 527 | 452 | const promptSections = [ |
| ... | ... | @@ -549,9 +474,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 549 | 474 | operation: "generateBranchName", |
| 550 | 475 | cwd: input.cwd, |
| 551 | 476 | prompt, |
| 552 | | outputSchemaJson: BRANCH_NAME_OUTPUT_SCHEMA_JSON, |
| 477 | outputSchemaJson: Schema.Struct({ |
| 478 | branch: Schema.String, |
| 479 | }), |
| 553 | 480 | imagePaths, |
| 554 | | parse: (raw) => parseBranchNameOutput(raw), |
| 555 | 481 | }); |
| 556 | 482 | |
| 557 | 483 | return { |