| ... | ... | @@ -85,44 +85,43 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 85 | 85 | const safeUnlink = (filePath: string): Effect.Effect<void, never> => |
| 86 | 86 | fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void)); |
| 87 | 87 | |
| 88 | | const materializeImageAttachments = ( |
| 88 | const materializeImageAttachments = Effect.fn("materializeImageAttachments")(function* ( |
| 89 | 89 | _operation: |
| 90 | 90 | | "generateCommitMessage" |
| 91 | 91 | | "generatePrContent" |
| 92 | 92 | | "generateBranchName" |
| 93 | 93 | | "generateThreadTitle", |
| 94 | 94 | attachments: BranchNameGenerationInput["attachments"], |
| 95 | | ): Effect.Effect<MaterializedImageAttachments, TextGenerationError> => |
| 96 | | Effect.gen(function* () { |
| 97 | | if (!attachments || attachments.length === 0) { |
| 98 | | return { imagePaths: [] }; |
| 99 | | } |
| 95 | ): Effect.fn.Return<MaterializedImageAttachments, TextGenerationError> { |
| 96 | if (!attachments || attachments.length === 0) { |
| 97 | return { imagePaths: [] }; |
| 98 | } |
| 100 | 99 | |
| 101 | | const imagePaths: string[] = []; |
| 102 | | for (const attachment of attachments) { |
| 103 | | if (attachment.type !== "image") { |
| 104 | | continue; |
| 105 | | } |
| 100 | const imagePaths: string[] = []; |
| 101 | for (const attachment of attachments) { |
| 102 | if (attachment.type !== "image") { |
| 103 | continue; |
| 104 | } |
| 106 | 105 | |
| 107 | | const resolvedPath = resolveAttachmentPath({ |
| 108 | | attachmentsDir: serverConfig.attachmentsDir, |
| 109 | | attachment, |
| 110 | | }); |
| 111 | | if (!resolvedPath || !path.isAbsolute(resolvedPath)) { |
| 112 | | continue; |
| 113 | | } |
| 114 | | const fileInfo = yield* fileSystem |
| 115 | | .stat(resolvedPath) |
| 116 | | .pipe(Effect.catch(() => Effect.succeed(null))); |
| 117 | | if (!fileInfo || fileInfo.type !== "File") { |
| 118 | | continue; |
| 119 | | } |
| 120 | | imagePaths.push(resolvedPath); |
| 106 | const resolvedPath = resolveAttachmentPath({ |
| 107 | attachmentsDir: serverConfig.attachmentsDir, |
| 108 | attachment, |
| 109 | }); |
| 110 | if (!resolvedPath || !path.isAbsolute(resolvedPath)) { |
| 111 | continue; |
| 121 | 112 | } |
| 122 | | return { imagePaths }; |
| 123 | | }); |
| 113 | const fileInfo = yield* fileSystem |
| 114 | .stat(resolvedPath) |
| 115 | .pipe(Effect.catch(() => Effect.succeed(null))); |
| 116 | if (!fileInfo || fileInfo.type !== "File") { |
| 117 | continue; |
| 118 | } |
| 119 | imagePaths.push(resolvedPath); |
| 120 | } |
| 121 | return { imagePaths }; |
| 122 | }); |
| 124 | 123 | |
| 125 | | const runCodexJson = <S extends Schema.Top>({ |
| 124 | const runCodexJson = Effect.fn("runCodexJson")(function* <S extends Schema.Top>({ |
| 126 | 125 | operation, |
| 127 | 126 | cwd, |
| 128 | 127 | prompt, |
| ... | ... | @@ -142,138 +141,137 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 142 | 141 | imagePaths?: ReadonlyArray<string>; |
| 143 | 142 | cleanupPaths?: ReadonlyArray<string>; |
| 144 | 143 | modelSelection: CodexModelSelection; |
| 145 | | }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> => |
| 146 | | Effect.gen(function* () { |
| 147 | | const schemaPath = yield* writeTempFile( |
| 148 | | operation, |
| 149 | | "codex-schema", |
| 150 | | JSON.stringify(toJsonSchemaObject(outputSchemaJson)), |
| 151 | | ); |
| 152 | | const outputPath = yield* writeTempFile(operation, "codex-output", ""); |
| 144 | }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> { |
| 145 | const schemaPath = yield* writeTempFile( |
| 146 | operation, |
| 147 | "codex-schema", |
| 148 | JSON.stringify(toJsonSchemaObject(outputSchemaJson)), |
| 149 | ); |
| 150 | const outputPath = yield* writeTempFile(operation, "codex-output", ""); |
| 153 | 151 | |
| 154 | | const codexSettings = yield* Effect.map( |
| 155 | | serverSettingsService.getSettings, |
| 156 | | (settings) => settings.providers.codex, |
| 157 | | ).pipe(Effect.catch(() => Effect.undefined)); |
| 152 | const codexSettings = yield* Effect.map( |
| 153 | serverSettingsService.getSettings, |
| 154 | (settings) => settings.providers.codex, |
| 155 | ).pipe(Effect.catch(() => Effect.undefined)); |
| 158 | 156 | |
| 159 | | const runCodexCommand = Effect.gen(function* () { |
| 160 | | const normalizedOptions = normalizeCodexModelOptionsWithCapabilities( |
| 161 | | getCodexModelCapabilities(modelSelection.model), |
| 162 | | modelSelection.options, |
| 163 | | ); |
| 164 | | const reasoningEffort = |
| 165 | | modelSelection.options?.reasoningEffort ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; |
| 166 | | const command = ChildProcess.make( |
| 167 | | codexSettings?.binaryPath || "codex", |
| 168 | | [ |
| 169 | | "exec", |
| 170 | | "--ephemeral", |
| 171 | | "-s", |
| 172 | | "read-only", |
| 173 | | "--model", |
| 174 | | modelSelection.model, |
| 175 | | "--config", |
| 176 | | `model_reasoning_effort="${reasoningEffort}"`, |
| 177 | | ...(normalizedOptions?.fastMode ? ["--config", `service_tier="fast"`] : []), |
| 178 | | "--output-schema", |
| 179 | | schemaPath, |
| 180 | | "--output-last-message", |
| 181 | | outputPath, |
| 182 | | ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), |
| 183 | | "-", |
| 184 | | ], |
| 185 | | { |
| 186 | | env: { |
| 187 | | ...process.env, |
| 188 | | ...(codexSettings?.homePath ? { CODEX_HOME: codexSettings.homePath } : {}), |
| 189 | | }, |
| 190 | | cwd, |
| 191 | | shell: process.platform === "win32", |
| 192 | | stdin: { |
| 193 | | stream: Stream.encodeText(Stream.make(prompt)), |
| 194 | | }, |
| 157 | const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () { |
| 158 | const normalizedOptions = normalizeCodexModelOptionsWithCapabilities( |
| 159 | getCodexModelCapabilities(modelSelection.model), |
| 160 | modelSelection.options, |
| 161 | ); |
| 162 | const reasoningEffort = |
| 163 | modelSelection.options?.reasoningEffort ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; |
| 164 | const command = ChildProcess.make( |
| 165 | codexSettings?.binaryPath || "codex", |
| 166 | [ |
| 167 | "exec", |
| 168 | "--ephemeral", |
| 169 | "-s", |
| 170 | "read-only", |
| 171 | "--model", |
| 172 | modelSelection.model, |
| 173 | "--config", |
| 174 | `model_reasoning_effort="${reasoningEffort}"`, |
| 175 | ...(normalizedOptions?.fastMode ? ["--config", `service_tier="fast"`] : []), |
| 176 | "--output-schema", |
| 177 | schemaPath, |
| 178 | "--output-last-message", |
| 179 | outputPath, |
| 180 | ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), |
| 181 | "-", |
| 182 | ], |
| 183 | { |
| 184 | env: { |
| 185 | ...process.env, |
| 186 | ...(codexSettings?.homePath ? { CODEX_HOME: codexSettings.homePath } : {}), |
| 195 | 187 | }, |
| 188 | cwd, |
| 189 | shell: process.platform === "win32", |
| 190 | stdin: { |
| 191 | stream: Stream.encodeText(Stream.make(prompt)), |
| 192 | }, |
| 193 | }, |
| 194 | ); |
| 195 | |
| 196 | const child = yield* commandSpawner |
| 197 | .spawn(command) |
| 198 | .pipe( |
| 199 | Effect.mapError((cause) => |
| 200 | normalizeCliError("codex", operation, cause, "Failed to spawn Codex CLI process"), |
| 201 | ), |
| 196 | 202 | ); |
| 197 | 203 | |
| 198 | | const child = yield* commandSpawner |
| 199 | | .spawn(command) |
| 200 | | .pipe( |
| 204 | const [stdout, stderr, exitCode] = yield* Effect.all( |
| 205 | [ |
| 206 | readStreamAsString(operation, child.stdout), |
| 207 | readStreamAsString(operation, child.stderr), |
| 208 | child.exitCode.pipe( |
| 201 | 209 | Effect.mapError((cause) => |
| 202 | | normalizeCliError("codex", operation, cause, "Failed to spawn Codex CLI process"), |
| 203 | | ), |
| 204 | | ); |
| 205 | | |
| 206 | | const [stdout, stderr, exitCode] = yield* Effect.all( |
| 207 | | [ |
| 208 | | readStreamAsString(operation, child.stdout), |
| 209 | | readStreamAsString(operation, child.stderr), |
| 210 | | child.exitCode.pipe( |
| 211 | | Effect.mapError((cause) => |
| 212 | | normalizeCliError("codex", operation, cause, "Failed to read Codex CLI exit code"), |
| 213 | | ), |
| 210 | normalizeCliError("codex", operation, cause, "Failed to read Codex CLI exit code"), |
| 214 | 211 | ), |
| 215 | | ], |
| 216 | | { concurrency: "unbounded" }, |
| 217 | | ); |
| 212 | ), |
| 213 | ], |
| 214 | { concurrency: "unbounded" }, |
| 215 | ); |
| 218 | 216 | |
| 219 | | if (exitCode !== 0) { |
| 220 | | const stderrDetail = stderr.trim(); |
| 221 | | const stdoutDetail = stdout.trim(); |
| 222 | | const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; |
| 223 | | return yield* new TextGenerationError({ |
| 224 | | operation, |
| 225 | | detail: |
| 226 | | detail.length > 0 |
| 227 | | ? `Codex CLI command failed: ${detail}` |
| 228 | | : `Codex CLI command failed with code ${exitCode}.`, |
| 229 | | }); |
| 230 | | } |
| 231 | | }); |
| 217 | if (exitCode !== 0) { |
| 218 | const stderrDetail = stderr.trim(); |
| 219 | const stdoutDetail = stdout.trim(); |
| 220 | const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; |
| 221 | return yield* new TextGenerationError({ |
| 222 | operation, |
| 223 | detail: |
| 224 | detail.length > 0 |
| 225 | ? `Codex CLI command failed: ${detail}` |
| 226 | : `Codex CLI command failed with code ${exitCode}.`, |
| 227 | }); |
| 228 | } |
| 229 | }); |
| 232 | 230 | |
| 233 | | const cleanup = Effect.all( |
| 234 | | [schemaPath, outputPath, ...cleanupPaths].map((filePath) => safeUnlink(filePath)), |
| 235 | | { |
| 236 | | concurrency: "unbounded", |
| 237 | | }, |
| 238 | | ).pipe(Effect.asVoid); |
| 239 | | |
| 240 | | return yield* Effect.gen(function* () { |
| 241 | | yield* runCodexCommand.pipe( |
| 242 | | Effect.scoped, |
| 243 | | Effect.timeoutOption(CODEX_TIMEOUT_MS), |
| 244 | | Effect.flatMap( |
| 245 | | Option.match({ |
| 246 | | onNone: () => |
| 247 | | Effect.fail( |
| 248 | | new TextGenerationError({ operation, detail: "Codex CLI request timed out." }), |
| 249 | | ), |
| 250 | | onSome: () => Effect.void, |
| 251 | | }), |
| 252 | | ), |
| 253 | | ); |
| 231 | const cleanup = Effect.all( |
| 232 | [schemaPath, outputPath, ...cleanupPaths].map((filePath) => safeUnlink(filePath)), |
| 233 | { |
| 234 | concurrency: "unbounded", |
| 235 | }, |
| 236 | ).pipe(Effect.asVoid); |
| 237 | |
| 238 | return yield* Effect.gen(function* () { |
| 239 | yield* runCodexCommand().pipe( |
| 240 | Effect.scoped, |
| 241 | Effect.timeoutOption(CODEX_TIMEOUT_MS), |
| 242 | Effect.flatMap( |
| 243 | Option.match({ |
| 244 | onNone: () => |
| 245 | Effect.fail( |
| 246 | new TextGenerationError({ operation, detail: "Codex CLI request timed out." }), |
| 247 | ), |
| 248 | onSome: () => Effect.void, |
| 249 | }), |
| 250 | ), |
| 251 | ); |
| 254 | 252 | |
| 255 | | return yield* fileSystem.readFileString(outputPath).pipe( |
| 256 | | Effect.mapError( |
| 257 | | (cause) => |
| 258 | | new TextGenerationError({ |
| 259 | | operation, |
| 260 | | detail: "Failed to read Codex output file.", |
| 261 | | cause, |
| 262 | | }), |
| 263 | | ), |
| 264 | | Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))), |
| 265 | | Effect.catchTag("SchemaError", (cause) => |
| 266 | | Effect.fail( |
| 267 | | new TextGenerationError({ |
| 268 | | operation, |
| 269 | | detail: "Codex returned invalid structured output.", |
| 270 | | cause, |
| 271 | | }), |
| 272 | | ), |
| 253 | return yield* fileSystem.readFileString(outputPath).pipe( |
| 254 | Effect.mapError( |
| 255 | (cause) => |
| 256 | new TextGenerationError({ |
| 257 | operation, |
| 258 | detail: "Failed to read Codex output file.", |
| 259 | cause, |
| 260 | }), |
| 261 | ), |
| 262 | Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))), |
| 263 | Effect.catchTag("SchemaError", (cause) => |
| 264 | Effect.fail( |
| 265 | new TextGenerationError({ |
| 266 | operation, |
| 267 | detail: "Codex returned invalid structured output.", |
| 268 | cause, |
| 269 | }), |
| 273 | 270 | ), |
| 274 | | ); |
| 275 | | }).pipe(Effect.ensuring(cleanup)); |
| 276 | | }); |
| 271 | ), |
| 272 | ); |
| 273 | }).pipe(Effect.ensuring(cleanup)); |
| 274 | }); |
| 277 | 275 | |
| 278 | 276 | const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( |
| 279 | 277 | "CodexTextGeneration.generateCommitMessage", |