From c14198fcb1aa588aa7ea5cfb466d2d333a315b07 Mon Sep 17 00:00:00 2001 From: keyzou Date: Mon, 23 Mar 2026 00:49:38 +0100 Subject: [PATCH 01/14] refactor: extract shared text generation utilities Move limitSection, sanitizeCommitSubject, and sanitizePrTitle into a dedicated textGenerationUtils module so they can be reused by multiple text generation providers. Co-Authored-By: Claude Opus 4.6 --- .../src/git/Layers/CodexTextGeneration.ts | 31 ++-------------- .../src/git/Layers/textGenerationUtils.ts | 35 +++++++++++++++++++ 2 files changed, 38 insertions(+), 28 deletions(-) create mode 100644 apps/server/src/git/Layers/textGenerationUtils.ts diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 373c19123..1d7b61080 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,6 +17,7 @@ import { type TextGenerationShape, TextGeneration, } from "../Services/TextGeneration.ts"; +import { limitSection, sanitizeCommitSubject, sanitizePrTitle } from "./textGenerationUtils.ts"; const CODEX_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; @@ -68,32 +69,6 @@ function normalizeCodexError( }); } -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; @@ -214,7 +189,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", diff --git a/apps/server/src/git/Layers/textGenerationUtils.ts b/apps/server/src/git/Layers/textGenerationUtils.ts new file mode 100644 index 000000000..e01bc41ca --- /dev/null +++ b/apps/server/src/git/Layers/textGenerationUtils.ts @@ -0,0 +1,35 @@ +/** + * Shared utilities for text generation layers (Codex, Claude, etc.). + * + * @module textGenerationUtils + */ + +/** 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"; +} From 85b874ac51e7e11cac2e39c54b69d92a46627578 Mon Sep 17 00:00:00 2001 From: keyzou Date: Mon, 23 Mar 2026 00:49:43 +0100 Subject: [PATCH 02/14] contracts: add provider-aware text generation model defaults Replace the single DEFAULT_GIT_TEXT_GENERATION_MODEL constant with a DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER map keyed by provider kind, enabling per-provider default models. Update all consumers. Co-Authored-By: Claude Opus 4.6 --- .../src/orchestration/Layers/ProviderCommandReactor.ts | 4 ++-- packages/contracts/src/git.ts | 5 +++-- packages/contracts/src/model.ts | 8 +++++++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 57405ca51..c99d52fff 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/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index b7eadce12..1f1b0c67d 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 dac8ce6ae..9d8b39671 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -59,7 +59,13 @@ 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; + +/** Single source of truth for 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: { From 2ed6e753a32da24efac7bde300785de051e0249f Mon Sep 17 00:00:00 2001 From: keyzou Date: Mon, 23 Mar 2026 00:49:47 +0100 Subject: [PATCH 03/14] feat: add TextGenerationProvider type and provider field to service interfaces Introduce a standalone TextGenerationProvider union type and add an optional provider field to all text generation input interfaces, allowing callers to select which backend generates the content. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/git/Services/TextGeneration.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index b4650ed57..06931ea52 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 { From 96d1185263064efd13438aab84920ca44d4d5dc5 Mon Sep 17 00:00:00 2001 From: keyzou Date: Mon, 23 Mar 2026 00:49:55 +0100 Subject: [PATCH 04/14] feat: add Claude CLI text generation and routing layer Add ClaudeTextGeneration layer that spawns `claude -p` with structured JSON output for commit messages, PR content, and branch names. Introduce a RoutingTextGeneration layer that dispatches to Codex or Claude based on the provider field, and wire it into the server DI graph. Thread the provider selection through GitManager to all text generation call sites. Co-Authored-By: Claude Opus 4.6 --- .../src/git/Layers/ClaudeTextGeneration.ts | 384 ++++++++++++++++++ apps/server/src/git/Layers/GitManager.ts | 14 +- .../src/git/Layers/RoutingTextGeneration.ts | 73 ++++ apps/server/src/serverLayers.ts | 4 +- 4 files changed, 470 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/git/Layers/ClaudeTextGeneration.ts create mode 100644 apps/server/src/git/Layers/RoutingTextGeneration.ts diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts new file mode 100644 index 000000000..27c96c09a --- /dev/null +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -0,0 +1,384 @@ +/** + * 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 BranchNameGenerationInput, + type BranchNameGenerationResult, + type CommitMessageGenerationResult, + type PrContentGenerationResult, + type TextGenerationShape, + TextGeneration, +} from "../Services/TextGeneration.ts"; +import { limitSection, sanitizeCommitSubject, sanitizePrTitle } from "./textGenerationUtils.ts"; + +const CLAUDE_TIMEOUT_MS = 180_000; + +/** Build a JSON-schema string suitable for the Claude CLI `--json-schema` flag. */ +function toClaudeJsonSchemaString(schema: Schema.Top): string { + const document = Schema.toJsonSchemaDocument(schema); + const schemaObj = + document.definitions && Object.keys(document.definitions).length > 0 + ? { ...document.schema, $defs: document.definitions } + : document.schema; + return JSON.stringify(schemaObj); +} + +function normalizeClaudeError( + 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: claude") || + lower.includes("spawn claude") || + lower.includes("enoent") + ) { + return new TextGenerationError({ + operation, + detail: "Claude CLI (`claude`) 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, + }); +} + +/** + * 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) => + normalizeClaudeError(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 = toClaudeJsonSchemaString(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", + "low", + "--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) => + normalizeClaudeError(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) => + normalizeClaudeError(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; + }); + + // Run with timeout, then parse the envelope. + 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), + }), + ), + ); + + // Parse the wrapper envelope to extract `structured_output`. + 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, + }), + ), + ), + ); + + // Validate the structured_output against the caller's schema. + 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 — identical prompts to CodexTextGeneration + // --------------------------------------------------------------------------- + + 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, + }); + + return runClaudeJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson, + ...(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 = [ + "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"); + + return runClaudeJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: Schema.Struct({ + title: Schema.String, + body: Schema.String, + }), + ...(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 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 generated = yield* runClaudeJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: Schema.Struct({ + branch: Schema.String, + }), + ...(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/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 4f240e004..cdd269bc9 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( @@ -1205,6 +1212,7 @@ export const makeGitManager = Effect.gen(function* () { preResolvedCommitSuggestion, input.filePaths, input.textGenerationModel, + input.textGenerationProvider, options?.progressReporter, progress.actionId, ); @@ -1237,7 +1245,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 000000000..4cfd97297 --- /dev/null +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -0,0 +1,73 @@ +/** + * 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; +}); + +// Re-tag the concrete layers to the internal tags so they don't collide on +// the public TextGeneration tag. +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/serverLayers.ts b/apps/server/src/serverLayers.ts index 1cd8edac2..68fa9e870 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( From 783f669659f6f95149956ed2784383e8ff650e1e Mon Sep 17 00:00:00 2001 From: keyzou Date: Mon, 23 Mar 2026 00:50:01 +0100 Subject: [PATCH 05/14] feat: add text generation provider settings UI and frontend wiring Add a provider selector (Codex/Claude) to the git settings panel with dynamic model options per provider. Pass the selected provider through to the stacked action mutation. Also fix a pre-existing bug where the model field name was incorrect in the RPC call. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/appSettings.ts | 3 + apps/web/src/components/GitActionsControl.tsx | 1 + apps/web/src/lib/gitReactQuery.ts | 6 +- apps/web/src/routes/_chat.settings.tsx | 56 +++++++++++++++++-- 4 files changed, 59 insertions(+), 7 deletions(-) diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index cb7b5fd9c..3f9ce98ed 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -61,6 +61,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; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index afbc71814..25d5f464e 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 d6a72859f..47a5b5fb9 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 05fd640d0..e564c2b2a 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -2,7 +2,10 @@ 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 ProviderKind, + DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, +} from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { getAppModelOptions, @@ -214,15 +217,17 @@ function SettingsRouteView() { const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; + const textGenProvider = settings.textGenerationProvider; + const textGenDefaultModel = DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[textGenProvider]; const gitTextGenerationModelOptions = getAppModelOptions( - "codex", - settings.customCodexModels, + textGenProvider, + textGenProvider === "codex" ? settings.customCodexModels : settings.customClaudeModels, settings.textGenerationModel, ); const currentGitTextGenerationModel = - settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL; + settings.textGenerationModel ?? textGenDefaultModel; const defaultGitTextGenerationModel = - defaults.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL; + defaults.textGenerationModel ?? textGenDefaultModel; const isGitTextGenerationModelDirty = currentGitTextGenerationModel !== defaultGitTextGenerationModel; const selectedGitTextGenerationModelLabel = @@ -260,6 +265,9 @@ function SettingsRouteView() { ...(settings.confirmThreadDelete !== defaults.confirmThreadDelete ? ["Delete confirmation"] : []), + ...(settings.textGenerationProvider !== defaults.textGenerationProvider + ? ["Git writing provider"] + : []), ...(isGitTextGenerationModelDirty ? ["Git writing model"] : []), ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0 ? ["Custom models"] @@ -629,9 +637,45 @@ function SettingsRouteView() { + { + if (value === "codex" || value === "claudeAgent") { + updateSettings({ + textGenerationProvider: value, + textGenerationModel: + DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[value], + }); + } + }} + > + + + {textGenProvider === "claudeAgent" ? "Claude" : "Codex"} + + + + + Codex + + + Claude + + + + } + /> + Date: Mon, 23 Mar 2026 01:19:07 +0100 Subject: [PATCH 06/14] Extract Claude reasoning effort into named constant Match the pattern already used by Codex (CODEX_REASONING_EFFORT) to make the setting visible and easy to change. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/git/Layers/ClaudeTextGeneration.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 27c96c09a..5959fdf6d 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -24,6 +24,7 @@ import { } from "../Services/TextGeneration.ts"; import { limitSection, sanitizeCommitSubject, sanitizePrTitle } from "./textGenerationUtils.ts"; +const CLAUDE_REASONING_EFFORT = "low"; const CLAUDE_TIMEOUT_MS = 180_000; /** Build a JSON-schema string suitable for the Claude CLI `--json-schema` flag. */ @@ -133,7 +134,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { "--model", model ?? DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.claudeAgent, "--effort", - "low", + CLAUDE_REASONING_EFFORT, "--dangerously-skip-permissions", ], { From 75fe8da0c1799881800ccce6d99bece3d536f70d Mon Sep 17 00:00:00 2001 From: keyzou Date: Mon, 23 Mar 2026 01:21:20 +0100 Subject: [PATCH 07/14] Extract shared prompt builders and error normalization - Extract prompt construction logic into buildCommitMessagePrompt, buildPrContentPrompt, and buildBranchNamePrompt in new textGenerationPrompts.ts - Replace duplicate normalizeClaudeError and normalizeCodexError with shared normalizeCliError function parameterized by CLI name - Update Claude and Codex text generation layers to use shared utilities, reducing duplication - Add comprehensive tests for prompt builders and error normalization --- .../src/git/Layers/ClaudeTextGeneration.ts | 160 ++++------------- .../src/git/Layers/CodexTextGeneration.ts | 158 +++------------- .../git/Layers/textGenerationPrompts.test.ts | 150 ++++++++++++++++ .../src/git/Layers/textGenerationPrompts.ts | 168 ++++++++++++++++++ .../src/git/Layers/textGenerationUtils.ts | 51 ++++++ 5 files changed, 428 insertions(+), 259 deletions(-) create mode 100644 apps/server/src/git/Layers/textGenerationPrompts.test.ts create mode 100644 apps/server/src/git/Layers/textGenerationPrompts.ts diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 5959fdf6d..92c1de2a6 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -15,14 +15,18 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar import { TextGenerationError } from "../Errors.ts"; import { - type BranchNameGenerationInput, type BranchNameGenerationResult, type CommitMessageGenerationResult, type PrContentGenerationResult, type TextGenerationShape, TextGeneration, } from "../Services/TextGeneration.ts"; -import { limitSection, sanitizeCommitSubject, sanitizePrTitle } from "./textGenerationUtils.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, +} from "./textGenerationPrompts.ts"; +import { normalizeCliError, sanitizeCommitSubject, sanitizePrTitle } from "./textGenerationUtils.ts"; const CLAUDE_REASONING_EFFORT = "low"; const CLAUDE_TIMEOUT_MS = 180_000; @@ -37,42 +41,6 @@ function toClaudeJsonSchemaString(schema: Schema.Top): string { return JSON.stringify(schemaObj); } -function normalizeClaudeError( - 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: claude") || - lower.includes("spawn claude") || - lower.includes("enoent") - ) { - return new TextGenerationError({ - operation, - detail: "Claude CLI (`claude`) 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, - }); -} - /** * Schema for the wrapper JSON returned by `claude -p --output-format json`. * We only care about `structured_output`. @@ -96,7 +64,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { }), ).pipe( Effect.mapError((cause) => - normalizeClaudeError(operation, cause, "Failed to collect process output"), + normalizeCliError("claude", operation, cause, "Failed to collect process output"), ), ); return text; @@ -150,7 +118,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { .spawn(command) .pipe( Effect.mapError((cause) => - normalizeClaudeError(operation, cause, "Failed to spawn Claude CLI process"), + normalizeCliError("claude", operation, cause, "Failed to spawn Claude CLI process"), ), ); @@ -161,7 +129,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { child.exitCode.pipe( Effect.map((value) => Number(value)), Effect.mapError((cause) => - normalizeClaudeError(operation, cause, "Failed to read Claude CLI exit code"), + normalizeCliError("claude", operation, cause, "Failed to read Claude CLI exit code"), ), ), ], @@ -229,50 +197,22 @@ const makeClaudeTextGeneration = Effect.gen(function* () { }); // --------------------------------------------------------------------------- - // TextGenerationShape methods — identical prompts to CodexTextGeneration + // TextGenerationShape methods // --------------------------------------------------------------------------- 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 runClaudeJson({ operation: "generateCommitMessage", cwd: input.cwd, prompt, - outputSchemaJson, + outputSchemaJson: outputSchema, ...(input.model ? { model: input.model } : {}), }).pipe( Effect.map( @@ -289,36 +229,19 @@ const makeClaudeTextGeneration = 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 runClaudeJson({ 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( @@ -333,39 +256,16 @@ const makeClaudeTextGeneration = Effect.gen(function* () { const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => { return Effect.gen(function* () { - 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* runClaudeJson({ operation: "generateBranchName", cwd: input.cwd, prompt, - outputSchemaJson: Schema.Struct({ - branch: Schema.String, - }), + outputSchemaJson: outputSchema, ...(input.model ? { model: input.model } : {}), }); diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 1d7b61080..08e7e9d22 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -17,7 +17,12 @@ import { type TextGenerationShape, TextGeneration, } from "../Services/TextGeneration.ts"; -import { limitSection, sanitizeCommitSubject, sanitizePrTitle } from "./textGenerationUtils.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, +} from "./textGenerationPrompts.ts"; +import { normalizeCliError, sanitizeCommitSubject, sanitizePrTitle } from "./textGenerationUtils.ts"; const CODEX_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; @@ -33,43 +38,6 @@ function toCodexOutputJsonSchema(schema: Schema.Top): unknown { 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, - }); -} - - const makeCodexTextGeneration = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -92,7 +60,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; @@ -212,7 +180,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"), ), ); @@ -223,7 +191,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"), ), ), ], @@ -290,46 +258,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( @@ -346,36 +286,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( @@ -394,39 +317,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/textGenerationPrompts.test.ts b/apps/server/src/git/Layers/textGenerationPrompts.test.ts new file mode 100644 index 000000000..afea8fe2c --- /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 000000000..c5676e329 --- /dev/null +++ b/apps/server/src/git/Layers/textGenerationPrompts.ts @@ -0,0 +1,168 @@ +/** + * 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 interface CommitMessagePromptResult { + prompt: string; + outputSchema: Schema.Struct.Type; +} + +export function buildCommitMessagePrompt(input: CommitMessagePromptInput): CommitMessagePromptResult { + 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"); + + const outputSchema = wantsBranch + ? Schema.Struct({ + subject: Schema.String, + body: Schema.String, + branch: Schema.String, + }) + : Schema.Struct({ + subject: Schema.String, + body: Schema.String, + }); + + return { prompt, outputSchema }; +} + +// --------------------------------------------------------------------------- +// PR content +// --------------------------------------------------------------------------- + +export interface PrContentPromptInput { + baseBranch: string; + headBranch: string; + commitSummary: string; + diffSummary: string; + diffPatch: string; +} + +export interface PrContentPromptResult { + prompt: string; + outputSchema: Schema.Struct<{ + title: typeof Schema.String; + body: typeof Schema.String; + }>; +} + +export function buildPrContentPrompt(input: PrContentPromptInput): PrContentPromptResult { + 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 interface BranchNamePromptResult { + prompt: string; + outputSchema: Schema.Struct<{ + branch: typeof Schema.String; + }>; +} + +export function buildBranchNamePrompt(input: BranchNamePromptInput): BranchNamePromptResult { + 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 index e01bc41ca..0dcc3661b 100644 --- a/apps/server/src/git/Layers/textGenerationUtils.ts +++ b/apps/server/src/git/Layers/textGenerationUtils.ts @@ -3,6 +3,9 @@ * * @module textGenerationUtils */ +import { Schema } from "effect"; + +import { TextGenerationError } from "../Errors.ts"; /** Truncate a text section to `maxChars`, appending a `[truncated]` marker when needed. */ export function limitSection(value: string, maxChars: number): string { @@ -33,3 +36,51 @@ export function sanitizePrTitle(raw: string): string { } 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, + }); +} From 71e18bc5288cefdf71f344d50864ba836f1d4bd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Ch=C3=A9rif?= Date: Mon, 23 Mar 2026 18:09:34 +0100 Subject: [PATCH 08/14] chore: remove a bit of comment-slop --- apps/server/src/git/Layers/ClaudeTextGeneration.ts | 3 --- apps/server/src/git/Layers/RoutingTextGeneration.ts | 2 -- packages/contracts/src/model.ts | 3 +-- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 92c1de2a6..70133c76a 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -152,7 +152,6 @@ const makeClaudeTextGeneration = Effect.gen(function* () { return stdout; }); - // Run with timeout, then parse the envelope. const rawStdout = yield* runClaudeCommand.pipe( Effect.scoped, Effect.timeoutOption(CLAUDE_TIMEOUT_MS), @@ -167,7 +166,6 @@ const makeClaudeTextGeneration = Effect.gen(function* () { ), ); - // Parse the wrapper envelope to extract `structured_output`. const envelope = yield* Schema.decodeEffect(Schema.fromJsonString(ClaudeOutputEnvelope))( rawStdout, ).pipe( @@ -182,7 +180,6 @@ const makeClaudeTextGeneration = Effect.gen(function* () { ), ); - // Validate the structured_output against the caller's schema. return yield* Schema.decodeEffect(outputSchemaJson)(envelope.structured_output).pipe( Effect.catchTag("SchemaError", (cause) => Effect.fail( diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts index 4cfd97297..87e27d3d9 100644 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -49,8 +49,6 @@ const makeRoutingTextGeneration = Effect.gen(function* () { } satisfies TextGenerationShape; }); -// Re-tag the concrete layers to the internal tags so they don't collide on -// the public TextGeneration tag. const InternalCodexLayer = Layer.effect( CodexTextGen, Effect.gen(function* () { diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9d8b39671..d5bc12406 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -60,13 +60,12 @@ export const DEFAULT_MODEL_BY_PROVIDER: Record = { export const MODEL_OPTIONS = MODEL_OPTIONS_BY_PROVIDER.codex; export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; -/** Single source of truth for per-provider text generation model defaults. */ +/** 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: { "5.4": "gpt-5.4", From ba74436817bc161611dff0cc0748c99ebd8342c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Ch=C3=A9rif?= Date: Mon, 23 Mar 2026 19:38:52 +0100 Subject: [PATCH 09/14] Extract provider/model selection to ProviderModelPicker - Consolidate provider and model selection into reusable component - Replace getAppModelOptions with getCustomModelOptionsByProvider - Simplifies settings UI and enables reuse across the app --- apps/web/src/routes/_chat.settings.tsx | 103 +++++-------------------- 1 file changed, 18 insertions(+), 85 deletions(-) diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index e564c2b2a..8f41be84f 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -3,12 +3,13 @@ import { useQuery } from "@tanstack/react-query"; import { ChevronDownIcon, PlusIcon, RotateCcwIcon, Undo2Icon, XIcon } from "lucide-react"; import { type ReactNode, useCallback, useState } from "react"; 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, @@ -28,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"; @@ -219,20 +221,8 @@ function SettingsRouteView() { const textGenProvider = settings.textGenerationProvider; const textGenDefaultModel = DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[textGenProvider]; - const gitTextGenerationModelOptions = getAppModelOptions( - textGenProvider, - textGenProvider === "codex" ? settings.customCodexModels : settings.customClaudeModels, - settings.textGenerationModel, - ); - const currentGitTextGenerationModel = - settings.textGenerationModel ?? textGenDefaultModel; - const defaultGitTextGenerationModel = - defaults.textGenerationModel ?? textGenDefaultModel; - const isGitTextGenerationModelDirty = - currentGitTextGenerationModel !== defaultGitTextGenerationModel; - const selectedGitTextGenerationModelLabel = - gitTextGenerationModelOptions.find((option) => option.slug === currentGitTextGenerationModel) - ?.name ?? currentGitTextGenerationModel; + const textGenModel = (settings.textGenerationModel ?? textGenDefaultModel) as ModelSlug; + const gitModelOptionsByProvider = getCustomModelOptionsByProvider(settings); const selectedCustomModelProviderSettings = MODEL_PROVIDER_SETTINGS.find( (providerSettings) => providerSettings.provider === selectedCustomModelProvider, )!; @@ -268,7 +258,9 @@ function SettingsRouteView() { ...(settings.textGenerationProvider !== defaults.textGenerationProvider ? ["Git writing provider"] : []), - ...(isGitTextGenerationModelDirty ? ["Git writing model"] : []), + ...(settings.textGenerationModel !== defaults.textGenerationModel + ? ["Git writing model"] + : []), ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0 ? ["Custom models"] : []), @@ -637,81 +629,22 @@ function SettingsRouteView() { - { - if (value === "codex" || value === "claudeAgent") { - updateSettings({ - textGenerationProvider: value, - textGenerationModel: - DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[value], - }); - } - }} - > - - - {textGenProvider === "claudeAgent" ? "Claude" : "Codex"} - - - - - Codex - - - Claude - - - - } - /> - - updateSettings({ - textGenerationModel: defaults.textGenerationModel, - }) - } - /> - ) : null - } + description="Provider and model used for auto-generated git content." control={ - + /> } /> From 163bc30d57c5e9c1260a496481145da0cc14d74d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Ch=C3=A9rif?= Date: Tue, 24 Mar 2026 09:30:37 +0100 Subject: [PATCH 10/14] Fix type safety loss in buildCommitMessagePrompt output schema Remove explicit result interfaces with incorrect type annotations (Schema.Struct.Type) and let TypeScript infer return types. Split the ternary schema assignment into separate return branches so TypeScript preserves a proper union instead of widening to any. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/git/Layers/textGenerationPrompts.ts | 48 +++++++------------ 1 file changed, 17 insertions(+), 31 deletions(-) diff --git a/apps/server/src/git/Layers/textGenerationPrompts.ts b/apps/server/src/git/Layers/textGenerationPrompts.ts index c5676e329..545c73a02 100644 --- a/apps/server/src/git/Layers/textGenerationPrompts.ts +++ b/apps/server/src/git/Layers/textGenerationPrompts.ts @@ -22,12 +22,7 @@ export interface CommitMessagePromptInput { includeBranch: boolean; } -export interface CommitMessagePromptResult { - prompt: string; - outputSchema: Schema.Struct.Type; -} - -export function buildCommitMessagePrompt(input: CommitMessagePromptInput): CommitMessagePromptResult { +export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { const wantsBranch = input.includeBranch; const prompt = [ @@ -52,18 +47,24 @@ export function buildCommitMessagePrompt(input: CommitMessagePromptInput): Commi limitSection(input.stagedPatch, 40_000), ].join("\n"); - const outputSchema = wantsBranch - ? Schema.Struct({ + if (wantsBranch) { + return { + prompt, + outputSchema: Schema.Struct({ subject: Schema.String, body: Schema.String, branch: Schema.String, - }) - : Schema.Struct({ - subject: Schema.String, - body: Schema.String, - }); + }), + }; + } - return { prompt, outputSchema }; + return { + prompt, + outputSchema: Schema.Struct({ + subject: Schema.String, + body: Schema.String, + }), + }; } // --------------------------------------------------------------------------- @@ -78,15 +79,7 @@ export interface PrContentPromptInput { diffPatch: string; } -export interface PrContentPromptResult { - prompt: string; - outputSchema: Schema.Struct<{ - title: typeof Schema.String; - body: typeof Schema.String; - }>; -} - -export function buildPrContentPrompt(input: PrContentPromptInput): PrContentPromptResult { +export function buildPrContentPrompt(input: PrContentPromptInput) { const prompt = [ "You write GitHub pull request content.", "Return a JSON object with keys: title, body.", @@ -126,14 +119,7 @@ export interface BranchNamePromptInput { attachments?: ReadonlyArray | undefined; } -export interface BranchNamePromptResult { - prompt: string; - outputSchema: Schema.Struct<{ - branch: typeof Schema.String; - }>; -} - -export function buildBranchNamePrompt(input: BranchNamePromptInput): BranchNamePromptResult { +export function buildBranchNamePrompt(input: BranchNamePromptInput) { const attachmentLines = (input.attachments ?? []).map( (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, From f21ef2aaa0c898b2a04e15593c35b023547fc081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Ch=C3=A9rif?= Date: Tue, 24 Mar 2026 09:55:43 +0100 Subject: [PATCH 11/14] Extract shared toJsonSchemaObject helper for JSON Schema conversion Both ClaudeTextGeneration and CodexTextGeneration had identical logic to convert an Effect Schema into a flat JSON Schema object. Move it into textGenerationUtils.ts as a single shared helper. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../server/src/git/Layers/ClaudeTextGeneration.ts | 14 ++------------ apps/server/src/git/Layers/CodexTextGeneration.ts | 15 ++------------- apps/server/src/git/Layers/textGenerationUtils.ts | 9 +++++++++ 3 files changed, 13 insertions(+), 25 deletions(-) diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 70133c76a..39f077f1a 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -26,21 +26,11 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, } from "./textGenerationPrompts.ts"; -import { normalizeCliError, sanitizeCommitSubject, sanitizePrTitle } from "./textGenerationUtils.ts"; +import { normalizeCliError, sanitizeCommitSubject, sanitizePrTitle, toJsonSchemaObject } from "./textGenerationUtils.ts"; const CLAUDE_REASONING_EFFORT = "low"; const CLAUDE_TIMEOUT_MS = 180_000; -/** Build a JSON-schema string suitable for the Claude CLI `--json-schema` flag. */ -function toClaudeJsonSchemaString(schema: Schema.Top): string { - const document = Schema.toJsonSchemaDocument(schema); - const schemaObj = - document.definitions && Object.keys(document.definitions).length > 0 - ? { ...document.schema, $defs: document.definitions } - : document.schema; - return JSON.stringify(schemaObj); -} - /** * Schema for the wrapper JSON returned by `claude -p --output-format json`. * We only care about `structured_output`. @@ -88,7 +78,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { model?: string; }): Effect.Effect => Effect.gen(function* () { - const jsonSchemaStr = toClaudeJsonSchemaString(outputSchemaJson); + const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson)); const runClaudeCommand = Effect.gen(function* () { const command = ChildProcess.make( diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 08e7e9d22..f20c78c75 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -22,22 +22,11 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, } from "./textGenerationPrompts.ts"; -import { normalizeCliError, sanitizeCommitSubject, sanitizePrTitle } from "./textGenerationUtils.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; -} - const makeCodexTextGeneration = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -144,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", ""); diff --git a/apps/server/src/git/Layers/textGenerationUtils.ts b/apps/server/src/git/Layers/textGenerationUtils.ts index 0dcc3661b..5db6b02c3 100644 --- a/apps/server/src/git/Layers/textGenerationUtils.ts +++ b/apps/server/src/git/Layers/textGenerationUtils.ts @@ -7,6 +7,15 @@ 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; From eb88f1ebc2ddd5bf4165feba71b6b5d35b6586dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Ch=C3=A9rif?= Date: Tue, 24 Mar 2026 10:18:57 +0100 Subject: [PATCH 12/14] Fix model picker missing selected model and restore reset button Pass selectedModel (scoped to the active provider) through to getAppModelOptions so non-built-in models appear in the dropdown. Restore the per-setting reset button on the git writing model row. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/src/appSettings.ts | 6 ++++-- apps/web/src/components/ChatView.tsx | 4 ++-- apps/web/src/routes/_chat.settings.tsx | 16 +++++++++++++++- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 3f9ce98ed..62461d070 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -222,11 +222,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 9bc4010b4..2aa891812 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/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 8f41be84f..de9d3e16a 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -222,7 +222,7 @@ function SettingsRouteView() { 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); + const gitModelOptionsByProvider = getCustomModelOptionsByProvider(settings, textGenProvider, textGenModel); const selectedCustomModelProviderSettings = MODEL_PROVIDER_SETTINGS.find( (providerSettings) => providerSettings.provider === selectedCustomModelProvider, )!; @@ -632,6 +632,20 @@ function SettingsRouteView() { { + updateSettings({ + textGenerationProvider: defaults.textGenerationProvider, + textGenerationModel: defaults.textGenerationModel, + }); + }} + /> + ) : null + } control={ Date: Tue, 24 Mar 2026 10:47:23 +0100 Subject: [PATCH 13/14] Fix feature branch step ignoring selected text generation provider Pass input.textGenerationProvider to runFeatureBranchStep so the routing layer respects the user's provider choice instead of always falling back to Codex. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/server/src/git/Layers/GitManager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index cdd269bc9..358cfb047 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -1193,6 +1193,7 @@ export const makeGitManager = Effect.gen(function* () { input.commitMessage, input.filePaths, input.textGenerationModel, + input.textGenerationProvider, ); branchStep = result.branchStep; commitMessageForStep = result.resolvedCommitMessage; From ec38c6236a5b89b53e0bb998c639913f6f6ec716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Ch=C3=A9rif?= Date: Tue, 24 Mar 2026 19:02:31 +0100 Subject: [PATCH 14/14] chore: format with oxfmt --- apps/server/src/git/Layers/ClaudeTextGeneration.ts | 14 ++++++++++++-- apps/server/src/git/Layers/CodexTextGeneration.ts | 7 ++++++- apps/server/src/git/Layers/GitManager.ts | 14 ++++++++++++-- .../server/src/git/Layers/textGenerationPrompts.ts | 3 +-- apps/web/src/appSettings.ts | 12 ++++++++++-- apps/web/src/routes/_chat.settings.tsx | 10 ++++++---- packages/contracts/src/git.ts | 4 +++- 7 files changed, 50 insertions(+), 14 deletions(-) diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 39f077f1a..de32af31f 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -26,7 +26,12 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, } from "./textGenerationPrompts.ts"; -import { normalizeCliError, sanitizeCommitSubject, sanitizePrTitle, toJsonSchemaObject } from "./textGenerationUtils.ts"; +import { + normalizeCliError, + sanitizeCommitSubject, + sanitizePrTitle, + toJsonSchemaObject, +} from "./textGenerationUtils.ts"; const CLAUDE_REASONING_EFFORT = "low"; const CLAUDE_TIMEOUT_MS = 180_000; @@ -119,7 +124,12 @@ const makeClaudeTextGeneration = Effect.gen(function* () { child.exitCode.pipe( Effect.map((value) => Number(value)), Effect.mapError((cause) => - normalizeCliError("claude", operation, cause, "Failed to read Claude CLI exit code"), + normalizeCliError( + "claude", + operation, + cause, + "Failed to read Claude CLI exit code", + ), ), ), ], diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index f20c78c75..493ea53bf 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -22,7 +22,12 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, } from "./textGenerationPrompts.ts"; -import { normalizeCliError, sanitizeCommitSubject, sanitizePrTitle, toJsonSchemaObject } from "./textGenerationUtils.ts"; +import { + normalizeCliError, + sanitizeCommitSubject, + sanitizePrTitle, + toJsonSchemaObject, +} from "./textGenerationUtils.ts"; const CODEX_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 358cfb047..dcac3b0fd 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -841,7 +841,12 @@ export const makeGitManager = Effect.gen(function* () { }; }); - const runPrStep = (cwd: string, fallbackBranch: string | null, model?: string, provider?: TextGenerationProvider) => + 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; @@ -1246,7 +1251,12 @@ export const makeGitManager = Effect.gen(function* () { Effect.flatMap(() => Effect.gen(function* () { currentPhase = "pr"; - return yield* runPrStep(input.cwd, currentBranch, input.textGenerationModel, input.textGenerationProvider); + return yield* runPrStep( + input.cwd, + currentBranch, + input.textGenerationModel, + input.textGenerationProvider, + ); }), ), ) diff --git a/apps/server/src/git/Layers/textGenerationPrompts.ts b/apps/server/src/git/Layers/textGenerationPrompts.ts index 545c73a02..d01adb6b0 100644 --- a/apps/server/src/git/Layers/textGenerationPrompts.ts +++ b/apps/server/src/git/Layers/textGenerationPrompts.ts @@ -121,8 +121,7 @@ export interface BranchNamePromptInput { export function buildBranchNamePrompt(input: BranchNamePromptInput) { const attachmentLines = (input.attachments ?? []).map( - (attachment) => - `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, + (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, ); const promptSections = [ diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 62461d070..fdc1a17d4 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -227,8 +227,16 @@ export function getCustomModelOptionsByProvider( ): Record> { const customModelsByProvider = getCustomModelsByProvider(settings); return { - codex: getAppModelOptions("codex", customModelsByProvider.codex, selectedProvider === "codex" ? selectedModel : undefined), - claudeAgent: getAppModelOptions("claudeAgent", customModelsByProvider.claudeAgent, selectedProvider === "claudeAgent" ? selectedModel : undefined), + codex: getAppModelOptions( + "codex", + customModelsByProvider.codex, + selectedProvider === "codex" ? selectedModel : undefined, + ), + claudeAgent: getAppModelOptions( + "claudeAgent", + customModelsByProvider.claudeAgent, + selectedProvider === "claudeAgent" ? selectedModel : undefined, + ), }; } diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index de9d3e16a..fc0b12548 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -222,7 +222,11 @@ function SettingsRouteView() { 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 gitModelOptionsByProvider = getCustomModelOptionsByProvider( + settings, + textGenProvider, + textGenModel, + ); const selectedCustomModelProviderSettings = MODEL_PROVIDER_SETTINGS.find( (providerSettings) => providerSettings.provider === selectedCustomModelProvider, )!; @@ -258,9 +262,7 @@ function SettingsRouteView() { ...(settings.textGenerationProvider !== defaults.textGenerationProvider ? ["Git writing provider"] : []), - ...(settings.textGenerationModel !== defaults.textGenerationModel - ? ["Git writing model"] - : []), + ...(settings.textGenerationModel !== defaults.textGenerationModel ? ["Git writing model"] : []), ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0 ? ["Custom models"] : []), diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 1f1b0c67d..015283c29 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -81,7 +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_BY_PROVIDER.codex)), + Schema.withConstructorDefault(() => + Option.some(DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex), + ), ), textGenerationProvider: Schema.optional(Schema.Literals(["codex", "claudeAgent"])), });