| author | |
| committer | |
| log | 4ae976dbae39b3e80243b864a8b61da00f642dc4 |
| tree | 4fdcfb6934314f4225a9affc682814c64fc25bab |
| parent | 442735897f6f92af33798075baa12d4fd4d710dd |
| signature | Signed by PGP key B5690EEEBB952194 |
10 files changed, 317 insertions(+), 1049 deletions(-)
apps/server/src/textGeneration/AntigravityTextGeneration.ts+6-114| ... | @@ -1,10 +1,4 @@ | ... | @@ -1,10 +1,4 @@ |
| 1 | import { | 1 | import { type ProviderSetupError, TextGenerationError } from "@t3tools/contracts"; |
| 2 | type ModelSelection, | ||
| 3 | type ProviderSetupError, | ||
| 4 | TextGenerationError, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { formatGeneratedBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
| 7 | import { extractJsonObject } from "@t3tools/shared/schemaJson"; | ||
| 8 | import * as Deferred from "effect/Deferred"; | 2 | import * as Deferred from "effect/Deferred"; |
| 9 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 10 | import * as Exit from "effect/Exit"; | 4 | import * as Exit from "effect/Exit"; |
| ... | @@ -20,18 +14,7 @@ import { type AcpError, AcpRequestError } from "effect-acp/errors"; | ... | @@ -20,18 +14,7 @@ import { type AcpError, AcpRequestError } from "effect-acp/errors"; |
| 20 | import { applyAntigravityAcpModelSelection } from "../provider/acp/AntigravityAcpSupport.ts"; | 14 | import { applyAntigravityAcpModelSelection } from "../provider/acp/AntigravityAcpSupport.ts"; |
| 21 | import { removeAntigravitySessionFiles } from "../provider/acp/AntigravitySessionFiles.ts"; | 15 | import { removeAntigravitySessionFiles } from "../provider/acp/AntigravitySessionFiles.ts"; |
| 22 | import type { AcpSessionRuntime } from "../provider/acp/AcpSessionRuntime.ts"; | 16 | import type { AcpSessionRuntime } from "../provider/acp/AcpSessionRuntime.ts"; |
| 23 | import type * as TextGeneration from "./TextGeneration.ts"; | 17 | import * as TextGenerationOperations from "./TextGenerationOperations.ts"; |
| 24 | import { | ||
| 25 | buildBranchNamePrompt, | ||
| 26 | buildCommitMessagePrompt, | ||
| 27 | buildPrContentPrompt, | ||
| 28 | buildThreadTitlePrompt, | ||
| 29 | } from "./TextGenerationPrompts.ts"; | ||
| 30 | import { | ||
| 31 | sanitizeCommitSubject, | ||
| 32 | sanitizePrTitle, | ||
| 33 | sanitizeThreadTitle, | ||
| 34 | } from "./TextGenerationUtils.ts"; | ||
| 35 | 18 | ||
| 36 | const ANTIGRAVITY_TIMEOUT_MS = 180_000; | 19 | const ANTIGRAVITY_TIMEOUT_MS = 180_000; |
| 37 | const MAX_OUTPUT_CHARS = 128_000; | 20 | const MAX_OUTPUT_CHARS = 128_000; |
| ... | @@ -119,13 +102,9 @@ export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGener | ... | @@ -119,13 +102,9 @@ export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGener |
| 119 | Effect.provideService(Path.Path, path), | 102 | Effect.provideService(Path.Path, path), |
| 120 | ); | 103 | ); |
| 121 | 104 | ||
| 105 | // Ignores `cwd`: the helper runs in an empty temp directory, away from the project. | ||
| 122 | const runAntigravityJson = Effect.fn("AntigravityTextGeneration.runJson")( | 106 | const runAntigravityJson = Effect.fn("AntigravityTextGeneration.runJson")( |
| 123 | function* <S extends Schema.Top>(input: { | 107 | function* <S extends Schema.Top>(input: TextGenerationOperations.Request<S>) { |
| 124 | readonly operation: keyof TextGeneration.TextGeneration["Service"]; | ||
| 125 | readonly prompt: string; | ||
| 126 | readonly outputSchema: S; | ||
| 127 | readonly modelSelection: ModelSelection; | ||
| 128 | }) { | ||
| 129 | const { operation } = input; | 108 | const { operation } = input; |
| 130 | const scope = yield* Scope.make(); | 109 | const scope = yield* Scope.make(); |
| 131 | yield* Effect.addFinalizer((exit) => Scope.close(scope, exit)); | 110 | yield* Effect.addFinalizer((exit) => Scope.close(scope, exit)); |
| ... | @@ -290,17 +269,7 @@ export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGener | ... | @@ -290,17 +269,7 @@ export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGener |
| 290 | detail: "Antigravity returned empty text generation output.", | 269 | detail: "Antigravity returned empty text generation output.", |
| 291 | }); | 270 | }); |
| 292 | } | 271 | } |
| 293 | const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchema)); | 272 | return yield* TextGenerationOperations.decodeJsonReply(input, "Antigravity", rawResult); |
| 294 | return yield* decodeOutput(extractJsonObject(rawResult)).pipe( | ||
| 295 | Effect.mapError( | ||
| 296 | (cause) => | ||
| 297 | new TextGenerationError({ | ||
| 298 | operation, | ||
| 299 | detail: "Antigravity returned invalid structured output.", | ||
| 300 | cause, | ||
| 301 | }), | ||
| 302 | ), | ||
| 303 | ); | ||
| 304 | }).pipe( | 273 | }).pipe( |
| 305 | Effect.scoped, | 274 | Effect.scoped, |
| 306 | Effect.timeoutOption(ANTIGRAVITY_TIMEOUT_MS), | 275 | Effect.timeoutOption(ANTIGRAVITY_TIMEOUT_MS), |
| ... | @@ -337,82 +306,5 @@ export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGener | ... | @@ -337,82 +306,5 @@ export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGener |
| 337 | ), | 306 | ), |
| 338 | ); | 307 | ); |
| 339 | 308 | ||
| 340 | const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = | 309 | return TextGenerationOperations.fromRunner("AntigravityTextGeneration", runAntigravityJson); |
| 341 | Effect.fn("AntigravityTextGeneration.generateCommitMessage")(function* (input) { | ||
| 342 | const generated = yield* runAntigravityJson({ | ||
| 343 | operation: "generateCommitMessage", | ||
| 344 | ...buildCommitMessagePrompt({ | ||
| 345 | branch: input.branch, | ||
| 346 | stagedSummary: input.stagedSummary, | ||
| 347 | stagedPatch: input.stagedPatch, | ||
| 348 | includeBranch: input.includeBranch === true, | ||
| 349 | policy: input.policy, | ||
| 350 | }), | ||
| 351 | modelSelection: input.modelSelection, | ||
| 352 | }); | ||
| 353 | return { | ||
| 354 | subject: sanitizeCommitSubject(generated.subject), | ||
| 355 | body: generated.body.trim(), | ||
| 356 | ...("branch" in generated && typeof generated.branch === "string" | ||
| 357 | ? { branch: sanitizeFeatureBranchName(generated.branch) } | ||
| 358 | : {}), | ||
| 359 | }; | ||
| 360 | }); | ||
| 361 | |||
| 362 | const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = | ||
| 363 | Effect.fn("AntigravityTextGeneration.generatePrContent")(function* (input) { | ||
| 364 | const generated = yield* runAntigravityJson({ | ||
| 365 | operation: "generatePrContent", | ||
| 366 | ...buildPrContentPrompt({ | ||
| 367 | baseBranch: input.baseBranch, | ||
| 368 | headBranch: input.headBranch, | ||
| 369 | commitSummary: input.commitSummary, | ||
| 370 | diffSummary: input.diffSummary, | ||
| 371 | diffPatch: input.diffPatch, | ||
| 372 | policy: input.policy, | ||
| 373 | changeRequestTemplate: input.changeRequestTemplate, | ||
| 374 | }), | ||
| 375 | modelSelection: input.modelSelection, | ||
| 376 | }); | ||
| 377 | return { title: sanitizePrTitle(generated.title), body: generated.body.trim() }; | ||
| 378 | }); | ||
| 379 | |||
| 380 | const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = | ||
| 381 | Effect.fn("AntigravityTextGeneration.generateBranchName")(function* (input) { | ||
| 382 | const generated = yield* runAntigravityJson({ | ||
| 383 | operation: "generateBranchName", | ||
| 384 | ...buildBranchNamePrompt({ | ||
| 385 | message: input.message, | ||
| 386 | attachments: input.attachments, | ||
| 387 | naming: input.naming, | ||
| 388 | }), | ||
| 389 | modelSelection: input.modelSelection, | ||
| 390 | }); | ||
| 391 | return { branch: formatGeneratedBranchName(generated.branch, input.naming) }; | ||
| 392 | }); | ||
| 393 | |||
| 394 | const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = | ||
| 395 | Effect.fn("AntigravityTextGeneration.generateThreadTitle")(function* (input) { | ||
| 396 | const generated = yield* runAntigravityJson({ | ||
| 397 | operation: "generateThreadTitle", | ||
| 398 | ...buildThreadTitlePrompt({ | ||
| 399 | message: input.message, | ||
| 400 | previousTitle: input.previousTitle, | ||
| 401 | linkedContext: input.linkedContext, | ||
| 402 | attachments: input.attachments, | ||
| 403 | }), | ||
| 404 | modelSelection: input.modelSelection, | ||
| 405 | }); | ||
| 406 | return { | ||
| 407 | title: sanitizeThreadTitle(generated.title), | ||
| 408 | ...(generated.needsRefinement ? { needsRefinement: true } : {}), | ||
| 409 | }; | ||
| 410 | }); | ||
| 411 | |||
| 412 | return { | ||
| 413 | generateCommitMessage, | ||
| 414 | generatePrContent, | ||
| 415 | generateBranchName, | ||
| 416 | generateThreadTitle, | ||
| 417 | } satisfies TextGeneration.TextGeneration["Service"]; | ||
| 418 | }); | 310 | }); |
apps/server/src/textGeneration/ClaudeTextGeneration.ts+11-141| ... | @@ -14,25 +14,11 @@ import * as Schema from "effect/Schema"; | ... | @@ -14,25 +14,11 @@ import * as Schema from "effect/Schema"; |
| 14 | import * as Stream from "effect/Stream"; | 14 | import * as Stream from "effect/Stream"; |
| 15 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | 15 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 16 | 16 | ||
| 17 | import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts"; | 17 | import { type ClaudeSettings, TextGenerationError } from "@t3tools/contracts"; |
| 18 | import { formatGeneratedBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
| 19 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | 18 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 20 | 19 | ||
| 21 | import { TextGenerationError } from "@t3tools/contracts"; | 20 | import * as TextGenerationOperations from "./TextGenerationOperations.ts"; |
| 22 | import * as TextGeneration from "./TextGeneration.ts"; | 21 | import { normalizeCliError, toJsonSchemaObject } from "./TextGenerationUtils.ts"; |
| 23 | import { | ||
| 24 | buildBranchNamePrompt, | ||
| 25 | buildCommitMessagePrompt, | ||
| 26 | buildPrContentPrompt, | ||
| 27 | buildThreadTitlePrompt, | ||
| 28 | } from "./TextGenerationPrompts.ts"; | ||
| 29 | import { | ||
| 30 | normalizeCliError, | ||
| 31 | sanitizeCommitSubject, | ||
| 32 | sanitizePrTitle, | ||
| 33 | sanitizeThreadTitle, | ||
| 34 | toJsonSchemaObject, | ||
| 35 | } from "./TextGenerationUtils.ts"; | ||
| 36 | import { | 22 | import { |
| 37 | getModelSelectionStringOptionValue, | 23 | getModelSelectionStringOptionValue, |
| 38 | getProviderOptionDescriptors, | 24 | getProviderOptionDescriptors, |
| ... | @@ -98,11 +84,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu | ... | @@ -98,11 +84,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu |
| 98 | ); | 84 | ); |
| 99 | 85 | ||
| 100 | const encodeJsonForOperation = ( | 86 | const encodeJsonForOperation = ( |
| 101 | operation: | 87 | operation: TextGenerationOperations.Operation, |
| 102 | | "generateCommitMessage" | ||
| 103 | | "generatePrContent" | ||
| 104 | | "generateBranchName" | ||
| 105 | | "generateThreadTitle", | ||
| 106 | value: unknown, | 88 | value: unknown, |
| 107 | detail: string, | 89 | detail: string, |
| 108 | ): Effect.Effect<string, TextGenerationError> => | 90 | ): Effect.Effect<string, TextGenerationError> => |
| ... | @@ -125,19 +107,13 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu | ... | @@ -125,19 +107,13 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu |
| 125 | operation, | 107 | operation, |
| 126 | cwd, | 108 | cwd, |
| 127 | prompt, | 109 | prompt, |
| 128 | outputSchemaJson, | 110 | outputSchema: outputSchemaJson, |
| 129 | modelSelection, | 111 | modelSelection, |
| 130 | }: { | 112 | }: TextGenerationOperations.Request<S>): Effect.fn.Return< |
| 131 | operation: | 113 | S["Type"], |
| 132 | | "generateCommitMessage" | 114 | TextGenerationError, |
| 133 | | "generatePrContent" | 115 | S["DecodingServices"] |
| 134 | | "generateBranchName" | 116 | > { |
| 135 | | "generateThreadTitle"; | ||
| 136 | cwd: string; | ||
| 137 | prompt: string; | ||
| 138 | outputSchemaJson: S; | ||
| 139 | modelSelection: ModelSelection; | ||
| 140 | }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> { | ||
| 141 | const catalog = yield* scopedModelCatalog; | 117 | const catalog = yield* scopedModelCatalog; |
| 142 | const resolvedModelSelection = { | 118 | const resolvedModelSelection = { |
| 143 | ...modelSelection, | 119 | ...modelSelection, |
| ... | @@ -310,111 +286,5 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu | ... | @@ -310,111 +286,5 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu |
| 310 | ); | 286 | ); |
| 311 | }); | 287 | }); |
| 312 | 288 | ||
| 313 | // --------------------------------------------------------------------------- | 289 | return TextGenerationOperations.fromRunner("ClaudeTextGeneration", runClaudeJson); |
| 314 | // TextGeneration service methods | ||
| 315 | // --------------------------------------------------------------------------- | ||
| 316 | |||
| 317 | const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = | ||
| 318 | Effect.fn("ClaudeTextGeneration.generateCommitMessage")(function* (input) { | ||
| 319 | const { prompt, outputSchema } = buildCommitMessagePrompt({ | ||
| 320 | branch: input.branch, | ||
| 321 | stagedSummary: input.stagedSummary, | ||
| 322 | stagedPatch: input.stagedPatch, | ||
| 323 | includeBranch: input.includeBranch === true, | ||
| 324 | policy: input.policy, | ||
| 325 | }); | ||
| 326 | |||
| 327 | const generated = yield* runClaudeJson({ | ||
| 328 | operation: "generateCommitMessage", | ||
| 329 | cwd: input.cwd, | ||
| 330 | prompt, | ||
| 331 | outputSchemaJson: outputSchema, | ||
| 332 | modelSelection: input.modelSelection, | ||
| 333 | }); | ||
| 334 | |||
| 335 | return { | ||
| 336 | subject: sanitizeCommitSubject(generated.subject), | ||
| 337 | body: generated.body.trim(), | ||
| 338 | ...("branch" in generated && typeof generated.branch === "string" | ||
| 339 | ? { branch: sanitizeFeatureBranchName(generated.branch) } | ||
| 340 | : {}), | ||
| 341 | }; | ||
| 342 | }); | ||
| 343 | |||
| 344 | const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = | ||
| 345 | Effect.fn("ClaudeTextGeneration.generatePrContent")(function* (input) { | ||
| 346 | const { prompt, outputSchema } = buildPrContentPrompt({ | ||
| 347 | baseBranch: input.baseBranch, | ||
| 348 | headBranch: input.headBranch, | ||
| 349 | commitSummary: input.commitSummary, | ||
| 350 | diffSummary: input.diffSummary, | ||
| 351 | diffPatch: input.diffPatch, | ||
| 352 | policy: input.policy, | ||
| 353 | changeRequestTemplate: input.changeRequestTemplate, | ||
| 354 | }); | ||
| 355 | |||
| 356 | const generated = yield* runClaudeJson({ | ||
| 357 | operation: "generatePrContent", | ||
| 358 | cwd: input.cwd, | ||
| 359 | prompt, | ||
| 360 | outputSchemaJson: outputSchema, | ||
| 361 | modelSelection: input.modelSelection, | ||
| 362 | }); | ||
| 363 | |||
| 364 | return { | ||
| 365 | title: sanitizePrTitle(generated.title), | ||
| 366 | body: generated.body.trim(), | ||
| 367 | }; | ||
| 368 | }); | ||
| 369 | |||
| 370 | const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = | ||
| 371 | Effect.fn("ClaudeTextGeneration.generateBranchName")(function* (input) { | ||
| 372 | const { prompt, outputSchema } = buildBranchNamePrompt({ | ||
| 373 | message: input.message, | ||
| 374 | attachments: input.attachments, | ||
| 375 | naming: input.naming, | ||
| 376 | }); | ||
| 377 | |||
| 378 | const generated = yield* runClaudeJson({ | ||
| 379 | operation: "generateBranchName", | ||
| 380 | cwd: input.cwd, | ||
| 381 | prompt, | ||
| 382 | outputSchemaJson: outputSchema, | ||
| 383 | modelSelection: input.modelSelection, | ||
| 384 | }); | ||
| 385 | |||
| 386 | return { | ||
| 387 | branch: formatGeneratedBranchName(generated.branch, input.naming), | ||
| 388 | }; | ||
| 389 | }); | ||
| 390 | |||
| 391 | const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = | ||
| 392 | Effect.fn("ClaudeTextGeneration.generateThreadTitle")(function* (input) { | ||
| 393 | const { prompt, outputSchema } = buildThreadTitlePrompt({ | ||
| 394 | message: input.message, | ||
| 395 | previousTitle: input.previousTitle, | ||
| 396 | linkedContext: input.linkedContext, | ||
| 397 | attachments: input.attachments, | ||
| 398 | }); | ||
| 399 | |||
| 400 | const generated = yield* runClaudeJson({ | ||
| 401 | operation: "generateThreadTitle", | ||
| 402 | cwd: input.cwd, | ||
| 403 | prompt, | ||
| 404 | outputSchemaJson: outputSchema, | ||
| 405 | modelSelection: input.modelSelection, | ||
| 406 | }); | ||
| 407 | |||
| 408 | return { | ||
| 409 | title: sanitizeThreadTitle(generated.title), | ||
| 410 | ...(generated.needsRefinement ? { needsRefinement: true } : {}), | ||
| 411 | }; | ||
| 412 | }); | ||
| 413 | |||
| 414 | return { | ||
| 415 | generateCommitMessage, | ||
| 416 | generatePrContent, | ||
| 417 | generateBranchName, | ||
| 418 | generateThreadTitle, | ||
| 419 | } satisfies TextGeneration.TextGeneration["Service"]; | ||
| 420 | }); | 290 | }); |
apps/server/src/textGeneration/CodexTextGeneration.ts+19-176| ... | @@ -10,31 +10,17 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; | ... | @@ -10,31 +10,17 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 10 | import { | 10 | import { |
| 11 | type CodexSettings, | 11 | type CodexSettings, |
| 12 | DEFAULT_TEXT_GENERATION_REASONING_EFFORT, | 12 | DEFAULT_TEXT_GENERATION_REASONING_EFFORT, |
| 13 | type ModelSelection, | ||
| 14 | type ServerProviderModel, | 13 | type ServerProviderModel, |
| 15 | TextGenerationError, | 14 | TextGenerationError, |
| 16 | } from "@t3tools/contracts"; | 15 | } from "@t3tools/contracts"; |
| 17 | import { formatGeneratedBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
| 18 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | 16 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 19 | 17 | ||
| 20 | import { resolveAttachmentPath } from "../attachmentStore.ts"; | 18 | import { resolveAttachmentPath } from "../attachmentStore.ts"; |
| 21 | import * as ServerConfig from "../config.ts"; | 19 | import * as ServerConfig from "../config.ts"; |
| 22 | import { expandHomePath } from "../pathExpansion.ts"; | 20 | import { expandHomePath } from "../pathExpansion.ts"; |
| 23 | import { codexExecLaunchArgs, resolveCodexLaunchArgs } from "../provider/codexLaunchArgs.ts"; | 21 | import { codexExecLaunchArgs, resolveCodexLaunchArgs } from "../provider/codexLaunchArgs.ts"; |
| 24 | import * as TextGeneration from "./TextGeneration.ts"; | 22 | import * as TextGenerationOperations from "./TextGenerationOperations.ts"; |
| 25 | import { | 23 | import { normalizeCliError, toJsonSchemaObject } from "./TextGenerationUtils.ts"; |
| 26 | buildBranchNamePrompt, | ||
| 27 | buildCommitMessagePrompt, | ||
| 28 | buildPrContentPrompt, | ||
| 29 | buildThreadTitlePrompt, | ||
| 30 | } from "./TextGenerationPrompts.ts"; | ||
| 31 | import { | ||
| 32 | normalizeCliError, | ||
| 33 | sanitizeCommitSubject, | ||
| 34 | sanitizePrTitle, | ||
| 35 | sanitizeThreadTitle, | ||
| 36 | toJsonSchemaObject, | ||
| 37 | } from "./TextGenerationUtils.ts"; | ||
| 38 | import { codexModelFamily, getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | 24 | import { codexModelFamily, getModelSelectionStringOptionValue } from "@t3tools/shared/model"; |
| 39 | import { getCodexServiceTierOptionValue } from "../codexModelOptions.ts"; | 25 | import { getCodexServiceTierOptionValue } from "../codexModelOptions.ts"; |
| 40 | 26 | ||
| ... | @@ -60,10 +46,6 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | ... | @@ -60,10 +46,6 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func |
| 60 | const serverConfig = yield* Effect.service(ServerConfig.ServerConfig); | 46 | const serverConfig = yield* Effect.service(ServerConfig.ServerConfig); |
| 61 | const resolvedEnvironment = environment ?? process.env; | 47 | const resolvedEnvironment = environment ?? process.env; |
| 62 | 48 | ||
| 63 | type MaterializedImageAttachments = { | ||
| 64 | readonly imagePaths: ReadonlyArray<string>; | ||
| 65 | }; | ||
| 66 | |||
| 67 | const readStreamAsString = <E>( | 49 | const readStreamAsString = <E>( |
| 68 | operation: string, | 50 | operation: string, |
| 69 | stream: Stream.Stream<Uint8Array, E>, | 51 | stream: Stream.Stream<Uint8Array, E>, |
| ... | @@ -79,9 +61,6 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | ... | @@ -79,9 +61,6 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func |
| 79 | ), | 61 | ), |
| 80 | ); | 62 | ); |
| 81 | 63 | ||
| 82 | const safeUnlink = (filePath: string): Effect.Effect<void, never> => | ||
| 83 | fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void)); | ||
| 84 | |||
| 85 | const removeTempFileDir = (filePath: string): Effect.Effect<void, never> => | 64 | const removeTempFileDir = (filePath: string): Effect.Effect<void, never> => |
| 86 | fileSystem | 65 | fileSystem |
| 87 | .remove(path.dirname(filePath), { recursive: true }) | 66 | .remove(path.dirname(filePath), { recursive: true }) |
| ... | @@ -117,11 +96,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | ... | @@ -117,11 +96,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func |
| 117 | ); | 96 | ); |
| 118 | 97 | ||
| 119 | const encodeJsonForOperation = ( | 98 | const encodeJsonForOperation = ( |
| 120 | operation: | 99 | operation: TextGenerationOperations.Operation, |
| 121 | | "generateCommitMessage" | ||
| 122 | | "generatePrContent" | ||
| 123 | | "generateBranchName" | ||
| 124 | | "generateThreadTitle", | ||
| 125 | value: unknown, | 100 | value: unknown, |
| 126 | ): Effect.Effect<string, TextGenerationError> => | 101 | ): Effect.Effect<string, TextGenerationError> => |
| 127 | encodeJsonString(value).pipe( | 102 | encodeJsonString(value).pipe( |
| ... | @@ -136,15 +111,10 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | ... | @@ -136,15 +111,10 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func |
| 136 | ); | 111 | ); |
| 137 | 112 | ||
| 138 | const materializeImageAttachments = Effect.fn("materializeImageAttachments")(function* ( | 113 | const materializeImageAttachments = Effect.fn("materializeImageAttachments")(function* ( |
| 139 | _operation: | 114 | attachments: TextGenerationOperations.Request<Schema.Top>["attachments"], |
| 140 | | "generateCommitMessage" | 115 | ) { |
| 141 | | "generatePrContent" | ||
| 142 | | "generateBranchName" | ||
| 143 | | "generateThreadTitle", | ||
| 144 | attachments: TextGeneration.BranchNameGenerationInput["attachments"], | ||
| 145 | ): Effect.fn.Return<MaterializedImageAttachments, TextGenerationError> { | ||
| 146 | if (!attachments || attachments.length === 0) { | 116 | if (!attachments || attachments.length === 0) { |
| 147 | return { imagePaths: [] }; | 117 | return []; |
| 148 | } | 118 | } |
| 149 | 119 | ||
| 150 | const imagePaths: string[] = []; | 120 | const imagePaths: string[] = []; |
| ... | @@ -166,30 +136,22 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | ... | @@ -166,30 +136,22 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func |
| 166 | } | 136 | } |
| 167 | imagePaths.push(resolvedPath); | 137 | imagePaths.push(resolvedPath); |
| 168 | } | 138 | } |
| 169 | return { imagePaths }; | 139 | return imagePaths; |
| 170 | }); | 140 | }); |
| 171 | 141 | ||
| 172 | const runCodexJson = Effect.fn("runCodexJson")(function* <S extends Schema.Top>({ | 142 | const runCodexJson = Effect.fn("runCodexJson")(function* <S extends Schema.Top>({ |
| 173 | operation, | 143 | operation, |
| 174 | cwd, | 144 | cwd, |
| 175 | prompt, | 145 | prompt, |
| 176 | outputSchemaJson, | 146 | outputSchema: outputSchemaJson, |
| 177 | imagePaths = [], | ||
| 178 | cleanupPaths = [], | ||
| 179 | modelSelection, | 147 | modelSelection, |
| 180 | }: { | 148 | attachments, |
| 181 | operation: | 149 | }: TextGenerationOperations.Request<S>): Effect.fn.Return< |
| 182 | | "generateCommitMessage" | 150 | S["Type"], |
| 183 | | "generatePrContent" | 151 | TextGenerationError, |
| 184 | | "generateBranchName" | 152 | S["DecodingServices"] |
| 185 | | "generateThreadTitle"; | 153 | > { |
| 186 | cwd: string; | 154 | const imagePaths = yield* materializeImageAttachments(attachments); |
| 187 | prompt: string; | ||
| 188 | outputSchemaJson: S; | ||
| 189 | imagePaths?: ReadonlyArray<string>; | ||
| 190 | cleanupPaths?: ReadonlyArray<string>; | ||
| 191 | modelSelection: ModelSelection; | ||
| 192 | }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> { | ||
| 193 | const schemaJson = yield* encodeJsonForOperation( | 155 | const schemaJson = yield* encodeJsonForOperation( |
| 194 | operation, | 156 | operation, |
| 195 | toJsonSchemaObject(outputSchemaJson), | 157 | toJsonSchemaObject(outputSchemaJson), |
| ... | @@ -294,16 +256,9 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | ... | @@ -294,16 +256,9 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func |
| 294 | } | 256 | } |
| 295 | }); | 257 | }); |
| 296 | 258 | ||
| 297 | const cleanup = Effect.all( | 259 | const cleanup = Effect.all([removeTempFileDir(schemaPath), removeTempFileDir(outputPath)], { |
| 298 | [ | 260 | concurrency: "unbounded", |
| 299 | removeTempFileDir(schemaPath), | 261 | }).pipe(Effect.asVoid); |
| 300 | removeTempFileDir(outputPath), | ||
| 301 | ...cleanupPaths.map((filePath) => safeUnlink(filePath)), | ||
| 302 | ], | ||
| 303 | { | ||
| 304 | concurrency: "unbounded", | ||
| 305 | }, | ||
| 306 | ).pipe(Effect.asVoid); | ||
| 307 | 262 | ||
| 308 | return yield* Effect.gen(function* () { | 263 | return yield* Effect.gen(function* () { |
| 309 | yield* runCodexCommand().pipe( | 264 | yield* runCodexCommand().pipe( |
| ... | @@ -346,117 +301,5 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | ... | @@ -346,117 +301,5 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func |
| 346 | }).pipe(Effect.ensuring(cleanup)); | 301 | }).pipe(Effect.ensuring(cleanup)); |
| 347 | }); | 302 | }); |
| 348 | 303 | ||
| 349 | const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = | 304 | return TextGenerationOperations.fromRunner("CodexTextGeneration", runCodexJson); |
| 350 | Effect.fn("CodexTextGeneration.generateCommitMessage")(function* (input) { | ||
| 351 | const { prompt, outputSchema } = buildCommitMessagePrompt({ | ||
| 352 | branch: input.branch, | ||
| 353 | stagedSummary: input.stagedSummary, | ||
| 354 | stagedPatch: input.stagedPatch, | ||
| 355 | includeBranch: input.includeBranch === true, | ||
| 356 | policy: input.policy, | ||
| 357 | }); | ||
| 358 | |||
| 359 | const generated = yield* runCodexJson({ | ||
| 360 | operation: "generateCommitMessage", | ||
| 361 | cwd: input.cwd, | ||
| 362 | prompt, | ||
| 363 | outputSchemaJson: outputSchema, | ||
| 364 | modelSelection: input.modelSelection, | ||
| 365 | }); | ||
| 366 | |||
| 367 | return { | ||
| 368 | subject: sanitizeCommitSubject(generated.subject), | ||
| 369 | body: generated.body.trim(), | ||
| 370 | ...("branch" in generated && typeof generated.branch === "string" | ||
| 371 | ? { branch: sanitizeFeatureBranchName(generated.branch) } | ||
| 372 | : {}), | ||
| 373 | }; | ||
| 374 | }); | ||
| 375 | |||
| 376 | const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = | ||
| 377 | Effect.fn("CodexTextGeneration.generatePrContent")(function* (input) { | ||
| 378 | const { prompt, outputSchema } = buildPrContentPrompt({ | ||
| 379 | baseBranch: input.baseBranch, | ||
| 380 | headBranch: input.headBranch, | ||
| 381 | commitSummary: input.commitSummary, | ||
| 382 | diffSummary: input.diffSummary, | ||
| 383 | diffPatch: input.diffPatch, | ||
| 384 | policy: input.policy, | ||
| 385 | changeRequestTemplate: input.changeRequestTemplate, | ||
| 386 | }); | ||
| 387 | |||
| 388 | const generated = yield* runCodexJson({ | ||
| 389 | operation: "generatePrContent", | ||
| 390 | cwd: input.cwd, | ||
| 391 | prompt, | ||
| 392 | outputSchemaJson: outputSchema, | ||
| 393 | modelSelection: input.modelSelection, | ||
| 394 | }); | ||
| 395 | |||
| 396 | return { | ||
| 397 | title: sanitizePrTitle(generated.title), | ||
| 398 | body: generated.body.trim(), | ||
| 399 | }; | ||
| 400 | }); | ||
| 401 | |||
| 402 | const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = | ||
| 403 | Effect.fn("CodexTextGeneration.generateBranchName")(function* (input) { | ||
| 404 | const { imagePaths } = yield* materializeImageAttachments( | ||
| 405 | "generateBranchName", | ||
| 406 | input.attachments, | ||
| 407 | ); | ||
| 408 | const { prompt, outputSchema } = buildBranchNamePrompt({ | ||
| 409 | message: input.message, | ||
| 410 | attachments: input.attachments, | ||
| 411 | naming: input.naming, | ||
| 412 | }); | ||
| 413 | |||
| 414 | const generated = yield* runCodexJson({ | ||
| 415 | operation: "generateBranchName", | ||
| 416 | cwd: input.cwd, | ||
| 417 | prompt, | ||
| 418 | outputSchemaJson: outputSchema, | ||
| 419 | imagePaths, | ||
| 420 | modelSelection: input.modelSelection, | ||
| 421 | }); | ||
| 422 | |||
| 423 | return { | ||
| 424 | branch: formatGeneratedBranchName(generated.branch, input.naming), | ||
| 425 | }; | ||
| 426 | }); | ||
| 427 | |||
| 428 | const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = | ||
| 429 | Effect.fn("CodexTextGeneration.generateThreadTitle")(function* (input) { | ||
| 430 | const { imagePaths } = yield* materializeImageAttachments( | ||
| 431 | "generateThreadTitle", | ||
| 432 | input.attachments, | ||
| 433 | ); | ||
| 434 | const { prompt, outputSchema } = buildThreadTitlePrompt({ | ||
| 435 | message: input.message, | ||
| 436 | previousTitle: input.previousTitle, | ||
| 437 | linkedContext: input.linkedContext, | ||
| 438 | attachments: input.attachments, | ||
| 439 | }); | ||
| 440 | |||
| 441 | const generated = yield* runCodexJson({ | ||
| 442 | operation: "generateThreadTitle", | ||
| 443 | cwd: input.cwd, | ||
| 444 | prompt, | ||
| 445 | outputSchemaJson: outputSchema, | ||
| 446 | imagePaths, | ||
| 447 | modelSelection: input.modelSelection, | ||
| 448 | }); | ||
| 449 | |||
| 450 | return { | ||
| 451 | title: sanitizeThreadTitle(generated.title), | ||
| 452 | ...(generated.needsRefinement ? { needsRefinement: true } : {}), | ||
| 453 | } satisfies TextGeneration.ThreadTitleGenerationResult; | ||
| 454 | }); | ||
| 455 | |||
| 456 | return { | ||
| 457 | generateCommitMessage, | ||
| 458 | generatePrContent, | ||
| 459 | generateBranchName, | ||
| 460 | generateThreadTitle, | ||
| 461 | } satisfies TextGeneration.TextGeneration["Service"]; | ||
| 462 | }); | 305 | }); |
apps/server/src/textGeneration/CursorTextGeneration.ts+10-146| ... | @@ -9,36 +9,17 @@ import * as Schema from "effect/Schema"; | ... | @@ -9,36 +9,17 @@ import * as Schema from "effect/Schema"; |
| 9 | 9 | ||
| 10 | import { | 10 | import { |
| 11 | type CursorSettings, | 11 | type CursorSettings, |
| 12 | type ModelSelection, | ||
| 13 | type ProviderSetupError, | 12 | type ProviderSetupError, |
| 13 | TextGenerationError, | ||
| 14 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| 15 | import { formatGeneratedBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
| 16 | import { extractJsonObject } from "@t3tools/shared/schemaJson"; | ||
| 17 | 15 | ||
| 18 | import { TextGenerationError } from "@t3tools/contracts"; | 16 | import * as TextGenerationOperations from "./TextGenerationOperations.ts"; |
| 19 | import * as TextGeneration from "./TextGeneration.ts"; | ||
| 20 | import { | ||
| 21 | buildBranchNamePrompt, | ||
| 22 | buildCommitMessagePrompt, | ||
| 23 | buildPrContentPrompt, | ||
| 24 | buildThreadTitlePrompt, | ||
| 25 | } from "./TextGenerationPrompts.ts"; | ||
| 26 | import { | ||
| 27 | sanitizeCommitSubject, | ||
| 28 | sanitizePrTitle, | ||
| 29 | sanitizeThreadTitle, | ||
| 30 | } from "./TextGenerationUtils.ts"; | ||
| 31 | import { cursorSdkModelSelection } from "../provider/cursorSdkModel.ts"; | 17 | import { cursorSdkModelSelection } from "../provider/cursorSdkModel.ts"; |
| 32 | import type { CursorAuth } from "../provider/CursorAuth.ts"; | 18 | import type { CursorAuth } from "../provider/CursorAuth.ts"; |
| 33 | 19 | ||
| 34 | const CURSOR_TIMEOUT_MS = 180_000; | 20 | const CURSOR_TIMEOUT_MS = 180_000; |
| 35 | 21 | ||
| 36 | const isTextGenerationError = Schema.is(TextGenerationError); | 22 | const isTextGenerationError = Schema.is(TextGenerationError); |
| 37 | type CursorTextGenerationOperation = | ||
| 38 | | "generateCommitMessage" | ||
| 39 | | "generatePrContent" | ||
| 40 | | "generateBranchName" | ||
| 41 | | "generateThreadTitle"; | ||
| 42 | 23 | ||
| 43 | function cursorSdkResultDetail(result: RunResult): string { | 24 | function cursorSdkResultDetail(result: RunResult): string { |
| 44 | switch (result.status) { | 25 | switch (result.status) { |
| ... | @@ -82,7 +63,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu | ... | @@ -82,7 +63,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu |
| 82 | const fs = yield* FileSystem.FileSystem; | 63 | const fs = yield* FileSystem.FileSystem; |
| 83 | const resolvedEnvironment = environment ?? process.env; | 64 | const resolvedEnvironment = environment ?? process.env; |
| 84 | 65 | ||
| 85 | const resolveCursorApiKey = (operation: CursorTextGenerationOperation) => | 66 | const resolveCursorApiKey = (operation: TextGenerationOperations.Operation) => |
| 86 | Effect.gen(function* () { | 67 | Effect.gen(function* () { |
| 87 | if (!cursorSettings.enabled) { | 68 | if (!cursorSettings.enabled) { |
| 88 | return yield* new TextGenerationError({ | 69 | return yield* new TextGenerationError({ |
| ... | @@ -104,18 +85,10 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu | ... | @@ -104,18 +85,10 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu |
| 104 | return apiKey; | 85 | return apiKey; |
| 105 | }); | 86 | }); |
| 106 | 87 | ||
| 107 | const runCursorJson = <S extends Schema.Top>({ | 88 | // Ignores `cwd`: the agent runs in an empty temp directory, away from the project. |
| 108 | operation, | 89 | const runCursorJson: TextGenerationOperations.Runner = (input) => { |
| 109 | prompt, | 90 | const { operation, prompt, modelSelection } = input; |
| 110 | outputSchemaJson, | 91 | return Effect.gen(function* () { |
| 111 | modelSelection, | ||
| 112 | }: { | ||
| 113 | operation: CursorTextGenerationOperation; | ||
| 114 | prompt: string; | ||
| 115 | outputSchemaJson: S; | ||
| 116 | modelSelection: ModelSelection; | ||
| 117 | }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> => | ||
| 118 | Effect.gen(function* () { | ||
| 119 | const apiKey = yield* resolveCursorApiKey(operation); | 92 | const apiKey = yield* resolveCursorApiKey(operation); |
| 120 | // The SDK loads sandbox.json independently of settingSources and lets it | 93 | // The SDK loads sandbox.json independently of settingSources and lets it |
| 121 | // expand the writable paths. Its public API cannot override that policy. | 94 | // expand the writable paths. Its public API cannot override that policy. |
| ... | @@ -211,19 +184,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu | ... | @@ -211,19 +184,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu |
| 211 | }); | 184 | }); |
| 212 | } | 185 | } |
| 213 | 186 | ||
| 214 | const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); | 187 | return yield* TextGenerationOperations.decodeJsonReply(input, "Cursor SDK", rawResult); |
| 215 | return yield* decodeOutput(extractJsonObject(rawResult)).pipe( | ||
| 216 | Effect.catchTags({ | ||
| 217 | SchemaError: (cause) => | ||
| 218 | Effect.fail( | ||
| 219 | new TextGenerationError({ | ||
| 220 | operation, | ||
| 221 | detail: "Cursor SDK returned invalid structured output.", | ||
| 222 | cause, | ||
| 223 | }), | ||
| 224 | ), | ||
| 225 | }), | ||
| 226 | ); | ||
| 227 | }).pipe( | 188 | }).pipe( |
| 228 | (effect) => (withAccess ? withAccess(effect) : effect), | 189 | (effect) => (withAccess ? withAccess(effect) : effect), |
| 229 | Effect.scoped, | 190 | Effect.scoped, |
| ... | @@ -237,104 +198,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu | ... | @@ -237,104 +198,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu |
| 237 | }), | 198 | }), |
| 238 | ), | 199 | ), |
| 239 | ); | 200 | ); |
| 201 | }; | ||
| 240 | 202 | ||
| 241 | const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = | 203 | return TextGenerationOperations.fromRunner("CursorTextGeneration", runCursorJson); |
| 242 | Effect.fn("CursorTextGeneration.generateCommitMessage")(function* (input) { | ||
| 243 | const { prompt, outputSchema } = buildCommitMessagePrompt({ | ||
| 244 | branch: input.branch, | ||
| 245 | stagedSummary: input.stagedSummary, | ||
| 246 | stagedPatch: input.stagedPatch, | ||
| 247 | includeBranch: input.includeBranch === true, | ||
| 248 | policy: input.policy, | ||
| 249 | }); | ||
| 250 | |||
| 251 | const generated = yield* runCursorJson({ | ||
| 252 | operation: "generateCommitMessage", | ||
| 253 | prompt, | ||
| 254 | outputSchemaJson: outputSchema, | ||
| 255 | modelSelection: input.modelSelection, | ||
| 256 | }); | ||
| 257 | |||
| 258 | return { | ||
| 259 | subject: sanitizeCommitSubject(generated.subject), | ||
| 260 | body: generated.body.trim(), | ||
| 261 | ...("branch" in generated && typeof generated.branch === "string" | ||
| 262 | ? { branch: sanitizeFeatureBranchName(generated.branch) } | ||
| 263 | : {}), | ||
| 264 | }; | ||
| 265 | }); | ||
| 266 | |||
| 267 | const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = | ||
| 268 | Effect.fn("CursorTextGeneration.generatePrContent")(function* (input) { | ||
| 269 | const { prompt, outputSchema } = buildPrContentPrompt({ | ||
| 270 | baseBranch: input.baseBranch, | ||
| 271 | headBranch: input.headBranch, | ||
| 272 | commitSummary: input.commitSummary, | ||
| 273 | diffSummary: input.diffSummary, | ||
| 274 | diffPatch: input.diffPatch, | ||
| 275 | policy: input.policy, | ||
| 276 | changeRequestTemplate: input.changeRequestTemplate, | ||
| 277 | }); | ||
| 278 | |||
| 279 | const generated = yield* runCursorJson({ | ||
| 280 | operation: "generatePrContent", | ||
| 281 | prompt, | ||
| 282 | outputSchemaJson: outputSchema, | ||
| 283 | modelSelection: input.modelSelection, | ||
| 284 | }); | ||
| 285 | |||
| 286 | return { | ||
| 287 | title: sanitizePrTitle(generated.title), | ||
| 288 | body: generated.body.trim(), | ||
| 289 | }; | ||
| 290 | }); | ||
| 291 | |||
| 292 | const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = | ||
| 293 | Effect.fn("CursorTextGeneration.generateBranchName")(function* (input) { | ||
| 294 | const { prompt, outputSchema } = buildBranchNamePrompt({ | ||
| 295 | message: input.message, | ||
| 296 | attachments: input.attachments, | ||
| 297 | naming: input.naming, | ||
| 298 | }); | ||
| 299 | |||
| 300 | const generated = yield* runCursorJson({ | ||
| 301 | operation: "generateBranchName", | ||
| 302 | prompt, | ||
| 303 | outputSchemaJson: outputSchema, | ||
| 304 | modelSelection: input.modelSelection, | ||
| 305 | }); | ||
| 306 | |||
| 307 | return { | ||
| 308 | branch: formatGeneratedBranchName(generated.branch, input.naming), | ||
| 309 | }; | ||
| 310 | }); | ||
| 311 | |||
| 312 | const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = | ||
| 313 | Effect.fn("CursorTextGeneration.generateThreadTitle")(function* (input) { | ||
| 314 | const { prompt, outputSchema } = buildThreadTitlePrompt({ | ||
| 315 | message: input.message, | ||
| 316 | previousTitle: input.previousTitle, | ||
| 317 | linkedContext: input.linkedContext, | ||
| 318 | attachments: input.attachments, | ||
| 319 | }); | ||
| 320 | |||
| 321 | const generated = yield* runCursorJson({ | ||
| 322 | operation: "generateThreadTitle", | ||
| 323 | prompt, | ||
| 324 | outputSchemaJson: outputSchema, | ||
| 325 | modelSelection: input.modelSelection, | ||
| 326 | }); | ||
| 327 | |||
| 328 | return { | ||
| 329 | title: sanitizeThreadTitle(generated.title), | ||
| 330 | ...(generated.needsRefinement ? { needsRefinement: true } : {}), | ||
| 331 | } satisfies TextGeneration.ThreadTitleGenerationResult; | ||
| 332 | }); | ||
| 333 | |||
| 334 | return { | ||
| 335 | generateCommitMessage, | ||
| 336 | generatePrContent, | ||
| 337 | generateBranchName, | ||
| 338 | generateThreadTitle, | ||
| 339 | } satisfies TextGeneration.TextGeneration["Service"]; | ||
| 340 | }); | 204 | }); |
apps/server/src/textGeneration/GrokTextGeneration.ts+8-150| ... | @@ -6,24 +6,10 @@ import * as Schema from "effect/Schema"; | ... | @@ -6,24 +6,10 @@ import * as Schema from "effect/Schema"; |
| 6 | import { ChildProcessSpawner } from "effect/process"; | 6 | import { ChildProcessSpawner } from "effect/process"; |
| 7 | import type * as EffectAcpErrors from "effect-acp/errors"; | 7 | import type * as EffectAcpErrors from "effect-acp/errors"; |
| 8 | 8 | ||
| 9 | import { type GrokSettings, type ModelSelection } from "@t3tools/contracts"; | 9 | import { type GrokSettings, TextGenerationError } from "@t3tools/contracts"; |
| 10 | import { formatGeneratedBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
| 11 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | 10 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; |
| 12 | import { extractJsonObject } from "@t3tools/shared/schemaJson"; | ||
| 13 | 11 | ||
| 14 | import { TextGenerationError } from "@t3tools/contracts"; | 12 | import * as TextGenerationOperations from "./TextGenerationOperations.ts"; |
| 15 | import * as TextGeneration from "./TextGeneration.ts"; | ||
| 16 | import { | ||
| 17 | buildBranchNamePrompt, | ||
| 18 | buildCommitMessagePrompt, | ||
| 19 | buildPrContentPrompt, | ||
| 20 | buildThreadTitlePrompt, | ||
| 21 | } from "./TextGenerationPrompts.ts"; | ||
| 22 | import { | ||
| 23 | sanitizeCommitSubject, | ||
| 24 | sanitizePrTitle, | ||
| 25 | sanitizeThreadTitle, | ||
| 26 | } from "./TextGenerationUtils.ts"; | ||
| 27 | import { | 13 | import { |
| 28 | applyGrokAcpModelSelection, | 14 | applyGrokAcpModelSelection, |
| 29 | currentGrokModelIdFromSessionSetup, | 15 | currentGrokModelIdFromSessionSetup, |
| ... | @@ -43,24 +29,9 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi | ... | @@ -43,24 +29,9 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi |
| 43 | const crypto = yield* Crypto.Crypto; | 29 | const crypto = yield* Crypto.Crypto; |
| 44 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 30 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 45 | 31 | ||
| 46 | const runGrokJson = <S extends Schema.Top>({ | 32 | const runGrokJson: TextGenerationOperations.Runner = (request) => { |
| 47 | operation, | 33 | const { operation, cwd, prompt, modelSelection } = request; |
| 48 | cwd, | 34 | return Effect.gen(function* () { |
| 49 | prompt, | ||
| 50 | outputSchemaJson, | ||
| 51 | modelSelection, | ||
| 52 | }: { | ||
| 53 | operation: | ||
| 54 | | "generateCommitMessage" | ||
| 55 | | "generatePrContent" | ||
| 56 | | "generateBranchName" | ||
| 57 | | "generateThreadTitle"; | ||
| 58 | cwd: string; | ||
| 59 | prompt: string; | ||
| 60 | outputSchemaJson: S; | ||
| 61 | modelSelection: ModelSelection; | ||
| 62 | }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> => | ||
| 63 | Effect.gen(function* () { | ||
| 64 | const outputRef = yield* Ref.make(""); | 35 | const outputRef = yield* Ref.make(""); |
| 65 | const runtime = yield* makeGrokAcpRuntime({ | 36 | const runtime = yield* makeGrokAcpRuntime({ |
| 66 | grokSettings, | 37 | grokSettings, |
| ... | @@ -140,19 +111,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi | ... | @@ -140,19 +111,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi |
| 140 | }); | 111 | }); |
| 141 | } | 112 | } |
| 142 | 113 | ||
| 143 | const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); | 114 | return yield* TextGenerationOperations.decodeJsonReply(request, "Grok Agent", trimmed); |
| 144 | return yield* decodeOutput(extractJsonObject(trimmed)).pipe( | ||
| 145 | Effect.catchTags({ | ||
| 146 | SchemaError: (cause) => | ||
| 147 | Effect.fail( | ||
| 148 | new TextGenerationError({ | ||
| 149 | operation, | ||
| 150 | detail: "Grok Agent returned invalid structured output.", | ||
| 151 | cause, | ||
| 152 | }), | ||
| 153 | ), | ||
| 154 | }), | ||
| 155 | ); | ||
| 156 | }).pipe( | 115 | }).pipe( |
| 157 | Effect.mapError((cause) => | 116 | Effect.mapError((cause) => |
| 158 | isTextGenerationError(cause) | 117 | isTextGenerationError(cause) |
| ... | @@ -165,108 +124,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi | ... | @@ -165,108 +124,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi |
| 165 | ), | 124 | ), |
| 166 | Effect.scoped, | 125 | Effect.scoped, |
| 167 | ); | 126 | ); |
| 127 | }; | ||
| 168 | 128 | ||
| 169 | const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = | 129 | return TextGenerationOperations.fromRunner("GrokTextGeneration", runGrokJson); |
| 170 | Effect.fn("GrokTextGeneration.generateCommitMessage")(function* (input) { | ||
| 171 | const { prompt, outputSchema } = buildCommitMessagePrompt({ | ||
| 172 | branch: input.branch, | ||
| 173 | stagedSummary: input.stagedSummary, | ||
| 174 | stagedPatch: input.stagedPatch, | ||
| 175 | includeBranch: input.includeBranch === true, | ||
| 176 | policy: input.policy, | ||
| 177 | }); | ||
| 178 | |||
| 179 | const generated = yield* runGrokJson({ | ||
| 180 | operation: "generateCommitMessage", | ||
| 181 | cwd: input.cwd, | ||
| 182 | prompt, | ||
| 183 | outputSchemaJson: outputSchema, | ||
| 184 | modelSelection: input.modelSelection, | ||
| 185 | }); | ||
| 186 | |||
| 187 | return { | ||
| 188 | subject: sanitizeCommitSubject(generated.subject), | ||
| 189 | body: generated.body.trim(), | ||
| 190 | ...("branch" in generated && typeof generated.branch === "string" | ||
| 191 | ? { branch: sanitizeFeatureBranchName(generated.branch) } | ||
| 192 | : {}), | ||
| 193 | }; | ||
| 194 | }); | ||
| 195 | |||
| 196 | const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = | ||
| 197 | Effect.fn("GrokTextGeneration.generatePrContent")(function* (input) { | ||
| 198 | const { prompt, outputSchema } = buildPrContentPrompt({ | ||
| 199 | baseBranch: input.baseBranch, | ||
| 200 | headBranch: input.headBranch, | ||
| 201 | commitSummary: input.commitSummary, | ||
| 202 | diffSummary: input.diffSummary, | ||
| 203 | diffPatch: input.diffPatch, | ||
| 204 | policy: input.policy, | ||
| 205 | changeRequestTemplate: input.changeRequestTemplate, | ||
| 206 | }); | ||
| 207 | |||
| 208 | const generated = yield* runGrokJson({ | ||
| 209 | operation: "generatePrContent", | ||
| 210 | cwd: input.cwd, | ||
| 211 | prompt, | ||
| 212 | outputSchemaJson: outputSchema, | ||
| 213 | modelSelection: input.modelSelection, | ||
| 214 | }); | ||
| 215 | |||
| 216 | return { | ||
| 217 | title: sanitizePrTitle(generated.title), | ||
| 218 | body: generated.body.trim(), | ||
| 219 | }; | ||
| 220 | }); | ||
| 221 | |||
| 222 | const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = | ||
| 223 | Effect.fn("GrokTextGeneration.generateBranchName")(function* (input) { | ||
| 224 | const { prompt, outputSchema } = buildBranchNamePrompt({ | ||
| 225 | message: input.message, | ||
| 226 | attachments: input.attachments, | ||
| 227 | naming: input.naming, | ||
| 228 | }); | ||
| 229 | |||
| 230 | const generated = yield* runGrokJson({ | ||
| 231 | operation: "generateBranchName", | ||
| 232 | cwd: input.cwd, | ||
| 233 | prompt, | ||
| 234 | outputSchemaJson: outputSchema, | ||
| 235 | modelSelection: input.modelSelection, | ||
| 236 | }); | ||
| 237 | |||
| 238 | return { | ||
| 239 | branch: formatGeneratedBranchName(generated.branch, input.naming), | ||
| 240 | }; | ||
| 241 | }); | ||
| 242 | |||
| 243 | const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = | ||
| 244 | Effect.fn("GrokTextGeneration.generateThreadTitle")(function* (input) { | ||
| 245 | const { prompt, outputSchema } = buildThreadTitlePrompt({ | ||
| 246 | message: input.message, | ||
| 247 | previousTitle: input.previousTitle, | ||
| 248 | linkedContext: input.linkedContext, | ||
| 249 | attachments: input.attachments, | ||
| 250 | }); | ||
| 251 | |||
| 252 | const generated = yield* runGrokJson({ | ||
| 253 | operation: "generateThreadTitle", | ||
| 254 | cwd: input.cwd, | ||
| 255 | prompt, | ||
| 256 | outputSchemaJson: outputSchema, | ||
| 257 | modelSelection: input.modelSelection, | ||
| 258 | }); | ||
| 259 | |||
| 260 | return { | ||
| 261 | title: sanitizeThreadTitle(generated.title), | ||
| 262 | ...(generated.needsRefinement ? { needsRefinement: true } : {}), | ||
| 263 | } satisfies TextGeneration.ThreadTitleGenerationResult; | ||
| 264 | }); | ||
| 265 | |||
| 266 | return { | ||
| 267 | generateCommitMessage, | ||
| 268 | generatePrContent, | ||
| 269 | generateBranchName, | ||
| 270 | generateThreadTitle, | ||
| 271 | } satisfies TextGeneration.TextGeneration["Service"]; | ||
| 272 | }); | 130 | }); |
apps/server/src/textGeneration/OpenCode2TextGeneration.ts+6-21| ... | @@ -9,7 +9,6 @@ | ... | @@ -9,7 +9,6 @@ |
| 9 | import { AbsolutePath, Location, Model, Provider, Session } from "@opencode/client/effect"; | 9 | import { AbsolutePath, Location, Model, Provider, Session } from "@opencode/client/effect"; |
| 10 | import { TextGenerationError } from "@t3tools/contracts"; | 10 | import { TextGenerationError } from "@t3tools/contracts"; |
| 11 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | 11 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; |
| 12 | import { extractJsonObject } from "@t3tools/shared/schemaJson"; | ||
| 13 | import * as Deferred from "effect/Deferred"; | 12 | import * as Deferred from "effect/Deferred"; |
| 14 | import * as Effect from "effect/Effect"; | 13 | import * as Effect from "effect/Effect"; |
| 15 | import * as Schema from "effect/Schema"; | 14 | import * as Schema from "effect/Schema"; |
| ... | @@ -20,7 +19,7 @@ import { resolveAttachmentPath } from "../attachmentStore.ts"; | ... | @@ -20,7 +19,7 @@ import { resolveAttachmentPath } from "../attachmentStore.ts"; |
| 20 | import type { OpenCode2Connection } from "../provider/opencode2/OpenCode2Server.ts"; | 19 | import type { OpenCode2Connection } from "../provider/opencode2/OpenCode2Server.ts"; |
| 21 | import * as OpenCode2Server from "../provider/opencode2/OpenCode2Server.ts"; | 20 | import * as OpenCode2Server from "../provider/opencode2/OpenCode2Server.ts"; |
| 22 | import { parseOpenCodeModelSlug } from "../provider/opencodeRuntime.ts"; | 21 | import { parseOpenCodeModelSlug } from "../provider/opencodeRuntime.ts"; |
| 23 | import { makeOpenCodeOperations, type OpenCodeJsonRunner } from "./OpenCodeTextGeneration.ts"; | 22 | import * as TextGenerationOperations from "./TextGenerationOperations.ts"; |
| 24 | 23 | ||
| 25 | const isTextGenerationError = Schema.is(TextGenerationError); | 24 | const isTextGenerationError = Schema.is(TextGenerationError); |
| 26 | 25 | ||
| ... | @@ -42,7 +41,7 @@ type Outcome = | ... | @@ -42,7 +41,7 @@ type Outcome = |
| 42 | 41 | ||
| 43 | const runOnServer = ( | 42 | const runOnServer = ( |
| 44 | connection: OpenCode2Connection, | 43 | connection: OpenCode2Connection, |
| 45 | input: Parameters<OpenCodeJsonRunner>[0], | 44 | input: TextGenerationOperations.Request<Schema.Top>, |
| 46 | attachmentsDir: string, | 45 | attachmentsDir: string, |
| 47 | ) => | 46 | ) => |
| 48 | Effect.gen(function* () { | 47 | Effect.gen(function* () { |
| ... | @@ -188,10 +187,8 @@ const runOnServer = ( | ... | @@ -188,10 +187,8 @@ const runOnServer = ( |
| 188 | export const make = Effect.fn("OpenCode2TextGeneration.make")(function* () { | 187 | export const make = Effect.fn("OpenCode2TextGeneration.make")(function* () { |
| 189 | const server = yield* OpenCode2Server.OpenCode2Server; | 188 | const server = yield* OpenCode2Server.OpenCode2Server; |
| 190 | const { attachmentsDir } = yield* ServerConfig.ServerConfig; | 189 | const { attachmentsDir } = yield* ServerConfig.ServerConfig; |
| 191 | const run: OpenCodeJsonRunner = (input) => { | 190 | const run: TextGenerationOperations.Runner = (input) => |
| 192 | // Each operation has its own output schema, as in 1.x. | 191 | server |
| 193 | const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson)); | ||
| 194 | return server | ||
| 195 | .withConnection((connection) => runOnServer(connection, input, attachmentsDir)) | 192 | .withConnection((connection) => runOnServer(connection, input, attachmentsDir)) |
| 196 | .pipe( | 193 | .pipe( |
| 197 | Effect.mapError((cause) => | 194 | Effect.mapError((cause) => |
| ... | @@ -203,19 +200,7 @@ export const make = Effect.fn("OpenCode2TextGeneration.make")(function* () { | ... | @@ -203,19 +200,7 @@ export const make = Effect.fn("OpenCode2TextGeneration.make")(function* () { |
| 203 | cause, | 200 | cause, |
| 204 | }), | 201 | }), |
| 205 | ), | 202 | ), |
| 206 | Effect.flatMap((raw) => | 203 | Effect.flatMap((raw) => TextGenerationOperations.decodeJsonReply(input, "OpenCode", raw)), |
| 207 | decodeOutput(extractJsonObject(raw)).pipe( | ||
| 208 | Effect.mapError( | ||
| 209 | (cause) => | ||
| 210 | new TextGenerationError({ | ||
| 211 | operation: input.operation, | ||
| 212 | detail: "OpenCode returned invalid structured output.", | ||
| 213 | cause, | ||
| 214 | }), | ||
| 215 | ), | ||
| 216 | ), | ||
| 217 | ), | ||
| 218 | ); | 204 | ); |
| 219 | }; | 205 | return TextGenerationOperations.fromRunner("OpenCode2TextGeneration", run); |
| 220 | return makeOpenCodeOperations(run); | ||
| 221 | }); | 206 | }); |
apps/server/src/textGeneration/OpenCodeTextGeneration.ts+7-162| ... | @@ -1,31 +1,12 @@ | ... | @@ -1,31 +1,12 @@ |
| 1 | import * as Effect from "effect/Effect"; | 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { NonNegativeInt, TextGenerationError, type OpenCodeSettings } from "@t3tools/contracts"; |
| 5 | NonNegativeInt, | ||
| 6 | TextGenerationError, | ||
| 7 | type ChatAttachment, | ||
| 8 | type ModelSelection, | ||
| 9 | type OpenCodeSettings, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import { formatGeneratedBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
| 12 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | 5 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; |
| 13 | import { extractJsonObject } from "@t3tools/shared/schemaJson"; | ||
| 14 | 6 | ||
| 15 | import * as ServerConfig from "../config.ts"; | 7 | import * as ServerConfig from "../config.ts"; |
| 16 | import { resolveAttachmentPath } from "../attachmentStore.ts"; | 8 | import { resolveAttachmentPath } from "../attachmentStore.ts"; |
| 17 | import { | 9 | import * as TextGenerationOperations from "./TextGenerationOperations.ts"; |
| 18 | buildBranchNamePrompt, | ||
| 19 | buildCommitMessagePrompt, | ||
| 20 | buildPrContentPrompt, | ||
| 21 | buildThreadTitlePrompt, | ||
| 22 | } from "./TextGenerationPrompts.ts"; | ||
| 23 | import * as TextGeneration from "./TextGeneration.ts"; | ||
| 24 | import { | ||
| 25 | sanitizeCommitSubject, | ||
| 26 | sanitizePrTitle, | ||
| 27 | sanitizeThreadTitle, | ||
| 28 | } from "./TextGenerationUtils.ts"; | ||
| 29 | import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; | 10 | import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; |
| 30 | import * as OpenCodeServerOwner from "../provider/OpenCodeServerOwner.ts"; | 11 | import * as OpenCodeServerOwner from "../provider/OpenCodeServerOwner.ts"; |
| 31 | 12 | ||
| ... | @@ -36,8 +17,6 @@ const OpenCodeTextGenerationOperation = Schema.Literals([ | ... | @@ -36,8 +17,6 @@ const OpenCodeTextGenerationOperation = Schema.Literals([ |
| 36 | "generateThreadTitle", | 17 | "generateThreadTitle", |
| 37 | ]); | 18 | ]); |
| 38 | 19 | ||
| 39 | type OpenCodeTextGenerationOperation = typeof OpenCodeTextGenerationOperation.Type; | ||
| 40 | |||
| 41 | const openCodeTextGenerationErrorContext = { | 20 | const openCodeTextGenerationErrorContext = { |
| 42 | operation: OpenCodeTextGenerationOperation, | 21 | operation: OpenCodeTextGenerationOperation, |
| 43 | cwd: Schema.String, | 22 | cwd: Schema.String, |
| ... | @@ -177,14 +156,9 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" | ... | @@ -177,14 +156,9 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" |
| 177 | const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; | 156 | const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; |
| 178 | const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; | 157 | const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; |
| 179 | 158 | ||
| 180 | const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* <S extends Schema.Top>(input: { | 159 | const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* <S extends Schema.Top>( |
| 181 | readonly operation: OpenCodeTextGenerationOperation; | 160 | input: TextGenerationOperations.Request<S>, |
| 182 | readonly cwd: string; | 161 | ) { |
| 183 | readonly prompt: string; | ||
| 184 | readonly outputSchemaJson: S; | ||
| 185 | readonly modelSelection: ModelSelection; | ||
| 186 | readonly attachments?: ReadonlyArray<ChatAttachment> | undefined; | ||
| 187 | }) { | ||
| 188 | const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model); | 162 | const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model); |
| 189 | if (!parsedModel) { | 163 | if (!parsedModel) { |
| 190 | return yield* new TextGenerationError({ | 164 | return yield* new TextGenerationError({ |
| ... | @@ -344,137 +318,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" | ... | @@ -344,137 +318,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" |
| 344 | }), | 318 | }), |
| 345 | ); | 319 | ); |
| 346 | 320 | ||
| 347 | const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson)); | 321 | return yield* TextGenerationOperations.decodeJsonReply(input, "OpenCode", rawOutput); |
| 348 | return yield* decodeOutput(extractJsonObject(rawOutput)).pipe( | ||
| 349 | Effect.catchTags({ | ||
| 350 | SchemaError: (cause) => | ||
| 351 | Effect.fail( | ||
| 352 | new TextGenerationError({ | ||
| 353 | operation: input.operation, | ||
| 354 | detail: "OpenCode returned invalid structured output.", | ||
| 355 | cause, | ||
| 356 | }), | ||
| 357 | ), | ||
| 358 | }), | ||
| 359 | ); | ||
| 360 | }); | 322 | }); |
| 361 | 323 | ||
| 362 | return makeOpenCodeOperations(runOpenCodeJson); | 324 | return TextGenerationOperations.fromRunner("OpenCodeTextGeneration", runOpenCodeJson); |
| 363 | }); | 325 | }); |
| 364 | |||
| 365 | /** Runs one prompt and decodes its reply as `outputSchemaJson`, for either OpenCode runtime. */ | ||
| 366 | export type OpenCodeJsonRunner = <S extends Schema.Top>(input: { | ||
| 367 | readonly operation: OpenCodeTextGenerationOperation; | ||
| 368 | readonly cwd: string; | ||
| 369 | readonly prompt: string; | ||
| 370 | readonly outputSchemaJson: S; | ||
| 371 | readonly modelSelection: ModelSelection; | ||
| 372 | readonly attachments?: ReadonlyArray<ChatAttachment> | undefined; | ||
| 373 | }) => Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]>; | ||
| 374 | |||
| 375 | /** The four text generation operations over an OpenCode prompt runner. */ | ||
| 376 | export function makeOpenCodeOperations( | ||
| 377 | runOpenCodeJson: OpenCodeJsonRunner, | ||
| 378 | ): TextGeneration.TextGeneration["Service"] { | ||
| 379 | const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = | ||
| 380 | Effect.fn("OpenCodeTextGeneration.generateCommitMessage")(function* (input) { | ||
| 381 | const { prompt, outputSchema } = buildCommitMessagePrompt({ | ||
| 382 | branch: input.branch, | ||
| 383 | stagedSummary: input.stagedSummary, | ||
| 384 | stagedPatch: input.stagedPatch, | ||
| 385 | includeBranch: input.includeBranch === true, | ||
| 386 | policy: input.policy, | ||
| 387 | }); | ||
| 388 | const generated = yield* runOpenCodeJson({ | ||
| 389 | operation: "generateCommitMessage", | ||
| 390 | cwd: input.cwd, | ||
| 391 | prompt, | ||
| 392 | outputSchemaJson: outputSchema, | ||
| 393 | modelSelection: input.modelSelection, | ||
| 394 | }); | ||
| 395 | |||
| 396 | return { | ||
| 397 | subject: sanitizeCommitSubject(generated.subject), | ||
| 398 | body: generated.body.trim(), | ||
| 399 | ...("branch" in generated && typeof generated.branch === "string" | ||
| 400 | ? { branch: sanitizeFeatureBranchName(generated.branch) } | ||
| 401 | : {}), | ||
| 402 | }; | ||
| 403 | }); | ||
| 404 | |||
| 405 | const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = | ||
| 406 | Effect.fn("OpenCodeTextGeneration.generatePrContent")(function* (input) { | ||
| 407 | const { prompt, outputSchema } = buildPrContentPrompt({ | ||
| 408 | baseBranch: input.baseBranch, | ||
| 409 | headBranch: input.headBranch, | ||
| 410 | commitSummary: input.commitSummary, | ||
| 411 | diffSummary: input.diffSummary, | ||
| 412 | diffPatch: input.diffPatch, | ||
| 413 | policy: input.policy, | ||
| 414 | changeRequestTemplate: input.changeRequestTemplate, | ||
| 415 | }); | ||
| 416 | const generated = yield* runOpenCodeJson({ | ||
| 417 | operation: "generatePrContent", | ||
| 418 | cwd: input.cwd, | ||
| 419 | prompt, | ||
| 420 | outputSchemaJson: outputSchema, | ||
| 421 | modelSelection: input.modelSelection, | ||
| 422 | }); | ||
| 423 | |||
| 424 | return { | ||
| 425 | title: sanitizePrTitle(generated.title), | ||
| 426 | body: generated.body.trim(), | ||
| 427 | }; | ||
| 428 | }); | ||
| 429 | |||
| 430 | const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = | ||
| 431 | Effect.fn("OpenCodeTextGeneration.generateBranchName")(function* (input) { | ||
| 432 | const { prompt, outputSchema } = buildBranchNamePrompt({ | ||
| 433 | message: input.message, | ||
| 434 | attachments: input.attachments, | ||
| 435 | naming: input.naming, | ||
| 436 | }); | ||
| 437 | const generated = yield* runOpenCodeJson({ | ||
| 438 | operation: "generateBranchName", | ||
| 439 | cwd: input.cwd, | ||
| 440 | prompt, | ||
| 441 | outputSchemaJson: outputSchema, | ||
| 442 | modelSelection: input.modelSelection, | ||
| 443 | attachments: input.attachments, | ||
| 444 | }); | ||
| 445 | |||
| 446 | return { | ||
| 447 | branch: formatGeneratedBranchName(generated.branch, input.naming), | ||
| 448 | }; | ||
| 449 | }); | ||
| 450 | |||
| 451 | const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = | ||
| 452 | Effect.fn("OpenCodeTextGeneration.generateThreadTitle")(function* (input) { | ||
| 453 | const { prompt, outputSchema } = buildThreadTitlePrompt({ | ||
| 454 | message: input.message, | ||
| 455 | previousTitle: input.previousTitle, | ||
| 456 | linkedContext: input.linkedContext, | ||
| 457 | attachments: input.attachments, | ||
| 458 | }); | ||
| 459 | const generated = yield* runOpenCodeJson({ | ||
| 460 | operation: "generateThreadTitle", | ||
| 461 | cwd: input.cwd, | ||
| 462 | prompt, | ||
| 463 | outputSchemaJson: outputSchema, | ||
| 464 | modelSelection: input.modelSelection, | ||
| 465 | attachments: input.attachments, | ||
| 466 | }); | ||
| 467 | |||
| 468 | return { | ||
| 469 | title: sanitizeThreadTitle(generated.title), | ||
| 470 | ...(generated.needsRefinement ? { needsRefinement: true } : {}), | ||
| 471 | }; | ||
| 472 | }); | ||
| 473 | |||
| 474 | return { | ||
| 475 | generateCommitMessage, | ||
| 476 | generatePrContent, | ||
| 477 | generateBranchName, | ||
| 478 | generateThreadTitle, | ||
| 479 | } satisfies TextGeneration.TextGeneration["Service"]; | ||
| 480 | } |
apps/server/src/textGeneration/PiTextGeneration.test.ts created+89| ... | @@ -0,0 +1,89 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { ProviderInstanceId } from "@t3tools/contracts"; | ||
| 3 | import { createModelSelection } from "@t3tools/shared/model"; | ||
| 4 | import * as Cause from "effect/Cause"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Queue from "effect/Queue"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | import * as Sink from "effect/Sink"; | ||
| 9 | import * as Stream from "effect/Stream"; | ||
| 10 | import { ChildProcessSpawner } from "effect/process"; | ||
| 11 | |||
| 12 | import { makePiTextGeneration } from "./PiTextGeneration.ts"; | ||
| 13 | |||
| 14 | const decodeJsonLine = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 15 | const encodeJsonLine = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 16 | |||
| 17 | /** | ||
| 18 | * In-process `pi --mode rpc` that answers one prompt with `reply`. Every | ||
| 19 | * record written to its stdin lands in `received`. | ||
| 20 | */ | ||
| 21 | const makeFakePi = (reply: string) => | ||
| 22 | Effect.gen(function* () { | ||
| 23 | const stdout = yield* Queue.unbounded<Uint8Array, Cause.Done>(); | ||
| 24 | const received: Array<Record<string, unknown>> = []; | ||
| 25 | const emit = (record: Record<string, unknown>) => | ||
| 26 | Queue.offer(stdout, new TextEncoder().encode(`${encodeJsonLine(record)}\n`)); | ||
| 27 | let buffered = ""; | ||
| 28 | const onStdin = (chunk: Uint8Array) => | ||
| 29 | Effect.gen(function* () { | ||
| 30 | buffered += new TextDecoder().decode(chunk); | ||
| 31 | const lines = buffered.split("\n"); | ||
| 32 | buffered = lines.pop() ?? ""; | ||
| 33 | for (const line of lines.filter((candidate) => candidate.length > 0)) { | ||
| 34 | const record = decodeJsonLine(line) as Record<string, unknown>; | ||
| 35 | received.push(record); | ||
| 36 | const response = { type: "response", id: record["id"], command: record["type"] }; | ||
| 37 | if (record["type"] === "prompt") { | ||
| 38 | yield* emit({ ...response, success: true }); | ||
| 39 | yield* emit({ type: "agent_settled" }); | ||
| 40 | } else if (record["type"] === "get_last_assistant_text") { | ||
| 41 | yield* emit({ ...response, success: true, data: { text: reply } }); | ||
| 42 | } else { | ||
| 43 | yield* emit({ ...response, success: true }); | ||
| 44 | } | ||
| 45 | } | ||
| 46 | }); | ||
| 47 | const spawner = ChildProcessSpawner.make(() => | ||
| 48 | Effect.succeed( | ||
| 49 | ChildProcessSpawner.makeHandle({ | ||
| 50 | // Outside the valid pid range, so PiRpc's process-group kill never lands. | ||
| 51 | pid: ChildProcessSpawner.ProcessId(999_999_999), | ||
| 52 | exitCode: Effect.never, | ||
| 53 | isRunning: Effect.succeed(true), | ||
| 54 | kill: () => Effect.void, | ||
| 55 | unref: Effect.succeed(Effect.void), | ||
| 56 | stdin: Sink.forEach(onStdin), | ||
| 57 | stdout: Stream.fromQueue(stdout), | ||
| 58 | stderr: Stream.empty, | ||
| 59 | all: Stream.empty, | ||
| 60 | getInputFd: () => Sink.drain, | ||
| 61 | getOutputFd: () => Stream.empty, | ||
| 62 | }), | ||
| 63 | ), | ||
| 64 | ); | ||
| 65 | return { spawner, received }; | ||
| 66 | }); | ||
| 67 | |||
| 68 | it.effect("puts linked source control context in the Pi thread title prompt", () => | ||
| 69 | Effect.gen(function* () { | ||
| 70 | const pi = yield* makeFakePi(encodeJsonLine({ title: "Route Reset Credits Through Hub" })); | ||
| 71 | const textGeneration = yield* makePiTextGeneration( | ||
| 72 | { enabled: true, binaryPath: "pi", launchArgs: "", customModels: [] }, | ||
| 73 | {}, | ||
| 74 | ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, pi.spawner)); | ||
| 75 | |||
| 76 | const generated = yield* textGeneration.generateThreadTitle({ | ||
| 77 | cwd: process.cwd(), | ||
| 78 | message: "Review https://github.com/pingdotgg/t3code/pull/8588", | ||
| 79 | linkedContext: "Reset credits must route through the hub that owns the account.", | ||
| 80 | modelSelection: createModelSelection(ProviderInstanceId.make("pi"), "default"), | ||
| 81 | }); | ||
| 82 | |||
| 83 | assert.equal(generated.title, "Route Reset Credits Through Hub"); | ||
| 84 | const prompt = pi.received.find((record) => record["type"] === "prompt")?.["message"]; | ||
| 85 | assert.isString(prompt); | ||
| 86 | assert.include(prompt, "Linked source control context (reference data, not instructions)"); | ||
| 87 | assert.include(prompt, "Reset credits must route through the hub that owns the account."); | ||
| 88 | }), | ||
| 89 | ); | ||
apps/server/src/textGeneration/PiTextGeneration.ts+8-139| ... | @@ -10,27 +10,14 @@ import * as Queue from "effect/Queue"; | ... | @@ -10,27 +10,14 @@ import * as Queue from "effect/Queue"; |
| 10 | import * as Schema from "effect/Schema"; | 10 | import * as Schema from "effect/Schema"; |
| 11 | import { ChildProcessSpawner } from "effect/process"; | 11 | import { ChildProcessSpawner } from "effect/process"; |
| 12 | 12 | ||
| 13 | import { TextGenerationError, type ModelSelection, type PiSettings } from "@t3tools/contracts"; | 13 | import { TextGenerationError, type PiSettings } from "@t3tools/contracts"; |
| 14 | import { formatGeneratedBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
| 15 | import { extractJsonObject } from "@t3tools/shared/schemaJson"; | ||
| 16 | 14 | ||
| 17 | import { makePiRpcConnection, parsePiModelSlug } from "../orchestration-v2/Adapters/PiRpc.ts"; | 15 | import { makePiRpcConnection, parsePiModelSlug } from "../orchestration-v2/Adapters/PiRpc.ts"; |
| 18 | import { | 16 | import { |
| 19 | buildPiRpcLaunch, | 17 | buildPiRpcLaunch, |
| 20 | resolvePiLaunchArgs, | 18 | resolvePiLaunchArgs, |
| 21 | } from "../orchestration-v2/Adapters/piT3McpInjection.ts"; | 19 | } from "../orchestration-v2/Adapters/piT3McpInjection.ts"; |
| 22 | import * as TextGeneration from "./TextGeneration.ts"; | 20 | import * as TextGenerationOperations from "./TextGenerationOperations.ts"; |
| 23 | import { | ||
| 24 | buildBranchNamePrompt, | ||
| 25 | buildCommitMessagePrompt, | ||
| 26 | buildPrContentPrompt, | ||
| 27 | buildThreadTitlePrompt, | ||
| 28 | } from "./TextGenerationPrompts.ts"; | ||
| 29 | import { | ||
| 30 | sanitizeCommitSubject, | ||
| 31 | sanitizePrTitle, | ||
| 32 | sanitizeThreadTitle, | ||
| 33 | } from "./TextGenerationUtils.ts"; | ||
| 34 | 21 | ||
| 35 | const PI_TIMEOUT_MS = 180_000; | 22 | const PI_TIMEOUT_MS = 180_000; |
| 36 | 23 | ||
| ... | @@ -42,24 +29,9 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* | ... | @@ -42,24 +29,9 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* |
| 42 | ) { | 29 | ) { |
| 43 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 30 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 44 | 31 | ||
| 45 | const runPiJson = <S extends Schema.Top>({ | 32 | const runPiJson: TextGenerationOperations.Runner = (request) => { |
| 46 | operation, | 33 | const { operation, cwd, prompt, modelSelection } = request; |
| 47 | cwd, | 34 | return Effect.gen(function* () { |
| 48 | prompt, | ||
| 49 | outputSchemaJson, | ||
| 50 | modelSelection, | ||
| 51 | }: { | ||
| 52 | operation: | ||
| 53 | | "generateCommitMessage" | ||
| 54 | | "generatePrContent" | ||
| 55 | | "generateBranchName" | ||
| 56 | | "generateThreadTitle"; | ||
| 57 | cwd: string; | ||
| 58 | prompt: string; | ||
| 59 | outputSchemaJson: S; | ||
| 60 | modelSelection: ModelSelection; | ||
| 61 | }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> => | ||
| 62 | Effect.gen(function* () { | ||
| 63 | const resolvedLaunchArgs = resolvePiLaunchArgs(piSettings.launchArgs); | 35 | const resolvedLaunchArgs = resolvePiLaunchArgs(piSettings.launchArgs); |
| 64 | if (!resolvedLaunchArgs.ok) { | 36 | if (!resolvedLaunchArgs.ok) { |
| 65 | return yield* new TextGenerationError({ | 37 | return yield* new TextGenerationError({ |
| ... | @@ -126,19 +98,7 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* | ... | @@ -126,19 +98,7 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* |
| 126 | detail: "Pi returned empty output.", | 98 | detail: "Pi returned empty output.", |
| 127 | }); | 99 | }); |
| 128 | } | 100 | } |
| 129 | const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); | 101 | return yield* TextGenerationOperations.decodeJsonReply(request, "Pi", text); |
| 130 | return yield* decodeOutput(extractJsonObject(text)).pipe( | ||
| 131 | Effect.catchTags({ | ||
| 132 | SchemaError: (cause) => | ||
| 133 | Effect.fail( | ||
| 134 | new TextGenerationError({ | ||
| 135 | operation, | ||
| 136 | detail: "Pi returned invalid structured output.", | ||
| 137 | cause, | ||
| 138 | }), | ||
| 139 | ), | ||
| 140 | }), | ||
| 141 | ); | ||
| 142 | }).pipe( | 102 | }).pipe( |
| 143 | Effect.timeoutOption(PI_TIMEOUT_MS), | 103 | Effect.timeoutOption(PI_TIMEOUT_MS), |
| 144 | Effect.flatMap( | 104 | Effect.flatMap( |
| ... | @@ -159,98 +119,7 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* | ... | @@ -159,98 +119,7 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* |
| 159 | ), | 119 | ), |
| 160 | Effect.scoped, | 120 | Effect.scoped, |
| 161 | ); | 121 | ); |
| 122 | }; | ||
| 162 | 123 | ||
| 163 | const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = | 124 | return TextGenerationOperations.fromRunner("PiTextGeneration", runPiJson); |
| 164 | Effect.fn("PiTextGeneration.generateCommitMessage")(function* (input) { | ||
| 165 | const { prompt, outputSchema } = buildCommitMessagePrompt({ | ||
| 166 | branch: input.branch, | ||
| 167 | stagedSummary: input.stagedSummary, | ||
| 168 | stagedPatch: input.stagedPatch, | ||
| 169 | includeBranch: input.includeBranch === true, | ||
| 170 | policy: input.policy, | ||
| 171 | }); | ||
| 172 | const generated = yield* runPiJson({ | ||
| 173 | operation: "generateCommitMessage", | ||
| 174 | cwd: input.cwd, | ||
| 175 | prompt, | ||
| 176 | outputSchemaJson: outputSchema, | ||
| 177 | modelSelection: input.modelSelection, | ||
| 178 | }); | ||
| 179 | return { | ||
| 180 | subject: sanitizeCommitSubject(generated.subject), | ||
| 181 | body: generated.body.trim(), | ||
| 182 | ...("branch" in generated && typeof generated.branch === "string" | ||
| 183 | ? { branch: sanitizeFeatureBranchName(generated.branch) } | ||
| 184 | : {}), | ||
| 185 | }; | ||
| 186 | }); | ||
| 187 | |||
| 188 | const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = | ||
| 189 | Effect.fn("PiTextGeneration.generatePrContent")(function* (input) { | ||
| 190 | const { prompt, outputSchema } = buildPrContentPrompt({ | ||
| 191 | baseBranch: input.baseBranch, | ||
| 192 | headBranch: input.headBranch, | ||
| 193 | commitSummary: input.commitSummary, | ||
| 194 | diffSummary: input.diffSummary, | ||
| 195 | diffPatch: input.diffPatch, | ||
| 196 | policy: input.policy, | ||
| 197 | changeRequestTemplate: input.changeRequestTemplate, | ||
| 198 | }); | ||
| 199 | const generated = yield* runPiJson({ | ||
| 200 | operation: "generatePrContent", | ||
| 201 | cwd: input.cwd, | ||
| 202 | prompt, | ||
| 203 | outputSchemaJson: outputSchema, | ||
| 204 | modelSelection: input.modelSelection, | ||
| 205 | }); | ||
| 206 | return { | ||
| 207 | title: sanitizePrTitle(generated.title), | ||
| 208 | body: generated.body.trim(), | ||
| 209 | }; | ||
| 210 | }); | ||
| 211 | |||
| 212 | const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = | ||
| 213 | Effect.fn("PiTextGeneration.generateBranchName")(function* (input) { | ||
| 214 | const { prompt, outputSchema } = buildBranchNamePrompt({ | ||
| 215 | message: input.message, | ||
| 216 | attachments: input.attachments, | ||
| 217 | naming: input.naming, | ||
| 218 | }); | ||
| 219 | const generated = yield* runPiJson({ | ||
| 220 | operation: "generateBranchName", | ||
| 221 | cwd: input.cwd, | ||
| 222 | prompt, | ||
| 223 | outputSchemaJson: outputSchema, | ||
| 224 | modelSelection: input.modelSelection, | ||
| 225 | }); | ||
| 226 | return { | ||
| 227 | branch: formatGeneratedBranchName(generated.branch, input.naming), | ||
| 228 | }; | ||
| 229 | }); | ||
| 230 | |||
| 231 | const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = | ||
| 232 | Effect.fn("PiTextGeneration.generateThreadTitle")(function* (input) { | ||
| 233 | const { prompt, outputSchema } = buildThreadTitlePrompt({ | ||
| 234 | message: input.message, | ||
| 235 | previousTitle: input.previousTitle, | ||
| 236 | attachments: input.attachments, | ||
| 237 | }); | ||
| 238 | const generated = yield* runPiJson({ | ||
| 239 | operation: "generateThreadTitle", | ||
| 240 | cwd: input.cwd, | ||
| 241 | prompt, | ||
| 242 | outputSchemaJson: outputSchema, | ||
| 243 | modelSelection: input.modelSelection, | ||
| 244 | }); | ||
| 245 | return { | ||
| 246 | title: sanitizeThreadTitle(generated.title), | ||
| 247 | } satisfies TextGeneration.ThreadTitleGenerationResult; | ||
| 248 | }); | ||
| 249 | |||
| 250 | return { | ||
| 251 | generateCommitMessage, | ||
| 252 | generatePrContent, | ||
| 253 | generateBranchName, | ||
| 254 | generateThreadTitle, | ||
| 255 | } satisfies TextGeneration.TextGeneration["Service"]; | ||
| 256 | }); | 125 | }); |
apps/server/src/textGeneration/TextGenerationOperations.ts created+153| ... | @@ -0,0 +1,153 @@ | ||
| 1 | /** | ||
| 2 | * The text generation operations every provider shares. A provider supplies a | ||
| 3 | * runner that sends one prompt and decodes the reply; prompts, linked source | ||
| 4 | * control context, and output sanitizing live here so providers cannot drift | ||
| 5 | * on them. | ||
| 6 | * | ||
| 7 | * @module textGeneration/TextGenerationOperations | ||
| 8 | */ | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Schema from "effect/Schema"; | ||
| 11 | |||
| 12 | import { type ChatAttachment, type ModelSelection, TextGenerationError } from "@t3tools/contracts"; | ||
| 13 | import { formatGeneratedBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
| 14 | import { extractJsonObject } from "@t3tools/shared/schemaJson"; | ||
| 15 | |||
| 16 | import type * as TextGeneration from "./TextGeneration.ts"; | ||
| 17 | import { | ||
| 18 | buildBranchNamePrompt, | ||
| 19 | buildCommitMessagePrompt, | ||
| 20 | buildPrContentPrompt, | ||
| 21 | buildThreadTitlePrompt, | ||
| 22 | } from "./TextGenerationPrompts.ts"; | ||
| 23 | import { | ||
| 24 | sanitizeCommitSubject, | ||
| 25 | sanitizePrTitle, | ||
| 26 | sanitizeThreadTitle, | ||
| 27 | } from "./TextGenerationUtils.ts"; | ||
| 28 | |||
| 29 | export type Operation = keyof TextGeneration.TextGeneration["Service"]; | ||
| 30 | |||
| 31 | /** One prompt for a provider to run. */ | ||
| 32 | export interface Request<S extends Schema.Top> { | ||
| 33 | readonly operation: Operation; | ||
| 34 | readonly cwd: string; | ||
| 35 | readonly prompt: string; | ||
| 36 | readonly outputSchema: S; | ||
| 37 | readonly modelSelection: ModelSelection; | ||
| 38 | /** The user's attachments, already listed in the prompt. Providers that accept images send them. */ | ||
| 39 | readonly attachments?: ReadonlyArray<ChatAttachment> | undefined; | ||
| 40 | } | ||
| 41 | |||
| 42 | /** Runs one request and decodes the reply as its `outputSchema`. */ | ||
| 43 | export type Runner = <S extends Schema.Top>( | ||
| 44 | request: Request<S>, | ||
| 45 | ) => Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]>; | ||
| 46 | |||
| 47 | /** Decodes the JSON object in a text reply, which models often wrap in prose. */ | ||
| 48 | export const decodeJsonReply = <S extends Schema.Top>( | ||
| 49 | request: Pick<Request<S>, "operation" | "outputSchema">, | ||
| 50 | provider: string, | ||
| 51 | text: string, | ||
| 52 | ) => { | ||
| 53 | // Each request builds its own output schema, so the decoder cannot be hoisted. | ||
| 54 | const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(request.outputSchema)); | ||
| 55 | return decodeOutput(extractJsonObject(text)).pipe( | ||
| 56 | Effect.mapError( | ||
| 57 | (cause) => | ||
| 58 | new TextGenerationError({ | ||
| 59 | operation: request.operation, | ||
| 60 | detail: `${provider} returned invalid structured output.`, | ||
| 61 | cause, | ||
| 62 | }), | ||
| 63 | ), | ||
| 64 | ); | ||
| 65 | }; | ||
| 66 | |||
| 67 | /** The text generation service over `run`. `name` prefixes each operation's span. */ | ||
| 68 | export function fromRunner(name: string, run: Runner): TextGeneration.TextGeneration["Service"] { | ||
| 69 | const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = | ||
| 70 | Effect.fn(`${name}.generateCommitMessage`)(function* (input) { | ||
| 71 | const generated = yield* run({ | ||
| 72 | operation: "generateCommitMessage", | ||
| 73 | cwd: input.cwd, | ||
| 74 | modelSelection: input.modelSelection, | ||
| 75 | ...buildCommitMessagePrompt({ | ||
| 76 | branch: input.branch, | ||
| 77 | stagedSummary: input.stagedSummary, | ||
| 78 | stagedPatch: input.stagedPatch, | ||
| 79 | includeBranch: input.includeBranch === true, | ||
| 80 | policy: input.policy, | ||
| 81 | }), | ||
| 82 | }); | ||
| 83 | return { | ||
| 84 | subject: sanitizeCommitSubject(generated.subject), | ||
| 85 | body: generated.body.trim(), | ||
| 86 | ...("branch" in generated && typeof generated.branch === "string" | ||
| 87 | ? { branch: sanitizeFeatureBranchName(generated.branch) } | ||
| 88 | : {}), | ||
| 89 | }; | ||
| 90 | }); | ||
| 91 | |||
| 92 | const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = | ||
| 93 | Effect.fn(`${name}.generatePrContent`)(function* (input) { | ||
| 94 | const generated = yield* run({ | ||
| 95 | operation: "generatePrContent", | ||
| 96 | cwd: input.cwd, | ||
| 97 | modelSelection: input.modelSelection, | ||
| 98 | ...buildPrContentPrompt({ | ||
| 99 | baseBranch: input.baseBranch, | ||
| 100 | headBranch: input.headBranch, | ||
| 101 | commitSummary: input.commitSummary, | ||
| 102 | diffSummary: input.diffSummary, | ||
| 103 | diffPatch: input.diffPatch, | ||
| 104 | policy: input.policy, | ||
| 105 | changeRequestTemplate: input.changeRequestTemplate, | ||
| 106 | }), | ||
| 107 | }); | ||
| 108 | return { title: sanitizePrTitle(generated.title), body: generated.body.trim() }; | ||
| 109 | }); | ||
| 110 | |||
| 111 | const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = | ||
| 112 | Effect.fn(`${name}.generateBranchName`)(function* (input) { | ||
| 113 | const generated = yield* run({ | ||
| 114 | operation: "generateBranchName", | ||
| 115 | cwd: input.cwd, | ||
| 116 | modelSelection: input.modelSelection, | ||
| 117 | attachments: input.attachments, | ||
| 118 | ...buildBranchNamePrompt({ | ||
| 119 | message: input.message, | ||
| 120 | attachments: input.attachments, | ||
| 121 | naming: input.naming, | ||
| 122 | }), | ||
| 123 | }); | ||
| 124 | return { branch: formatGeneratedBranchName(generated.branch, input.naming) }; | ||
| 125 | }); | ||
| 126 | |||
| 127 | const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = | ||
| 128 | Effect.fn(`${name}.generateThreadTitle`)(function* (input) { | ||
| 129 | const generated = yield* run({ | ||
| 130 | operation: "generateThreadTitle", | ||
| 131 | cwd: input.cwd, | ||
| 132 | modelSelection: input.modelSelection, | ||
| 133 | attachments: input.attachments, | ||
| 134 | ...buildThreadTitlePrompt({ | ||
| 135 | message: input.message, | ||
| 136 | previousTitle: input.previousTitle, | ||
| 137 | linkedContext: input.linkedContext, | ||
| 138 | attachments: input.attachments, | ||
| 139 | }), | ||
| 140 | }); | ||
| 141 | return { | ||
| 142 | title: sanitizeThreadTitle(generated.title), | ||
| 143 | ...(generated.needsRefinement ? { needsRefinement: true } : {}), | ||
| 144 | }; | ||
| 145 | }); | ||
| 146 | |||
| 147 | return { | ||
| 148 | generateCommitMessage, | ||
| 149 | generatePrContent, | ||
| 150 | generateBranchName, | ||
| 151 | generateThreadTitle, | ||
| 152 | } satisfies TextGeneration.TextGeneration["Service"]; | ||
| 153 | } | ||