diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts new file mode 100644 index 0000000000..39f077f1a7 --- /dev/null +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -0,0 +1,272 @@ +/** + * ClaudeTextGeneration – Text generation layer using the Claude CLI. + * + * Implements the same TextGenerationShape contract as CodexTextGeneration but + * delegates to the `claude` CLI (`claude -p`) with structured JSON output + * instead of the `codex exec` CLI. + * + * @module ClaudeTextGeneration + */ +import { Effect, Layer, Option, Schema, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; + +import { TextGenerationError } from "../Errors.ts"; +import { + type BranchNameGenerationResult, + type CommitMessageGenerationResult, + type PrContentGenerationResult, + type TextGenerationShape, + TextGeneration, +} from "../Services/TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, +} from "./textGenerationPrompts.ts"; +import { normalizeCliError, sanitizeCommitSubject, sanitizePrTitle, toJsonSchemaObject } from "./textGenerationUtils.ts"; + +const CLAUDE_REASONING_EFFORT = "low"; +const CLAUDE_TIMEOUT_MS = 180_000; + +/** + * Schema for the wrapper JSON returned by `claude -p --output-format json`. + * We only care about `structured_output`. + */ +const ClaudeOutputEnvelope = Schema.Struct({ + structured_output: Schema.Unknown, +}); + +const makeClaudeTextGeneration = Effect.gen(function* () { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const readStreamAsString = ( + operation: string, + stream: Stream.Stream, + ): Effect.Effect => + Effect.gen(function* () { + let text = ""; + yield* Stream.runForEach(stream, (chunk) => + Effect.sync(() => { + text += Buffer.from(chunk).toString("utf8"); + }), + ).pipe( + Effect.mapError((cause) => + normalizeCliError("claude", operation, cause, "Failed to collect process output"), + ), + ); + return text; + }); + + /** + * Spawn the Claude CLI with structured JSON output and return the parsed, + * schema-validated result. + */ + const runClaudeJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + model, + }: { + operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; + cwd: string; + prompt: string; + outputSchemaJson: S; + model?: string; + }): Effect.Effect => + Effect.gen(function* () { + const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson)); + + const runClaudeCommand = Effect.gen(function* () { + const command = ChildProcess.make( + "claude", + [ + "-p", + "--output-format", + "json", + "--json-schema", + jsonSchemaStr, + "--model", + model ?? DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.claudeAgent, + "--effort", + CLAUDE_REASONING_EFFORT, + "--dangerously-skip-permissions", + ], + { + cwd, + shell: process.platform === "win32", + stdin: { + stream: Stream.make(new TextEncoder().encode(prompt)), + }, + }, + ); + + const child = yield* commandSpawner + .spawn(command) + .pipe( + Effect.mapError((cause) => + normalizeCliError("claude", operation, cause, "Failed to spawn Claude CLI process"), + ), + ); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + readStreamAsString(operation, child.stdout), + readStreamAsString(operation, child.stderr), + child.exitCode.pipe( + Effect.map((value) => Number(value)), + Effect.mapError((cause) => + normalizeCliError("claude", operation, cause, "Failed to read Claude CLI exit code"), + ), + ), + ], + { concurrency: "unbounded" }, + ); + + if (exitCode !== 0) { + const stderrDetail = stderr.trim(); + const stdoutDetail = stdout.trim(); + const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; + return yield* new TextGenerationError({ + operation, + detail: + detail.length > 0 + ? `Claude CLI command failed: ${detail}` + : `Claude CLI command failed with code ${exitCode}.`, + }); + } + + return stdout; + }); + + const rawStdout = yield* runClaudeCommand.pipe( + Effect.scoped, + Effect.timeoutOption(CLAUDE_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ operation, detail: "Claude CLI request timed out." }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + ); + + const envelope = yield* Schema.decodeEffect(Schema.fromJsonString(ClaudeOutputEnvelope))( + rawStdout, + ).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Claude CLI returned unexpected output format.", + cause, + }), + ), + ), + ); + + return yield* Schema.decodeEffect(outputSchemaJson)(envelope.structured_output).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Claude returned invalid structured output.", + cause, + }), + ), + ), + ); + }); + + // --------------------------------------------------------------------------- + // TextGenerationShape methods + // --------------------------------------------------------------------------- + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + + return runClaudeJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + ...(input.model ? { model: input.model } : {}), + }).pipe( + Effect.map( + (generated) => + ({ + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }) satisfies CommitMessageGenerationResult, + ), + ); + }; + + const generatePrContent: TextGenerationShape["generatePrContent"] = (input) => { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + + return runClaudeJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + ...(input.model ? { model: input.model } : {}), + }).pipe( + Effect.map( + (generated) => + ({ + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }) satisfies PrContentGenerationResult, + ), + ); + }; + + const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => { + return Effect.gen(function* () { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runClaudeJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + ...(input.model ? { model: input.model } : {}), + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + } satisfies BranchNameGenerationResult; + }); + }; + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + } satisfies TextGenerationShape; +}); + +export const ClaudeTextGenerationLive = Layer.effect(TextGeneration, makeClaudeTextGeneration); diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 373c191236..f20c78c754 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts"; +import { DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -17,84 +17,16 @@ import { type TextGenerationShape, TextGeneration, } from "../Services/TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, +} from "./textGenerationPrompts.ts"; +import { normalizeCliError, sanitizeCommitSubject, sanitizePrTitle, toJsonSchemaObject } from "./textGenerationUtils.ts"; const CODEX_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; -function toCodexOutputJsonSchema(schema: Schema.Top): unknown { - const document = Schema.toJsonSchemaDocument(schema); - if (document.definitions && Object.keys(document.definitions).length > 0) { - return { - ...document.schema, - $defs: document.definitions, - }; - } - return document.schema; -} - -function normalizeCodexError( - operation: string, - error: unknown, - fallback: string, -): TextGenerationError { - if (Schema.is(TextGenerationError)(error)) { - return error; - } - - if (error instanceof Error) { - const lower = error.message.toLowerCase(); - if ( - error.message.includes("Command not found: codex") || - lower.includes("spawn codex") || - lower.includes("enoent") - ) { - return new TextGenerationError({ - operation, - detail: "Codex CLI (`codex`) is required but not available on PATH.", - cause: error, - }); - } - return new TextGenerationError({ - operation, - detail: `${fallback}: ${error.message}`, - cause: error, - }); - } - - return new TextGenerationError({ - operation, - detail: fallback, - cause: error, - }); -} - -function limitSection(value: string, maxChars: number): string { - if (value.length <= maxChars) return value; - const truncated = value.slice(0, maxChars); - return `${truncated}\n\n[truncated]`; -} - -function sanitizeCommitSubject(raw: string): string { - const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; - const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); - if (withoutTrailingPeriod.length === 0) { - return "Update project files"; - } - - if (withoutTrailingPeriod.length <= 72) { - return withoutTrailingPeriod; - } - return withoutTrailingPeriod.slice(0, 72).trimEnd(); -} - -function sanitizePrTitle(raw: string): string { - const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; - if (singleLine.length > 0) { - return singleLine; - } - return "Update project changes"; -} - const makeCodexTextGeneration = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -117,7 +49,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { }), ).pipe( Effect.mapError((cause) => - normalizeCodexError(operation, cause, "Failed to collect process output"), + normalizeCliError("codex", operation, cause, "Failed to collect process output"), ), ); return text; @@ -201,7 +133,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { const schemaPath = yield* writeTempFile( operation, "codex-schema", - JSON.stringify(toCodexOutputJsonSchema(outputSchemaJson)), + JSON.stringify(toJsonSchemaObject(outputSchemaJson)), ); const outputPath = yield* writeTempFile(operation, "codex-output", ""); @@ -214,7 +146,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { "-s", "read-only", "--model", - model ?? DEFAULT_GIT_TEXT_GENERATION_MODEL, + model ?? DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, "--config", `model_reasoning_effort="${CODEX_REASONING_EFFORT}"`, "--output-schema", @@ -237,7 +169,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { .spawn(command) .pipe( Effect.mapError((cause) => - normalizeCodexError(operation, cause, "Failed to spawn Codex CLI process"), + normalizeCliError("codex", operation, cause, "Failed to spawn Codex CLI process"), ), ); @@ -248,7 +180,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { child.exitCode.pipe( Effect.map((value) => Number(value)), Effect.mapError((cause) => - normalizeCodexError(operation, cause, "Failed to read Codex CLI exit code"), + normalizeCliError("codex", operation, cause, "Failed to read Codex CLI exit code"), ), ), ], @@ -315,46 +247,18 @@ const makeCodexTextGeneration = Effect.gen(function* () { }); const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => { - const wantsBranch = input.includeBranch === true; - - const prompt = [ - "You write concise git commit messages.", - wantsBranch - ? "Return a JSON object with keys: subject, body, branch." - : "Return a JSON object with keys: subject, body.", - "Rules:", - "- subject must be imperative, <= 72 chars, and no trailing period", - "- body can be empty string or short bullet points", - ...(wantsBranch - ? ["- branch must be a short semantic git branch fragment for this change"] - : []), - "- capture the primary user-visible or developer-visible change", - "", - `Branch: ${input.branch ?? "(detached)"}`, - "", - "Staged files:", - limitSection(input.stagedSummary, 6_000), - "", - "Staged patch:", - limitSection(input.stagedPatch, 40_000), - ].join("\n"); - - const outputSchemaJson = wantsBranch - ? Schema.Struct({ - subject: Schema.String, - body: Schema.String, - branch: Schema.String, - }) - : Schema.Struct({ - subject: Schema.String, - body: Schema.String, - }); + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); return runCodexJson({ operation: "generateCommitMessage", cwd: input.cwd, prompt, - outputSchemaJson, + outputSchemaJson: outputSchema, ...(input.model ? { model: input.model } : {}), }).pipe( Effect.map( @@ -371,36 +275,19 @@ const makeCodexTextGeneration = Effect.gen(function* () { }; const generatePrContent: TextGenerationShape["generatePrContent"] = (input) => { - const prompt = [ - "You write GitHub pull request content.", - "Return a JSON object with keys: title, body.", - "Rules:", - "- title should be concise and specific", - "- body must be markdown and include headings '## Summary' and '## Testing'", - "- under Summary, provide short bullet points", - "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", - "", - `Base branch: ${input.baseBranch}`, - `Head branch: ${input.headBranch}`, - "", - "Commits:", - limitSection(input.commitSummary, 12_000), - "", - "Diff stat:", - limitSection(input.diffSummary, 12_000), - "", - "Diff patch:", - limitSection(input.diffPatch, 40_000), - ].join("\n"); + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); return runCodexJson({ operation: "generatePrContent", cwd: input.cwd, prompt, - outputSchemaJson: Schema.Struct({ - title: Schema.String, - body: Schema.String, - }), + outputSchemaJson: outputSchema, ...(input.model ? { model: input.model } : {}), }).pipe( Effect.map( @@ -419,39 +306,16 @@ const makeCodexTextGeneration = Effect.gen(function* () { "generateBranchName", input.attachments, ); - const attachmentLines = (input.attachments ?? []).map( - (attachment) => - `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, - ); - - const promptSections = [ - "You generate concise git branch names.", - "Return a JSON object with key: branch.", - "Rules:", - "- Branch should describe the requested work from the user message.", - "- Keep it short and specific (2-6 words).", - "- Use plain words only, no issue prefixes and no punctuation-heavy text.", - "- If images are attached, use them as primary context for visual/UI issues.", - "", - "User message:", - limitSection(input.message, 8_000), - ]; - if (attachmentLines.length > 0) { - promptSections.push( - "", - "Attachment metadata:", - limitSection(attachmentLines.join("\n"), 4_000), - ); - } - const prompt = promptSections.join("\n"); + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); const generated = yield* runCodexJson({ operation: "generateBranchName", cwd: input.cwd, prompt, - outputSchemaJson: Schema.Struct({ - branch: Schema.String, - }), + outputSchemaJson: outputSchema, imagePaths, ...(input.model ? { model: input.model } : {}), }); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 4f240e0044..358cfb047f 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -18,7 +18,7 @@ import { } from "../Services/GitManager.ts"; import { GitCore } from "../Services/GitCore.ts"; import { GitHubCli } from "../Services/GitHubCli.ts"; -import { TextGeneration } from "../Services/TextGeneration.ts"; +import { TextGeneration, type TextGenerationProvider } from "../Services/TextGeneration.ts"; const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; @@ -685,6 +685,7 @@ export const makeGitManager = Effect.gen(function* () { includeBranch?: boolean; filePaths?: readonly string[]; model?: string; + provider?: TextGenerationProvider; }) => Effect.gen(function* () { const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths); @@ -712,6 +713,7 @@ export const makeGitManager = Effect.gen(function* () { stagedPatch: limitContext(context.stagedPatch, 50_000), ...(input.includeBranch ? { includeBranch: true } : {}), ...(input.model ? { model: input.model } : {}), + ...(input.provider ? { provider: input.provider } : {}), }) .pipe(Effect.map((result) => sanitizeCommitMessage(result))); @@ -731,6 +733,7 @@ export const makeGitManager = Effect.gen(function* () { preResolvedSuggestion?: CommitAndBranchSuggestion, filePaths?: readonly string[], model?: string, + provider?: TextGenerationProvider, progressReporter?: GitActionProgressReporter, actionId?: string, ) => @@ -761,6 +764,7 @@ export const makeGitManager = Effect.gen(function* () { ...(commitMessage ? { commitMessage } : {}), ...(filePaths ? { filePaths } : {}), ...(model ? { model } : {}), + ...(provider ? { provider } : {}), }); } if (!suggestion) { @@ -837,7 +841,7 @@ export const makeGitManager = Effect.gen(function* () { }; }); - const runPrStep = (cwd: string, fallbackBranch: string | null, model?: string) => + const runPrStep = (cwd: string, fallbackBranch: string | null, model?: string, provider?: TextGenerationProvider) => Effect.gen(function* () { const details = yield* gitCore.statusDetails(cwd); const branch = details.branch ?? fallbackBranch; @@ -882,6 +886,7 @@ export const makeGitManager = Effect.gen(function* () { diffSummary: limitContext(rangeContext.diffSummary, 20_000), diffPatch: limitContext(rangeContext.diffPatch, 60_000), ...(model ? { model } : {}), + ...(provider ? { provider } : {}), }); const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`); @@ -1107,6 +1112,7 @@ export const makeGitManager = Effect.gen(function* () { commitMessage?: string, filePaths?: readonly string[], model?: string, + provider?: TextGenerationProvider, ) => Effect.gen(function* () { const suggestion = yield* resolveCommitAndBranchSuggestion({ @@ -1116,6 +1122,7 @@ export const makeGitManager = Effect.gen(function* () { ...(filePaths ? { filePaths } : {}), includeBranch: true, ...(model ? { model } : {}), + ...(provider ? { provider } : {}), }); if (!suggestion) { return yield* gitManagerError( @@ -1186,6 +1193,7 @@ export const makeGitManager = Effect.gen(function* () { input.commitMessage, input.filePaths, input.textGenerationModel, + input.textGenerationProvider, ); branchStep = result.branchStep; commitMessageForStep = result.resolvedCommitMessage; @@ -1205,6 +1213,7 @@ export const makeGitManager = Effect.gen(function* () { preResolvedCommitSuggestion, input.filePaths, input.textGenerationModel, + input.textGenerationProvider, options?.progressReporter, progress.actionId, ); @@ -1237,7 +1246,7 @@ export const makeGitManager = Effect.gen(function* () { Effect.flatMap(() => Effect.gen(function* () { currentPhase = "pr"; - return yield* runPrStep(input.cwd, currentBranch, input.textGenerationModel); + return yield* runPrStep(input.cwd, currentBranch, input.textGenerationModel, input.textGenerationProvider); }), ), ) diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts new file mode 100644 index 0000000000..87e27d3d94 --- /dev/null +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -0,0 +1,71 @@ +/** + * RoutingTextGeneration – Dispatches text generation requests to either the + * Codex CLI or Claude CLI implementation based on the `provider` field in each + * request input. + * + * When `provider` is `"claudeAgent"` the request is forwarded to the Claude + * layer; for any other value (including the default `undefined`) it falls + * through to the Codex layer. + * + * @module RoutingTextGeneration + */ +import { Effect, Layer, ServiceMap } from "effect"; + +import { + TextGeneration, + type TextGenerationProvider, + type TextGenerationShape, +} from "../Services/TextGeneration.ts"; +import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; +import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; + +// --------------------------------------------------------------------------- +// Internal service tags so both concrete layers can coexist. +// --------------------------------------------------------------------------- + +class CodexTextGen extends ServiceMap.Service()( + "t3/git/Layers/RoutingTextGeneration/CodexTextGen", +) {} + +class ClaudeTextGen extends ServiceMap.Service()( + "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen", +) {} + +// --------------------------------------------------------------------------- +// Routing implementation +// --------------------------------------------------------------------------- + +const makeRoutingTextGeneration = Effect.gen(function* () { + const codex = yield* CodexTextGen; + const claude = yield* ClaudeTextGen; + + const route = (provider?: TextGenerationProvider): TextGenerationShape => + provider === "claudeAgent" ? claude : codex; + + return { + generateCommitMessage: (input) => route(input.provider).generateCommitMessage(input), + generatePrContent: (input) => route(input.provider).generatePrContent(input), + generateBranchName: (input) => route(input.provider).generateBranchName(input), + } satisfies TextGenerationShape; +}); + +const InternalCodexLayer = Layer.effect( + CodexTextGen, + Effect.gen(function* () { + const svc = yield* TextGeneration; + return svc; + }), +).pipe(Layer.provide(CodexTextGenerationLive)); + +const InternalClaudeLayer = Layer.effect( + ClaudeTextGen, + Effect.gen(function* () { + const svc = yield* TextGeneration; + return svc; + }), +).pipe(Layer.provide(ClaudeTextGenerationLive)); + +export const RoutingTextGenerationLive = Layer.effect( + TextGeneration, + makeRoutingTextGeneration, +).pipe(Layer.provide(InternalCodexLayer), Layer.provide(InternalClaudeLayer)); diff --git a/apps/server/src/git/Layers/textGenerationPrompts.test.ts b/apps/server/src/git/Layers/textGenerationPrompts.test.ts new file mode 100644 index 0000000000..afea8fe2c3 --- /dev/null +++ b/apps/server/src/git/Layers/textGenerationPrompts.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; + +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, +} from "./textGenerationPrompts.ts"; +import { normalizeCliError } from "./textGenerationUtils.ts"; +import { TextGenerationError } from "../Errors.ts"; + +describe("buildCommitMessagePrompt", () => { + it("includes staged patch and summary in the prompt", () => { + const result = buildCommitMessagePrompt({ + branch: "main", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md\n+hello", + includeBranch: false, + }); + + expect(result.prompt).toContain("Staged files:"); + expect(result.prompt).toContain("M README.md"); + expect(result.prompt).toContain("Staged patch:"); + expect(result.prompt).toContain("diff --git a/README.md b/README.md"); + expect(result.prompt).toContain("Branch: main"); + // Should NOT include the branch generation instruction + expect(result.prompt).not.toContain("branch must be a short semantic git branch fragment"); + }); + + it("includes branch generation instruction when includeBranch is true", () => { + const result = buildCommitMessagePrompt({ + branch: "feature/foo", + stagedSummary: "M README.md", + stagedPatch: "diff", + includeBranch: true, + }); + + expect(result.prompt).toContain("branch must be a short semantic git branch fragment"); + expect(result.prompt).toContain("Return a JSON object with keys: subject, body, branch."); + }); + + it("shows (detached) when branch is null", () => { + const result = buildCommitMessagePrompt({ + branch: null, + stagedSummary: "M a.ts", + stagedPatch: "diff", + includeBranch: false, + }); + + expect(result.prompt).toContain("Branch: (detached)"); + }); +}); + +describe("buildPrContentPrompt", () => { + it("includes branch names, commits, and diff in the prompt", () => { + const result = buildPrContentPrompt({ + baseBranch: "main", + headBranch: "feature/auth", + commitSummary: "feat: add login page", + diffSummary: "3 files changed", + diffPatch: "diff --git a/auth.ts b/auth.ts\n+export function login()", + }); + + expect(result.prompt).toContain("Base branch: main"); + expect(result.prompt).toContain("Head branch: feature/auth"); + expect(result.prompt).toContain("Commits:"); + expect(result.prompt).toContain("feat: add login page"); + expect(result.prompt).toContain("Diff stat:"); + expect(result.prompt).toContain("3 files changed"); + expect(result.prompt).toContain("Diff patch:"); + expect(result.prompt).toContain("export function login()"); + }); +}); + +describe("buildBranchNamePrompt", () => { + it("includes the user message in the prompt", () => { + const result = buildBranchNamePrompt({ + message: "Fix the login timeout bug", + }); + + expect(result.prompt).toContain("User message:"); + expect(result.prompt).toContain("Fix the login timeout bug"); + expect(result.prompt).not.toContain("Attachment metadata:"); + }); + + it("includes attachment metadata when attachments are provided", () => { + const result = buildBranchNamePrompt({ + message: "Fix the layout from screenshot", + attachments: [ + { + type: "image" as const, + id: "att-123", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 12345, + }, + ], + }); + + expect(result.prompt).toContain("Attachment metadata:"); + expect(result.prompt).toContain("screenshot.png"); + expect(result.prompt).toContain("image/png"); + expect(result.prompt).toContain("12345 bytes"); + }); +}); + +describe("normalizeCliError", () => { + it("detects 'Command not found' and includes CLI name in the message", () => { + const error = normalizeCliError( + "claude", + "generateCommitMessage", + new Error("Command not found: claude"), + "Something went wrong", + ); + + expect(error).toBeInstanceOf(TextGenerationError); + expect(error.detail).toContain("Claude CLI"); + expect(error.detail).toContain("not available on PATH"); + }); + + it("uses the CLI name from the first argument for codex", () => { + const error = normalizeCliError( + "codex", + "generateBranchName", + new Error("Command not found: codex"), + "Something went wrong", + ); + + expect(error).toBeInstanceOf(TextGenerationError); + expect(error.detail).toContain("Codex CLI"); + expect(error.detail).toContain("not available on PATH"); + }); + + it("returns the error as-is if it is already a TextGenerationError", () => { + const existing = new TextGenerationError({ + operation: "generatePrContent", + detail: "Already wrapped", + }); + + const result = normalizeCliError("claude", "generatePrContent", existing, "fallback"); + + expect(result).toBe(existing); + }); + + it("wraps unknown non-Error values with the fallback message", () => { + const result = normalizeCliError("codex", "generateCommitMessage", "string error", "fallback"); + + expect(result).toBeInstanceOf(TextGenerationError); + expect(result.detail).toBe("fallback"); + }); +}); diff --git a/apps/server/src/git/Layers/textGenerationPrompts.ts b/apps/server/src/git/Layers/textGenerationPrompts.ts new file mode 100644 index 0000000000..545c73a02b --- /dev/null +++ b/apps/server/src/git/Layers/textGenerationPrompts.ts @@ -0,0 +1,154 @@ +/** + * Shared prompt builders for text generation providers. + * + * Extracts the prompt construction logic that is identical across + * Codex, Claude, and any future CLI-based text generation backends. + * + * @module textGenerationPrompts + */ +import { Schema } from "effect"; +import type { ChatAttachment } from "@t3tools/contracts"; + +import { limitSection } from "./textGenerationUtils.ts"; + +// --------------------------------------------------------------------------- +// Commit message +// --------------------------------------------------------------------------- + +export interface CommitMessagePromptInput { + branch: string | null; + stagedSummary: string; + stagedPatch: string; + includeBranch: boolean; +} + +export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { + const wantsBranch = input.includeBranch; + + const prompt = [ + "You write concise git commit messages.", + wantsBranch + ? "Return a JSON object with keys: subject, body, branch." + : "Return a JSON object with keys: subject, body.", + "Rules:", + "- subject must be imperative, <= 72 chars, and no trailing period", + "- body can be empty string or short bullet points", + ...(wantsBranch + ? ["- branch must be a short semantic git branch fragment for this change"] + : []), + "- capture the primary user-visible or developer-visible change", + "", + `Branch: ${input.branch ?? "(detached)"}`, + "", + "Staged files:", + limitSection(input.stagedSummary, 6_000), + "", + "Staged patch:", + limitSection(input.stagedPatch, 40_000), + ].join("\n"); + + if (wantsBranch) { + return { + prompt, + outputSchema: Schema.Struct({ + subject: Schema.String, + body: Schema.String, + branch: Schema.String, + }), + }; + } + + return { + prompt, + outputSchema: Schema.Struct({ + subject: Schema.String, + body: Schema.String, + }), + }; +} + +// --------------------------------------------------------------------------- +// PR content +// --------------------------------------------------------------------------- + +export interface PrContentPromptInput { + baseBranch: string; + headBranch: string; + commitSummary: string; + diffSummary: string; + diffPatch: string; +} + +export function buildPrContentPrompt(input: PrContentPromptInput) { + const prompt = [ + "You write GitHub pull request content.", + "Return a JSON object with keys: title, body.", + "Rules:", + "- title should be concise and specific", + "- body must be markdown and include headings '## Summary' and '## Testing'", + "- under Summary, provide short bullet points", + "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", + "", + `Base branch: ${input.baseBranch}`, + `Head branch: ${input.headBranch}`, + "", + "Commits:", + limitSection(input.commitSummary, 12_000), + "", + "Diff stat:", + limitSection(input.diffSummary, 12_000), + "", + "Diff patch:", + limitSection(input.diffPatch, 40_000), + ].join("\n"); + + const outputSchema = Schema.Struct({ + title: Schema.String, + body: Schema.String, + }); + + return { prompt, outputSchema }; +} + +// --------------------------------------------------------------------------- +// Branch name +// --------------------------------------------------------------------------- + +export interface BranchNamePromptInput { + message: string; + attachments?: ReadonlyArray | undefined; +} + +export function buildBranchNamePrompt(input: BranchNamePromptInput) { + const attachmentLines = (input.attachments ?? []).map( + (attachment) => + `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, + ); + + const promptSections = [ + "You generate concise git branch names.", + "Return a JSON object with key: branch.", + "Rules:", + "- Branch should describe the requested work from the user message.", + "- Keep it short and specific (2-6 words).", + "- Use plain words only, no issue prefixes and no punctuation-heavy text.", + "- If images are attached, use them as primary context for visual/UI issues.", + "", + "User message:", + limitSection(input.message, 8_000), + ]; + if (attachmentLines.length > 0) { + promptSections.push( + "", + "Attachment metadata:", + limitSection(attachmentLines.join("\n"), 4_000), + ); + } + + const prompt = promptSections.join("\n"); + const outputSchema = Schema.Struct({ + branch: Schema.String, + }); + + return { prompt, outputSchema }; +} diff --git a/apps/server/src/git/Layers/textGenerationUtils.ts b/apps/server/src/git/Layers/textGenerationUtils.ts new file mode 100644 index 0000000000..5db6b02c3e --- /dev/null +++ b/apps/server/src/git/Layers/textGenerationUtils.ts @@ -0,0 +1,95 @@ +/** + * Shared utilities for text generation layers (Codex, Claude, etc.). + * + * @module textGenerationUtils + */ +import { Schema } from "effect"; + +import { TextGenerationError } from "../Errors.ts"; + +/** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */ +export function toJsonSchemaObject(schema: Schema.Top): unknown { + const document = Schema.toJsonSchemaDocument(schema); + if (document.definitions && Object.keys(document.definitions).length > 0) { + return { ...document.schema, $defs: document.definitions }; + } + return document.schema; +} + +/** Truncate a text section to `maxChars`, appending a `[truncated]` marker when needed. */ +export function limitSection(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + const truncated = value.slice(0, maxChars); + return `${truncated}\n\n[truncated]`; +} + +/** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */ +export function sanitizeCommitSubject(raw: string): string { + const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; + const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); + if (withoutTrailingPeriod.length === 0) { + return "Update project files"; + } + + if (withoutTrailingPeriod.length <= 72) { + return withoutTrailingPeriod; + } + return withoutTrailingPeriod.slice(0, 72).trimEnd(); +} + +/** Normalise a raw PR title to a single line with a sensible fallback. */ +export function sanitizePrTitle(raw: string): string { + const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; + if (singleLine.length > 0) { + return singleLine; + } + return "Update project changes"; +} + +/** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */ +function cliLabel(cliName: string): string { + const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1); + return `${capitalized} CLI (\`${cliName}\`)`; +} + +/** + * Normalize an unknown error from a CLI text generation process into a + * typed `TextGenerationError`. Parameterized by CLI name so both Codex + * and Claude (and future providers) can share the same logic. + */ +export function normalizeCliError( + cliName: string, + operation: string, + error: unknown, + fallback: string, +): TextGenerationError { + if (Schema.is(TextGenerationError)(error)) { + return error; + } + + if (error instanceof Error) { + const lower = error.message.toLowerCase(); + if ( + error.message.includes(`Command not found: ${cliName}`) || + lower.includes(`spawn ${cliName}`) || + lower.includes("enoent") + ) { + return new TextGenerationError({ + operation, + detail: `${cliLabel(cliName)} is required but not available on PATH.`, + cause: error, + }); + } + return new TextGenerationError({ + operation, + detail: `${fallback}: ${error.message}`, + cause: error, + }); + } + + return new TextGenerationError({ + operation, + detail: fallback, + cause: error, + }); +} diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index b4650ed570..06931ea523 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -12,6 +12,9 @@ import type { ChatAttachment } from "@t3tools/contracts"; import type { TextGenerationError } from "../Errors.ts"; +/** Providers that support git text generation (commit messages, PR content, branch names). */ +export type TextGenerationProvider = "codex" | "claudeAgent"; + export interface CommitMessageGenerationInput { cwd: string; branch: string | null; @@ -19,8 +22,10 @@ export interface CommitMessageGenerationInput { stagedPatch: string; /** When true, the model also returns a semantic branch name for the change. */ includeBranch?: boolean; - /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */ + /** Model to use for generation. Defaults to the provider's default if not specified. */ model?: string; + /** Provider to use for generation. Defaults to "codex" if not specified. */ + provider?: TextGenerationProvider; } export interface CommitMessageGenerationResult { @@ -37,8 +42,10 @@ export interface PrContentGenerationInput { commitSummary: string; diffSummary: string; diffPatch: string; - /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */ + /** Model to use for generation. Defaults to the provider's default if not specified. */ model?: string; + /** Provider to use for generation. Defaults to "codex" if not specified. */ + provider?: TextGenerationProvider; } export interface PrContentGenerationResult { @@ -50,8 +57,10 @@ export interface BranchNameGenerationInput { cwd: string; message: string; attachments?: ReadonlyArray | undefined; - /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */ + /** Model to use for generation. Defaults to the provider's default if not specified. */ model?: string; + /** Provider to use for generation. Defaults to "codex" if not specified. */ + provider?: TextGenerationProvider; } export interface BranchNameGenerationResult { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 57405ca515..c99d52fffe 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1,7 +1,7 @@ import { type ChatAttachment, CommandId, - DEFAULT_GIT_TEXT_GENERATION_MODEL, + DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, EventId, type OrchestrationEvent, type ProviderModelOptions, @@ -449,7 +449,7 @@ const make = Effect.gen(function* () { cwd, message: input.messageText, ...(attachments.length > 0 ? { attachments } : {}), - model: DEFAULT_GIT_TEXT_GENERATION_MODEL, + model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, }) .pipe( Effect.catch((error) => diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 1cd8edac26..68fa9e8708 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -30,7 +30,7 @@ import { KeybindingsLive } from "./keybindings"; import { GitManagerLive } from "./git/Layers/GitManager"; import { GitCoreLive } from "./git/Layers/GitCore"; import { GitHubCliLive } from "./git/Layers/GitHubCli"; -import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration"; +import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; import { PtyAdapter } from "./terminal/Services/PTY"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; @@ -85,7 +85,7 @@ export function makeServerProviderLayer(): Layer.Layer< } export function makeServerRuntimeServicesLayer() { - const textGenerationLayer = CodexTextGenerationLive; + const textGenerationLayer = RoutingTextGenerationLive; const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); const orchestrationLayer = OrchestrationEngineLive.pipe( diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 99a62f663f..af4d4fc258 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -60,6 +60,9 @@ export const AppSettingsSchema = Schema.Struct({ timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)), customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), + textGenerationProvider: Schema.Literals(["codex", "claudeAgent"]).pipe( + withDefaults(() => "codex" as const), + ), textGenerationModel: Schema.optional(TrimmedNonEmptyString), }); export type AppSettings = typeof AppSettingsSchema.Type; @@ -218,11 +221,13 @@ export function resolveAppModelSelection( export function getCustomModelOptionsByProvider( settings: Pick, + selectedProvider?: ProviderKind | null, + selectedModel?: string | null, ): Record> { const customModelsByProvider = getCustomModelsByProvider(settings); return { - codex: getAppModelOptions("codex", customModelsByProvider.codex), - claudeAgent: getAppModelOptions("claudeAgent", customModelsByProvider.claudeAgent), + codex: getAppModelOptions("codex", customModelsByProvider.codex, selectedProvider === "codex" ? selectedModel : undefined), + claudeAgent: getAppModelOptions("claudeAgent", customModelsByProvider.claudeAgent, selectedProvider === "claudeAgent" ? selectedModel : undefined), }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9bc4010b4b..2aa891812e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -623,8 +623,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]); const selectedModelForPicker = selectedModel; const modelOptionsByProvider = useMemo( - () => getCustomModelOptionsByProvider(settings), - [settings], + () => getCustomModelOptionsByProvider(settings, selectedProvider, selectedModel), + [settings, selectedProvider, selectedModel], ); const selectedModelForPickerWithCustomFallback = useMemo(() => { const currentOptions = modelOptionsByProvider[selectedProvider]; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index afbc718147..25d5f464e2 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -262,6 +262,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions cwd: gitCwd, queryClient, model: settings.textGenerationModel ?? null, + textGenerationProvider: settings.textGenerationProvider, }), ); const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index d6a72859f3..47a5b5fb97 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -113,6 +113,7 @@ export function gitRunStackedActionMutationOptions(input: { cwd: string | null; queryClient: QueryClient; model?: string | null; + textGenerationProvider?: "codex" | "claudeAgent"; }) { return mutationOptions({ mutationKey: gitMutationKeys.runStackedAction(input.cwd), @@ -138,7 +139,10 @@ export function gitRunStackedActionMutationOptions(input: { ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), ...(filePaths ? { filePaths } : {}), - ...(input.model ? { model: input.model } : {}), + ...(input.model ? { textGenerationModel: input.model } : {}), + ...(input.textGenerationProvider + ? { textGenerationProvider: input.textGenerationProvider } + : {}), }); }, onSettled: async () => { diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 0fbff1cdad..32183608da 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -2,10 +2,14 @@ import { createFileRoute } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; import { ChevronDownIcon, PlusIcon, RotateCcwIcon, Undo2Icon, XIcon } from "lucide-react"; import { type ReactNode, useCallback, useState } from "react"; -import { type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts"; +import { + type ModelSlug, + type ProviderKind, + DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, +} from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { - getAppModelOptions, + getCustomModelOptionsByProvider, getCustomModelsForProvider, MAX_CUSTOM_MODEL_LENGTH, MODEL_PROVIDER_SETTINGS, @@ -25,6 +29,7 @@ import { } from "../components/ui/select"; import { SidebarTrigger } from "../components/ui/sidebar"; import { Switch } from "../components/ui/switch"; +import { ProviderModelPicker } from "../components/chat/ProviderModelPicker"; import { SidebarInset } from "../components/ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; @@ -214,20 +219,10 @@ function SettingsRouteView() { const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; - const gitTextGenerationModelOptions = getAppModelOptions( - "codex", - settings.customCodexModels, - settings.textGenerationModel, - ); - const currentGitTextGenerationModel = - settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL; - const defaultGitTextGenerationModel = - defaults.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL; - const isGitTextGenerationModelDirty = - currentGitTextGenerationModel !== defaultGitTextGenerationModel; - const selectedGitTextGenerationModelLabel = - gitTextGenerationModelOptions.find((option) => option.slug === currentGitTextGenerationModel) - ?.name ?? currentGitTextGenerationModel; + const textGenProvider = settings.textGenerationProvider; + const textGenDefaultModel = DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[textGenProvider]; + const textGenModel = (settings.textGenerationModel ?? textGenDefaultModel) as ModelSlug; + const gitModelOptionsByProvider = getCustomModelOptionsByProvider(settings, textGenProvider, textGenModel); const selectedCustomModelProviderSettings = MODEL_PROVIDER_SETTINGS.find( (providerSettings) => providerSettings.provider === selectedCustomModelProvider, )!; @@ -259,7 +254,12 @@ function SettingsRouteView() { ...(settings.confirmThreadDelete !== defaults.confirmThreadDelete ? ["Delete confirmation"] : []), - ...(isGitTextGenerationModelDirty ? ["Git writing model"] : []), + ...(settings.textGenerationProvider !== defaults.textGenerationProvider + ? ["Git writing provider"] + : []), + ...(settings.textGenerationModel !== defaults.textGenerationModel + ? ["Git writing model"] + : []), ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0 ? ["Custom models"] : []), @@ -602,43 +602,34 @@ function SettingsRouteView() { + onClick={() => { updateSettings({ + textGenerationProvider: defaults.textGenerationProvider, textGenerationModel: defaults.textGenerationModel, - }) - } + }); + }} /> ) : null } control={ - + /> } /> diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index b7eadce123..1f1b0c67de 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,6 +1,6 @@ import { Option, Schema } from "effect"; import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; -import { DEFAULT_GIT_TEXT_GENERATION_MODEL } from "./model"; +import { DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER } from "./model"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; @@ -81,8 +81,9 @@ export const GitRunStackedActionInput = Schema.Struct({ Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)), ), textGenerationModel: Schema.optional(TrimmedNonEmptyStringSchema).pipe( - Schema.withConstructorDefault(() => Option.some(DEFAULT_GIT_TEXT_GENERATION_MODEL)), + Schema.withConstructorDefault(() => Option.some(DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex)), ), + textGenerationProvider: Schema.optional(Schema.Literals(["codex", "claudeAgent"])), }); export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index dac8ce6ae0..d5bc12406c 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -59,7 +59,12 @@ export const DEFAULT_MODEL_BY_PROVIDER: Record = { // Backward compatibility for existing Codex-only call sites. export const MODEL_OPTIONS = MODEL_OPTIONS_BY_PROVIDER.codex; export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; -export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini" as const; + +/** Per-provider text generation model defaults. */ +export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record = { + codex: "gpt-5.4-mini", + claudeAgent: "claude-haiku-4-5", +}; export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record> = { codex: {