diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts
new file mode 100644
index 0000000000..0a3829798e
--- /dev/null
+++ b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts
@@ -0,0 +1,248 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { it } from "@effect/vitest";
+import { Effect, FileSystem, Layer, Path } from "effect";
+import { expect } from "vitest";
+
+import { ServerConfig } from "../../config.ts";
+import { TextGeneration } from "../Services/TextGeneration.ts";
+import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";
+
+const ClaudeTextGenerationTestLayer = ClaudeTextGenerationLive.pipe(
+ Layer.provideMerge(
+ ServerConfig.layerTest(process.cwd(), {
+ prefix: "t3code-claude-text-generation-test-",
+ }),
+ ),
+ Layer.provideMerge(NodeServices.layer),
+);
+
+function makeFakeClaudeBinary(dir: string) {
+ return Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const binDir = path.join(dir, "bin");
+ const claudePath = path.join(binDir, "claude");
+ yield* fs.makeDirectory(binDir, { recursive: true });
+
+ yield* fs.writeFileString(
+ claudePath,
+ [
+ "#!/bin/sh",
+ 'args="$*"',
+ 'stdin_content="$(cat)"',
+ 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" ]; then',
+ ' printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" >/dev/null || {',
+ ' printf "%s\\n" "args missing expected content" >&2',
+ " exit 2",
+ " }",
+ "fi",
+ 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" ]; then',
+ ' if printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" >/dev/null; then',
+ ' printf "%s\\n" "args contained forbidden content" >&2',
+ " exit 3",
+ " fi",
+ "fi",
+ 'if [ -n "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" ]; then',
+ ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" >/dev/null || {',
+ ' printf "%s\\n" "stdin missing expected content" >&2',
+ " exit 4",
+ " }",
+ "fi",
+ 'if [ -n "$T3_FAKE_CLAUDE_STDERR" ]; then',
+ ' printf "%s\\n" "$T3_FAKE_CLAUDE_STDERR" >&2',
+ "fi",
+ 'printf "%s" "$T3_FAKE_CLAUDE_OUTPUT"',
+ 'exit "${T3_FAKE_CLAUDE_EXIT_CODE:-0}"',
+ "",
+ ].join("\n"),
+ );
+ yield* fs.chmod(claudePath, 0o755);
+ return binDir;
+ });
+}
+
+function withFakeClaudeEnv(
+ input: {
+ output: string;
+ exitCode?: number;
+ stderr?: string;
+ argsMustContain?: string;
+ argsMustNotContain?: string;
+ stdinMustContain?: string;
+ },
+ effect: Effect.Effect,
+) {
+ return Effect.acquireUseRelease(
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" });
+ const binDir = yield* makeFakeClaudeBinary(tempDir);
+ const previousPath = process.env.PATH;
+ const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT;
+ const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE;
+ const previousStderr = process.env.T3_FAKE_CLAUDE_STDERR;
+ const previousArgsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN;
+ const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;
+ const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;
+
+ yield* Effect.sync(() => {
+ process.env.PATH = `${binDir}:${previousPath ?? ""}`;
+ process.env.T3_FAKE_CLAUDE_OUTPUT = input.output;
+
+ if (input.exitCode !== undefined) {
+ process.env.T3_FAKE_CLAUDE_EXIT_CODE = String(input.exitCode);
+ } else {
+ delete process.env.T3_FAKE_CLAUDE_EXIT_CODE;
+ }
+
+ if (input.stderr !== undefined) {
+ process.env.T3_FAKE_CLAUDE_STDERR = input.stderr;
+ } else {
+ delete process.env.T3_FAKE_CLAUDE_STDERR;
+ }
+
+ if (input.argsMustContain !== undefined) {
+ process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = input.argsMustContain;
+ } else {
+ delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN;
+ }
+
+ if (input.argsMustNotContain !== undefined) {
+ process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = input.argsMustNotContain;
+ } else {
+ delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;
+ }
+
+ if (input.stdinMustContain !== undefined) {
+ process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = input.stdinMustContain;
+ } else {
+ delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;
+ }
+ });
+
+ return {
+ previousPath,
+ previousOutput,
+ previousExitCode,
+ previousStderr,
+ previousArgsMustContain,
+ previousArgsMustNotContain,
+ previousStdinMustContain,
+ };
+ }),
+ () => effect,
+ (previous) =>
+ Effect.sync(() => {
+ process.env.PATH = previous.previousPath;
+
+ if (previous.previousOutput === undefined) {
+ delete process.env.T3_FAKE_CLAUDE_OUTPUT;
+ } else {
+ process.env.T3_FAKE_CLAUDE_OUTPUT = previous.previousOutput;
+ }
+
+ if (previous.previousExitCode === undefined) {
+ delete process.env.T3_FAKE_CLAUDE_EXIT_CODE;
+ } else {
+ process.env.T3_FAKE_CLAUDE_EXIT_CODE = previous.previousExitCode;
+ }
+
+ if (previous.previousStderr === undefined) {
+ delete process.env.T3_FAKE_CLAUDE_STDERR;
+ } else {
+ process.env.T3_FAKE_CLAUDE_STDERR = previous.previousStderr;
+ }
+
+ if (previous.previousArgsMustContain === undefined) {
+ delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN;
+ } else {
+ process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = previous.previousArgsMustContain;
+ }
+
+ if (previous.previousArgsMustNotContain === undefined) {
+ delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;
+ } else {
+ process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = previous.previousArgsMustNotContain;
+ }
+
+ if (previous.previousStdinMustContain === undefined) {
+ delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;
+ } else {
+ process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previous.previousStdinMustContain;
+ }
+ }),
+ );
+}
+
+it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => {
+ it.effect("forwards Claude thinking settings for Haiku without passing effort", () =>
+ withFakeClaudeEnv(
+ {
+ output: JSON.stringify({
+ structured_output: {
+ subject: "Add important change",
+ body: "",
+ },
+ }),
+ argsMustContain: '--settings {"alwaysThinkingEnabled":false}',
+ argsMustNotContain: "--effort",
+ },
+ Effect.gen(function* () {
+ const textGeneration = yield* TextGeneration;
+
+ const generated = yield* textGeneration.generateCommitMessage({
+ cwd: process.cwd(),
+ branch: "feature/claude-effect",
+ stagedSummary: "M README.md",
+ stagedPatch: "diff --git a/README.md b/README.md",
+ modelSelection: {
+ provider: "claudeAgent",
+ model: "claude-haiku-4-5",
+ options: {
+ thinking: false,
+ effort: "high",
+ },
+ },
+ });
+
+ expect(generated.subject).toBe("Add important change");
+ }),
+ ),
+ );
+
+ it.effect("forwards Claude fast mode and supported effort", () =>
+ withFakeClaudeEnv(
+ {
+ output: JSON.stringify({
+ structured_output: {
+ title: "Improve orchestration flow",
+ body: "Body",
+ },
+ }),
+ argsMustContain: '--effort max --settings {"fastMode":true}',
+ },
+ Effect.gen(function* () {
+ const textGeneration = yield* TextGeneration;
+
+ const generated = yield* textGeneration.generatePrContent({
+ cwd: process.cwd(),
+ baseBranch: "main",
+ headBranch: "feature/claude-effect",
+ commitSummary: "Improve orchestration",
+ diffSummary: "1 file changed",
+ diffPatch: "diff --git a/README.md b/README.md",
+ modelSelection: {
+ provider: "claudeAgent",
+ model: "claude-opus-4-6",
+ options: {
+ effort: "max",
+ fastMode: true,
+ },
+ },
+ });
+
+ expect(generated.title).toBe("Improve orchestration flow");
+ }),
+ ),
+ );
+});
diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts
new file mode 100644
index 0000000000..9f48a07c51
--- /dev/null
+++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts
@@ -0,0 +1,301 @@
+/**
+ * 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 { ClaudeModelSelection } from "@t3tools/contracts";
+import { normalizeClaudeModelOptions } from "@t3tools/shared/model";
+import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
+
+import { TextGenerationError } from "../Errors.ts";
+import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";
+import {
+ buildBranchNamePrompt,
+ buildCommitMessagePrompt,
+ buildPrContentPrompt,
+} from "../Prompts.ts";
+import {
+ normalizeCliError,
+ sanitizeCommitSubject,
+ sanitizePrTitle,
+ toJsonSchemaObject,
+} from "../Utils.ts";
+
+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 =>
+ stream.pipe(
+ Stream.decodeText(),
+ Stream.runFold(
+ () => "",
+ (acc, chunk) => acc + chunk,
+ ),
+ Effect.mapError((cause) =>
+ normalizeCliError("claude", operation, cause, "Failed to collect process output"),
+ ),
+ );
+
+ /**
+ * Spawn the Claude CLI with structured JSON output and return the parsed,
+ * schema-validated result.
+ */
+ const runClaudeJson = ({
+ operation,
+ cwd,
+ prompt,
+ outputSchemaJson,
+ modelSelection,
+ }: {
+ operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";
+ cwd: string;
+ prompt: string;
+ outputSchemaJson: S;
+ modelSelection: ClaudeModelSelection;
+ }): Effect.Effect =>
+ Effect.gen(function* () {
+ const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson));
+ const normalizedOptions = normalizeClaudeModelOptions(
+ modelSelection.model,
+ modelSelection.options,
+ );
+ const settings = {
+ ...(typeof normalizedOptions?.thinking === "boolean"
+ ? { alwaysThinkingEnabled: normalizedOptions.thinking }
+ : {}),
+ ...(normalizedOptions?.fastMode ? { fastMode: true } : {}),
+ };
+
+ const runClaudeCommand = Effect.gen(function* () {
+ const command = ChildProcess.make(
+ "claude",
+ [
+ "-p",
+ "--output-format",
+ "json",
+ "--json-schema",
+ jsonSchemaStr,
+ "--model",
+ modelSelection.model,
+ ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []),
+ ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []),
+ "--dangerously-skip-permissions",
+ ],
+ {
+ cwd,
+ shell: process.platform === "win32",
+ stdin: {
+ stream: Stream.encodeText(Stream.make(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.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"] = Effect.fn(
+ "ClaudeTextGeneration.generateCommitMessage",
+ )(function* (input) {
+ const { prompt, outputSchema } = buildCommitMessagePrompt({
+ branch: input.branch,
+ stagedSummary: input.stagedSummary,
+ stagedPatch: input.stagedPatch,
+ includeBranch: input.includeBranch === true,
+ });
+
+ if (input.modelSelection.provider !== "claudeAgent") {
+ return yield* new TextGenerationError({
+ operation: "generateCommitMessage",
+ detail: "Invalid model selection.",
+ });
+ }
+
+ const generated = yield* runClaudeJson({
+ operation: "generateCommitMessage",
+ cwd: input.cwd,
+ prompt,
+ outputSchemaJson: outputSchema,
+ modelSelection: input.modelSelection,
+ });
+
+ return {
+ subject: sanitizeCommitSubject(generated.subject),
+ body: generated.body.trim(),
+ ...("branch" in generated && typeof generated.branch === "string"
+ ? { branch: sanitizeFeatureBranchName(generated.branch) }
+ : {}),
+ };
+ });
+
+ const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
+ "ClaudeTextGeneration.generatePrContent",
+ )(function* (input) {
+ const { prompt, outputSchema } = buildPrContentPrompt({
+ baseBranch: input.baseBranch,
+ headBranch: input.headBranch,
+ commitSummary: input.commitSummary,
+ diffSummary: input.diffSummary,
+ diffPatch: input.diffPatch,
+ });
+
+ if (input.modelSelection.provider !== "claudeAgent") {
+ return yield* new TextGenerationError({
+ operation: "generatePrContent",
+ detail: "Invalid model selection.",
+ });
+ }
+
+ const generated = yield* runClaudeJson({
+ operation: "generatePrContent",
+ cwd: input.cwd,
+ prompt,
+ outputSchemaJson: outputSchema,
+ modelSelection: input.modelSelection,
+ });
+
+ return {
+ title: sanitizePrTitle(generated.title),
+ body: generated.body.trim(),
+ };
+ });
+
+ const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(
+ "ClaudeTextGeneration.generateBranchName",
+ )(function* (input) {
+ const { prompt, outputSchema } = buildBranchNamePrompt({
+ message: input.message,
+ attachments: input.attachments,
+ });
+
+ if (input.modelSelection.provider !== "claudeAgent") {
+ return yield* new TextGenerationError({
+ operation: "generateBranchName",
+ detail: "Invalid model selection.",
+ });
+ }
+
+ const generated = yield* runClaudeJson({
+ operation: "generateBranchName",
+ cwd: input.cwd,
+ prompt,
+ outputSchemaJson: outputSchema,
+ modelSelection: input.modelSelection,
+ });
+
+ return {
+ branch: sanitizeBranchFragment(generated.branch),
+ };
+ });
+
+ return {
+ generateCommitMessage,
+ generatePrContent,
+ generateBranchName,
+ } satisfies TextGenerationShape;
+});
+
+export const ClaudeTextGenerationLive = Layer.effect(TextGeneration, makeClaudeTextGeneration);
diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts
index 0170d207fe..b53d7f15bd 100644
--- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts
+++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts
@@ -1,6 +1,6 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { it } from "@effect/vitest";
-import { Effect, FileSystem, Layer, Path } from "effect";
+import { Effect, FileSystem, Layer, Path, Result } from "effect";
import { expect } from "vitest";
import { ServerConfig } from "../../config.ts";
@@ -8,6 +8,11 @@ import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
import { TextGenerationError } from "../Errors.ts";
import { TextGeneration } from "../Services/TextGeneration.ts";
+const DEFAULT_TEST_MODEL_SELECTION = {
+ provider: "codex" as const,
+ model: "gpt-5.4-mini",
+};
+
const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe(
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), {
@@ -38,6 +43,18 @@ function makeFakeCodexBinary(dir: string) {
" fi",
" continue",
" fi",
+ ' if [ "$1" = "--config" ]; then',
+ " shift",
+ ' if [ "$1" = "service_tier=\\"fast\\"" ]; then',
+ ' seen_fast_service_tier="1"',
+ " fi",
+ ' case "$1" in',
+ " model_reasoning_effort=*)",
+ ' seen_reasoning_effort="$1"',
+ " ;;",
+ " esac",
+ " continue",
+ " fi",
' if [ "$1" = "--output-last-message" ]; then',
" shift",
' output_path="$1"',
@@ -49,6 +66,18 @@ function makeFakeCodexBinary(dir: string) {
' printf "%s\\n" "missing --image input" >&2',
" exit 2",
"fi",
+ 'if [ "$T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER" = "1" ] && [ "$seen_fast_service_tier" != "1" ]; then',
+ ' printf "%s\\n" "missing fast service tier config" >&2',
+ " exit 5",
+ "fi",
+ 'if [ -n "$T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT" ] && [ "$seen_reasoning_effort" != "model_reasoning_effort=\\"$T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT\\"" ]; then',
+ ' printf "%s\\n" "unexpected reasoning effort config: $seen_reasoning_effort" >&2',
+ " exit 6",
+ "fi",
+ 'if [ "$T3_FAKE_CODEX_FORBID_REASONING_EFFORT" = "1" ] && [ -n "$seen_reasoning_effort" ]; then',
+ ' printf "%s\\n" "reasoning effort config should be omitted: $seen_reasoning_effort" >&2',
+ " exit 7",
+ "fi",
'if [ -n "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" ]; then',
' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" >/dev/null || {',
' printf "%s\\n" "stdin missing expected content" >&2',
@@ -82,6 +111,9 @@ function withFakeCodexEnv(
exitCode?: number;
stderr?: string;
requireImage?: boolean;
+ requireFastServiceTier?: boolean;
+ requireReasoningEffort?: string;
+ forbidReasoningEffort?: boolean;
stdinMustContain?: string;
stdinMustNotContain?: string;
},
@@ -97,6 +129,9 @@ function withFakeCodexEnv(
const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE;
const previousStderr = process.env.T3_FAKE_CODEX_STDERR;
const previousRequireImage = process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;
+ const previousRequireFastServiceTier = process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER;
+ const previousRequireReasoningEffort = process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT;
+ const previousForbidReasoningEffort = process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT;
const previousStdinMustContain = process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN;
const previousStdinMustNotContain = process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN;
@@ -122,6 +157,24 @@ function withFakeCodexEnv(
delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;
}
+ if (input.requireFastServiceTier) {
+ process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER = "1";
+ } else {
+ delete process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER;
+ }
+
+ if (input.requireReasoningEffort !== undefined) {
+ process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT = input.requireReasoningEffort;
+ } else {
+ delete process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT;
+ }
+
+ if (input.forbidReasoningEffort) {
+ process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT = "1";
+ } else {
+ delete process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT;
+ }
+
if (input.stdinMustContain !== undefined) {
process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN = input.stdinMustContain;
} else {
@@ -141,6 +194,9 @@ function withFakeCodexEnv(
previousExitCode,
previousStderr,
previousRequireImage,
+ previousRequireFastServiceTier,
+ previousRequireReasoningEffort,
+ previousForbidReasoningEffort,
previousStdinMustContain,
previousStdinMustNotContain,
};
@@ -174,6 +230,27 @@ function withFakeCodexEnv(
process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = previous.previousRequireImage;
}
+ if (previous.previousRequireFastServiceTier === undefined) {
+ delete process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER;
+ } else {
+ process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER =
+ previous.previousRequireFastServiceTier;
+ }
+
+ if (previous.previousRequireReasoningEffort === undefined) {
+ delete process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT;
+ } else {
+ process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT =
+ previous.previousRequireReasoningEffort;
+ }
+
+ if (previous.previousForbidReasoningEffort === undefined) {
+ delete process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT;
+ } else {
+ process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT =
+ previous.previousForbidReasoningEffort;
+ }
+
if (previous.previousStdinMustContain === undefined) {
delete process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN;
} else {
@@ -208,6 +285,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
branch: "feature/codex-effect",
stagedSummary: "M README.md",
stagedPatch: "diff --git a/README.md b/README.md",
+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
});
expect(generated.subject.length).toBeLessThanOrEqual(72);
@@ -218,6 +296,63 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
),
);
+ it.effect(
+ "forwards codex fast mode and non-default reasoning effort into codex exec config",
+ () =>
+ withFakeCodexEnv(
+ {
+ output: JSON.stringify({
+ subject: "Add important change",
+ body: "",
+ }),
+ requireFastServiceTier: true,
+ requireReasoningEffort: "xhigh",
+ stdinMustNotContain: "branch must be a short semantic git branch fragment",
+ },
+ Effect.gen(function* () {
+ const textGeneration = yield* TextGeneration;
+
+ yield* textGeneration.generateCommitMessage({
+ cwd: process.cwd(),
+ branch: "feature/codex-effect",
+ stagedSummary: "M README.md",
+ stagedPatch: "diff --git a/README.md b/README.md",
+ modelSelection: {
+ provider: "codex",
+ model: "gpt-5.4",
+ options: {
+ reasoningEffort: "xhigh",
+ fastMode: true,
+ },
+ },
+ });
+ }),
+ ),
+ );
+
+ it.effect("defaults git text generation codex effort to low", () =>
+ withFakeCodexEnv(
+ {
+ output: JSON.stringify({
+ subject: "Add important change",
+ body: "",
+ }),
+ requireReasoningEffort: "low",
+ },
+ Effect.gen(function* () {
+ const textGeneration = yield* TextGeneration;
+
+ yield* textGeneration.generateCommitMessage({
+ cwd: process.cwd(),
+ branch: "feature/codex-effect",
+ stagedSummary: "M README.md",
+ stagedPatch: "diff --git a/README.md b/README.md",
+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
+ });
+ }),
+ ),
+ );
+
it.effect("generates commit message with branch when includeBranch is true", () =>
withFakeCodexEnv(
{
@@ -237,6 +372,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
stagedSummary: "M README.md",
stagedPatch: "diff --git a/README.md b/README.md",
includeBranch: true,
+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
});
expect(generated.subject).toBe("Add important change");
@@ -263,6 +399,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
commitSummary: "feat: improve orchestration flow",
diffSummary: "2 files changed",
diffPatch: "diff --git a/a.ts b/a.ts",
+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
});
expect(generated.title).toBe("Improve orchestration flow");
@@ -286,6 +423,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
const generated = yield* textGeneration.generateBranchName({
cwd: process.cwd(),
message: "Please update session handling.",
+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
});
expect(generated.branch).toBe("feat/session");
@@ -307,6 +445,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
const generated = yield* textGeneration.generateBranchName({
cwd: process.cwd(),
message: "Fix timeout behavior.",
+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
});
expect(generated.branch).toBe("fix/session-timeout");
@@ -333,21 +472,20 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
yield* fs.writeFile(attachmentPath, Buffer.from("hello"));
const textGeneration = yield* TextGeneration;
- const generated = yield* textGeneration
- .generateBranchName({
- cwd: process.cwd(),
- message: "Fix layout bug from screenshot.",
- attachments: [
- {
- type: "image",
- id: attachmentId,
- name: "bug.png",
- mimeType: "image/png",
- sizeBytes: 5,
- },
- ],
- })
- .pipe(Effect.ensuring(fs.remove(attachmentPath).pipe(Effect.catch(() => Effect.void))));
+ const generated = yield* textGeneration.generateBranchName({
+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
+ cwd: process.cwd(),
+ message: "Fix layout bug from screenshot.",
+ attachments: [
+ {
+ type: "image",
+ id: attachmentId,
+ name: "bug.png",
+ mimeType: "image/png",
+ sizeBytes: 5,
+ },
+ ],
+ });
expect(generated.branch).toBe("fix/ui-regression");
}),
@@ -374,6 +512,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
const textGeneration = yield* TextGeneration;
const generated = yield* textGeneration
.generateBranchName({
+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
cwd: process.cwd(),
message: "Fix layout bug from screenshot.",
attachments: [
@@ -421,6 +560,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
const textGeneration = yield* TextGeneration;
const result = yield* textGeneration
.generateBranchName({
+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
cwd: process.cwd(),
message: "Fix layout bug from screenshot.",
attachments: [
@@ -433,17 +573,12 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
},
],
})
- .pipe(
- Effect.match({
- onFailure: (error) => ({ _tag: "Left" as const, left: error }),
- onSuccess: (value) => ({ _tag: "Right" as const, right: value }),
- }),
- );
+ .pipe(Effect.result);
- expect(result._tag).toBe("Left");
- if (result._tag === "Left") {
- expect(result.left).toBeInstanceOf(TextGenerationError);
- expect(result.left.message).toContain("missing --image input");
+ expect(Result.isFailure(result)).toBe(true);
+ if (Result.isFailure(result)) {
+ expect(result.failure).toBeInstanceOf(TextGenerationError);
+ expect(result.failure.message).toContain("missing --image input");
}
}),
),
@@ -465,18 +600,14 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
.generateBranchName({
cwd: process.cwd(),
message: "Fix websocket reconnect flake",
+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
})
- .pipe(
- Effect.match({
- onFailure: (error) => ({ _tag: "Left" as const, left: error }),
- onSuccess: (value) => ({ _tag: "Right" as const, right: value }),
- }),
- );
-
- expect(result._tag).toBe("Left");
- if (result._tag === "Left") {
- expect(result.left).toBeInstanceOf(TextGenerationError);
- expect(result.left.message).toContain("Codex returned invalid structured output");
+ .pipe(Effect.result);
+
+ expect(Result.isFailure(result)).toBe(true);
+ if (Result.isFailure(result)) {
+ expect(result.failure).toBeInstanceOf(TextGenerationError);
+ expect(result.failure.message).toContain("Codex returned invalid structured output");
}
}),
),
@@ -498,18 +629,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
branch: "feature/codex-error",
stagedSummary: "M README.md",
stagedPatch: "diff --git a/README.md b/README.md",
+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
})
- .pipe(
- Effect.match({
- onFailure: (error) => ({ _tag: "Left" as const, left: error }),
- onSuccess: (value) => ({ _tag: "Right" as const, right: value }),
- }),
- );
+ .pipe(Effect.result);
- expect(result._tag).toBe("Left");
- if (result._tag === "Left") {
- expect(result.left).toBeInstanceOf(TextGenerationError);
- expect(result.left.message).toContain("Codex CLI command failed: codex execution failed");
+ expect(Result.isFailure(result)).toBe(true);
+ if (Result.isFailure(result)) {
+ expect(result.failure).toBeInstanceOf(TextGenerationError);
+ expect(result.failure.message).toContain(
+ "Codex CLI command failed: codex execution failed",
+ );
}
}),
),
diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts
index 373c191236..afe972ab4a 100644
--- a/apps/server/src/git/Layers/CodexTextGeneration.ts
+++ b/apps/server/src/git/Layers/CodexTextGeneration.ts
@@ -1,9 +1,10 @@
import { randomUUID } from "node:crypto";
-import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect";
+import { Effect, FileSystem, Layer, Option, Path, Schema, Scope, Stream } from "effect";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
-import { DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts";
+import { CodexModelSelection } from "@t3tools/contracts";
+import { normalizeCodexModelOptions } from "@t3tools/shared/model";
import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
import { resolveAttachmentPath } from "../../attachmentStore.ts";
@@ -11,90 +12,24 @@ import { ServerConfig } from "../../config.ts";
import { TextGenerationError } from "../Errors.ts";
import {
type BranchNameGenerationInput,
- type BranchNameGenerationResult,
- type CommitMessageGenerationResult,
- type PrContentGenerationResult,
type TextGenerationShape,
TextGeneration,
} from "../Services/TextGeneration.ts";
+import {
+ buildBranchNamePrompt,
+ buildCommitMessagePrompt,
+ buildPrContentPrompt,
+} from "../Prompts.ts";
+import {
+ normalizeCliError,
+ sanitizeCommitSubject,
+ sanitizePrTitle,
+ toJsonSchemaObject,
+} from "../Utils.ts";
-const CODEX_REASONING_EFFORT = "low";
+const CODEX_GIT_TEXT_GENERATION_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;
@@ -109,39 +44,37 @@ const makeCodexTextGeneration = Effect.gen(function* () {
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) =>
- normalizeCodexError(operation, cause, "Failed to collect process output"),
- ),
- );
- return text;
- });
-
- const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";
+ stream.pipe(
+ Stream.decodeText(),
+ Stream.runFold(
+ () => "",
+ (acc, chunk) => acc + chunk,
+ ),
+ Effect.mapError((cause) =>
+ normalizeCliError("codex", operation, cause, "Failed to collect process output"),
+ ),
+ );
const writeTempFile = (
operation: string,
prefix: string,
content: string,
- ): Effect.Effect => {
- const filePath = path.join(tempDir, `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`);
- return fileSystem.writeFileString(filePath, content).pipe(
- Effect.mapError(
- (cause) =>
- new TextGenerationError({
- operation,
- detail: `Failed to write temp file at ${filePath}.`,
- cause,
- }),
- ),
- Effect.as(filePath),
- );
+ ): Effect.Effect => {
+ return fileSystem
+ .makeTempFileScoped({
+ prefix: `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`,
+ })
+ .pipe(
+ Effect.tap((filePath) => fileSystem.writeFileString(filePath, content)),
+ Effect.mapError(
+ (cause) =>
+ new TextGenerationError({
+ operation,
+ detail: `Failed to write temp file`,
+ cause,
+ }),
+ ),
+ );
};
const safeUnlink = (filePath: string): Effect.Effect =>
@@ -187,7 +120,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
outputSchemaJson,
imagePaths = [],
cleanupPaths = [],
- model,
+ modelSelection,
}: {
operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";
cwd: string;
@@ -195,17 +128,23 @@ const makeCodexTextGeneration = Effect.gen(function* () {
outputSchemaJson: S;
imagePaths?: ReadonlyArray;
cleanupPaths?: ReadonlyArray;
- model?: string;
+ modelSelection: CodexModelSelection;
}): Effect.Effect =>
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", "");
const runCodexCommand = Effect.gen(function* () {
+ const normalizedOptions = normalizeCodexModelOptions(
+ modelSelection.model,
+ modelSelection.options,
+ );
+ const reasoningEffort =
+ modelSelection.options?.reasoningEffort ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT;
const command = ChildProcess.make(
"codex",
[
@@ -214,9 +153,10 @@ const makeCodexTextGeneration = Effect.gen(function* () {
"-s",
"read-only",
"--model",
- model ?? DEFAULT_GIT_TEXT_GENERATION_MODEL,
+ modelSelection.model,
"--config",
- `model_reasoning_effort="${CODEX_REASONING_EFFORT}"`,
+ `model_reasoning_effort="${reasoningEffort}"`,
+ ...(normalizedOptions?.fastMode ? ["--config", `service_tier="fast"`] : []),
"--output-schema",
schemaPath,
"--output-last-message",
@@ -228,7 +168,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
cwd,
shell: process.platform === "win32",
stdin: {
- stream: Stream.make(new TextEncoder().encode(prompt)),
+ stream: Stream.encodeText(Stream.make(prompt)),
},
},
);
@@ -237,7 +177,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"),
),
);
@@ -246,9 +186,8 @@ const makeCodexTextGeneration = Effect.gen(function* () {
readStreamAsString(operation, child.stdout),
readStreamAsString(operation, child.stderr),
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"),
),
),
],
@@ -314,153 +253,104 @@ const makeCodexTextGeneration = Effect.gen(function* () {
}).pipe(Effect.ensuring(cleanup));
});
- 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 generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(
+ "CodexTextGeneration.generateCommitMessage",
+ )(function* (input) {
+ const { prompt, outputSchema } = buildCommitMessagePrompt({
+ branch: input.branch,
+ stagedSummary: input.stagedSummary,
+ stagedPatch: input.stagedPatch,
+ includeBranch: input.includeBranch === true,
+ });
- return runCodexJson({
+ if (input.modelSelection.provider !== "codex") {
+ return yield* new TextGenerationError({
+ operation: "generateCommitMessage",
+ detail: "Invalid model selection.",
+ });
+ }
+
+ const generated = yield* runCodexJson({
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,
- ),
- );
- };
+ outputSchemaJson: outputSchema,
+ modelSelection: input.modelSelection,
+ });
+
+ return {
+ subject: sanitizeCommitSubject(generated.subject),
+ body: generated.body.trim(),
+ ...("branch" in generated && typeof generated.branch === "string"
+ ? { branch: sanitizeFeatureBranchName(generated.branch) }
+ : {}),
+ };
+ });
+
+ const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
+ "CodexTextGeneration.generatePrContent",
+ )(function* (input) {
+ const { prompt, outputSchema } = buildPrContentPrompt({
+ baseBranch: input.baseBranch,
+ headBranch: input.headBranch,
+ commitSummary: input.commitSummary,
+ diffSummary: input.diffSummary,
+ diffPatch: input.diffPatch,
+ });
- 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 runCodexJson({
+ if (input.modelSelection.provider !== "codex") {
+ return yield* new TextGenerationError({
+ operation: "generatePrContent",
+ detail: "Invalid model selection.",
+ });
+ }
+
+ const generated = yield* runCodexJson({
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,
- ),
- );
- };
+ outputSchemaJson: outputSchema,
+ modelSelection: input.modelSelection,
+ });
- const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => {
- return Effect.gen(function* () {
- const { imagePaths } = yield* materializeImageAttachments(
- "generateBranchName",
- input.attachments,
- );
- const attachmentLines = (input.attachments ?? []).map(
- (attachment) =>
- `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
- );
+ return {
+ title: sanitizePrTitle(generated.title),
+ body: generated.body.trim(),
+ };
+ });
- 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 generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(
+ "CodexTextGeneration.generateBranchName",
+ )(function* (input) {
+ const { imagePaths } = yield* materializeImageAttachments(
+ "generateBranchName",
+ input.attachments,
+ );
+ const { prompt, outputSchema } = buildBranchNamePrompt({
+ message: input.message,
+ attachments: input.attachments,
+ });
- const generated = yield* runCodexJson({
+ if (input.modelSelection.provider !== "codex") {
+ return yield* new TextGenerationError({
operation: "generateBranchName",
- cwd: input.cwd,
- prompt,
- outputSchemaJson: Schema.Struct({
- branch: Schema.String,
- }),
- imagePaths,
- ...(input.model ? { model: input.model } : {}),
+ detail: "Invalid model selection.",
});
+ }
- return {
- branch: sanitizeBranchFragment(generated.branch),
- } satisfies BranchNameGenerationResult;
+ const generated = yield* runCodexJson({
+ operation: "generateBranchName",
+ cwd: input.cwd,
+ prompt,
+ outputSchemaJson: outputSchema,
+ imagePaths,
+ modelSelection: input.modelSelection,
});
- };
+
+ return {
+ branch: sanitizeBranchFragment(generated.branch),
+ };
+ });
return {
generateCommitMessage,
diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts
index ce252f739f..9302c562e8 100644
--- a/apps/server/src/git/Layers/GitManager.test.ts
+++ b/apps/server/src/git/Layers/GitManager.test.ts
@@ -21,6 +21,11 @@ import { GitCore } from "../Services/GitCore.ts";
import { makeGitManager } from "./GitManager.ts";
import { ServerConfig } from "../../config.ts";
+const DEFAULT_TEST_MODEL_SELECTION = {
+ provider: "codex",
+ model: "gpt-5.4-mini",
+} as const;
+
interface FakeGhScenario {
prListSequence?: string[];
prListByHeadSelector?: Record;
@@ -461,6 +466,7 @@ function runStackedAction(
{
...input,
actionId: input.actionId ?? "test-action-id",
+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
},
options,
);
diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts
index 4f240e0044..e2ae56a90c 100644
--- a/apps/server/src/git/Layers/GitManager.ts
+++ b/apps/server/src/git/Layers/GitManager.ts
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { realpathSync } from "node:fs";
import { Effect, FileSystem, Layer, Path } from "effect";
-import type { GitActionProgressEvent, GitActionProgressPhase } from "@t3tools/contracts";
+import { GitActionProgressEvent, GitActionProgressPhase, ModelSelection } from "@t3tools/contracts";
import {
resolveAutoFeatureBranchName,
sanitizeBranchFragment,
@@ -684,7 +684,7 @@ export const makeGitManager = Effect.gen(function* () {
/** When true, also produce a semantic feature branch name. */
includeBranch?: boolean;
filePaths?: readonly string[];
- model?: string;
+ modelSelection: ModelSelection;
}) =>
Effect.gen(function* () {
const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths);
@@ -711,7 +711,7 @@ export const makeGitManager = Effect.gen(function* () {
stagedSummary: limitContext(context.stagedSummary, 8_000),
stagedPatch: limitContext(context.stagedPatch, 50_000),
...(input.includeBranch ? { includeBranch: true } : {}),
- ...(input.model ? { model: input.model } : {}),
+ modelSelection: input.modelSelection,
})
.pipe(Effect.map((result) => sanitizeCommitMessage(result)));
@@ -724,13 +724,13 @@ export const makeGitManager = Effect.gen(function* () {
});
const runCommitStep = (
+ modelSelection: ModelSelection,
cwd: string,
action: "commit" | "commit_push" | "commit_push_pr",
branch: string | null,
commitMessage?: string,
preResolvedSuggestion?: CommitAndBranchSuggestion,
filePaths?: readonly string[],
- model?: string,
progressReporter?: GitActionProgressReporter,
actionId?: string,
) =>
@@ -760,7 +760,7 @@ export const makeGitManager = Effect.gen(function* () {
branch,
...(commitMessage ? { commitMessage } : {}),
...(filePaths ? { filePaths } : {}),
- ...(model ? { model } : {}),
+ modelSelection,
});
}
if (!suggestion) {
@@ -837,7 +837,7 @@ export const makeGitManager = Effect.gen(function* () {
};
});
- const runPrStep = (cwd: string, fallbackBranch: string | null, model?: string) =>
+ const runPrStep = (modelSelection: ModelSelection, cwd: string, fallbackBranch: string | null) =>
Effect.gen(function* () {
const details = yield* gitCore.statusDetails(cwd);
const branch = details.branch ?? fallbackBranch;
@@ -881,7 +881,7 @@ export const makeGitManager = Effect.gen(function* () {
commitSummary: limitContext(rangeContext.commitSummary, 20_000),
diffSummary: limitContext(rangeContext.diffSummary, 20_000),
diffPatch: limitContext(rangeContext.diffPatch, 60_000),
- ...(model ? { model } : {}),
+ modelSelection,
});
const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`);
@@ -1102,11 +1102,11 @@ export const makeGitManager = Effect.gen(function* () {
);
const runFeatureBranchStep = (
+ modelSelection: ModelSelection,
cwd: string,
branch: string | null,
commitMessage?: string,
filePaths?: readonly string[],
- model?: string,
) =>
Effect.gen(function* () {
const suggestion = yield* resolveCommitAndBranchSuggestion({
@@ -1115,7 +1115,7 @@ export const makeGitManager = Effect.gen(function* () {
...(commitMessage ? { commitMessage } : {}),
...(filePaths ? { filePaths } : {}),
includeBranch: true,
- ...(model ? { model } : {}),
+ modelSelection,
});
if (!suggestion) {
return yield* gitManagerError(
@@ -1181,11 +1181,11 @@ export const makeGitManager = Effect.gen(function* () {
label: "Preparing feature branch...",
});
const result = yield* runFeatureBranchStep(
+ input.modelSelection,
input.cwd,
initialStatus.branch,
input.commitMessage,
input.filePaths,
- input.textGenerationModel,
);
branchStep = result.branchStep;
commitMessageForStep = result.resolvedCommitMessage;
@@ -1198,13 +1198,13 @@ export const makeGitManager = Effect.gen(function* () {
currentPhase = "commit";
const commit = yield* runCommitStep(
+ input.modelSelection,
input.cwd,
input.action,
currentBranch,
commitMessageForStep,
preResolvedCommitSuggestion,
input.filePaths,
- input.textGenerationModel,
options?.progressReporter,
progress.actionId,
);
@@ -1237,7 +1237,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.modelSelection, input.cwd, currentBranch);
}),
),
)
diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts
new file mode 100644
index 0000000000..7915131385
--- /dev/null
+++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts
@@ -0,0 +1,72 @@
+/**
+ * RoutingTextGeneration – Dispatches text generation requests to either the
+ * Codex CLI or Claude CLI implementation based on the provider in each
+ * request input.
+ *
+ * When `modelSelection.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.modelSelection.provider).generateCommitMessage(input),
+ generatePrContent: (input) => route(input.modelSelection.provider).generatePrContent(input),
+ generateBranchName: (input) => route(input.modelSelection.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/Prompts.test.ts b/apps/server/src/git/Prompts.test.ts
new file mode 100644
index 0000000000..23c3eca557
--- /dev/null
+++ b/apps/server/src/git/Prompts.test.ts
@@ -0,0 +1,150 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ buildBranchNamePrompt,
+ buildCommitMessagePrompt,
+ buildPrContentPrompt,
+} from "./Prompts.ts";
+import { normalizeCliError } from "./Utils.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/Prompts.ts b/apps/server/src/git/Prompts.ts
new file mode 100644
index 0000000000..2eacf370eb
--- /dev/null
+++ b/apps/server/src/git/Prompts.ts
@@ -0,0 +1,153 @@
+/**
+ * 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 "./Utils.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/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts
index b4650ed570..e9f2230f43 100644
--- a/apps/server/src/git/Services/TextGeneration.ts
+++ b/apps/server/src/git/Services/TextGeneration.ts
@@ -8,10 +8,13 @@
*/
import { ServiceMap } from "effect";
import type { Effect } from "effect";
-import type { ChatAttachment } from "@t3tools/contracts";
+import type { ChatAttachment, ModelSelection } 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,8 @@ 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?: string;
+ /** What model and provider to use for generation. */
+ modelSelection: ModelSelection;
}
export interface CommitMessageGenerationResult {
@@ -37,8 +40,8 @@ export interface PrContentGenerationInput {
commitSummary: string;
diffSummary: string;
diffPatch: string;
- /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */
- model?: string;
+ /** What model and provider to use for generation. */
+ modelSelection: ModelSelection;
}
export interface PrContentGenerationResult {
@@ -50,8 +53,8 @@ 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?: string;
+ /** What model and provider to use for generation. */
+ modelSelection: ModelSelection;
}
export interface BranchNameGenerationResult {
diff --git a/apps/server/src/git/Utils.ts b/apps/server/src/git/Utils.ts
new file mode 100644
index 0000000000..eb208deccb
--- /dev/null
+++ b/apps/server/src/git/Utils.ts
@@ -0,0 +1,102 @@
+/**
+ * Shared utilities for text generation layers (Codex, Claude, etc.).
+ *
+ * @module textGenerationUtils
+ */
+import { Schema } from "effect";
+
+import { TextGenerationError } from "./Errors.ts";
+
+import { existsSync } from "node:fs";
+import { join } from "node:path";
+
+export function isGitRepository(cwd: string): boolean {
+ return existsSync(join(cwd, ".git"));
+}
+
+/** 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/isRepo.ts b/apps/server/src/git/isRepo.ts
deleted file mode 100644
index 6faf3e99c7..0000000000
--- a/apps/server/src/git/isRepo.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { existsSync } from "node:fs";
-import { join } from "node:path";
-
-export function isGitRepository(cwd: string): boolean {
- return existsSync(join(cwd, ".git"));
-}
diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts
index ab38c10332..e4a673342c 100644
--- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts
+++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts
@@ -24,7 +24,7 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts";
import { CheckpointStoreError } from "../../checkpointing/Errors.ts";
import { OrchestrationDispatchError } from "../Errors.ts";
-import { isGitRepository } from "../../git/isRepo.ts";
+import { isGitRepository } from "../../git/Utils.ts";
type ReactorInput =
| {
diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
index 9399bcc280..737ad665d7 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 ModelSelection,
type OrchestrationEvent,
@@ -437,7 +437,10 @@ const make = Effect.gen(function* () {
cwd,
message: input.messageText,
...(attachments.length > 0 ? { attachments } : {}),
- model: DEFAULT_GIT_TEXT_GENERATION_MODEL,
+ modelSelection: {
+ provider: "codex",
+ model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex,
+ },
})
.pipe(
Effect.catch((error) =>
diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
index 170b5bc717..0b7af8bd4b 100644
--- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
+++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
@@ -20,7 +20,7 @@ import { ProviderService } from "../../provider/Services/ProviderService.ts";
import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";
import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";
import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
-import { isGitRepository } from "../../git/isRepo.ts";
+import { isGitRepository } from "../../git/Utils.ts";
import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
import {
ProviderRuntimeIngestionService,
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/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts
index ff95b54112..7dc4a59e7c 100644
--- a/apps/server/src/wsServer.test.ts
+++ b/apps/server/src/wsServer.test.ts
@@ -1838,6 +1838,10 @@ describe("WebSocket Server", () => {
actionId: "client-action-1",
cwd: "/test",
action: "commit_push",
+ modelSelection: {
+ provider: "codex",
+ model: "gpt-5.4-mini",
+ },
});
expect(response.result).toBeUndefined();
expect(response.error?.message).toContain("detached HEAD");
@@ -1846,6 +1850,10 @@ describe("WebSocket Server", () => {
actionId: "client-action-1",
cwd: "/test",
action: "commit_push",
+ modelSelection: {
+ provider: "codex",
+ model: "gpt-5.4-mini",
+ },
},
expect.objectContaining({
actionId: "client-action-1",
@@ -1901,6 +1909,10 @@ describe("WebSocket Server", () => {
actionId: "client-action-2",
cwd: "/test",
action: "commit",
+ modelSelection: {
+ provider: "codex",
+ model: "gpt-5.4-mini",
+ },
});
const progressPush = await waitForPush(initiatingWs, WS_CHANNELS.gitActionProgress);
diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts
index 74d5b29df2..fea74edd72 100644
--- a/apps/web/src/appSettings.test.ts
+++ b/apps/web/src/appSettings.test.ts
@@ -6,17 +6,20 @@ import {
DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
DEFAULT_TIMESTAMP_FORMAT,
+ getProviderStartOptions,
+} from "./appSettings";
+import {
getAppModelOptions,
getCustomModelOptionsByProvider,
getCustomModelsByProvider,
getCustomModelsForProvider,
getDefaultCustomModelsForProvider,
- getProviderStartOptions,
MODEL_PROVIDER_SETTINGS,
normalizeCustomModelSlugs,
patchCustomModels,
+ resolveAppModelSelectionState,
resolveAppModelSelection,
-} from "./appSettings";
+} from "./modelSelection";
describe("normalizeCustomModelSlugs", () => {
it("normalizes aliases, removes built-ins, and deduplicates values", () => {
@@ -265,3 +268,58 @@ describe("AppSettingsSchema", () => {
});
});
});
+
+describe("resolveAppModelSelectionState", () => {
+ it("falls back to the default git-writing codex selection", () => {
+ expect(
+ resolveAppModelSelectionState({
+ customCodexModels: [],
+ customClaudeModels: [],
+ textGenerationModelSelection: undefined,
+ }),
+ ).toEqual({
+ provider: "codex",
+ model: "gpt-5.4-mini",
+ });
+ });
+
+ it("preserves the selected provider and resolves saved custom models", () => {
+ expect(
+ resolveAppModelSelectionState({
+ customCodexModels: [],
+ customClaudeModels: ["claude/custom-haiku"],
+ textGenerationModelSelection: {
+ provider: "claudeAgent",
+ model: "claude/custom-haiku",
+ },
+ }),
+ ).toEqual({
+ provider: "claudeAgent",
+ model: "claude/custom-haiku",
+ });
+ });
+
+ it("normalizes provider options against the resolved model capabilities", () => {
+ expect(
+ resolveAppModelSelectionState({
+ customCodexModels: [],
+ customClaudeModels: [],
+ textGenerationModelSelection: {
+ provider: "claudeAgent",
+ model: "claude-haiku-4-5",
+ options: {
+ effort: "max",
+ thinking: false,
+ fastMode: true,
+ },
+ },
+ }),
+ ).toEqual({
+ provider: "claudeAgent",
+ model: "claude-haiku-4-5",
+ options: {
+ thinking: false,
+ },
+ });
+ });
+});
diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts
index be9c376989..e2aac52a84 100644
--- a/apps/web/src/appSettings.ts
+++ b/apps/web/src/appSettings.ts
@@ -1,22 +1,15 @@
import { useCallback } from "react";
import { Option, Schema } from "effect";
import {
- TrimmedNonEmptyString,
- type ProviderKind,
+ DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
+ ModelSelection,
type ProviderStartOptions,
} from "@t3tools/contracts";
-import {
- getDefaultModel,
- getModelOptions,
- normalizeModelSlug,
- resolveSelectableModel,
-} from "@t3tools/shared/model";
import { useLocalStorage } from "./hooks/useLocalStorage";
import { EnvMode } from "./components/BranchToolbar.logic";
+import { normalizeCustomModelSlugs } from "./modelSelection";
const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
-const MAX_CUSTOM_MODEL_COUNT = 32;
-export const MAX_CUSTOM_MODEL_LENGTH = 256;
export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]);
export type TimestampFormat = typeof TimestampFormat.Type;
@@ -27,21 +20,6 @@ export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "upda
export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]);
export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type;
export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at";
-type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels";
-export type ProviderCustomModelConfig = {
- provider: ProviderKind;
- settingsKey: CustomModelSettingsKey;
- defaultSettingsKey: CustomModelSettingsKey;
- title: string;
- description: string;
- placeholder: string;
- example: string;
-};
-
-const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = {
- codex: new Set(getModelOptions("codex").map((option) => option.slug)),
- claudeAgent: new Set(getModelOptions("claudeAgent").map((option) => option.slug)),
-};
const withDefaults =
<
@@ -73,66 +51,16 @@ 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(() => [])),
- textGenerationModel: Schema.optional(TrimmedNonEmptyString),
+ textGenerationModelSelection: ModelSelection.pipe(
+ withDefaults(() => ({
+ provider: "codex" as const,
+ model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex,
+ })),
+ ),
});
export type AppSettings = typeof AppSettingsSchema.Type;
-export interface AppModelOption {
- slug: string;
- name: string;
- isCustom: boolean;
-}
const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});
-const PROVIDER_CUSTOM_MODEL_CONFIG: Record = {
- codex: {
- provider: "codex",
- settingsKey: "customCodexModels",
- defaultSettingsKey: "customCodexModels",
- title: "Codex",
- description: "Save additional Codex model slugs for the picker and `/model` command.",
- placeholder: "your-codex-model-slug",
- example: "gpt-6.7-codex-ultra-preview",
- },
- claudeAgent: {
- provider: "claudeAgent",
- settingsKey: "customClaudeModels",
- defaultSettingsKey: "customClaudeModels",
- title: "Claude",
- description: "Save additional Claude model slugs for the picker and `/model` command.",
- placeholder: "your-claude-model-slug",
- example: "claude-sonnet-5-0",
- },
-};
-export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG);
-
-export function normalizeCustomModelSlugs(
- models: Iterable,
- provider: ProviderKind = "codex",
-): string[] {
- const normalizedModels: string[] = [];
- const seen = new Set();
- const builtInModelSlugs = BUILT_IN_MODEL_SLUGS_BY_PROVIDER[provider];
-
- for (const candidate of models) {
- const normalized = normalizeModelSlug(candidate, provider);
- if (
- !normalized ||
- normalized.length > MAX_CUSTOM_MODEL_LENGTH ||
- builtInModelSlugs.has(normalized) ||
- seen.has(normalized)
- ) {
- continue;
- }
-
- seen.add(normalized);
- normalizedModels.push(normalized);
- if (normalizedModels.length >= MAX_CUSTOM_MODEL_COUNT) {
- break;
- }
- }
-
- return normalizedModels;
-}
function normalizeAppSettings(settings: AppSettings): AppSettings {
return {
@@ -142,103 +70,6 @@ function normalizeAppSettings(settings: AppSettings): AppSettings {
};
}
-export function getCustomModelsForProvider(
- settings: Pick,
- provider: ProviderKind,
-): readonly string[] {
- return settings[PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey];
-}
-
-export function getDefaultCustomModelsForProvider(
- defaults: Pick,
- provider: ProviderKind,
-): readonly string[] {
- return defaults[PROVIDER_CUSTOM_MODEL_CONFIG[provider].defaultSettingsKey];
-}
-
-export function patchCustomModels(
- provider: ProviderKind,
- models: string[],
-): Partial> {
- return {
- [PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]: models,
- };
-}
-
-export function getCustomModelsByProvider(
- settings: Pick,
-): Record {
- return {
- codex: getCustomModelsForProvider(settings, "codex"),
- claudeAgent: getCustomModelsForProvider(settings, "claudeAgent"),
- };
-}
-
-export function getAppModelOptions(
- provider: ProviderKind,
- customModels: readonly string[],
- selectedModel?: string | null,
-): AppModelOption[] {
- const options: AppModelOption[] = getModelOptions(provider).map(({ slug, name }) => ({
- slug,
- name,
- isCustom: false,
- }));
- const seen = new Set(options.map((option) => option.slug));
- const trimmedSelectedModel = selectedModel?.trim().toLowerCase();
-
- for (const slug of normalizeCustomModelSlugs(customModels, provider)) {
- if (seen.has(slug)) {
- continue;
- }
-
- seen.add(slug);
- options.push({
- slug,
- name: slug,
- isCustom: true,
- });
- }
-
- const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider);
- const selectedModelMatchesExistingName =
- typeof trimmedSelectedModel === "string" &&
- options.some((option) => option.name.toLowerCase() === trimmedSelectedModel);
- if (
- normalizedSelectedModel &&
- !seen.has(normalizedSelectedModel) &&
- !selectedModelMatchesExistingName
- ) {
- options.push({
- slug: normalizedSelectedModel,
- name: normalizedSelectedModel,
- isCustom: true,
- });
- }
-
- return options;
-}
-
-export function resolveAppModelSelection(
- provider: ProviderKind,
- customModels: Record,
- selectedModel: string | null | undefined,
-): string {
- const customModelsForProvider = customModels[provider];
- const options = getAppModelOptions(provider, customModelsForProvider, selectedModel);
- return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider);
-}
-
-export function getCustomModelOptionsByProvider(
- settings: Pick,
-): Record> {
- const customModelsByProvider = getCustomModelsByProvider(settings);
- return {
- codex: getAppModelOptions("codex", customModelsByProvider.codex),
- claudeAgent: getAppModelOptions("claudeAgent", customModelsByProvider.claudeAgent),
- };
-}
-
export function getProviderStartOptions(
settings: Pick,
): ProviderStartOptions | undefined {
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index fbc887bf62..ace74a5cc8 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -120,13 +120,12 @@ import {
import { SidebarTrigger } from "./ui/sidebar";
import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
import { readNativeApi } from "~/nativeApi";
+import { getProviderStartOptions, useAppSettings } from "../appSettings";
import {
getCustomModelOptionsByProvider,
getCustomModelsByProvider,
- getProviderStartOptions,
resolveAppModelSelection,
- useAppSettings,
-} from "../appSettings";
+} from "../modelSelection";
import { isTerminalFocused } from "../lib/terminalFocus";
import {
type ComposerImageAttachment,
@@ -642,8 +641,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 4d563d77fd..79fb1ff11e 100644
--- a/apps/web/src/components/GitActionsControl.tsx
+++ b/apps/web/src/components/GitActionsControl.tsx
@@ -261,7 +261,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
gitRunStackedActionMutationOptions({
cwd: gitCwd,
queryClient,
- model: settings.textGenerationModel ?? null,
+ modelSelection: settings.textGenerationModelSelection,
}),
);
const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));
diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx
index 1aef9c46c1..fad008eba7 100644
--- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx
+++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx
@@ -21,6 +21,7 @@ async function mountPicker(props: {
provider: ProviderKind;
model: ModelSlug;
lockedProvider: ProviderKind | null;
+ triggerVariant?: "ghost" | "outline";
}) {
const host = document.createElement("div");
document.body.append(host);
@@ -31,6 +32,7 @@ async function mountPicker(props: {
model={props.model}
lockedProvider={props.lockedProvider}
modelOptionsByProvider={MODEL_OPTIONS_BY_PROVIDER}
+ triggerVariant={props.triggerVariant}
onProviderModelChange={onProviderModelChange}
/>,
{ container: host },
@@ -156,4 +158,24 @@ describe("ProviderModelPicker", () => {
await mounted.cleanup();
}
});
+
+ it("accepts outline trigger styling", async () => {
+ const mounted = await mountPicker({
+ provider: "codex",
+ model: "gpt-5-codex",
+ lockedProvider: null,
+ triggerVariant: "outline",
+ });
+
+ try {
+ const button = document.querySelector("button");
+ if (!(button instanceof HTMLButtonElement)) {
+ throw new Error("Expected picker trigger button to be rendered.");
+ }
+ expect(button.className).toContain("border-input");
+ expect(button.className).toContain("bg-popover");
+ } finally {
+ await mounted.cleanup();
+ }
+ });
});
diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx
index 2fc20a9731..ccf756fec6 100644
--- a/apps/web/src/components/chat/ProviderModelPicker.tsx
+++ b/apps/web/src/components/chat/ProviderModelPicker.tsx
@@ -1,9 +1,10 @@
import { type ModelSlug, type ProviderKind } from "@t3tools/contracts";
import { resolveSelectableModel } from "@t3tools/shared/model";
import { memo, useState } from "react";
+import type { VariantProps } from "class-variance-authority";
import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic";
import { ChevronDownIcon } from "lucide-react";
-import { Button } from "../ui/button";
+import { Button, buttonVariants } from "../ui/button";
import {
Menu,
MenuGroup,
@@ -56,6 +57,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
activeProviderIconClassName?: string;
compact?: boolean;
disabled?: boolean;
+ triggerVariant?: VariantProps["variant"];
+ triggerClassName?: string;
onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
}) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -92,10 +95,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
render={
diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx
index 811ad5bb35..61697c944a 100644
--- a/apps/web/src/components/chat/TraitsPicker.browser.tsx
+++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx
@@ -29,6 +29,7 @@ const CLAUDE_THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits");
function ClaudeTraitsPickerHarness(props: {
model: string;
fallbackModelSelection: ModelSelection | null;
+ triggerVariant?: "ghost" | "outline";
}) {
const prompt = useComposerThreadDraft(CLAUDE_THREAD_ID).prompt;
const setPrompt = useComposerDraftStore((store) => store.setPrompt);
@@ -54,6 +55,7 @@ function ClaudeTraitsPickerHarness(props: {
prompt={prompt}
modelOptions={modelOptions?.claudeAgent}
onPromptChange={handlePromptChange}
+ triggerVariant={props.triggerVariant}
/>
);
}
@@ -68,6 +70,7 @@ async function mountClaudePicker(props?: {
fastMode?: boolean;
} | null;
skipDraftModelOptions?: boolean;
+ triggerVariant?: "ghost" | "outline";
}) {
const model = props?.model ?? "claude-opus-4-6";
const claudeOptions = !props?.skipDraftModelOptions ? props?.options : undefined;
@@ -110,7 +113,11 @@ async function mountClaudePicker(props?: {
} satisfies ModelSelection)
: null;
const screen = await render(
- ,
+ ,
{ container: host },
);
@@ -234,6 +241,19 @@ describe("TraitsPicker (Claude)", () => {
},
});
});
+
+ it("accepts outline trigger styling", async () => {
+ await using _ = await mountClaudePicker({
+ triggerVariant: "outline",
+ });
+
+ const button = document.querySelector("button");
+ if (!(button instanceof HTMLButtonElement)) {
+ throw new Error("Expected traits trigger button to be rendered.");
+ }
+ expect(button.className).toContain("border-input");
+ expect(button.className).toContain("bg-popover");
+ });
});
// ── Codex TraitsPicker tests ──────────────────────────────────────────
diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx
index e43c094283..f48d525d02 100644
--- a/apps/web/src/components/chat/TraitsPicker.tsx
+++ b/apps/web/src/components/chat/TraitsPicker.tsx
@@ -14,8 +14,9 @@ import {
hasEffortLevel,
} from "@t3tools/shared/model";
import { memo, useCallback, useState } from "react";
+import type { VariantProps } from "class-variance-authority";
import { ChevronDownIcon } from "lucide-react";
-import { Button } from "../ui/button";
+import { Button, buttonVariants } from "../ui/button";
import {
Menu,
MenuGroup,
@@ -26,8 +27,18 @@ import {
MenuTrigger,
} from "../ui/menu";
import { useComposerDraftStore } from "../../composerDraftStore";
+import { cn } from "~/lib/utils";
type ProviderOptions = ProviderModelOptions[ProviderKind];
+type TraitsPersistence =
+ | {
+ threadId: ThreadId;
+ onModelOptionsChange?: never;
+ }
+ | {
+ threadId?: undefined;
+ onModelOptionsChange: (nextOptions: ProviderOptions | undefined) => void;
+ };
const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n";
@@ -57,9 +68,14 @@ function getSelectedTraits(
model: string | null | undefined,
prompt: string,
modelOptions: ProviderOptions | null | undefined,
+ allowPromptInjectedEffort: boolean,
) {
const caps = getModelCapabilities(provider, model);
- const effortLevels = caps.reasoningEffortLevels;
+ const effortLevels = allowPromptInjectedEffort
+ ? caps.reasoningEffortLevels
+ : caps.reasoningEffortLevels.filter(
+ (option) => !caps.promptInjectedEffortLevels.includes(option.value),
+ );
const defaultEffort = getDefaultEffort(caps);
// Resolve effort from options (provider-specific key)
@@ -88,7 +104,9 @@ function getSelectedTraits(
// Prompt-controlled effort (e.g. ultrathink in prompt text)
const ultrathinkPromptControlled =
- caps.promptInjectedEffortLevels.length > 0 && isClaudeUltrathinkPrompt(prompt);
+ allowPromptInjectedEffort &&
+ caps.promptInjectedEffortLevels.length > 0 &&
+ isClaudeUltrathinkPrompt(prompt);
return {
caps,
@@ -102,22 +120,35 @@ function getSelectedTraits(
export interface TraitsMenuContentProps {
provider: ProviderKind;
- threadId: ThreadId;
model: string | null | undefined;
prompt: string;
onPromptChange: (prompt: string) => void;
modelOptions?: ProviderOptions | null | undefined;
+ allowPromptInjectedEffort?: boolean;
+ triggerVariant?: VariantProps["variant"];
+ triggerClassName?: string;
}
export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
provider,
- threadId,
model,
prompt,
onPromptChange,
modelOptions,
-}: TraitsMenuContentProps) {
+ allowPromptInjectedEffort = true,
+ ...persistence
+}: TraitsMenuContentProps & TraitsPersistence) {
const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions);
+ const updateModelOptions = useCallback(
+ (nextOptions: ProviderOptions | undefined) => {
+ if ("onModelOptionsChange" in persistence) {
+ persistence.onModelOptionsChange(nextOptions);
+ return;
+ }
+ setProviderModelOptions(persistence.threadId, provider, nextOptions, { persistSticky: true });
+ },
+ [persistence, provider, setProviderModelOptions],
+ );
const {
caps,
effort,
@@ -125,7 +156,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
thinkingEnabled,
fastModeEnabled,
ultrathinkPromptControlled,
- } = getSelectedTraits(provider, model, prompt, modelOptions);
+ } = getSelectedTraits(provider, model, prompt, modelOptions, allowPromptInjectedEffort);
const defaultEffort = getDefaultEffort(caps);
const handleEffortChange = useCallback(
@@ -143,19 +174,15 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
return;
}
const effortKey = provider === "codex" ? "reasoningEffort" : "effort";
- setProviderModelOptions(
- threadId,
- provider,
+ updateModelOptions(
buildNextOptions(provider, modelOptions, { [effortKey]: nextOption.value }),
- { persistSticky: true },
);
},
[
ultrathinkPromptControlled,
modelOptions,
onPromptChange,
- threadId,
- setProviderModelOptions,
+ updateModelOptions,
effortLevels,
prompt,
caps.promptInjectedEffortLevels,
@@ -198,11 +225,8 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
{
- setProviderModelOptions(
- threadId,
- provider,
+ updateModelOptions(
buildNextOptions(provider, modelOptions, { thinking: value === "on" }),
- { persistSticky: true },
);
}}
>
@@ -219,11 +243,8 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
{
- setProviderModelOptions(
- threadId,
- provider,
+ updateModelOptions(
buildNextOptions(provider, modelOptions, { fastMode: value === "on" }),
- { persistSticky: true },
);
}}
>
@@ -239,12 +260,15 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
export const TraitsPicker = memo(function TraitsPicker({
provider,
- threadId,
model,
prompt,
onPromptChange,
modelOptions,
-}: TraitsMenuContentProps) {
+ allowPromptInjectedEffort = true,
+ triggerVariant,
+ triggerClassName,
+ ...persistence
+}: TraitsMenuContentProps & TraitsPersistence) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const {
caps,
@@ -253,7 +277,7 @@ export const TraitsPicker = memo(function TraitsPicker({
thinkingEnabled,
fastModeEnabled,
ultrathinkPromptControlled,
- } = getSelectedTraits(provider, model, prompt, modelOptions);
+ } = getSelectedTraits(provider, model, prompt, modelOptions, allowPromptInjectedEffort);
const effortLabel = effort
? (effortLevels.find((l) => l.value === effort)?.label ?? effort)
@@ -284,12 +308,13 @@ export const TraitsPicker = memo(function TraitsPicker({
render={
}
>
@@ -308,11 +333,12 @@ export const TraitsPicker = memo(function TraitsPicker({
diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts
index fb9c0d5150..a4acd810e7 100644
--- a/apps/web/src/composerDraftStore.ts
+++ b/apps/web/src/composerDraftStore.ts
@@ -21,7 +21,7 @@ import {
} from "@t3tools/shared/model";
import { useMemo } from "react";
import { getLocalStorageItem } from "./hooks/useLocalStorage";
-import { resolveAppModelSelection } from "./appSettings";
+import { resolveAppModelSelection } from "./modelSelection";
import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types";
import {
type TerminalContextDraft,
diff --git a/apps/web/src/lib/gitReactQuery.test.ts b/apps/web/src/lib/gitReactQuery.test.ts
index 964d14fb8b..b5d75f743c 100644
--- a/apps/web/src/lib/gitReactQuery.test.ts
+++ b/apps/web/src/lib/gitReactQuery.test.ts
@@ -29,7 +29,14 @@ describe("git mutation options", () => {
const queryClient = new QueryClient();
it("attaches cwd-scoped mutation key for runStackedAction", () => {
- const options = gitRunStackedActionMutationOptions({ cwd: "/repo/a", queryClient });
+ const options = gitRunStackedActionMutationOptions({
+ cwd: "/repo/a",
+ queryClient,
+ modelSelection: {
+ provider: "codex",
+ model: "gpt-5.4",
+ },
+ });
expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction("/repo/a"));
});
diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts
index d6a72859f3..02d725d2b2 100644
--- a/apps/web/src/lib/gitReactQuery.ts
+++ b/apps/web/src/lib/gitReactQuery.ts
@@ -1,4 +1,4 @@
-import type { GitStackedAction } from "@t3tools/contracts";
+import { type GitStackedAction, type ModelSelection } from "@t3tools/contracts";
import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";
import { ensureNativeApi } from "../nativeApi";
@@ -112,7 +112,7 @@ export function gitCheckoutMutationOptions(input: {
export function gitRunStackedActionMutationOptions(input: {
cwd: string | null;
queryClient: QueryClient;
- model?: string | null;
+ modelSelection: ModelSelection;
}) {
return mutationOptions({
mutationKey: gitMutationKeys.runStackedAction(input.cwd),
@@ -134,11 +134,11 @@ export function gitRunStackedActionMutationOptions(input: {
return api.git.runStackedAction({
actionId,
cwd: input.cwd,
+ modelSelection: input.modelSelection,
action,
...(commitMessage ? { commitMessage } : {}),
...(featureBranch ? { featureBranch } : {}),
...(filePaths ? { filePaths } : {}),
- ...(input.model ? { model: input.model } : {}),
});
},
onSettled: async () => {
diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts
new file mode 100644
index 0000000000..9534170b1f
--- /dev/null
+++ b/apps/web/src/modelSelection.ts
@@ -0,0 +1,228 @@
+import {
+ DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
+ type ModelSelection,
+ type ProviderKind,
+} from "@t3tools/contracts";
+import {
+ getDefaultModel,
+ getModelOptions,
+ normalizeModelSlug,
+ resolveSelectableModel,
+} from "@t3tools/shared/model";
+import { getComposerProviderState } from "./components/chat/composerProviderRegistry";
+
+const MAX_CUSTOM_MODEL_COUNT = 32;
+export const MAX_CUSTOM_MODEL_LENGTH = 256;
+
+export type CustomModelSettings = {
+ customCodexModels: readonly string[];
+ customClaudeModels: readonly string[];
+};
+
+export type ProviderCustomModelConfig = {
+ provider: ProviderKind;
+ settingsKey: keyof CustomModelSettings;
+ defaultSettingsKey: keyof CustomModelSettings;
+ title: string;
+ description: string;
+ placeholder: string;
+ example: string;
+};
+
+export interface AppModelOption {
+ slug: string;
+ name: string;
+ isCustom: boolean;
+}
+
+const PROVIDER_CUSTOM_MODEL_CONFIG: Record = {
+ codex: {
+ provider: "codex",
+ settingsKey: "customCodexModels",
+ defaultSettingsKey: "customCodexModels",
+ title: "Codex",
+ description: "Save additional Codex model slugs for the picker and `/model` command.",
+ placeholder: "your-codex-model-slug",
+ example: "gpt-6.7-codex-ultra-preview",
+ },
+ claudeAgent: {
+ provider: "claudeAgent",
+ settingsKey: "customClaudeModels",
+ defaultSettingsKey: "customClaudeModels",
+ title: "Claude",
+ description: "Save additional Claude model slugs for the picker and `/model` command.",
+ placeholder: "your-claude-model-slug",
+ example: "claude-sonnet-5-0",
+ },
+};
+
+const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = {
+ codex: new Set(getModelOptions("codex").map((option) => option.slug)),
+ claudeAgent: new Set(getModelOptions("claudeAgent").map((option) => option.slug)),
+};
+
+export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG);
+
+export function normalizeCustomModelSlugs(
+ models: Iterable,
+ provider: ProviderKind = "codex",
+): string[] {
+ const normalizedModels: string[] = [];
+ const seen = new Set();
+ const builtInModelSlugs = BUILT_IN_MODEL_SLUGS_BY_PROVIDER[provider];
+
+ for (const candidate of models) {
+ const normalized = normalizeModelSlug(candidate, provider);
+ if (
+ !normalized ||
+ normalized.length > MAX_CUSTOM_MODEL_LENGTH ||
+ builtInModelSlugs.has(normalized) ||
+ seen.has(normalized)
+ ) {
+ continue;
+ }
+
+ seen.add(normalized);
+ normalizedModels.push(normalized);
+ if (normalizedModels.length >= MAX_CUSTOM_MODEL_COUNT) {
+ break;
+ }
+ }
+
+ return normalizedModels;
+}
+
+export function getCustomModelsForProvider(
+ settings: CustomModelSettings,
+ provider: ProviderKind,
+): readonly string[] {
+ return settings[PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey];
+}
+
+export function getDefaultCustomModelsForProvider(
+ defaults: CustomModelSettings,
+ provider: ProviderKind,
+): readonly string[] {
+ return defaults[PROVIDER_CUSTOM_MODEL_CONFIG[provider].defaultSettingsKey];
+}
+
+export function patchCustomModels(
+ provider: ProviderKind,
+ models: string[],
+): Partial {
+ return {
+ [PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]: models,
+ };
+}
+
+export function getCustomModelsByProvider(
+ settings: CustomModelSettings,
+): Record {
+ return {
+ codex: getCustomModelsForProvider(settings, "codex"),
+ claudeAgent: getCustomModelsForProvider(settings, "claudeAgent"),
+ };
+}
+
+export function getAppModelOptions(
+ provider: ProviderKind,
+ customModels: readonly string[],
+ selectedModel?: string | null,
+): AppModelOption[] {
+ const options: AppModelOption[] = getModelOptions(provider).map(({ slug, name }) => ({
+ slug,
+ name,
+ isCustom: false,
+ }));
+ const seen = new Set(options.map((option) => option.slug));
+ const trimmedSelectedModel = selectedModel?.trim().toLowerCase();
+
+ for (const slug of normalizeCustomModelSlugs(customModels, provider)) {
+ if (seen.has(slug)) {
+ continue;
+ }
+
+ seen.add(slug);
+ options.push({
+ slug,
+ name: slug,
+ isCustom: true,
+ });
+ }
+
+ const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider);
+ const selectedModelMatchesExistingName =
+ typeof trimmedSelectedModel === "string" &&
+ options.some((option) => option.name.toLowerCase() === trimmedSelectedModel);
+ if (
+ normalizedSelectedModel &&
+ !seen.has(normalizedSelectedModel) &&
+ !selectedModelMatchesExistingName
+ ) {
+ options.push({
+ slug: normalizedSelectedModel,
+ name: normalizedSelectedModel,
+ isCustom: true,
+ });
+ }
+
+ return options;
+}
+
+export function resolveAppModelSelection(
+ provider: ProviderKind,
+ customModels: Record,
+ selectedModel: string | null | undefined,
+): string {
+ const customModelsForProvider = customModels[provider];
+ const options = getAppModelOptions(provider, customModelsForProvider, selectedModel);
+ return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider);
+}
+
+export function getCustomModelOptionsByProvider(
+ settings: CustomModelSettings,
+ selectedProvider?: ProviderKind | null,
+ selectedModel?: string | null,
+): Record> {
+ const customModelsByProvider = getCustomModelsByProvider(settings);
+ return {
+ codex: getAppModelOptions(
+ "codex",
+ customModelsByProvider.codex,
+ selectedProvider === "codex" ? selectedModel : undefined,
+ ),
+ claudeAgent: getAppModelOptions(
+ "claudeAgent",
+ customModelsByProvider.claudeAgent,
+ selectedProvider === "claudeAgent" ? selectedModel : undefined,
+ ),
+ };
+}
+
+export function resolveAppModelSelectionState(
+ settings: CustomModelSettings & {
+ textGenerationModelSelection: ModelSelection | undefined;
+ },
+): ModelSelection {
+ const selection = settings.textGenerationModelSelection ?? {
+ provider: "codex" as const,
+ model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex,
+ };
+ const provider = selection.provider;
+ const customModelsByProvider = getCustomModelsByProvider(settings);
+ const model = resolveAppModelSelection(provider, customModelsByProvider, selection.model);
+ const { modelOptionsForDispatch } = getComposerProviderState({
+ provider,
+ model,
+ prompt: "",
+ modelOptions: {
+ [provider]: selection.options,
+ },
+ });
+
+ return {
+ provider,
+ model,
+ ...(modelOptionsForDispatch ? { options: modelOptionsForDispatch } : {}),
+ };
+}
diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx
index 05fd640d0f..62a27edba9 100644
--- a/apps/web/src/routes/_chat.settings.tsx
+++ b/apps/web/src/routes/_chat.settings.tsx
@@ -2,16 +2,17 @@ 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 } from "@t3tools/contracts";
import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
+import { useAppSettings } from "../appSettings";
import {
- getAppModelOptions,
+ getCustomModelOptionsByProvider,
getCustomModelsForProvider,
MAX_CUSTOM_MODEL_LENGTH,
MODEL_PROVIDER_SETTINGS,
patchCustomModels,
- useAppSettings,
-} from "../appSettings";
+ resolveAppModelSelectionState,
+} from "../modelSelection";
import { APP_VERSION } from "../branding";
import { Button } from "../components/ui/button";
import { Collapsible, CollapsibleContent } from "../components/ui/collapsible";
@@ -25,6 +26,8 @@ import {
} from "../components/ui/select";
import { SidebarTrigger } from "../components/ui/sidebar";
import { Switch } from "../components/ui/switch";
+import { ProviderModelPicker } from "../components/chat/ProviderModelPicker";
+import { TraitsPicker } from "../components/chat/TraitsPicker";
import { SidebarInset } from "../components/ui/sidebar";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";
import { resolveAndPersistPreferredEditor } from "../editorPreferences";
@@ -214,20 +217,15 @@ function SettingsRouteView() {
const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
const availableEditors = serverConfigQuery.data?.availableEditors;
- const gitTextGenerationModelOptions = getAppModelOptions(
- "codex",
- settings.customCodexModels,
- settings.textGenerationModel,
+ const textGenerationModelSelection = resolveAppModelSelectionState(settings);
+ const textGenProvider = textGenerationModelSelection.provider;
+ const textGenModel = textGenerationModelSelection.model;
+ const textGenModelOptions = textGenerationModelSelection.options;
+ const gitModelOptionsByProvider = getCustomModelOptionsByProvider(
+ settings,
+ textGenProvider,
+ textGenModel,
);
- 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 selectedCustomModelProviderSettings = MODEL_PROVIDER_SETTINGS.find(
(providerSettings) => providerSettings.provider === selectedCustomModelProvider,
)!;
@@ -260,7 +258,10 @@ function SettingsRouteView() {
...(settings.confirmThreadDelete !== defaults.confirmThreadDelete
? ["Delete confirmation"]
: []),
- ...(isGitTextGenerationModelDirty ? ["Git writing model"] : []),
+ ...(JSON.stringify(settings.textGenerationModelSelection ?? null) !==
+ JSON.stringify(defaults.textGenerationModelSelection ?? null)
+ ? ["Git writing model"]
+ : []),
...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0
? ["Custom models"]
: []),
@@ -631,43 +632,61 @@ function SettingsRouteView() {
+ onClick={() => {
updateSettings({
- textGenerationModel: defaults.textGenerationModel,
- })
- }
+ textGenerationModelSelection: defaults.textGenerationModelSelection,
+ });
+ }}
/>
) : null
}
control={
-
+
+
{
+ updateSettings({
+ textGenerationModelSelection: resolveAppModelSelectionState({
+ ...settings,
+ textGenerationModelSelection: { provider, model },
+ }),
+ });
+ }}
+ />
+ {}}
+ modelOptions={textGenModelOptions}
+ allowPromptInjectedEffort={false}
+ triggerVariant="outline"
+ triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
+ onModelOptionsChange={(nextOptions) => {
+ updateSettings({
+ textGenerationModelSelection: resolveAppModelSelectionState({
+ ...settings,
+ textGenerationModelSelection: {
+ provider: textGenProvider,
+ model: textGenModel,
+ ...(nextOptions ? { options: nextOptions } : {}),
+ },
+ }),
+ });
+ }}
+ />
+
}
/>
diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts
index e500b57791..86ac4e9ba6 100644
--- a/apps/web/src/wsNativeApi.test.ts
+++ b/apps/web/src/wsNativeApi.test.ts
@@ -353,11 +353,27 @@ describe("wsNativeApi", () => {
const { createWsNativeApi } = await import("./wsNativeApi");
const api = createWsNativeApi();
- await api.git.runStackedAction({ actionId: "action-1", cwd: "/repo", action: "commit" });
+ await api.git.runStackedAction({
+ actionId: "action-1",
+ cwd: "/repo",
+ action: "commit",
+ modelSelection: {
+ provider: "codex",
+ model: "gpt-5.4-mini",
+ },
+ });
expect(requestMock).toHaveBeenCalledWith(
WS_METHODS.gitRunStackedAction,
- { actionId: "action-1", cwd: "/repo", action: "commit" },
+ {
+ actionId: "action-1",
+ cwd: "/repo",
+ action: "commit",
+ modelSelection: {
+ provider: "codex",
+ model: "gpt-5.4-mini",
+ },
+ },
{ timeoutMs: null },
);
});
diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts
index afb79d3a7c..e9446b540a 100644
--- a/packages/contracts/src/git.test.ts
+++ b/packages/contracts/src/git.test.ts
@@ -65,9 +65,28 @@ describe("GitRunStackedActionInput", () => {
actionId: "action-1",
cwd: "/repo",
action: "commit",
+ modelSelection: {
+ provider: "codex",
+ model: "gpt-5.4-mini",
+ },
});
expect(parsed.actionId).toBe("action-1");
expect(parsed.action).toBe("commit");
});
+
+ it("accepts git text generation as a modelSelection", () => {
+ const parsed = decodeRunStackedActionInput({
+ actionId: "action-1",
+ cwd: "/repo",
+ action: "commit_push_pr",
+ modelSelection: {
+ provider: "claudeAgent",
+ model: "claude-haiku-4-5",
+ },
+ });
+
+ expect(parsed.modelSelection?.provider).toBe("claudeAgent");
+ expect(parsed.modelSelection?.model).toBe("claude-haiku-4-5");
+ });
});
diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts
index b7eadce123..93264626cd 100644
--- a/packages/contracts/src/git.ts
+++ b/packages/contracts/src/git.ts
@@ -1,6 +1,6 @@
-import { Option, Schema } from "effect";
+import { Schema } from "effect";
import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";
-import { DEFAULT_GIT_TEXT_GENERATION_MODEL } from "./model";
+import { ModelSelection } from "./orchestration";
const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
@@ -80,9 +80,7 @@ export const GitRunStackedActionInput = Schema.Struct({
filePaths: Schema.optional(
Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)),
),
- textGenerationModel: Schema.optional(TrimmedNonEmptyStringSchema).pipe(
- Schema.withConstructorDefault(() => Option.some(DEFAULT_GIT_TEXT_GENERATION_MODEL)),
- ),
+ modelSelection: ModelSelection,
});
export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;
diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts
index 9997809d2b..6e564fae06 100644
--- a/packages/contracts/src/model.ts
+++ b/packages/contracts/src/model.ts
@@ -199,7 +199,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: {