| ... | @@ -20,35 +20,6 @@ const CODEX_MODEL = "gpt-5.3-codex"; | ... | @@ -20,35 +20,6 @@ const CODEX_MODEL = "gpt-5.3-codex"; |
| 20 | const CODEX_REASONING_EFFORT = "low"; | 20 | const CODEX_REASONING_EFFORT = "low"; |
| 21 | const CODEX_TIMEOUT_MS = 180_000; | 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 | function normalizeCodexError( | 23 | function normalizeCodexError( |
| 53 | operation: string, | 24 | operation: string, |
| 54 | error: unknown, | 25 | error: unknown, |
| ... | @@ -85,44 +56,6 @@ function normalizeCodexError( | ... | @@ -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 | function limitSection(value: string, maxChars: number): string { | 59 | function limitSection(value: string, maxChars: number): string { |
| 127 | if (value.length <= maxChars) return value; | 60 | if (value.length <= maxChars) return value; |
| 128 | const truncated = value.slice(0, maxChars); | 61 | const truncated = value.slice(0, maxChars); |
| ... | @@ -276,20 +209,19 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -276,20 +209,19 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 276 | continue; | 209 | continue; |
| 277 | } | 210 | } |
| 278 | | 211 | |
| 279 | const persistedPath = | 212 | const persistedPath = Option.isSome(serverConfig) |
| 280 | Option.isSome(serverConfig) | 213 | ? resolveAttachmentRoutePath({ |
| 281 | ? resolveAttachmentRoutePath({ | 214 | stateDir: serverConfig.value.stateDir, |
| 282 | stateDir: serverConfig.value.stateDir, | 215 | dataUrl: attachment.dataUrl, |
| 283 | dataUrl: attachment.dataUrl, | 216 | }) |
| 284 | }) | 217 | : null; |
| 285 | : null; | | |
| 286 | const localPath = persistedPath ?? attachment.dataUrl; | 218 | const localPath = persistedPath ?? attachment.dataUrl; |
| 287 | if (!path.isAbsolute(localPath)) { | 219 | if (!path.isAbsolute(localPath)) { |
| 288 | continue; | 220 | continue; |
| 289 | } | 221 | } |
| 290 | const fileInfo = yield* fileSystem.stat(localPath).pipe( | 222 | const fileInfo = yield* fileSystem |
| 291 | Effect.catch(() => Effect.succeed(null)), | 223 | .stat(localPath) |
| 292 | ); | 224 | .pipe(Effect.catch(() => Effect.succeed(null))); |
| 293 | if (!fileInfo || fileInfo.type !== "File") { | 225 | if (!fileInfo || fileInfo.type !== "File") { |
| 294 | continue; | 226 | continue; |
| 295 | } | 227 | } |
| ... | @@ -298,26 +230,24 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -298,26 +230,24 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 298 | return imagePaths; | 230 | return imagePaths; |
| 299 | }); | 231 | }); |
| 300 | | 232 | |
| 301 | const runCodexJson = <T>({ | 233 | const runCodexJson = <S extends Schema.Top>({ |
| 302 | operation, | 234 | operation, |
| 303 | cwd, | 235 | cwd, |
| 304 | prompt, | 236 | prompt, |
| 305 | outputSchemaJson, | 237 | outputSchemaJson, |
| 306 | imagePaths = [], | 238 | imagePaths = [], |
| 307 | parse, | | |
| 308 | }: { | 239 | }: { |
| 309 | operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; | 240 | operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; |
| 310 | cwd: string; | 241 | cwd: string; |
| 311 | prompt: string; | 242 | prompt: string; |
| 312 | outputSchemaJson: object; | 243 | outputSchemaJson: S; |
| 313 | imagePaths?: ReadonlyArray<string>; | 244 | imagePaths?: ReadonlyArray<string>; |
| 314 | parse: (raw: unknown) => T; | 245 | }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> => |
| 315 | }): Effect.Effect<T, TextGenerationError> => | | |
| 316 | Effect.gen(function* () { | 246 | Effect.gen(function* () { |
| 317 | const schemaPath = yield* writeTempFile( | 247 | const schemaPath = yield* writeTempFile( |
| 318 | operation, | 248 | operation, |
| 319 | "codex-schema", | 249 | "codex-schema", |
| 320 | JSON.stringify(outputSchemaJson), | 250 | JSON.stringify(Schema.toJsonSchemaDocument(outputSchemaJson).schema), |
| 321 | ); | 251 | ); |
| 322 | const outputPath = yield* writeTempFile(operation, "codex-output", ""); | 252 | const outputPath = yield* writeTempFile(operation, "codex-output", ""); |
| 323 | | 253 | |
| ... | @@ -406,7 +336,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -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 | Effect.mapError( | 340 | Effect.mapError( |
| 411 | (cause) => | 341 | (cause) => |
| 412 | new TextGenerationError({ | 342 | new TextGenerationError({ |
| ... | @@ -415,30 +345,17 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -415,30 +345,17 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 415 | cause, | 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 | }).pipe(Effect.ensuring(cleanup)); | 359 | }).pipe(Effect.ensuring(cleanup)); |
| 443 | }); | 360 | }); |
| 444 | | 361 | |
| ... | @@ -464,8 +381,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -464,8 +381,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 464 | operation: "generateCommitMessage", | 381 | operation: "generateCommitMessage", |
| 465 | cwd: input.cwd, | 382 | cwd: input.cwd, |
| 466 | prompt, | 383 | prompt, |
| 467 | outputSchemaJson: COMMIT_OUTPUT_SCHEMA_JSON, | 384 | outputSchemaJson: Schema.Struct({ |
| 468 | parse: (raw) => parseCommitOutput(raw), | 385 | subject: Schema.String, |
| | 386 | body: Schema.String, |
| | 387 | }), |
| 469 | }).pipe( | 388 | }).pipe( |
| 470 | Effect.map( | 389 | Effect.map( |
| 471 | (generated) => | 390 | (generated) => |
| ... | @@ -504,8 +423,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -504,8 +423,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 504 | operation: "generatePrContent", | 423 | operation: "generatePrContent", |
| 505 | cwd: input.cwd, | 424 | cwd: input.cwd, |
| 506 | prompt, | 425 | prompt, |
| 507 | outputSchemaJson: PR_OUTPUT_SCHEMA_JSON, | 426 | outputSchemaJson: Schema.Struct({ |
| 508 | parse: (raw) => parsePrOutput(raw), | 427 | title: Schema.String, |
| | 428 | body: Schema.String, |
| | 429 | }), |
| 509 | }).pipe( | 430 | }).pipe( |
| 510 | Effect.map( | 431 | Effect.map( |
| 511 | (generated) => | 432 | (generated) => |
| ... | @@ -519,9 +440,13 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -519,9 +440,13 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 519 | | 440 | |
| 520 | const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => { | 441 | const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => { |
| 521 | return Effect.gen(function* () { | 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 | const attachmentLines = (input.attachments ?? []).map( | 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 | const promptSections = [ | 452 | const promptSections = [ |
| ... | @@ -549,9 +474,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -549,9 +474,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 549 | operation: "generateBranchName", | 474 | operation: "generateBranchName", |
| 550 | cwd: input.cwd, | 475 | cwd: input.cwd, |
| 551 | prompt, | 476 | prompt, |
| 552 | outputSchemaJson: BRANCH_NAME_OUTPUT_SCHEMA_JSON, | 477 | outputSchemaJson: Schema.Struct({ |
| | 478 | branch: Schema.String, |
| | 479 | }), |
| 553 | imagePaths, | 480 | imagePaths, |
| 554 | parse: (raw) => parseBranchNameOutput(raw), | | |
| 555 | }); | 481 | }); |
| 556 | | 482 | |
| 557 | return { | 483 | return { |