Showing the changes to apps/server/src/git (5 of 98 files). Show all
Unified · Split
| @@ -11,7 +11,6 @@ import { Effect, Layer, Option, Schema, Stream } from "effect"; · 10 unmodified lines | ||
| 11 | 11 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 12 | 12 | |
| 13 | 13 | import { ClaudeModelSelection } from "@t3tools/contracts"; |
| 14 | −import { resolveApiModelId } from "@t3tools/shared/model"; | |
| 15 | 14 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 16 | 15 | |
| 17 | 16 | import { TextGenerationError } from "@t3tools/contracts"; |
| @@ -30,6 +29,7 @@ import { · 12 unmodified lines | ||
| 30 | 29 | toJsonSchemaObject, |
| 31 | 30 | } from "../Utils.ts"; |
| 32 | 31 | import { normalizeClaudeModelOptionsWithCapabilities } from "@t3tools/shared/model"; |
| 32 | +import { resolveClaudeApiModelId } from "../../provider/Layers/ClaudeProvider.ts"; | |
| 33 | 33 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 34 | 34 | import { getClaudeModelCapabilities } from "../../provider/Layers/ClaudeProvider.ts"; |
| 35 | 35 | |
| @@ -110,7 +110,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { · 74 unmodified lines | ||
| 110 | 110 | "--json-schema", |
| 111 | 111 | jsonSchemaStr, |
| 112 | 112 | "--model", |
| 113 | − resolveApiModelId(modelSelection), | |
| 113 | + resolveClaudeApiModelId(modelSelection), | |
| 114 | 114 | ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []), |
| 115 | 115 | ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []), |
| 116 | 116 | "--dangerously-skip-permissions", |
| @@ -0,0 +1,298 @@ | ||
| 1 | +import * as path from "node:path"; | |
| 2 | +import * as os from "node:os"; | |
| 3 | +import { fileURLToPath } from "node:url"; | |
| 4 | +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 5 | + | |
| 6 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 7 | +import { it } from "@effect/vitest"; | |
| 8 | +import { Effect, Layer } from "effect"; | |
| 9 | +import { expect } from "vitest"; | |
| 10 | + | |
| 11 | +import { ServerSettingsError } from "@t3tools/contracts"; | |
| 12 | + | |
| 13 | +import { ServerConfig } from "../../config.ts"; | |
| 14 | +import { TextGeneration } from "../Services/TextGeneration.ts"; | |
| 15 | +import { CursorTextGenerationLive } from "./CursorTextGeneration.ts"; | |
| 16 | +import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 17 | + | |
| 18 | +const __dirname = path.dirname(fileURLToPath(import.meta.url)); | |
| 19 | +const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); | |
| 20 | + | |
| 21 | +function shellSingleQuote(value: string): string { | |
| 22 | + return `'${value.replaceAll("'", `'"'"'`)}'`; | |
| 23 | +} | |
| 24 | + | |
| 25 | +const CursorTextGenerationTestLayer = CursorTextGenerationLive.pipe( | |
| 26 | + Layer.provideMerge(ServerSettingsService.layerTest()), | |
| 27 | + Layer.provideMerge( | |
| 28 | + ServerConfig.layerTest(process.cwd(), { | |
| 29 | + prefix: "t3code-cursor-text-generation-test-", | |
| 30 | + }), | |
| 31 | + ), | |
| 32 | + Layer.provideMerge(NodeServices.layer), | |
| 33 | +); | |
| 34 | + | |
| 35 | +function makeAcpAgentWrapper(dir: string, env: Record<string, string>): string { | |
| 36 | + const binDir = path.join(dir, "bin"); | |
| 37 | + const agentPath = path.join(binDir, "agent"); | |
| 38 | + mkdirSync(binDir, { recursive: true }); | |
| 39 | + writeFileSync( | |
| 40 | + agentPath, | |
| 41 | + [ | |
| 42 | + "#!/bin/sh", | |
| 43 | + ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`), | |
| 44 | + 'if [ "$1" != "acp" ]; then', | |
| 45 | + ' printf "%s\\n" "unexpected args: $*" >&2', | |
| 46 | + " exit 11", | |
| 47 | + "fi", | |
| 48 | + `exec bun ${JSON.stringify(mockAgentPath)}`, | |
| 49 | + "", | |
| 50 | + ].join("\n"), | |
| 51 | + "utf8", | |
| 52 | + ); | |
| 53 | + chmodSync(agentPath, 0o755); | |
| 54 | + return agentPath; | |
| 55 | +} | |
| 56 | + | |
| 57 | +function withFakeAcpAgent<A, E, R>( | |
| 58 | + env: Record<string, string>, | |
| 59 | + effect: Effect.Effect<A, E, R>, | |
| 60 | +): Effect.Effect<A, E | ServerSettingsError, R | ServerSettingsService> { | |
| 61 | + return Effect.gen(function* () { | |
| 62 | + const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-acp-")); | |
| 63 | + const agentPath = makeAcpAgentWrapper(tempDir, env); | |
| 64 | + const serverSettings = yield* ServerSettingsService; | |
| 65 | + const previousSettings = yield* serverSettings.getSettings; | |
| 66 | + | |
| 67 | + yield* serverSettings.updateSettings({ | |
| 68 | + providers: { | |
| 69 | + cursor: { | |
| 70 | + binaryPath: agentPath, | |
| 71 | + }, | |
| 72 | + }, | |
| 73 | + }); | |
| 74 | + | |
| 75 | + return yield* effect.pipe( | |
| 76 | + Effect.ensuring( | |
| 77 | + serverSettings | |
| 78 | + .updateSettings({ | |
| 79 | + providers: { | |
| 80 | + cursor: { | |
| 81 | + binaryPath: previousSettings.providers.cursor.binaryPath, | |
| 82 | + }, | |
| 83 | + }, | |
| 84 | + }) | |
| 85 | + .pipe( | |
| 86 | + Effect.catch(() => Effect.void), | |
| 87 | + Effect.ensuring( | |
| 88 | + Effect.sync(() => { | |
| 89 | + rmSync(tempDir, { recursive: true, force: true }); | |
| 90 | + }), | |
| 91 | + ), | |
| 92 | + Effect.asVoid, | |
| 93 | + ), | |
| 94 | + ), | |
| 95 | + ); | |
| 96 | + }); | |
| 97 | +} | |
| 98 | + | |
| 99 | +function waitForFileContent(path: string): Effect.Effect<string> { | |
| 100 | + return Effect.promise(async () => { | |
| 101 | + const deadline = Date.now() + 5_000; | |
| 102 | + for (;;) { | |
| 103 | + try { | |
| 104 | + return readFileSync(path, "utf8"); | |
| 105 | + } catch (error) { | |
| 106 | + if (Date.now() >= deadline) { | |
| 107 | + throw error instanceof Error ? error : new Error(String(error)); | |
| 108 | + } | |
| 109 | + } | |
| 110 | + await new Promise((resolve) => setTimeout(resolve, 25)); | |
| 111 | + } | |
| 112 | + }); | |
| 113 | +} | |
| 114 | + | |
| 115 | +it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { | |
| 116 | + it.effect("uses ACP model config options instead of raw CLI model ids", () => { | |
| 117 | + const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-log-")); | |
| 118 | + const requestLogPath = path.join(requestLogDir, "requests.ndjson"); | |
| 119 | + | |
| 120 | + return withFakeAcpAgent( | |
| 121 | + { | |
| 122 | + T3_ACP_REQUEST_LOG_PATH: requestLogPath, | |
| 123 | + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ | |
| 124 | + subject: "Add generated commit message", | |
| 125 | + body: "- verify cursor acp model config path", | |
| 126 | + }), | |
| 127 | + }, | |
| 128 | + Effect.gen(function* () { | |
| 129 | + const textGeneration = yield* TextGeneration; | |
| 130 | + | |
| 131 | + const generated = yield* textGeneration.generateCommitMessage({ | |
| 132 | + cwd: process.cwd(), | |
| 133 | + branch: "feature/cursor-text-generation", | |
| 134 | + stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts", | |
| 135 | + stagedPatch: | |
| 136 | + "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", | |
| 137 | + modelSelection: { | |
| 138 | + provider: "cursor", | |
| 139 | + model: "gpt-5.4", | |
| 140 | + options: { | |
| 141 | + reasoning: "xhigh", | |
| 142 | + fastMode: true, | |
| 143 | + contextWindow: "1m", | |
| 144 | + }, | |
| 145 | + }, | |
| 146 | + }); | |
| 147 | + | |
| 148 | + expect(generated.subject).toBe("Add generated commit message"); | |
| 149 | + expect(generated.body).toBe("- verify cursor acp model config path"); | |
| 150 | + | |
| 151 | + const requests = readFileSync(requestLogPath, "utf8") | |
| 152 | + .trim() | |
| 153 | + .split("\n") | |
| 154 | + .filter((line) => line.length > 0) | |
| 155 | + .map((line) => JSON.parse(line) as { method?: string; params?: Record<string, unknown> }); | |
| 156 | + | |
| 157 | + expect( | |
| 158 | + requests.find((request) => request.method === "initialize")?.params?.clientCapabilities, | |
| 159 | + ).toMatchObject({ | |
| 160 | + _meta: { | |
| 161 | + parameterizedModelPicker: true, | |
| 162 | + }, | |
| 163 | + }); | |
| 164 | + expect( | |
| 165 | + requests.some( | |
| 166 | + (request) => | |
| 167 | + request.method === "session/set_config_option" && | |
| 168 | + request.params?.configId === "model" && | |
| 169 | + request.params?.value === "gpt-5.4", | |
| 170 | + ), | |
| 171 | + ).toBe(true); | |
| 172 | + expect( | |
| 173 | + requests.some( | |
| 174 | + (request) => | |
| 175 | + request.method === "session/set_config_option" && | |
| 176 | + request.params?.configId === "reasoning" && | |
| 177 | + request.params?.value === "extra-high", | |
| 178 | + ), | |
| 179 | + ).toBe(true); | |
| 180 | + expect( | |
| 181 | + requests.some( | |
| 182 | + (request) => | |
| 183 | + request.method === "session/set_config_option" && | |
| 184 | + request.params?.configId === "context" && | |
| 185 | + request.params?.value === "1m", | |
| 186 | + ), | |
| 187 | + ).toBe(true); | |
| 188 | + expect( | |
| 189 | + requests.some( | |
| 190 | + (request) => | |
| 191 | + request.method === "session/set_config_option" && | |
| 192 | + request.params?.configId === "fast" && | |
| 193 | + request.params?.value === "true", | |
| 194 | + ), | |
| 195 | + ).toBe(true); | |
| 196 | + expect( | |
| 197 | + requests.find((request) => request.method === "session/prompt")?.params?.prompt, | |
| 198 | + ).toEqual( | |
| 199 | + expect.arrayContaining([ | |
| 200 | + expect.objectContaining({ | |
| 201 | + type: "text", | |
| 202 | + text: expect.stringContaining("Staged patch:"), | |
| 203 | + }), | |
| 204 | + ]), | |
| 205 | + ); | |
| 206 | + | |
| 207 | + rmSync(requestLogDir, { recursive: true, force: true }); | |
| 208 | + }), | |
| 209 | + ); | |
| 210 | + }); | |
| 211 | + | |
| 212 | + it.effect("accepts json objects with extra assistant text around them", () => | |
| 213 | + withFakeAcpAgent( | |
| 214 | + { | |
| 215 | + T3_ACP_PROMPT_RESPONSE_TEXT: | |
| 216 | + 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.', | |
| 217 | + }, | |
| 218 | + Effect.gen(function* () { | |
| 219 | + const textGeneration = yield* TextGeneration; | |
| 220 | + | |
| 221 | + const generated = yield* textGeneration.generateCommitMessage({ | |
| 222 | + cwd: process.cwd(), | |
| 223 | + branch: "feature/cursor-noisy-json", | |
| 224 | + stagedSummary: "M README.md", | |
| 225 | + stagedPatch: "diff --git a/README.md b/README.md", | |
| 226 | + modelSelection: { | |
| 227 | + provider: "cursor", | |
| 228 | + model: "composer-2", | |
| 229 | + }, | |
| 230 | + }); | |
| 231 | + | |
| 232 | + expect(generated.subject).toBe("Update README dummy comment with attribution and date"); | |
| 233 | + expect(generated.body).toBe(""); | |
| 234 | + }), | |
| 235 | + ), | |
| 236 | + ); | |
| 237 | + | |
| 238 | + it.effect("generates thread titles through Cursor ACP text generation", () => | |
| 239 | + withFakeAcpAgent( | |
| 240 | + { | |
| 241 | + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ | |
| 242 | + title: '"Trim reconnect spinner status after resume."', | |
| 243 | + }), | |
| 244 | + }, | |
| 245 | + Effect.gen(function* () { | |
| 246 | + const textGeneration = yield* TextGeneration; | |
| 247 | + | |
| 248 | + const generated = yield* textGeneration.generateThreadTitle({ | |
| 249 | + cwd: process.cwd(), | |
| 250 | + message: "Fix the reconnect spinner after a resumed session.", | |
| 251 | + modelSelection: { | |
| 252 | + provider: "cursor", | |
| 253 | + model: "composer-2", | |
| 254 | + }, | |
| 255 | + }); | |
| 256 | + | |
| 257 | + expect(generated.title).toBe("Trim reconnect spinner status after resume."); | |
| 258 | + }), | |
| 259 | + ), | |
| 260 | + ); | |
| 261 | + | |
| 262 | + it.effect("closes the ACP child process after text generation completes", () => { | |
| 263 | + const exitLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-exit-log-")); | |
| 264 | + const exitLogPath = path.join(exitLogDir, "exit.log"); | |
| 265 | + | |
| 266 | + return withFakeAcpAgent( | |
| 267 | + { | |
| 268 | + T3_ACP_EXIT_LOG_PATH: exitLogPath, | |
| 269 | + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ | |
| 270 | + subject: "Close runtime after generation", | |
| 271 | + body: "", | |
| 272 | + }), | |
| 273 | + }, | |
| 274 | + Effect.gen(function* () { | |
| 275 | + const textGeneration = yield* TextGeneration; | |
| 276 | + | |
| 277 | + const generated = yield* textGeneration.generateCommitMessage({ | |
| 278 | + cwd: process.cwd(), | |
| 279 | + branch: "feature/cursor-runtime-close", | |
| 280 | + stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts", | |
| 281 | + stagedPatch: | |
| 282 | + "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", | |
| 283 | + modelSelection: { | |
| 284 | + provider: "cursor", | |
| 285 | + model: "composer-2", | |
| 286 | + }, | |
| 287 | + }); | |
| 288 | + | |
| 289 | + expect(generated.subject).toBe("Close runtime after generation"); | |
| 290 | + | |
| 291 | + const exitLog = yield* waitForFileContent(exitLogPath); | |
| 292 | + expect(exitLog).toContain("exit:0"); | |
| 293 | + | |
| 294 | + rmSync(exitLogDir, { recursive: true, force: true }); | |
| 295 | + }), | |
| 296 | + ); | |
| 297 | + }); | |
| 298 | +}); | |
| @@ -0,0 +1,352 @@ | ||
| 1 | +import { Effect, Layer, Option, Ref, Schema } from "effect"; | |
| 2 | +import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 3 | + | |
| 4 | +import { CursorModelSelection } from "@t3tools/contracts"; | |
| 5 | +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | |
| 6 | + | |
| 7 | +import { TextGenerationError } from "@t3tools/contracts"; | |
| 8 | +import { | |
| 9 | + type ThreadTitleGenerationResult, | |
| 10 | + type TextGenerationShape, | |
| 11 | + TextGeneration, | |
| 12 | +} from "../Services/TextGeneration.ts"; | |
| 13 | +import { | |
| 14 | + buildBranchNamePrompt, | |
| 15 | + buildCommitMessagePrompt, | |
| 16 | + buildPrContentPrompt, | |
| 17 | + buildThreadTitlePrompt, | |
| 18 | +} from "../Prompts.ts"; | |
| 19 | +import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle } from "../Utils.ts"; | |
| 20 | +import { | |
| 21 | + applyCursorAcpModelSelection, | |
| 22 | + makeCursorAcpRuntime, | |
| 23 | +} from "../../provider/acp/CursorAcpSupport.ts"; | |
| 24 | +import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 25 | + | |
| 26 | +const CURSOR_TIMEOUT_MS = 180_000; | |
| 27 | + | |
| 28 | +function extractJsonObject(raw: string): string { | |
| 29 | + const trimmed = raw.trim(); | |
| 30 | + if (trimmed.length === 0) { | |
| 31 | + return trimmed; | |
| 32 | + } | |
| 33 | + | |
| 34 | + const start = trimmed.indexOf("{"); | |
| 35 | + if (start < 0) { | |
| 36 | + return trimmed; | |
| 37 | + } | |
| 38 | + | |
| 39 | + let depth = 0; | |
| 40 | + let inString = false; | |
| 41 | + let escaping = false; | |
| 42 | + for (let index = start; index < trimmed.length; index += 1) { | |
| 43 | + const char = trimmed[index]; | |
| 44 | + if (inString) { | |
| 45 | + if (escaping) { | |
| 46 | + escaping = false; | |
| 47 | + } else if (char === "\\") { | |
| 48 | + escaping = true; | |
| 49 | + } else if (char === '"') { | |
| 50 | + inString = false; | |
| 51 | + } | |
| 52 | + continue; | |
| 53 | + } | |
| 54 | + | |
| 55 | + if (char === '"') { | |
| 56 | + inString = true; | |
| 57 | + continue; | |
| 58 | + } | |
| 59 | + | |
| 60 | + if (char === "{") { | |
| 61 | + depth += 1; | |
| 62 | + continue; | |
| 63 | + } | |
| 64 | + | |
| 65 | + if (char === "}") { | |
| 66 | + depth -= 1; | |
| 67 | + if (depth === 0) { | |
| 68 | + return trimmed.slice(start, index + 1); | |
| 69 | + } | |
| 70 | + } | |
| 71 | + } | |
| 72 | + | |
| 73 | + return trimmed.slice(start); | |
| 74 | +} | |
| 75 | + | |
| 76 | +function mapCursorAcpError( | |
| 77 | + operation: | |
| 78 | + | "generateCommitMessage" | |
| 79 | + | "generatePrContent" | |
| 80 | + | "generateBranchName" | |
| 81 | + | "generateThreadTitle", | |
| 82 | + detail: string, | |
| 83 | + cause: unknown, | |
| 84 | +): TextGenerationError { | |
| 85 | + return new TextGenerationError({ | |
| 86 | + operation, | |
| 87 | + detail, | |
| 88 | + ...(cause !== undefined ? { cause } : {}), | |
| 89 | + }); | |
| 90 | +} | |
| 91 | + | |
| 92 | +function isTextGenerationError(error: unknown): error is TextGenerationError { | |
| 93 | + return ( | |
| 94 | + typeof error === "object" && | |
| 95 | + error !== null && | |
| 96 | + "_tag" in error && | |
| 97 | + error._tag === "TextGenerationError" | |
| 98 | + ); | |
| 99 | +} | |
| 100 | + | |
| 101 | +const makeCursorTextGeneration = Effect.gen(function* () { | |
| 102 | + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 103 | + const serverSettingsService = yield* Effect.service(ServerSettingsService); | |
| 104 | + | |
| 105 | + const runCursorJson = <S extends Schema.Top>({ | |
| 106 | + operation, | |
| 107 | + cwd, | |
| 108 | + prompt, | |
| 109 | + outputSchemaJson, | |
| 110 | + modelSelection, | |
| 111 | + }: { | |
| 112 | + operation: | |
| 113 | + | "generateCommitMessage" | |
| 114 | + | "generatePrContent" | |
| 115 | + | "generateBranchName" | |
| 116 | + | "generateThreadTitle"; | |
| 117 | + cwd: string; | |
| 118 | + prompt: string; | |
| 119 | + outputSchemaJson: S; | |
| 120 | + modelSelection: CursorModelSelection; | |
| 121 | + }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> => | |
| 122 | + Effect.gen(function* () { | |
| 123 | + const cursorSettings = yield* Effect.map( | |
| 124 | + serverSettingsService.getSettings, | |
| 125 | + (settings) => settings.providers.cursor, | |
| 126 | + ).pipe(Effect.catch(() => Effect.undefined)); | |
| 127 | + | |
| 128 | + const outputRef = yield* Ref.make(""); | |
| 129 | + const runtime = yield* makeCursorAcpRuntime({ | |
| 130 | + cursorSettings, | |
| 131 | + childProcessSpawner: commandSpawner, | |
| 132 | + cwd, | |
| 133 | + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, | |
| 134 | + }); | |
| 135 | + | |
| 136 | + yield* runtime.handleSessionUpdate((notification) => { | |
| 137 | + const update = notification.update; | |
| 138 | + if (update.sessionUpdate !== "agent_message_chunk") { | |
| 139 | + return Effect.void; | |
| 140 | + } | |
| 141 | + const content = update.content; | |
| 142 | + if (content.type !== "text") { | |
| 143 | + return Effect.void; | |
| 144 | + } | |
| 145 | + return Ref.update(outputRef, (current) => current + content.text); | |
| 146 | + }); | |
| 147 | + | |
| 148 | + const promptResult = yield* Effect.gen(function* () { | |
| 149 | + yield* runtime.start(); | |
| 150 | + yield* Effect.ignore(runtime.setMode("ask")); | |
| 151 | + yield* applyCursorAcpModelSelection({ | |
| 152 | + runtime, | |
| 153 | + model: modelSelection.model, | |
| 154 | + modelOptions: modelSelection.options, | |
| 155 | + mapError: ({ cause, configId, step }) => | |
| 156 | + mapCursorAcpError( | |
| 157 | + operation, | |
| 158 | + step === "set-config-option" | |
| 159 | + ? `Failed to set Cursor ACP config option "${configId}" for text generation.` | |
| 160 | + : "Failed to set Cursor ACP base model for text generation.", | |
| 161 | + cause, | |
| 162 | + ), | |
| 163 | + }); | |
| 164 | + | |
| 165 | + return yield* runtime.prompt({ | |
| 166 | + prompt: [{ type: "text", text: prompt }], | |
| 167 | + }); | |
| 168 | + }).pipe( | |
| 169 | + Effect.timeoutOption(CURSOR_TIMEOUT_MS), | |
| 170 | + Effect.flatMap( | |
| 171 | + Option.match({ | |
| 172 | + onNone: () => | |
| 173 | + Effect.fail( | |
| 174 | + new TextGenerationError({ | |
| 175 | + operation, | |
| 176 | + detail: "Cursor Agent request timed out.", | |
| 177 | + }), | |
| 178 | + ), | |
| 179 | + onSome: (value) => Effect.succeed(value), | |
| 180 | + }), | |
| 181 | + ), | |
| 182 | + Effect.mapError((cause) => | |
| 183 | + isTextGenerationError(cause) | |
| 184 | + ? cause | |
| 185 | + : mapCursorAcpError(operation, "Cursor ACP request failed.", cause), | |
| 186 | + ), | |
| 187 | + ); | |
| 188 | + | |
| 189 | + const rawResult = (yield* Ref.get(outputRef)).trim(); | |
| 190 | + if (!rawResult) { | |
| 191 | + return yield* new TextGenerationError({ | |
| 192 | + operation, | |
| 193 | + detail: | |
| 194 | + promptResult.stopReason === "cancelled" | |
| 195 | + ? "Cursor ACP request was cancelled." | |
| 196 | + : "Cursor Agent returned empty output.", | |
| 197 | + }); | |
| 198 | + } | |
| 199 | + | |
| 200 | + return yield* Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))( | |
| 201 | + extractJsonObject(rawResult), | |
| 202 | + ).pipe( | |
| 203 | + Effect.catchTag("SchemaError", (cause) => | |
| 204 | + Effect.fail( | |
| 205 | + new TextGenerationError({ | |
| 206 | + operation, | |
| 207 | + detail: "Cursor Agent returned invalid structured output.", | |
| 208 | + cause, | |
| 209 | + }), | |
| 210 | + ), | |
| 211 | + ), | |
| 212 | + ); | |
| 213 | + }).pipe( | |
| 214 | + Effect.mapError((cause) => | |
| 215 | + isTextGenerationError(cause) | |
| 216 | + ? cause | |
| 217 | + : mapCursorAcpError(operation, "Cursor ACP text generation failed.", cause), | |
| 218 | + ), | |
| 219 | + Effect.scoped, | |
| 220 | + ); | |
| 221 | + | |
| 222 | + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( | |
| 223 | + "CursorTextGeneration.generateCommitMessage", | |
| 224 | + )(function* (input) { | |
| 225 | + const { prompt, outputSchema } = buildCommitMessagePrompt({ | |
| 226 | + branch: input.branch, | |
| 227 | + stagedSummary: input.stagedSummary, | |
| 228 | + stagedPatch: input.stagedPatch, | |
| 229 | + includeBranch: input.includeBranch === true, | |
| 230 | + }); | |
| 231 | + | |
| 232 | + if (input.modelSelection.provider !== "cursor") { | |
| 233 | + return yield* new TextGenerationError({ | |
| 234 | + operation: "generateCommitMessage", | |
| 235 | + detail: "Invalid model selection.", | |
| 236 | + }); | |
| 237 | + } | |
| 238 | + | |
| 239 | + const generated = yield* runCursorJson({ | |
| 240 | + operation: "generateCommitMessage", | |
| 241 | + cwd: input.cwd, | |
| 242 | + prompt, | |
| 243 | + outputSchemaJson: outputSchema, | |
| 244 | + modelSelection: input.modelSelection, | |
| 245 | + }); | |
| 246 | + | |
| 247 | + return { | |
| 248 | + subject: sanitizeCommitSubject(generated.subject), | |
| 249 | + body: generated.body.trim(), | |
| 250 | + ...("branch" in generated && typeof generated.branch === "string" | |
| 251 | + ? { branch: sanitizeFeatureBranchName(generated.branch) } | |
| 252 | + : {}), | |
| 253 | + }; | |
| 254 | + }); | |
| 255 | + | |
| 256 | + const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( | |
| 257 | + "CursorTextGeneration.generatePrContent", | |
| 258 | + )(function* (input) { | |
| 259 | + const { prompt, outputSchema } = buildPrContentPrompt({ | |
| 260 | + baseBranch: input.baseBranch, | |
| 261 | + headBranch: input.headBranch, | |
| 262 | + commitSummary: input.commitSummary, | |
| 263 | + diffSummary: input.diffSummary, | |
| 264 | + diffPatch: input.diffPatch, | |
| 265 | + }); | |
| 266 | + | |
| 267 | + if (input.modelSelection.provider !== "cursor") { | |
| 268 | + return yield* new TextGenerationError({ | |
| 269 | + operation: "generatePrContent", | |
| 270 | + detail: "Invalid model selection.", | |
| 271 | + }); | |
| 272 | + } | |
| 273 | + | |
| 274 | + const generated = yield* runCursorJson({ | |
| 275 | + operation: "generatePrContent", | |
| 276 | + cwd: input.cwd, | |
| 277 | + prompt, | |
| 278 | + outputSchemaJson: outputSchema, | |
| 279 | + modelSelection: input.modelSelection, | |
| 280 | + }); | |
| 281 | + | |
| 282 | + return { | |
| 283 | + title: sanitizePrTitle(generated.title), | |
| 284 | + body: generated.body.trim(), | |
| 285 | + }; | |
| 286 | + }); | |
| 287 | + | |
| 288 | + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( | |
| 289 | + "CursorTextGeneration.generateBranchName", | |
| 290 | + )(function* (input) { | |
| 291 | + const { prompt, outputSchema } = buildBranchNamePrompt({ | |
| 292 | + message: input.message, | |
| 293 | + attachments: input.attachments, | |
| 294 | + }); | |
| 295 | + | |
| 296 | + if (input.modelSelection.provider !== "cursor") { | |
| 297 | + return yield* new TextGenerationError({ | |
| 298 | + operation: "generateBranchName", | |
| 299 | + detail: "Invalid model selection.", | |
| 300 | + }); | |
| 301 | + } | |
| 302 | + | |
| 303 | + const generated = yield* runCursorJson({ | |
| 304 | + operation: "generateBranchName", | |
| 305 | + cwd: input.cwd, | |
| 306 | + prompt, | |
| 307 | + outputSchemaJson: outputSchema, | |
| 308 | + modelSelection: input.modelSelection, | |
| 309 | + }); | |
| 310 | + | |
| 311 | + return { | |
| 312 | + branch: sanitizeBranchFragment(generated.branch), | |
| 313 | + }; | |
| 314 | + }); | |
| 315 | + | |
| 316 | + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( | |
| 317 | + "CursorTextGeneration.generateThreadTitle", | |
| 318 | + )(function* (input) { | |
| 319 | + const { prompt, outputSchema } = buildThreadTitlePrompt({ | |
| 320 | + message: input.message, | |
| 321 | + attachments: input.attachments, | |
| 322 | + }); | |
| 323 | + | |
| 324 | + if (input.modelSelection.provider !== "cursor") { | |
| 325 | + return yield* new TextGenerationError({ | |
| 326 | + operation: "generateThreadTitle", | |
| 327 | + detail: "Invalid model selection.", | |
| 328 | + }); | |
| 329 | + } | |
| 330 | + | |
| 331 | + const generated = yield* runCursorJson({ | |
| 332 | + operation: "generateThreadTitle", | |
| 333 | + cwd: input.cwd, | |
| 334 | + prompt, | |
| 335 | + outputSchemaJson: outputSchema, | |
| 336 | + modelSelection: input.modelSelection, | |
| 337 | + }); | |
| 338 | + | |
| 339 | + return { | |
| 340 | + title: sanitizeThreadTitle(generated.title), | |
| 341 | + } satisfies ThreadTitleGenerationResult; | |
| 342 | + }); | |
| 343 | + | |
| 344 | + return { | |
| 345 | + generateCommitMessage, | |
| 346 | + generatePrContent, | |
| 347 | + generateBranchName, | |
| 348 | + generateThreadTitle, | |
| 349 | + } satisfies TextGenerationShape; | |
| 350 | +}); | |
| 351 | + | |
| 352 | +export const CursorTextGenerationLive = Layer.effect(TextGeneration, makeCursorTextGeneration); | |
| @@ -18,6 +18,7 @@ import { · 17 unmodified lines | ||
| 18 | 18 | } from "../Services/TextGeneration.ts"; |
| 19 | 19 | import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; |
| 20 | 20 | import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; |
| 21 | +import { CursorTextGenerationLive } from "./CursorTextGeneration.ts"; | |
| 21 | 22 | import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts"; |
| 22 | 23 | |
| 23 | 24 | // --------------------------------------------------------------------------- |
| @@ -32,6 +33,10 @@ class ClaudeTextGen extends Context.Service<ClaudeTextGen, TextGenerationShape>( · 8 unmodified lines | ||
| 32 | 33 | "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen", |
| 33 | 34 | ) {} |
| 34 | 35 | |
| 36 | +class CursorTextGen extends Context.Service<CursorTextGen, TextGenerationShape>()( | |
| 37 | + "t3/git/Layers/RoutingTextGeneration/CursorTextGen", | |
| 38 | +) {} | |
| 39 | + | |
| 35 | 40 | class OpenCodeTextGen extends Context.Service<OpenCodeTextGen, TextGenerationShape>()( |
| 36 | 41 | "t3/git/Layers/RoutingTextGeneration/OpenCodeTextGen", |
| 37 | 42 | ) {} |
| @@ -43,10 +48,17 @@ class OpenCodeTextGen extends Context.Service<OpenCodeTextGen, TextGenerationSha · 5 unmodified lines | ||
| 43 | 48 | const makeRoutingTextGeneration = Effect.gen(function* () { |
| 44 | 49 | const codex = yield* CodexTextGen; |
| 45 | 50 | const claude = yield* ClaudeTextGen; |
| 51 | + const cursor = yield* CursorTextGen; | |
| 46 | 52 | const openCode = yield* OpenCodeTextGen; |
| 47 | 53 | |
| 48 | 54 | const route = (provider?: TextGenerationProvider): TextGenerationShape => |
| 49 | − provider === "claudeAgent" ? claude : provider === "opencode" ? openCode : codex; | |
| 55 | + provider === "claudeAgent" | |
| 56 | + ? claude | |
| 57 | + : provider === "opencode" | |
| 58 | + ? openCode | |
| 59 | + : provider === "cursor" | |
| 60 | + ? cursor | |
| 61 | + : codex; | |
| 50 | 62 | |
| 51 | 63 | return { |
| 52 | 64 | generateCommitMessage: (input) => |
| @@ -73,6 +85,14 @@ const InternalClaudeLayer = Layer.effect( · 20 unmodified lines | ||
| 73 | 85 | }), |
| 74 | 86 | ).pipe(Layer.provide(ClaudeTextGenerationLive)); |
| 75 | 87 | |
| 88 | +const InternalCursorLayer = Layer.effect( | |
| 89 | + CursorTextGen, | |
| 90 | + Effect.gen(function* () { | |
| 91 | + const svc = yield* TextGeneration; | |
| 92 | + return svc; | |
| 93 | + }), | |
| 94 | +).pipe(Layer.provide(CursorTextGenerationLive)); | |
| 95 | + | |
| 76 | 96 | const InternalOpenCodeLayer = Layer.effect( |
| 77 | 97 | OpenCodeTextGen, |
| 78 | 98 | Effect.gen(function* () { |
| @@ -87,5 +107,6 @@ export const RoutingTextGenerationLive = Layer.effect( · 8 unmodified lines | ||
| 87 | 107 | ).pipe( |
| 88 | 108 | Layer.provide(InternalCodexLayer), |
| 89 | 109 | Layer.provide(InternalClaudeLayer), |
| 110 | + Layer.provide(InternalCursorLayer), | |
| 90 | 111 | Layer.provide(InternalOpenCodeLayer), |
| 91 | 112 | ); |
| @@ -13,7 +13,7 @@ import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; · 12 unmodified lines | ||
| 13 | 13 | import type { TextGenerationError } from "@t3tools/contracts"; |
| 14 | 14 | |
| 15 | 15 | /** Providers that support git text generation (commit messages, PR content, branch names). */ |
| 16 | −export type TextGenerationProvider = "codex" | "claudeAgent" | "opencode"; | |
| 16 | +export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "opencode"; | |
| 17 | 17 | |
| 18 | 18 | export interface CommitMessageGenerationInput { |
| 19 | 19 | cwd: string; |