authorgravatar for dean@yoshu.aiDean Chérif <dean@yoshu.ai> 2026-03-25 20:08:28+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-25 12:08:28-07:00
logff8d87ddee289d58c4b17281a74d84efe750cfdc
tree374eff8f43deb822a593523d248bdb1fd280ac0d
parent3289a25e57fe398176bfc85bd6849711ffa40352
signature Signed by PGP key B5690EEEBB952194

feat(git): Add Claude as a TextGenerationProvider (#1323)

Co-authored-by: keyzou <keyzou@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

34 files changed, 1920 insertions(+), 605 deletions(-)

apps/server/src/git/Layers/ClaudeTextGeneration.test.ts created+248
......@@ -0,0 +1,248 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it } from "@effect/vitest";
3import { Effect, FileSystem, Layer, Path } from "effect";
4import { expect } from "vitest";
5
6import { ServerConfig } from "../../config.ts";
7import { TextGeneration } from "../Services/TextGeneration.ts";
8import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";
9
10const ClaudeTextGenerationTestLayer = ClaudeTextGenerationLive.pipe(
11 Layer.provideMerge(
12 ServerConfig.layerTest(process.cwd(), {
13 prefix: "t3code-claude-text-generation-test-",
14 }),
15 ),
16 Layer.provideMerge(NodeServices.layer),
17);
18
19function makeFakeClaudeBinary(dir: string) {
20 return Effect.gen(function* () {
21 const fs = yield* FileSystem.FileSystem;
22 const path = yield* Path.Path;
23 const binDir = path.join(dir, "bin");
24 const claudePath = path.join(binDir, "claude");
25 yield* fs.makeDirectory(binDir, { recursive: true });
26
27 yield* fs.writeFileString(
28 claudePath,
29 [
30 "#!/bin/sh",
31 'args="$*"',
32 'stdin_content="$(cat)"',
33 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" ]; then',
34 ' printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" >/dev/null || {',
35 ' printf "%s\\n" "args missing expected content" >&2',
36 " exit 2",
37 " }",
38 "fi",
39 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" ]; then',
40 ' if printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" >/dev/null; then',
41 ' printf "%s\\n" "args contained forbidden content" >&2',
42 " exit 3",
43 " fi",
44 "fi",
45 'if [ -n "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" ]; then',
46 ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" >/dev/null || {',
47 ' printf "%s\\n" "stdin missing expected content" >&2',
48 " exit 4",
49 " }",
50 "fi",
51 'if [ -n "$T3_FAKE_CLAUDE_STDERR" ]; then',
52 ' printf "%s\\n" "$T3_FAKE_CLAUDE_STDERR" >&2',
53 "fi",
54 'printf "%s" "$T3_FAKE_CLAUDE_OUTPUT"',
55 'exit "${T3_FAKE_CLAUDE_EXIT_CODE:-0}"',
56 "",
57 ].join("\n"),
58 );
59 yield* fs.chmod(claudePath, 0o755);
60 return binDir;
61 });
62}
63
64function withFakeClaudeEnv<A, E, R>(
65 input: {
66 output: string;
67 exitCode?: number;
68 stderr?: string;
69 argsMustContain?: string;
70 argsMustNotContain?: string;
71 stdinMustContain?: string;
72 },
73 effect: Effect.Effect<A, E, R>,
74) {
75 return Effect.acquireUseRelease(
76 Effect.gen(function* () {
77 const fs = yield* FileSystem.FileSystem;
78 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" });
79 const binDir = yield* makeFakeClaudeBinary(tempDir);
80 const previousPath = process.env.PATH;
81 const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT;
82 const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE;
83 const previousStderr = process.env.T3_FAKE_CLAUDE_STDERR;
84 const previousArgsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN;
85 const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;
86 const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;
87
88 yield* Effect.sync(() => {
89 process.env.PATH = `${binDir}:${previousPath ?? ""}`;
90 process.env.T3_FAKE_CLAUDE_OUTPUT = input.output;
91
92 if (input.exitCode !== undefined) {
93 process.env.T3_FAKE_CLAUDE_EXIT_CODE = String(input.exitCode);
94 } else {
95 delete process.env.T3_FAKE_CLAUDE_EXIT_CODE;
96 }
97
98 if (input.stderr !== undefined) {
99 process.env.T3_FAKE_CLAUDE_STDERR = input.stderr;
100 } else {
101 delete process.env.T3_FAKE_CLAUDE_STDERR;
102 }
103
104 if (input.argsMustContain !== undefined) {
105 process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = input.argsMustContain;
106 } else {
107 delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN;
108 }
109
110 if (input.argsMustNotContain !== undefined) {
111 process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = input.argsMustNotContain;
112 } else {
113 delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;
114 }
115
116 if (input.stdinMustContain !== undefined) {
117 process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = input.stdinMustContain;
118 } else {
119 delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;
120 }
121 });
122
123 return {
124 previousPath,
125 previousOutput,
126 previousExitCode,
127 previousStderr,
128 previousArgsMustContain,
129 previousArgsMustNotContain,
130 previousStdinMustContain,
131 };
132 }),
133 () => effect,
134 (previous) =>
135 Effect.sync(() => {
136 process.env.PATH = previous.previousPath;
137
138 if (previous.previousOutput === undefined) {
139 delete process.env.T3_FAKE_CLAUDE_OUTPUT;
140 } else {
141 process.env.T3_FAKE_CLAUDE_OUTPUT = previous.previousOutput;
142 }
143
144 if (previous.previousExitCode === undefined) {
145 delete process.env.T3_FAKE_CLAUDE_EXIT_CODE;
146 } else {
147 process.env.T3_FAKE_CLAUDE_EXIT_CODE = previous.previousExitCode;
148 }
149
150 if (previous.previousStderr === undefined) {
151 delete process.env.T3_FAKE_CLAUDE_STDERR;
152 } else {
153 process.env.T3_FAKE_CLAUDE_STDERR = previous.previousStderr;
154 }
155
156 if (previous.previousArgsMustContain === undefined) {
157 delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN;
158 } else {
159 process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = previous.previousArgsMustContain;
160 }
161
162 if (previous.previousArgsMustNotContain === undefined) {
163 delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;
164 } else {
165 process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = previous.previousArgsMustNotContain;
166 }
167
168 if (previous.previousStdinMustContain === undefined) {
169 delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;
170 } else {
171 process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previous.previousStdinMustContain;
172 }
173 }),
174 );
175}
176
177it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => {
178 it.effect("forwards Claude thinking settings for Haiku without passing effort", () =>
179 withFakeClaudeEnv(
180 {
181 output: JSON.stringify({
182 structured_output: {
183 subject: "Add important change",
184 body: "",
185 },
186 }),
187 argsMustContain: '--settings {"alwaysThinkingEnabled":false}',
188 argsMustNotContain: "--effort",
189 },
190 Effect.gen(function* () {
191 const textGeneration = yield* TextGeneration;
192
193 const generated = yield* textGeneration.generateCommitMessage({
194 cwd: process.cwd(),
195 branch: "feature/claude-effect",
196 stagedSummary: "M README.md",
197 stagedPatch: "diff --git a/README.md b/README.md",
198 modelSelection: {
199 provider: "claudeAgent",
200 model: "claude-haiku-4-5",
201 options: {
202 thinking: false,
203 effort: "high",
204 },
205 },
206 });
207
208 expect(generated.subject).toBe("Add important change");
209 }),
210 ),
211 );
212
213 it.effect("forwards Claude fast mode and supported effort", () =>
214 withFakeClaudeEnv(
215 {
216 output: JSON.stringify({
217 structured_output: {
218 title: "Improve orchestration flow",
219 body: "Body",
220 },
221 }),
222 argsMustContain: '--effort max --settings {"fastMode":true}',
223 },
224 Effect.gen(function* () {
225 const textGeneration = yield* TextGeneration;
226
227 const generated = yield* textGeneration.generatePrContent({
228 cwd: process.cwd(),
229 baseBranch: "main",
230 headBranch: "feature/claude-effect",
231 commitSummary: "Improve orchestration",
232 diffSummary: "1 file changed",
233 diffPatch: "diff --git a/README.md b/README.md",
234 modelSelection: {
235 provider: "claudeAgent",
236 model: "claude-opus-4-6",
237 options: {
238 effort: "max",
239 fastMode: true,
240 },
241 },
242 });
243
244 expect(generated.title).toBe("Improve orchestration flow");
245 }),
246 ),
247 );
248});
apps/server/src/git/Layers/ClaudeTextGeneration.ts created+301
......@@ -0,0 +1,301 @@
1/**
2 * ClaudeTextGeneration – Text generation layer using the Claude CLI.
3 *
4 * Implements the same TextGenerationShape contract as CodexTextGeneration but
5 * delegates to the `claude` CLI (`claude -p`) with structured JSON output
6 * instead of the `codex exec` CLI.
7 *
8 * @module ClaudeTextGeneration
9 */
10import { Effect, Layer, Option, Schema, Stream } from "effect";
11import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
12
13import { ClaudeModelSelection } from "@t3tools/contracts";
14import { normalizeClaudeModelOptions } from "@t3tools/shared/model";
15import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
16
17import { TextGenerationError } from "../Errors.ts";
18import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";
19import {
20 buildBranchNamePrompt,
21 buildCommitMessagePrompt,
22 buildPrContentPrompt,
23} from "../Prompts.ts";
24import {
25 normalizeCliError,
26 sanitizeCommitSubject,
27 sanitizePrTitle,
28 toJsonSchemaObject,
29} from "../Utils.ts";
30
31const CLAUDE_TIMEOUT_MS = 180_000;
32
33/**
34 * Schema for the wrapper JSON returned by `claude -p --output-format json`.
35 * We only care about `structured_output`.
36 */
37const ClaudeOutputEnvelope = Schema.Struct({
38 structured_output: Schema.Unknown,
39});
40
41const makeClaudeTextGeneration = Effect.gen(function* () {
42 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
43
44 const readStreamAsString = <E>(
45 operation: string,
46 stream: Stream.Stream<Uint8Array, E>,
47 ): Effect.Effect<string, TextGenerationError> =>
48 stream.pipe(
49 Stream.decodeText(),
50 Stream.runFold(
51 () => "",
52 (acc, chunk) => acc + chunk,
53 ),
54 Effect.mapError((cause) =>
55 normalizeCliError("claude", operation, cause, "Failed to collect process output"),
56 ),
57 );
58
59 /**
60 * Spawn the Claude CLI with structured JSON output and return the parsed,
61 * schema-validated result.
62 */
63 const runClaudeJson = <S extends Schema.Top>({
64 operation,
65 cwd,
66 prompt,
67 outputSchemaJson,
68 modelSelection,
69 }: {
70 operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";
71 cwd: string;
72 prompt: string;
73 outputSchemaJson: S;
74 modelSelection: ClaudeModelSelection;
75 }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>
76 Effect.gen(function* () {
77 const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson));
78 const normalizedOptions = normalizeClaudeModelOptions(
79 modelSelection.model,
80 modelSelection.options,
81 );
82 const settings = {
83 ...(typeof normalizedOptions?.thinking === "boolean"
84 ? { alwaysThinkingEnabled: normalizedOptions.thinking }
85 : {}),
86 ...(normalizedOptions?.fastMode ? { fastMode: true } : {}),
87 };
88
89 const runClaudeCommand = Effect.gen(function* () {
90 const command = ChildProcess.make(
91 "claude",
92 [
93 "-p",
94 "--output-format",
95 "json",
96 "--json-schema",
97 jsonSchemaStr,
98 "--model",
99 modelSelection.model,
100 ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []),
101 ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []),
102 "--dangerously-skip-permissions",
103 ],
104 {
105 cwd,
106 shell: process.platform === "win32",
107 stdin: {
108 stream: Stream.encodeText(Stream.make(prompt)),
109 },
110 },
111 );
112
113 const child = yield* commandSpawner
114 .spawn(command)
115 .pipe(
116 Effect.mapError((cause) =>
117 normalizeCliError("claude", operation, cause, "Failed to spawn Claude CLI process"),
118 ),
119 );
120
121 const [stdout, stderr, exitCode] = yield* Effect.all(
122 [
123 readStreamAsString(operation, child.stdout),
124 readStreamAsString(operation, child.stderr),
125 child.exitCode.pipe(
126 Effect.mapError((cause) =>
127 normalizeCliError(
128 "claude",
129 operation,
130 cause,
131 "Failed to read Claude CLI exit code",
132 ),
133 ),
134 ),
135 ],
136 { concurrency: "unbounded" },
137 );
138
139 if (exitCode !== 0) {
140 const stderrDetail = stderr.trim();
141 const stdoutDetail = stdout.trim();
142 const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail;
143 return yield* new TextGenerationError({
144 operation,
145 detail:
146 detail.length > 0
147 ? `Claude CLI command failed: ${detail}`
148 : `Claude CLI command failed with code ${exitCode}.`,
149 });
150 }
151
152 return stdout;
153 });
154
155 const rawStdout = yield* runClaudeCommand.pipe(
156 Effect.scoped,
157 Effect.timeoutOption(CLAUDE_TIMEOUT_MS),
158 Effect.flatMap(
159 Option.match({
160 onNone: () =>
161 Effect.fail(
162 new TextGenerationError({ operation, detail: "Claude CLI request timed out." }),
163 ),
164 onSome: (value) => Effect.succeed(value),
165 }),
166 ),
167 );
168
169 const envelope = yield* Schema.decodeEffect(Schema.fromJsonString(ClaudeOutputEnvelope))(
170 rawStdout,
171 ).pipe(
172 Effect.catchTag("SchemaError", (cause) =>
173 Effect.fail(
174 new TextGenerationError({
175 operation,
176 detail: "Claude CLI returned unexpected output format.",
177 cause,
178 }),
179 ),
180 ),
181 );
182
183 return yield* Schema.decodeEffect(outputSchemaJson)(envelope.structured_output).pipe(
184 Effect.catchTag("SchemaError", (cause) =>
185 Effect.fail(
186 new TextGenerationError({
187 operation,
188 detail: "Claude returned invalid structured output.",
189 cause,
190 }),
191 ),
192 ),
193 );
194 });
195
196 // ---------------------------------------------------------------------------
197 // TextGenerationShape methods
198 // ---------------------------------------------------------------------------
199
200 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(
201 "ClaudeTextGeneration.generateCommitMessage",
202 )(function* (input) {
203 const { prompt, outputSchema } = buildCommitMessagePrompt({
204 branch: input.branch,
205 stagedSummary: input.stagedSummary,
206 stagedPatch: input.stagedPatch,
207 includeBranch: input.includeBranch === true,
208 });
209
210 if (input.modelSelection.provider !== "claudeAgent") {
211 return yield* new TextGenerationError({
212 operation: "generateCommitMessage",
213 detail: "Invalid model selection.",
214 });
215 }
216
217 const generated = yield* runClaudeJson({
218 operation: "generateCommitMessage",
219 cwd: input.cwd,
220 prompt,
221 outputSchemaJson: outputSchema,
222 modelSelection: input.modelSelection,
223 });
224
225 return {
226 subject: sanitizeCommitSubject(generated.subject),
227 body: generated.body.trim(),
228 ...("branch" in generated && typeof generated.branch === "string"
229 ? { branch: sanitizeFeatureBranchName(generated.branch) }
230 : {}),
231 };
232 });
233
234 const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
235 "ClaudeTextGeneration.generatePrContent",
236 )(function* (input) {
237 const { prompt, outputSchema } = buildPrContentPrompt({
238 baseBranch: input.baseBranch,
239 headBranch: input.headBranch,
240 commitSummary: input.commitSummary,
241 diffSummary: input.diffSummary,
242 diffPatch: input.diffPatch,
243 });
244
245 if (input.modelSelection.provider !== "claudeAgent") {
246 return yield* new TextGenerationError({
247 operation: "generatePrContent",
248 detail: "Invalid model selection.",
249 });
250 }
251
252 const generated = yield* runClaudeJson({
253 operation: "generatePrContent",
254 cwd: input.cwd,
255 prompt,
256 outputSchemaJson: outputSchema,
257 modelSelection: input.modelSelection,
258 });
259
260 return {
261 title: sanitizePrTitle(generated.title),
262 body: generated.body.trim(),
263 };
264 });
265
266 const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(
267 "ClaudeTextGeneration.generateBranchName",
268 )(function* (input) {
269 const { prompt, outputSchema } = buildBranchNamePrompt({
270 message: input.message,
271 attachments: input.attachments,
272 });
273
274 if (input.modelSelection.provider !== "claudeAgent") {
275 return yield* new TextGenerationError({
276 operation: "generateBranchName",
277 detail: "Invalid model selection.",
278 });
279 }
280
281 const generated = yield* runClaudeJson({
282 operation: "generateBranchName",
283 cwd: input.cwd,
284 prompt,
285 outputSchemaJson: outputSchema,
286 modelSelection: input.modelSelection,
287 });
288
289 return {
290 branch: sanitizeBranchFragment(generated.branch),
291 };
292 });
293
294 return {
295 generateCommitMessage,
296 generatePrContent,
297 generateBranchName,
298 } satisfies TextGenerationShape;
299});
300
301export const ClaudeTextGenerationLive = Layer.effect(TextGeneration, makeClaudeTextGeneration);
apps/server/src/git/Layers/CodexTextGeneration.test.ts+176-47
......@@ -1,6 +1,6 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import { it } from "@effect/vitest";
3import { Effect, FileSystem, Layer, Path } from "effect";
3import { Effect, FileSystem, Layer, Path, Result } from "effect";
44import { expect } from "vitest";
55
66import { ServerConfig } from "../../config.ts";
......@@ -8,6 +8,11 @@ import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
88import { TextGenerationError } from "../Errors.ts";
99import { TextGeneration } from "../Services/TextGeneration.ts";
1010
11const DEFAULT_TEST_MODEL_SELECTION = {
12 provider: "codex" as const,
13 model: "gpt-5.4-mini",
14};
15
1116const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe(
1217 Layer.provideMerge(
1318 ServerConfig.layerTest(process.cwd(), {
......@@ -38,6 +43,18 @@ function makeFakeCodexBinary(dir: string) {
3843 " fi",
3944 " continue",
4045 " fi",
46 ' if [ "$1" = "--config" ]; then',
47 " shift",
48 ' if [ "$1" = "service_tier=\\"fast\\"" ]; then',
49 ' seen_fast_service_tier="1"',
50 " fi",
51 ' case "$1" in',
52 " model_reasoning_effort=*)",
53 ' seen_reasoning_effort="$1"',
54 " ;;",
55 " esac",
56 " continue",
57 " fi",
4158 ' if [ "$1" = "--output-last-message" ]; then',
4259 " shift",
4360 ' output_path="$1"',
......@@ -49,6 +66,18 @@ function makeFakeCodexBinary(dir: string) {
4966 ' printf "%s\\n" "missing --image input" >&2',
5067 " exit 2",
5168 "fi",
69 'if [ "$T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER" = "1" ] && [ "$seen_fast_service_tier" != "1" ]; then',
70 ' printf "%s\\n" "missing fast service tier config" >&2',
71 " exit 5",
72 "fi",
73 'if [ -n "$T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT" ] && [ "$seen_reasoning_effort" != "model_reasoning_effort=\\"$T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT\\"" ]; then',
74 ' printf "%s\\n" "unexpected reasoning effort config: $seen_reasoning_effort" >&2',
75 " exit 6",
76 "fi",
77 'if [ "$T3_FAKE_CODEX_FORBID_REASONING_EFFORT" = "1" ] && [ -n "$seen_reasoning_effort" ]; then',
78 ' printf "%s\\n" "reasoning effort config should be omitted: $seen_reasoning_effort" >&2',
79 " exit 7",
80 "fi",
5281 'if [ -n "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" ]; then',
5382 ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" >/dev/null || {',
5483 ' printf "%s\\n" "stdin missing expected content" >&2',
......@@ -82,6 +111,9 @@ function withFakeCodexEnv<A, E, R>(
82111 exitCode?: number;
83112 stderr?: string;
84113 requireImage?: boolean;
114 requireFastServiceTier?: boolean;
115 requireReasoningEffort?: string;
116 forbidReasoningEffort?: boolean;
85117 stdinMustContain?: string;
86118 stdinMustNotContain?: string;
87119 },
......@@ -97,6 +129,9 @@ function withFakeCodexEnv<A, E, R>(
97129 const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE;
98130 const previousStderr = process.env.T3_FAKE_CODEX_STDERR;
99131 const previousRequireImage = process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;
132 const previousRequireFastServiceTier = process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER;
133 const previousRequireReasoningEffort = process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT;
134 const previousForbidReasoningEffort = process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT;
100135 const previousStdinMustContain = process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN;
101136 const previousStdinMustNotContain = process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN;
102137
......@@ -122,6 +157,24 @@ function withFakeCodexEnv<A, E, R>(
122157 delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;
123158 }
124159
160 if (input.requireFastServiceTier) {
161 process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER = "1";
162 } else {
163 delete process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER;
164 }
165
166 if (input.requireReasoningEffort !== undefined) {
167 process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT = input.requireReasoningEffort;
168 } else {
169 delete process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT;
170 }
171
172 if (input.forbidReasoningEffort) {
173 process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT = "1";
174 } else {
175 delete process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT;
176 }
177
125178 if (input.stdinMustContain !== undefined) {
126179 process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN = input.stdinMustContain;
127180 } else {
......@@ -141,6 +194,9 @@ function withFakeCodexEnv<A, E, R>(
141194 previousExitCode,
142195 previousStderr,
143196 previousRequireImage,
197 previousRequireFastServiceTier,
198 previousRequireReasoningEffort,
199 previousForbidReasoningEffort,
144200 previousStdinMustContain,
145201 previousStdinMustNotContain,
146202 };
......@@ -174,6 +230,27 @@ function withFakeCodexEnv<A, E, R>(
174230 process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = previous.previousRequireImage;
175231 }
176232
233 if (previous.previousRequireFastServiceTier === undefined) {
234 delete process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER;
235 } else {
236 process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER =
237 previous.previousRequireFastServiceTier;
238 }
239
240 if (previous.previousRequireReasoningEffort === undefined) {
241 delete process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT;
242 } else {
243 process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT =
244 previous.previousRequireReasoningEffort;
245 }
246
247 if (previous.previousForbidReasoningEffort === undefined) {
248 delete process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT;
249 } else {
250 process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT =
251 previous.previousForbidReasoningEffort;
252 }
253
177254 if (previous.previousStdinMustContain === undefined) {
178255 delete process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN;
179256 } else {
......@@ -208,6 +285,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
208285 branch: "feature/codex-effect",
209286 stagedSummary: "M README.md",
210287 stagedPatch: "diff --git a/README.md b/README.md",
288 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
211289 });
212290
213291 expect(generated.subject.length).toBeLessThanOrEqual(72);
......@@ -218,6 +296,63 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
218296 ),
219297 );
220298
299 it.effect(
300 "forwards codex fast mode and non-default reasoning effort into codex exec config",
301 () =>
302 withFakeCodexEnv(
303 {
304 output: JSON.stringify({
305 subject: "Add important change",
306 body: "",
307 }),
308 requireFastServiceTier: true,
309 requireReasoningEffort: "xhigh",
310 stdinMustNotContain: "branch must be a short semantic git branch fragment",
311 },
312 Effect.gen(function* () {
313 const textGeneration = yield* TextGeneration;
314
315 yield* textGeneration.generateCommitMessage({
316 cwd: process.cwd(),
317 branch: "feature/codex-effect",
318 stagedSummary: "M README.md",
319 stagedPatch: "diff --git a/README.md b/README.md",
320 modelSelection: {
321 provider: "codex",
322 model: "gpt-5.4",
323 options: {
324 reasoningEffort: "xhigh",
325 fastMode: true,
326 },
327 },
328 });
329 }),
330 ),
331 );
332
333 it.effect("defaults git text generation codex effort to low", () =>
334 withFakeCodexEnv(
335 {
336 output: JSON.stringify({
337 subject: "Add important change",
338 body: "",
339 }),
340 requireReasoningEffort: "low",
341 },
342 Effect.gen(function* () {
343 const textGeneration = yield* TextGeneration;
344
345 yield* textGeneration.generateCommitMessage({
346 cwd: process.cwd(),
347 branch: "feature/codex-effect",
348 stagedSummary: "M README.md",
349 stagedPatch: "diff --git a/README.md b/README.md",
350 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
351 });
352 }),
353 ),
354 );
355
221356 it.effect("generates commit message with branch when includeBranch is true", () =>
222357 withFakeCodexEnv(
223358 {
......@@ -237,6 +372,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
237372 stagedSummary: "M README.md",
238373 stagedPatch: "diff --git a/README.md b/README.md",
239374 includeBranch: true,
375 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
240376 });
241377
242378 expect(generated.subject).toBe("Add important change");
......@@ -263,6 +399,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
263399 commitSummary: "feat: improve orchestration flow",
264400 diffSummary: "2 files changed",
265401 diffPatch: "diff --git a/a.ts b/a.ts",
402 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
266403 });
267404
268405 expect(generated.title).toBe("Improve orchestration flow");
......@@ -286,6 +423,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
286423 const generated = yield* textGeneration.generateBranchName({
287424 cwd: process.cwd(),
288425 message: "Please update session handling.",
426 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
289427 });
290428
291429 expect(generated.branch).toBe("feat/session");
......@@ -307,6 +445,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
307445 const generated = yield* textGeneration.generateBranchName({
308446 cwd: process.cwd(),
309447 message: "Fix timeout behavior.",
448 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
310449 });
311450
312451 expect(generated.branch).toBe("fix/session-timeout");
......@@ -333,21 +472,20 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
333472 yield* fs.writeFile(attachmentPath, Buffer.from("hello"));
334473
335474 const textGeneration = yield* TextGeneration;
336 const generated = yield* textGeneration
337 .generateBranchName({
338 cwd: process.cwd(),
339 message: "Fix layout bug from screenshot.",
340 attachments: [
341 {
342 type: "image",
343 id: attachmentId,
344 name: "bug.png",
345 mimeType: "image/png",
346 sizeBytes: 5,
347 },
348 ],
349 })
350 .pipe(Effect.ensuring(fs.remove(attachmentPath).pipe(Effect.catch(() => Effect.void))));
475 const generated = yield* textGeneration.generateBranchName({
476 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
477 cwd: process.cwd(),
478 message: "Fix layout bug from screenshot.",
479 attachments: [
480 {
481 type: "image",
482 id: attachmentId,
483 name: "bug.png",
484 mimeType: "image/png",
485 sizeBytes: 5,
486 },
487 ],
488 });
351489
352490 expect(generated.branch).toBe("fix/ui-regression");
353491 }),
......@@ -374,6 +512,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
374512 const textGeneration = yield* TextGeneration;
375513 const generated = yield* textGeneration
376514 .generateBranchName({
515 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
377516 cwd: process.cwd(),
378517 message: "Fix layout bug from screenshot.",
379518 attachments: [
......@@ -421,6 +560,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
421560 const textGeneration = yield* TextGeneration;
422561 const result = yield* textGeneration
423562 .generateBranchName({
563 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
424564 cwd: process.cwd(),
425565 message: "Fix layout bug from screenshot.",
426566 attachments: [
......@@ -433,17 +573,12 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
433573 },
434574 ],
435575 })
436 .pipe(
437 Effect.match({
438 onFailure: (error) => ({ _tag: "Left" as const, left: error }),
439 onSuccess: (value) => ({ _tag: "Right" as const, right: value }),
440 }),
441 );
576 .pipe(Effect.result);
442577
443 expect(result._tag).toBe("Left");
444 if (result._tag === "Left") {
445 expect(result.left).toBeInstanceOf(TextGenerationError);
446 expect(result.left.message).toContain("missing --image input");
578 expect(Result.isFailure(result)).toBe(true);
579 if (Result.isFailure(result)) {
580 expect(result.failure).toBeInstanceOf(TextGenerationError);
581 expect(result.failure.message).toContain("missing --image input");
447582 }
448583 }),
449584 ),
......@@ -465,18 +600,14 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
465600 .generateBranchName({
466601 cwd: process.cwd(),
467602 message: "Fix websocket reconnect flake",
603 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
468604 })
469 .pipe(
470 Effect.match({
471 onFailure: (error) => ({ _tag: "Left" as const, left: error }),
472 onSuccess: (value) => ({ _tag: "Right" as const, right: value }),
473 }),
474 );
475
476 expect(result._tag).toBe("Left");
477 if (result._tag === "Left") {
478 expect(result.left).toBeInstanceOf(TextGenerationError);
479 expect(result.left.message).toContain("Codex returned invalid structured output");
605 .pipe(Effect.result);
606
607 expect(Result.isFailure(result)).toBe(true);
608 if (Result.isFailure(result)) {
609 expect(result.failure).toBeInstanceOf(TextGenerationError);
610 expect(result.failure.message).toContain("Codex returned invalid structured output");
480611 }
481612 }),
482613 ),
......@@ -498,18 +629,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
498629 branch: "feature/codex-error",
499630 stagedSummary: "M README.md",
500631 stagedPatch: "diff --git a/README.md b/README.md",
632 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
501633 })
502 .pipe(
503 Effect.match({
504 onFailure: (error) => ({ _tag: "Left" as const, left: error }),
505 onSuccess: (value) => ({ _tag: "Right" as const, right: value }),
506 }),
507 );
634 .pipe(Effect.result);
508635
509 expect(result._tag).toBe("Left");
510 if (result._tag === "Left") {
511 expect(result.left).toBeInstanceOf(TextGenerationError);
512 expect(result.left.message).toContain("Codex CLI command failed: codex execution failed");
636 expect(Result.isFailure(result)).toBe(true);
637 if (Result.isFailure(result)) {
638 expect(result.failure).toBeInstanceOf(TextGenerationError);
639 expect(result.failure.message).toContain(
640 "Codex CLI command failed: codex execution failed",
641 );
513642 }
514643 }),
515644 ),
apps/server/src/git/Layers/CodexTextGeneration.ts+139-249
......@@ -1,9 +1,10 @@
11import { randomUUID } from "node:crypto";
22
3import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect";
3import { Effect, FileSystem, Layer, Option, Path, Schema, Scope, Stream } from "effect";
44import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
55
6import { DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts";
6import { CodexModelSelection } from "@t3tools/contracts";
7import { normalizeCodexModelOptions } from "@t3tools/shared/model";
78import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
89
910import { resolveAttachmentPath } from "../../attachmentStore.ts";
......@@ -11,90 +12,24 @@ import { ServerConfig } from "../../config.ts";
1112import { TextGenerationError } from "../Errors.ts";
1213import {
1314 type BranchNameGenerationInput,
14 type BranchNameGenerationResult,
15 type CommitMessageGenerationResult,
16 type PrContentGenerationResult,
1715 type TextGenerationShape,
1816 TextGeneration,
1917} from "../Services/TextGeneration.ts";
18import {
19 buildBranchNamePrompt,
20 buildCommitMessagePrompt,
21 buildPrContentPrompt,
22} from "../Prompts.ts";
23import {
24 normalizeCliError,
25 sanitizeCommitSubject,
26 sanitizePrTitle,
27 toJsonSchemaObject,
28} from "../Utils.ts";
2029
21const CODEX_REASONING_EFFORT = "low";
30const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low";
2231const CODEX_TIMEOUT_MS = 180_000;
2332
24function toCodexOutputJsonSchema(schema: Schema.Top): unknown {
25 const document = Schema.toJsonSchemaDocument(schema);
26 if (document.definitions && Object.keys(document.definitions).length > 0) {
27 return {
28 ...document.schema,
29 $defs: document.definitions,
30 };
31 }
32 return document.schema;
33}
34
35function normalizeCodexError(
36 operation: string,
37 error: unknown,
38 fallback: string,
39): TextGenerationError {
40 if (Schema.is(TextGenerationError)(error)) {
41 return error;
42 }
43
44 if (error instanceof Error) {
45 const lower = error.message.toLowerCase();
46 if (
47 error.message.includes("Command not found: codex") ||
48 lower.includes("spawn codex") ||
49 lower.includes("enoent")
50 ) {
51 return new TextGenerationError({
52 operation,
53 detail: "Codex CLI (`codex`) is required but not available on PATH.",
54 cause: error,
55 });
56 }
57 return new TextGenerationError({
58 operation,
59 detail: `${fallback}: ${error.message}`,
60 cause: error,
61 });
62 }
63
64 return new TextGenerationError({
65 operation,
66 detail: fallback,
67 cause: error,
68 });
69}
70
71function limitSection(value: string, maxChars: number): string {
72 if (value.length <= maxChars) return value;
73 const truncated = value.slice(0, maxChars);
74 return `${truncated}\n\n[truncated]`;
75}
76
77function sanitizeCommitSubject(raw: string): string {
78 const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
79 const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim();
80 if (withoutTrailingPeriod.length === 0) {
81 return "Update project files";
82 }
83
84 if (withoutTrailingPeriod.length <= 72) {
85 return withoutTrailingPeriod;
86 }
87 return withoutTrailingPeriod.slice(0, 72).trimEnd();
88}
89
90function sanitizePrTitle(raw: string): string {
91 const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
92 if (singleLine.length > 0) {
93 return singleLine;
94 }
95 return "Update project changes";
96}
97
9833const makeCodexTextGeneration = Effect.gen(function* () {
9934 const fileSystem = yield* FileSystem.FileSystem;
10035 const path = yield* Path.Path;
......@@ -109,39 +44,37 @@ const makeCodexTextGeneration = Effect.gen(function* () {
10944 operation: string,
11045 stream: Stream.Stream<Uint8Array, E>,
11146 ): Effect.Effect<string, TextGenerationError> =>
112 Effect.gen(function* () {
113 let text = "";
114 yield* Stream.runForEach(stream, (chunk) =>
115 Effect.sync(() => {
116 text += Buffer.from(chunk).toString("utf8");
117 }),
118 ).pipe(
119 Effect.mapError((cause) =>
120 normalizeCodexError(operation, cause, "Failed to collect process output"),
121 ),
122 );
123 return text;
124 });
125
126 const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";
47 stream.pipe(
48 Stream.decodeText(),
49 Stream.runFold(
50 () => "",
51 (acc, chunk) => acc + chunk,
52 ),
53 Effect.mapError((cause) =>
54 normalizeCliError("codex", operation, cause, "Failed to collect process output"),
55 ),
56 );
12757
12858 const writeTempFile = (
12959 operation: string,
13060 prefix: string,
13161 content: string,
132 ): Effect.Effect<string, TextGenerationError> => {
133 const filePath = path.join(tempDir, `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`);
134 return fileSystem.writeFileString(filePath, content).pipe(
135 Effect.mapError(
136 (cause) =>
137 new TextGenerationError({
138 operation,
139 detail: `Failed to write temp file at ${filePath}.`,
140 cause,
141 }),
142 ),
143 Effect.as(filePath),
144 );
62 ): Effect.Effect<string, TextGenerationError, Scope.Scope> => {
63 return fileSystem
64 .makeTempFileScoped({
65 prefix: `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`,
66 })
67 .pipe(
68 Effect.tap((filePath) => fileSystem.writeFileString(filePath, content)),
69 Effect.mapError(
70 (cause) =>
71 new TextGenerationError({
72 operation,
73 detail: `Failed to write temp file`,
74 cause,
75 }),
76 ),
77 );
14578 };
14679
14780 const safeUnlink = (filePath: string): Effect.Effect<void, never> =>
......@@ -187,7 +120,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
187120 outputSchemaJson,
188121 imagePaths = [],
189122 cleanupPaths = [],
190 model,
123 modelSelection,
191124 }: {
192125 operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";
193126 cwd: string;
......@@ -195,17 +128,23 @@ const makeCodexTextGeneration = Effect.gen(function* () {
195128 outputSchemaJson: S;
196129 imagePaths?: ReadonlyArray<string>;
197130 cleanupPaths?: ReadonlyArray<string>;
198 model?: string;
131 modelSelection: CodexModelSelection;
199132 }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>
200133 Effect.gen(function* () {
201134 const schemaPath = yield* writeTempFile(
202135 operation,
203136 "codex-schema",
204 JSON.stringify(toCodexOutputJsonSchema(outputSchemaJson)),
137 JSON.stringify(toJsonSchemaObject(outputSchemaJson)),
205138 );
206139 const outputPath = yield* writeTempFile(operation, "codex-output", "");
207140
208141 const runCodexCommand = Effect.gen(function* () {
142 const normalizedOptions = normalizeCodexModelOptions(
143 modelSelection.model,
144 modelSelection.options,
145 );
146 const reasoningEffort =
147 modelSelection.options?.reasoningEffort ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT;
209148 const command = ChildProcess.make(
210149 "codex",
211150 [
......@@ -214,9 +153,10 @@ const makeCodexTextGeneration = Effect.gen(function* () {
214153 "-s",
215154 "read-only",
216155 "--model",
217 model ?? DEFAULT_GIT_TEXT_GENERATION_MODEL,
156 modelSelection.model,
218157 "--config",
219 `model_reasoning_effort="${CODEX_REASONING_EFFORT}"`,
158 `model_reasoning_effort="${reasoningEffort}"`,
159 ...(normalizedOptions?.fastMode ? ["--config", `service_tier="fast"`] : []),
220160 "--output-schema",
221161 schemaPath,
222162 "--output-last-message",
......@@ -228,7 +168,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
228168 cwd,
229169 shell: process.platform === "win32",
230170 stdin: {
231 stream: Stream.make(new TextEncoder().encode(prompt)),
171 stream: Stream.encodeText(Stream.make(prompt)),
232172 },
233173 },
234174 );
......@@ -237,7 +177,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
237177 .spawn(command)
238178 .pipe(
239179 Effect.mapError((cause) =>
240 normalizeCodexError(operation, cause, "Failed to spawn Codex CLI process"),
180 normalizeCliError("codex", operation, cause, "Failed to spawn Codex CLI process"),
241181 ),
242182 );
243183
......@@ -246,9 +186,8 @@ const makeCodexTextGeneration = Effect.gen(function* () {
246186 readStreamAsString(operation, child.stdout),
247187 readStreamAsString(operation, child.stderr),
248188 child.exitCode.pipe(
249 Effect.map((value) => Number(value)),
250189 Effect.mapError((cause) =>
251 normalizeCodexError(operation, cause, "Failed to read Codex CLI exit code"),
190 normalizeCliError("codex", operation, cause, "Failed to read Codex CLI exit code"),
252191 ),
253192 ),
254193 ],
......@@ -314,153 +253,104 @@ const makeCodexTextGeneration = Effect.gen(function* () {
314253 }).pipe(Effect.ensuring(cleanup));
315254 });
316255
317 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => {
318 const wantsBranch = input.includeBranch === true;
319
320 const prompt = [
321 "You write concise git commit messages.",
322 wantsBranch
323 ? "Return a JSON object with keys: subject, body, branch."
324 : "Return a JSON object with keys: subject, body.",
325 "Rules:",
326 "- subject must be imperative, <= 72 chars, and no trailing period",
327 "- body can be empty string or short bullet points",
328 ...(wantsBranch
329 ? ["- branch must be a short semantic git branch fragment for this change"]
330 : []),
331 "- capture the primary user-visible or developer-visible change",
332 "",
333 `Branch: ${input.branch ?? "(detached)"}`,
334 "",
335 "Staged files:",
336 limitSection(input.stagedSummary, 6_000),
337 "",
338 "Staged patch:",
339 limitSection(input.stagedPatch, 40_000),
340 ].join("\n");
341
342 const outputSchemaJson = wantsBranch
343 ? Schema.Struct({
344 subject: Schema.String,
345 body: Schema.String,
346 branch: Schema.String,
347 })
348 : Schema.Struct({
349 subject: Schema.String,
350 body: Schema.String,
351 });
256 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(
257 "CodexTextGeneration.generateCommitMessage",
258 )(function* (input) {
259 const { prompt, outputSchema } = buildCommitMessagePrompt({
260 branch: input.branch,
261 stagedSummary: input.stagedSummary,
262 stagedPatch: input.stagedPatch,
263 includeBranch: input.includeBranch === true,
264 });
352265
353 return runCodexJson({
266 if (input.modelSelection.provider !== "codex") {
267 return yield* new TextGenerationError({
268 operation: "generateCommitMessage",
269 detail: "Invalid model selection.",
270 });
271 }
272
273 const generated = yield* runCodexJson({
354274 operation: "generateCommitMessage",
355275 cwd: input.cwd,
356276 prompt,
357 outputSchemaJson,
358 ...(input.model ? { model: input.model } : {}),
359 }).pipe(
360 Effect.map(
361 (generated) =>
362 ({
363 subject: sanitizeCommitSubject(generated.subject),
364 body: generated.body.trim(),
365 ...("branch" in generated && typeof generated.branch === "string"
366 ? { branch: sanitizeFeatureBranchName(generated.branch) }
367 : {}),
368 }) satisfies CommitMessageGenerationResult,
369 ),
370 );
371 };
277 outputSchemaJson: outputSchema,
278 modelSelection: input.modelSelection,
279 });
280
281 return {
282 subject: sanitizeCommitSubject(generated.subject),
283 body: generated.body.trim(),
284 ...("branch" in generated && typeof generated.branch === "string"
285 ? { branch: sanitizeFeatureBranchName(generated.branch) }
286 : {}),
287 };
288 });
289
290 const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
291 "CodexTextGeneration.generatePrContent",
292 )(function* (input) {
293 const { prompt, outputSchema } = buildPrContentPrompt({
294 baseBranch: input.baseBranch,
295 headBranch: input.headBranch,
296 commitSummary: input.commitSummary,
297 diffSummary: input.diffSummary,
298 diffPatch: input.diffPatch,
299 });
372300
373 const generatePrContent: TextGenerationShape["generatePrContent"] = (input) => {
374 const prompt = [
375 "You write GitHub pull request content.",
376 "Return a JSON object with keys: title, body.",
377 "Rules:",
378 "- title should be concise and specific",
379 "- body must be markdown and include headings '## Summary' and '## Testing'",
380 "- under Summary, provide short bullet points",
381 "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate",
382 "",
383 `Base branch: ${input.baseBranch}`,
384 `Head branch: ${input.headBranch}`,
385 "",
386 "Commits:",
387 limitSection(input.commitSummary, 12_000),
388 "",
389 "Diff stat:",
390 limitSection(input.diffSummary, 12_000),
391 "",
392 "Diff patch:",
393 limitSection(input.diffPatch, 40_000),
394 ].join("\n");
395
396 return runCodexJson({
301 if (input.modelSelection.provider !== "codex") {
302 return yield* new TextGenerationError({
303 operation: "generatePrContent",
304 detail: "Invalid model selection.",
305 });
306 }
307
308 const generated = yield* runCodexJson({
397309 operation: "generatePrContent",
398310 cwd: input.cwd,
399311 prompt,
400 outputSchemaJson: Schema.Struct({
401 title: Schema.String,
402 body: Schema.String,
403 }),
404 ...(input.model ? { model: input.model } : {}),
405 }).pipe(
406 Effect.map(
407 (generated) =>
408 ({
409 title: sanitizePrTitle(generated.title),
410 body: generated.body.trim(),
411 }) satisfies PrContentGenerationResult,
412 ),
413 );
414 };
312 outputSchemaJson: outputSchema,
313 modelSelection: input.modelSelection,
314 });
415315
416 const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => {
417 return Effect.gen(function* () {
418 const { imagePaths } = yield* materializeImageAttachments(
419 "generateBranchName",
420 input.attachments,
421 );
422 const attachmentLines = (input.attachments ?? []).map(
423 (attachment) =>
424 `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
425 );
316 return {
317 title: sanitizePrTitle(generated.title),
318 body: generated.body.trim(),
319 };
320 });
426321
427 const promptSections = [
428 "You generate concise git branch names.",
429 "Return a JSON object with key: branch.",
430 "Rules:",
431 "- Branch should describe the requested work from the user message.",
432 "- Keep it short and specific (2-6 words).",
433 "- Use plain words only, no issue prefixes and no punctuation-heavy text.",
434 "- If images are attached, use them as primary context for visual/UI issues.",
435 "",
436 "User message:",
437 limitSection(input.message, 8_000),
438 ];
439 if (attachmentLines.length > 0) {
440 promptSections.push(
441 "",
442 "Attachment metadata:",
443 limitSection(attachmentLines.join("\n"), 4_000),
444 );
445 }
446 const prompt = promptSections.join("\n");
322 const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(
323 "CodexTextGeneration.generateBranchName",
324 )(function* (input) {
325 const { imagePaths } = yield* materializeImageAttachments(
326 "generateBranchName",
327 input.attachments,
328 );
329 const { prompt, outputSchema } = buildBranchNamePrompt({
330 message: input.message,
331 attachments: input.attachments,
332 });
447333
448 const generated = yield* runCodexJson({
334 if (input.modelSelection.provider !== "codex") {
335 return yield* new TextGenerationError({
449336 operation: "generateBranchName",
450 cwd: input.cwd,
451 prompt,
452 outputSchemaJson: Schema.Struct({
453 branch: Schema.String,
454 }),
455 imagePaths,
456 ...(input.model ? { model: input.model } : {}),
337 detail: "Invalid model selection.",
457338 });
339 }
458340
459 return {
460 branch: sanitizeBranchFragment(generated.branch),
461 } satisfies BranchNameGenerationResult;
341 const generated = yield* runCodexJson({
342 operation: "generateBranchName",
343 cwd: input.cwd,
344 prompt,
345 outputSchemaJson: outputSchema,
346 imagePaths,
347 modelSelection: input.modelSelection,
462348 });
463 };
349
350 return {
351 branch: sanitizeBranchFragment(generated.branch),
352 };
353 });
464354
465355 return {
466356 generateCommitMessage,
apps/server/src/git/Layers/GitManager.test.ts+6
......@@ -21,6 +21,11 @@ import { GitCore } from "../Services/GitCore.ts";
2121import { makeGitManager } from "./GitManager.ts";
2222import { ServerConfig } from "../../config.ts";
2323
24const DEFAULT_TEST_MODEL_SELECTION = {
25 provider: "codex",
26 model: "gpt-5.4-mini",
27} as const;
28
2429interface FakeGhScenario {
2530 prListSequence?: string[];
2631 prListByHeadSelector?: Record<string, string>;
......@@ -461,6 +466,7 @@ function runStackedAction(
461466 {
462467 ...input,
463468 actionId: input.actionId ?? "test-action-id",
469 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
464470 },
465471 options,
466472 );
apps/server/src/git/Layers/GitManager.ts+12-12
......@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
22import { realpathSync } from "node:fs";
33
44import { Effect, FileSystem, Layer, Path } from "effect";
5import type { GitActionProgressEvent, GitActionProgressPhase } from "@t3tools/contracts";
5import { GitActionProgressEvent, GitActionProgressPhase, ModelSelection } from "@t3tools/contracts";
66import {
77 resolveAutoFeatureBranchName,
88 sanitizeBranchFragment,
......@@ -684,7 +684,7 @@ export const makeGitManager = Effect.gen(function* () {
684684 /** When true, also produce a semantic feature branch name. */
685685 includeBranch?: boolean;
686686 filePaths?: readonly string[];
687 model?: string;
687 modelSelection: ModelSelection;
688688 }) =>
689689 Effect.gen(function* () {
690690 const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths);
......@@ -711,7 +711,7 @@ export const makeGitManager = Effect.gen(function* () {
711711 stagedSummary: limitContext(context.stagedSummary, 8_000),
712712 stagedPatch: limitContext(context.stagedPatch, 50_000),
713713 ...(input.includeBranch ? { includeBranch: true } : {}),
714 ...(input.model ? { model: input.model } : {}),
714 modelSelection: input.modelSelection,
715715 })
716716 .pipe(Effect.map((result) => sanitizeCommitMessage(result)));
717717
......@@ -724,13 +724,13 @@ export const makeGitManager = Effect.gen(function* () {
724724 });
725725
726726 const runCommitStep = (
727 modelSelection: ModelSelection,
727728 cwd: string,
728729 action: "commit" | "commit_push" | "commit_push_pr",
729730 branch: string | null,
730731 commitMessage?: string,
731732 preResolvedSuggestion?: CommitAndBranchSuggestion,
732733 filePaths?: readonly string[],
733 model?: string,
734734 progressReporter?: GitActionProgressReporter,
735735 actionId?: string,
736736 ) =>
......@@ -760,7 +760,7 @@ export const makeGitManager = Effect.gen(function* () {
760760 branch,
761761 ...(commitMessage ? { commitMessage } : {}),
762762 ...(filePaths ? { filePaths } : {}),
763 ...(model ? { model } : {}),
763 modelSelection,
764764 });
765765 }
766766 if (!suggestion) {
......@@ -837,7 +837,7 @@ export const makeGitManager = Effect.gen(function* () {
837837 };
838838 });
839839
840 const runPrStep = (cwd: string, fallbackBranch: string | null, model?: string) =>
840 const runPrStep = (modelSelection: ModelSelection, cwd: string, fallbackBranch: string | null) =>
841841 Effect.gen(function* () {
842842 const details = yield* gitCore.statusDetails(cwd);
843843 const branch = details.branch ?? fallbackBranch;
......@@ -881,7 +881,7 @@ export const makeGitManager = Effect.gen(function* () {
881881 commitSummary: limitContext(rangeContext.commitSummary, 20_000),
882882 diffSummary: limitContext(rangeContext.diffSummary, 20_000),
883883 diffPatch: limitContext(rangeContext.diffPatch, 60_000),
884 ...(model ? { model } : {}),
884 modelSelection,
885885 });
886886
887887 const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`);
......@@ -1102,11 +1102,11 @@ export const makeGitManager = Effect.gen(function* () {
11021102 );
11031103
11041104 const runFeatureBranchStep = (
1105 modelSelection: ModelSelection,
11051106 cwd: string,
11061107 branch: string | null,
11071108 commitMessage?: string,
11081109 filePaths?: readonly string[],
1109 model?: string,
11101110 ) =>
11111111 Effect.gen(function* () {
11121112 const suggestion = yield* resolveCommitAndBranchSuggestion({
......@@ -1115,7 +1115,7 @@ export const makeGitManager = Effect.gen(function* () {
11151115 ...(commitMessage ? { commitMessage } : {}),
11161116 ...(filePaths ? { filePaths } : {}),
11171117 includeBranch: true,
1118 ...(model ? { model } : {}),
1118 modelSelection,
11191119 });
11201120 if (!suggestion) {
11211121 return yield* gitManagerError(
......@@ -1181,11 +1181,11 @@ export const makeGitManager = Effect.gen(function* () {
11811181 label: "Preparing feature branch...",
11821182 });
11831183 const result = yield* runFeatureBranchStep(
1184 input.modelSelection,
11841185 input.cwd,
11851186 initialStatus.branch,
11861187 input.commitMessage,
11871188 input.filePaths,
1188 input.textGenerationModel,
11891189 );
11901190 branchStep = result.branchStep;
11911191 commitMessageForStep = result.resolvedCommitMessage;
......@@ -1198,13 +1198,13 @@ export const makeGitManager = Effect.gen(function* () {
11981198
11991199 currentPhase = "commit";
12001200 const commit = yield* runCommitStep(
1201 input.modelSelection,
12011202 input.cwd,
12021203 input.action,
12031204 currentBranch,
12041205 commitMessageForStep,
12051206 preResolvedCommitSuggestion,
12061207 input.filePaths,
1207 input.textGenerationModel,
12081208 options?.progressReporter,
12091209 progress.actionId,
12101210 );
......@@ -1237,7 +1237,7 @@ export const makeGitManager = Effect.gen(function* () {
12371237 Effect.flatMap(() =>
12381238 Effect.gen(function* () {
12391239 currentPhase = "pr";
1240 return yield* runPrStep(input.cwd, currentBranch, input.textGenerationModel);
1240 return yield* runPrStep(input.modelSelection, input.cwd, currentBranch);
12411241 }),
12421242 ),
12431243 )
apps/server/src/git/Layers/RoutingTextGeneration.ts created+72
......@@ -0,0 +1,72 @@
1/**
2 * RoutingTextGeneration – Dispatches text generation requests to either the
3 * Codex CLI or Claude CLI implementation based on the provider in each
4 * request input.
5 *
6 * When `modelSelection.provider` is `"claudeAgent"` the request is forwarded to
7 * the Claude layer; for any other value (including the default `undefined`) it
8 * falls through to the Codex layer.
9 *
10 * @module RoutingTextGeneration
11 */
12import { Effect, Layer, ServiceMap } from "effect";
13
14import {
15 TextGeneration,
16 type TextGenerationProvider,
17 type TextGenerationShape,
18} from "../Services/TextGeneration.ts";
19import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
20import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";
21
22// ---------------------------------------------------------------------------
23// Internal service tags so both concrete layers can coexist.
24// ---------------------------------------------------------------------------
25
26class CodexTextGen extends ServiceMap.Service<CodexTextGen, TextGenerationShape>()(
27 "t3/git/Layers/RoutingTextGeneration/CodexTextGen",
28) {}
29
30class ClaudeTextGen extends ServiceMap.Service<ClaudeTextGen, TextGenerationShape>()(
31 "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen",
32) {}
33
34// ---------------------------------------------------------------------------
35// Routing implementation
36// ---------------------------------------------------------------------------
37
38const makeRoutingTextGeneration = Effect.gen(function* () {
39 const codex = yield* CodexTextGen;
40 const claude = yield* ClaudeTextGen;
41
42 const route = (provider?: TextGenerationProvider): TextGenerationShape =>
43 provider === "claudeAgent" ? claude : codex;
44
45 return {
46 generateCommitMessage: (input) =>
47 route(input.modelSelection.provider).generateCommitMessage(input),
48 generatePrContent: (input) => route(input.modelSelection.provider).generatePrContent(input),
49 generateBranchName: (input) => route(input.modelSelection.provider).generateBranchName(input),
50 } satisfies TextGenerationShape;
51});
52
53const InternalCodexLayer = Layer.effect(
54 CodexTextGen,
55 Effect.gen(function* () {
56 const svc = yield* TextGeneration;
57 return svc;
58 }),
59).pipe(Layer.provide(CodexTextGenerationLive));
60
61const InternalClaudeLayer = Layer.effect(
62 ClaudeTextGen,
63 Effect.gen(function* () {
64 const svc = yield* TextGeneration;
65 return svc;
66 }),
67).pipe(Layer.provide(ClaudeTextGenerationLive));
68
69export const RoutingTextGenerationLive = Layer.effect(
70 TextGeneration,
71 makeRoutingTextGeneration,
72).pipe(Layer.provide(InternalCodexLayer), Layer.provide(InternalClaudeLayer));
apps/server/src/git/Prompts.test.ts created+150
......@@ -0,0 +1,150 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 buildBranchNamePrompt,
5 buildCommitMessagePrompt,
6 buildPrContentPrompt,
7} from "./Prompts.ts";
8import { normalizeCliError } from "./Utils.ts";
9import { TextGenerationError } from "./Errors.ts";
10
11describe("buildCommitMessagePrompt", () => {
12 it("includes staged patch and summary in the prompt", () => {
13 const result = buildCommitMessagePrompt({
14 branch: "main",
15 stagedSummary: "M README.md",
16 stagedPatch: "diff --git a/README.md b/README.md\n+hello",
17 includeBranch: false,
18 });
19
20 expect(result.prompt).toContain("Staged files:");
21 expect(result.prompt).toContain("M README.md");
22 expect(result.prompt).toContain("Staged patch:");
23 expect(result.prompt).toContain("diff --git a/README.md b/README.md");
24 expect(result.prompt).toContain("Branch: main");
25 // Should NOT include the branch generation instruction
26 expect(result.prompt).not.toContain("branch must be a short semantic git branch fragment");
27 });
28
29 it("includes branch generation instruction when includeBranch is true", () => {
30 const result = buildCommitMessagePrompt({
31 branch: "feature/foo",
32 stagedSummary: "M README.md",
33 stagedPatch: "diff",
34 includeBranch: true,
35 });
36
37 expect(result.prompt).toContain("branch must be a short semantic git branch fragment");
38 expect(result.prompt).toContain("Return a JSON object with keys: subject, body, branch.");
39 });
40
41 it("shows (detached) when branch is null", () => {
42 const result = buildCommitMessagePrompt({
43 branch: null,
44 stagedSummary: "M a.ts",
45 stagedPatch: "diff",
46 includeBranch: false,
47 });
48
49 expect(result.prompt).toContain("Branch: (detached)");
50 });
51});
52
53describe("buildPrContentPrompt", () => {
54 it("includes branch names, commits, and diff in the prompt", () => {
55 const result = buildPrContentPrompt({
56 baseBranch: "main",
57 headBranch: "feature/auth",
58 commitSummary: "feat: add login page",
59 diffSummary: "3 files changed",
60 diffPatch: "diff --git a/auth.ts b/auth.ts\n+export function login()",
61 });
62
63 expect(result.prompt).toContain("Base branch: main");
64 expect(result.prompt).toContain("Head branch: feature/auth");
65 expect(result.prompt).toContain("Commits:");
66 expect(result.prompt).toContain("feat: add login page");
67 expect(result.prompt).toContain("Diff stat:");
68 expect(result.prompt).toContain("3 files changed");
69 expect(result.prompt).toContain("Diff patch:");
70 expect(result.prompt).toContain("export function login()");
71 });
72});
73
74describe("buildBranchNamePrompt", () => {
75 it("includes the user message in the prompt", () => {
76 const result = buildBranchNamePrompt({
77 message: "Fix the login timeout bug",
78 });
79
80 expect(result.prompt).toContain("User message:");
81 expect(result.prompt).toContain("Fix the login timeout bug");
82 expect(result.prompt).not.toContain("Attachment metadata:");
83 });
84
85 it("includes attachment metadata when attachments are provided", () => {
86 const result = buildBranchNamePrompt({
87 message: "Fix the layout from screenshot",
88 attachments: [
89 {
90 type: "image" as const,
91 id: "att-123",
92 name: "screenshot.png",
93 mimeType: "image/png",
94 sizeBytes: 12345,
95 },
96 ],
97 });
98
99 expect(result.prompt).toContain("Attachment metadata:");
100 expect(result.prompt).toContain("screenshot.png");
101 expect(result.prompt).toContain("image/png");
102 expect(result.prompt).toContain("12345 bytes");
103 });
104});
105
106describe("normalizeCliError", () => {
107 it("detects 'Command not found' and includes CLI name in the message", () => {
108 const error = normalizeCliError(
109 "claude",
110 "generateCommitMessage",
111 new Error("Command not found: claude"),
112 "Something went wrong",
113 );
114
115 expect(error).toBeInstanceOf(TextGenerationError);
116 expect(error.detail).toContain("Claude CLI");
117 expect(error.detail).toContain("not available on PATH");
118 });
119
120 it("uses the CLI name from the first argument for codex", () => {
121 const error = normalizeCliError(
122 "codex",
123 "generateBranchName",
124 new Error("Command not found: codex"),
125 "Something went wrong",
126 );
127
128 expect(error).toBeInstanceOf(TextGenerationError);
129 expect(error.detail).toContain("Codex CLI");
130 expect(error.detail).toContain("not available on PATH");
131 });
132
133 it("returns the error as-is if it is already a TextGenerationError", () => {
134 const existing = new TextGenerationError({
135 operation: "generatePrContent",
136 detail: "Already wrapped",
137 });
138
139 const result = normalizeCliError("claude", "generatePrContent", existing, "fallback");
140
141 expect(result).toBe(existing);
142 });
143
144 it("wraps unknown non-Error values with the fallback message", () => {
145 const result = normalizeCliError("codex", "generateCommitMessage", "string error", "fallback");
146
147 expect(result).toBeInstanceOf(TextGenerationError);
148 expect(result.detail).toBe("fallback");
149 });
150});
apps/server/src/git/Prompts.ts created+153
......@@ -0,0 +1,153 @@
1/**
2 * Shared prompt builders for text generation providers.
3 *
4 * Extracts the prompt construction logic that is identical across
5 * Codex, Claude, and any future CLI-based text generation backends.
6 *
7 * @module textGenerationPrompts
8 */
9import { Schema } from "effect";
10import type { ChatAttachment } from "@t3tools/contracts";
11
12import { limitSection } from "./Utils.ts";
13
14// ---------------------------------------------------------------------------
15// Commit message
16// ---------------------------------------------------------------------------
17
18export interface CommitMessagePromptInput {
19 branch: string | null;
20 stagedSummary: string;
21 stagedPatch: string;
22 includeBranch: boolean;
23}
24
25export function buildCommitMessagePrompt(input: CommitMessagePromptInput) {
26 const wantsBranch = input.includeBranch;
27
28 const prompt = [
29 "You write concise git commit messages.",
30 wantsBranch
31 ? "Return a JSON object with keys: subject, body, branch."
32 : "Return a JSON object with keys: subject, body.",
33 "Rules:",
34 "- subject must be imperative, <= 72 chars, and no trailing period",
35 "- body can be empty string or short bullet points",
36 ...(wantsBranch
37 ? ["- branch must be a short semantic git branch fragment for this change"]
38 : []),
39 "- capture the primary user-visible or developer-visible change",
40 "",
41 `Branch: ${input.branch ?? "(detached)"}`,
42 "",
43 "Staged files:",
44 limitSection(input.stagedSummary, 6_000),
45 "",
46 "Staged patch:",
47 limitSection(input.stagedPatch, 40_000),
48 ].join("\n");
49
50 if (wantsBranch) {
51 return {
52 prompt,
53 outputSchema: Schema.Struct({
54 subject: Schema.String,
55 body: Schema.String,
56 branch: Schema.String,
57 }),
58 };
59 }
60
61 return {
62 prompt,
63 outputSchema: Schema.Struct({
64 subject: Schema.String,
65 body: Schema.String,
66 }),
67 };
68}
69
70// ---------------------------------------------------------------------------
71// PR content
72// ---------------------------------------------------------------------------
73
74export interface PrContentPromptInput {
75 baseBranch: string;
76 headBranch: string;
77 commitSummary: string;
78 diffSummary: string;
79 diffPatch: string;
80}
81
82export function buildPrContentPrompt(input: PrContentPromptInput) {
83 const prompt = [
84 "You write GitHub pull request content.",
85 "Return a JSON object with keys: title, body.",
86 "Rules:",
87 "- title should be concise and specific",
88 "- body must be markdown and include headings '## Summary' and '## Testing'",
89 "- under Summary, provide short bullet points",
90 "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate",
91 "",
92 `Base branch: ${input.baseBranch}`,
93 `Head branch: ${input.headBranch}`,
94 "",
95 "Commits:",
96 limitSection(input.commitSummary, 12_000),
97 "",
98 "Diff stat:",
99 limitSection(input.diffSummary, 12_000),
100 "",
101 "Diff patch:",
102 limitSection(input.diffPatch, 40_000),
103 ].join("\n");
104
105 const outputSchema = Schema.Struct({
106 title: Schema.String,
107 body: Schema.String,
108 });
109
110 return { prompt, outputSchema };
111}
112
113// ---------------------------------------------------------------------------
114// Branch name
115// ---------------------------------------------------------------------------
116
117export interface BranchNamePromptInput {
118 message: string;
119 attachments?: ReadonlyArray<ChatAttachment> | undefined;
120}
121
122export function buildBranchNamePrompt(input: BranchNamePromptInput) {
123 const attachmentLines = (input.attachments ?? []).map(
124 (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
125 );
126
127 const promptSections = [
128 "You generate concise git branch names.",
129 "Return a JSON object with key: branch.",
130 "Rules:",
131 "- Branch should describe the requested work from the user message.",
132 "- Keep it short and specific (2-6 words).",
133 "- Use plain words only, no issue prefixes and no punctuation-heavy text.",
134 "- If images are attached, use them as primary context for visual/UI issues.",
135 "",
136 "User message:",
137 limitSection(input.message, 8_000),
138 ];
139 if (attachmentLines.length > 0) {
140 promptSections.push(
141 "",
142 "Attachment metadata:",
143 limitSection(attachmentLines.join("\n"), 4_000),
144 );
145 }
146
147 const prompt = promptSections.join("\n");
148 const outputSchema = Schema.Struct({
149 branch: Schema.String,
150 });
151
152 return { prompt, outputSchema };
153}
apps/server/src/git/Services/TextGeneration.ts+10-7
......@@ -8,10 +8,13 @@
88 */
99import { ServiceMap } from "effect";
1010import type { Effect } from "effect";
11import type { ChatAttachment } from "@t3tools/contracts";
11import type { ChatAttachment, ModelSelection } from "@t3tools/contracts";
1212
1313import type { TextGenerationError } from "../Errors.ts";
1414
15/** Providers that support git text generation (commit messages, PR content, branch names). */
16export type TextGenerationProvider = "codex" | "claudeAgent";
17
1518export interface CommitMessageGenerationInput {
1619 cwd: string;
1720 branch: string | null;
......@@ -19,8 +22,8 @@ export interface CommitMessageGenerationInput {
1922 stagedPatch: string;
2023 /** When true, the model also returns a semantic branch name for the change. */
2124 includeBranch?: boolean;
22 /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */
23 model?: string;
25 /** What model and provider to use for generation. */
26 modelSelection: ModelSelection;
2427}
2528
2629export interface CommitMessageGenerationResult {
......@@ -37,8 +40,8 @@ export interface PrContentGenerationInput {
3740 commitSummary: string;
3841 diffSummary: string;
3942 diffPatch: string;
40 /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */
41 model?: string;
43 /** What model and provider to use for generation. */
44 modelSelection: ModelSelection;
4245}
4346
4447export interface PrContentGenerationResult {
......@@ -50,8 +53,8 @@ export interface BranchNameGenerationInput {
5053 cwd: string;
5154 message: string;
5255 attachments?: ReadonlyArray<ChatAttachment> | undefined;
53 /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */
54 model?: string;
56 /** What model and provider to use for generation. */
57 modelSelection: ModelSelection;
5558}
5659
5760export interface BranchNameGenerationResult {
apps/server/src/git/Utils.ts created+102
......@@ -0,0 +1,102 @@
1/**
2 * Shared utilities for text generation layers (Codex, Claude, etc.).
3 *
4 * @module textGenerationUtils
5 */
6import { Schema } from "effect";
7
8import { TextGenerationError } from "./Errors.ts";
9
10import { existsSync } from "node:fs";
11import { join } from "node:path";
12
13export function isGitRepository(cwd: string): boolean {
14 return existsSync(join(cwd, ".git"));
15}
16
17/** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */
18export function toJsonSchemaObject(schema: Schema.Top): unknown {
19 const document = Schema.toJsonSchemaDocument(schema);
20 if (document.definitions && Object.keys(document.definitions).length > 0) {
21 return { ...document.schema, $defs: document.definitions };
22 }
23 return document.schema;
24}
25
26/** Truncate a text section to `maxChars`, appending a `[truncated]` marker when needed. */
27export function limitSection(value: string, maxChars: number): string {
28 if (value.length <= maxChars) return value;
29 const truncated = value.slice(0, maxChars);
30 return `${truncated}\n\n[truncated]`;
31}
32
33/** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */
34export function sanitizeCommitSubject(raw: string): string {
35 const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
36 const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim();
37 if (withoutTrailingPeriod.length === 0) {
38 return "Update project files";
39 }
40
41 if (withoutTrailingPeriod.length <= 72) {
42 return withoutTrailingPeriod;
43 }
44 return withoutTrailingPeriod.slice(0, 72).trimEnd();
45}
46
47/** Normalise a raw PR title to a single line with a sensible fallback. */
48export function sanitizePrTitle(raw: string): string {
49 const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
50 if (singleLine.length > 0) {
51 return singleLine;
52 }
53 return "Update project changes";
54}
55
56/** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */
57function cliLabel(cliName: string): string {
58 const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1);
59 return `${capitalized} CLI (\`${cliName}\`)`;
60}
61
62/**
63 * Normalize an unknown error from a CLI text generation process into a
64 * typed `TextGenerationError`. Parameterized by CLI name so both Codex
65 * and Claude (and future providers) can share the same logic.
66 */
67export function normalizeCliError(
68 cliName: string,
69 operation: string,
70 error: unknown,
71 fallback: string,
72): TextGenerationError {
73 if (Schema.is(TextGenerationError)(error)) {
74 return error;
75 }
76
77 if (error instanceof Error) {
78 const lower = error.message.toLowerCase();
79 if (
80 error.message.includes(`Command not found: ${cliName}`) ||
81 lower.includes(`spawn ${cliName}`) ||
82 lower.includes("enoent")
83 ) {
84 return new TextGenerationError({
85 operation,
86 detail: `${cliLabel(cliName)} is required but not available on PATH.`,
87 cause: error,
88 });
89 }
90 return new TextGenerationError({
91 operation,
92 detail: `${fallback}: ${error.message}`,
93 cause: error,
94 });
95 }
96
97 return new TextGenerationError({
98 operation,
99 detail: fallback,
100 cause: error,
101 });
102}
apps/server/src/git/isRepo.ts deleted-6
......@@ -1,6 +0,0 @@
1import { existsSync } from "node:fs";
2import { join } from "node:path";
3
4export function isGitRepository(cwd: string): boolean {
5 return existsSync(join(cwd, ".git"));
6}
apps/server/src/orchestration/Layers/CheckpointReactor.ts+1-1
......@@ -24,7 +24,7 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
2424import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts";
2525import { CheckpointStoreError } from "../../checkpointing/Errors.ts";
2626import { OrchestrationDispatchError } from "../Errors.ts";
27import { isGitRepository } from "../../git/isRepo.ts";
27import { isGitRepository } from "../../git/Utils.ts";
2828
2929type ReactorInput =
3030 | {
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+5-2
......@@ -1,7 +1,7 @@
11import {
22 type ChatAttachment,
33 CommandId,
4 DEFAULT_GIT_TEXT_GENERATION_MODEL,
4 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
55 EventId,
66 type ModelSelection,
77 type OrchestrationEvent,
......@@ -437,7 +437,10 @@ const make = Effect.gen(function* () {
437437 cwd,
438438 message: input.messageText,
439439 ...(attachments.length > 0 ? { attachments } : {}),
440 model: DEFAULT_GIT_TEXT_GENERATION_MODEL,
440 modelSelection: {
441 provider: "codex",
442 model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex,
443 },
441444 })
442445 .pipe(
443446 Effect.catch((error) =>
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+1-1
......@@ -20,7 +20,7 @@ import { ProviderService } from "../../provider/Services/ProviderService.ts";
2020import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";
2121import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";
2222import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
23import { isGitRepository } from "../../git/isRepo.ts";
23import { isGitRepository } from "../../git/Utils.ts";
2424import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
2525import {
2626 ProviderRuntimeIngestionService,
apps/server/src/serverLayers.ts+2-2
......@@ -30,7 +30,7 @@ import { KeybindingsLive } from "./keybindings";
3030import { GitManagerLive } from "./git/Layers/GitManager";
3131import { GitCoreLive } from "./git/Layers/GitCore";
3232import { GitHubCliLive } from "./git/Layers/GitHubCli";
33import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration";
33import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration";
3434import { PtyAdapter } from "./terminal/Services/PTY";
3535import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
3636
......@@ -85,7 +85,7 @@ export function makeServerProviderLayer(): Layer.Layer<
8585}
8686
8787export function makeServerRuntimeServicesLayer() {
88 const textGenerationLayer = CodexTextGenerationLive;
88 const textGenerationLayer = RoutingTextGenerationLive;
8989 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive));
9090
9191 const orchestrationLayer = OrchestrationEngineLive.pipe(
apps/server/src/wsServer.test.ts+12
......@@ -1838,6 +1838,10 @@ describe("WebSocket Server", () => {
18381838 actionId: "client-action-1",
18391839 cwd: "/test",
18401840 action: "commit_push",
1841 modelSelection: {
1842 provider: "codex",
1843 model: "gpt-5.4-mini",
1844 },
18411845 });
18421846 expect(response.result).toBeUndefined();
18431847 expect(response.error?.message).toContain("detached HEAD");
......@@ -1846,6 +1850,10 @@ describe("WebSocket Server", () => {
18461850 actionId: "client-action-1",
18471851 cwd: "/test",
18481852 action: "commit_push",
1853 modelSelection: {
1854 provider: "codex",
1855 model: "gpt-5.4-mini",
1856 },
18491857 },
18501858 expect.objectContaining({
18511859 actionId: "client-action-1",
......@@ -1901,6 +1909,10 @@ describe("WebSocket Server", () => {
19011909 actionId: "client-action-2",
19021910 cwd: "/test",
19031911 action: "commit",
1912 modelSelection: {
1913 provider: "codex",
1914 model: "gpt-5.4-mini",
1915 },
19041916 });
19051917 const progressPush = await waitForPush(initiatingWs, WS_CHANNELS.gitActionProgress);
19061918
apps/web/src/appSettings.test.ts+60-2
......@@ -6,17 +6,20 @@ import {
66 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
77 DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
88 DEFAULT_TIMESTAMP_FORMAT,
9 getProviderStartOptions,
10} from "./appSettings";
11import {
912 getAppModelOptions,
1013 getCustomModelOptionsByProvider,
1114 getCustomModelsByProvider,
1215 getCustomModelsForProvider,
1316 getDefaultCustomModelsForProvider,
14 getProviderStartOptions,
1517 MODEL_PROVIDER_SETTINGS,
1618 normalizeCustomModelSlugs,
1719 patchCustomModels,
20 resolveAppModelSelectionState,
1821 resolveAppModelSelection,
19} from "./appSettings";
22} from "./modelSelection";
2023
2124describe("normalizeCustomModelSlugs", () => {
2225 it("normalizes aliases, removes built-ins, and deduplicates values", () => {
......@@ -265,3 +268,58 @@ describe("AppSettingsSchema", () => {
265268 });
266269 });
267270});
271
272describe("resolveAppModelSelectionState", () => {
273 it("falls back to the default git-writing codex selection", () => {
274 expect(
275 resolveAppModelSelectionState({
276 customCodexModels: [],
277 customClaudeModels: [],
278 textGenerationModelSelection: undefined,
279 }),
280 ).toEqual({
281 provider: "codex",
282 model: "gpt-5.4-mini",
283 });
284 });
285
286 it("preserves the selected provider and resolves saved custom models", () => {
287 expect(
288 resolveAppModelSelectionState({
289 customCodexModels: [],
290 customClaudeModels: ["claude/custom-haiku"],
291 textGenerationModelSelection: {
292 provider: "claudeAgent",
293 model: "claude/custom-haiku",
294 },
295 }),
296 ).toEqual({
297 provider: "claudeAgent",
298 model: "claude/custom-haiku",
299 });
300 });
301
302 it("normalizes provider options against the resolved model capabilities", () => {
303 expect(
304 resolveAppModelSelectionState({
305 customCodexModels: [],
306 customClaudeModels: [],
307 textGenerationModelSelection: {
308 provider: "claudeAgent",
309 model: "claude-haiku-4-5",
310 options: {
311 effort: "max",
312 thinking: false,
313 fastMode: true,
314 },
315 },
316 }),
317 ).toEqual({
318 provider: "claudeAgent",
319 model: "claude-haiku-4-5",
320 options: {
321 thinking: false,
322 },
323 });
324 });
325});
apps/web/src/appSettings.ts+9-178
......@@ -1,22 +1,15 @@
11import { useCallback } from "react";
22import { Option, Schema } from "effect";
33import {
4 TrimmedNonEmptyString,
5 type ProviderKind,
4 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
5 ModelSelection,
66 type ProviderStartOptions,
77} from "@t3tools/contracts";
8import {
9 getDefaultModel,
10 getModelOptions,
11 normalizeModelSlug,
12 resolveSelectableModel,
13} from "@t3tools/shared/model";
148import { useLocalStorage } from "./hooks/useLocalStorage";
159import { EnvMode } from "./components/BranchToolbar.logic";
10import { normalizeCustomModelSlugs } from "./modelSelection";
1611
1712const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
18const MAX_CUSTOM_MODEL_COUNT = 32;
19export const MAX_CUSTOM_MODEL_LENGTH = 256;
2013
2114export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]);
2215export type TimestampFormat = typeof TimestampFormat.Type;
......@@ -27,21 +20,6 @@ export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "upda
2720export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]);
2821export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type;
2922export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at";
30type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels";
31export type ProviderCustomModelConfig = {
32 provider: ProviderKind;
33 settingsKey: CustomModelSettingsKey;
34 defaultSettingsKey: CustomModelSettingsKey;
35 title: string;
36 description: string;
37 placeholder: string;
38 example: string;
39};
40
41const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {
42 codex: new Set(getModelOptions("codex").map((option) => option.slug)),
43 claudeAgent: new Set(getModelOptions("claudeAgent").map((option) => option.slug)),
44};
4523
4624const withDefaults =
4725 <
......@@ -73,66 +51,16 @@ export const AppSettingsSchema = Schema.Struct({
7351 timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)),
7452 customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])),
7553 customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])),
76 textGenerationModel: Schema.optional(TrimmedNonEmptyString),
54 textGenerationModelSelection: ModelSelection.pipe(
55 withDefaults(() => ({
56 provider: "codex" as const,
57 model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex,
58 })),
59 ),
7760});
7861export type AppSettings = typeof AppSettingsSchema.Type;
79export interface AppModelOption {
80 slug: string;
81 name: string;
82 isCustom: boolean;
83}
8462
8563const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});
86const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConfig> = {
87 codex: {
88 provider: "codex",
89 settingsKey: "customCodexModels",
90 defaultSettingsKey: "customCodexModels",
91 title: "Codex",
92 description: "Save additional Codex model slugs for the picker and `/model` command.",
93 placeholder: "your-codex-model-slug",
94 example: "gpt-6.7-codex-ultra-preview",
95 },
96 claudeAgent: {
97 provider: "claudeAgent",
98 settingsKey: "customClaudeModels",
99 defaultSettingsKey: "customClaudeModels",
100 title: "Claude",
101 description: "Save additional Claude model slugs for the picker and `/model` command.",
102 placeholder: "your-claude-model-slug",
103 example: "claude-sonnet-5-0",
104 },
105};
106export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG);
107
108export function normalizeCustomModelSlugs(
109 models: Iterable<string | null | undefined>,
110 provider: ProviderKind = "codex",
111): string[] {
112 const normalizedModels: string[] = [];
113 const seen = new Set<string>();
114 const builtInModelSlugs = BUILT_IN_MODEL_SLUGS_BY_PROVIDER[provider];
115
116 for (const candidate of models) {
117 const normalized = normalizeModelSlug(candidate, provider);
118 if (
119 !normalized ||
120 normalized.length > MAX_CUSTOM_MODEL_LENGTH ||
121 builtInModelSlugs.has(normalized) ||
122 seen.has(normalized)
123 ) {
124 continue;
125 }
126
127 seen.add(normalized);
128 normalizedModels.push(normalized);
129 if (normalizedModels.length >= MAX_CUSTOM_MODEL_COUNT) {
130 break;
131 }
132 }
133
134 return normalizedModels;
135}
13664
13765function normalizeAppSettings(settings: AppSettings): AppSettings {
13866 return {
......@@ -142,103 +70,6 @@ function normalizeAppSettings(settings: AppSettings): AppSettings {
14270 };
14371}
14472
145export function getCustomModelsForProvider(
146 settings: Pick<AppSettings, CustomModelSettingsKey>,
147 provider: ProviderKind,
148): readonly string[] {
149 return settings[PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey];
150}
151
152export function getDefaultCustomModelsForProvider(
153 defaults: Pick<AppSettings, CustomModelSettingsKey>,
154 provider: ProviderKind,
155): readonly string[] {
156 return defaults[PROVIDER_CUSTOM_MODEL_CONFIG[provider].defaultSettingsKey];
157}
158
159export function patchCustomModels(
160 provider: ProviderKind,
161 models: string[],
162): Partial<Pick<AppSettings, CustomModelSettingsKey>> {
163 return {
164 [PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]: models,
165 };
166}
167
168export function getCustomModelsByProvider(
169 settings: Pick<AppSettings, CustomModelSettingsKey>,
170): Record<ProviderKind, readonly string[]> {
171 return {
172 codex: getCustomModelsForProvider(settings, "codex"),
173 claudeAgent: getCustomModelsForProvider(settings, "claudeAgent"),
174 };
175}
176
177export function getAppModelOptions(
178 provider: ProviderKind,
179 customModels: readonly string[],
180 selectedModel?: string | null,
181): AppModelOption[] {
182 const options: AppModelOption[] = getModelOptions(provider).map(({ slug, name }) => ({
183 slug,
184 name,
185 isCustom: false,
186 }));
187 const seen = new Set(options.map((option) => option.slug));
188 const trimmedSelectedModel = selectedModel?.trim().toLowerCase();
189
190 for (const slug of normalizeCustomModelSlugs(customModels, provider)) {
191 if (seen.has(slug)) {
192 continue;
193 }
194
195 seen.add(slug);
196 options.push({
197 slug,
198 name: slug,
199 isCustom: true,
200 });
201 }
202
203 const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider);
204 const selectedModelMatchesExistingName =
205 typeof trimmedSelectedModel === "string" &&
206 options.some((option) => option.name.toLowerCase() === trimmedSelectedModel);
207 if (
208 normalizedSelectedModel &&
209 !seen.has(normalizedSelectedModel) &&
210 !selectedModelMatchesExistingName
211 ) {
212 options.push({
213 slug: normalizedSelectedModel,
214 name: normalizedSelectedModel,
215 isCustom: true,
216 });
217 }
218
219 return options;
220}
221
222export function resolveAppModelSelection(
223 provider: ProviderKind,
224 customModels: Record<ProviderKind, readonly string[]>,
225 selectedModel: string | null | undefined,
226): string {
227 const customModelsForProvider = customModels[provider];
228 const options = getAppModelOptions(provider, customModelsForProvider, selectedModel);
229 return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider);
230}
231
232export function getCustomModelOptionsByProvider(
233 settings: Pick<AppSettings, CustomModelSettingsKey>,
234): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> {
235 const customModelsByProvider = getCustomModelsByProvider(settings);
236 return {
237 codex: getAppModelOptions("codex", customModelsByProvider.codex),
238 claudeAgent: getAppModelOptions("claudeAgent", customModelsByProvider.claudeAgent),
239 };
240}
241
24273export function getProviderStartOptions(
24374 settings: Pick<AppSettings, "claudeBinaryPath" | "codexBinaryPath" | "codexHomePath">,
24475): ProviderStartOptions | undefined {
apps/web/src/components/ChatView.tsx+4-5
......@@ -120,13 +120,12 @@ import {
120120import { SidebarTrigger } from "./ui/sidebar";
121121import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
122122import { readNativeApi } from "~/nativeApi";
123import { getProviderStartOptions, useAppSettings } from "../appSettings";
123124import {
124125 getCustomModelOptionsByProvider,
125126 getCustomModelsByProvider,
126 getProviderStartOptions,
127127 resolveAppModelSelection,
128 useAppSettings,
129} from "../appSettings";
128} from "../modelSelection";
130129import { isTerminalFocused } from "../lib/terminalFocus";
131130import {
132131 type ComposerImageAttachment,
......@@ -642,8 +641,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
642641 const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]);
643642 const selectedModelForPicker = selectedModel;
644643 const modelOptionsByProvider = useMemo(
645 () => getCustomModelOptionsByProvider(settings),
646 [settings],
644 () => getCustomModelOptionsByProvider(settings, selectedProvider, selectedModel),
645 [settings, selectedProvider, selectedModel],
647646 );
648647 const selectedModelForPickerWithCustomFallback = useMemo(() => {
649648 const currentOptions = modelOptionsByProvider[selectedProvider];
apps/web/src/components/GitActionsControl.tsx+1-1
......@@ -261,7 +261,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
261261 gitRunStackedActionMutationOptions({
262262 cwd: gitCwd,
263263 queryClient,
264 model: settings.textGenerationModel ?? null,
264 modelSelection: settings.textGenerationModelSelection,
265265 }),
266266 );
267267 const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));
apps/web/src/components/chat/ProviderModelPicker.browser.tsx+22
......@@ -21,6 +21,7 @@ async function mountPicker(props: {
2121 provider: ProviderKind;
2222 model: ModelSlug;
2323 lockedProvider: ProviderKind | null;
24 triggerVariant?: "ghost" | "outline";
2425}) {
2526 const host = document.createElement("div");
2627 document.body.append(host);
......@@ -31,6 +32,7 @@ async function mountPicker(props: {
3132 model={props.model}
3233 lockedProvider={props.lockedProvider}
3334 modelOptionsByProvider={MODEL_OPTIONS_BY_PROVIDER}
35 triggerVariant={props.triggerVariant}
3436 onProviderModelChange={onProviderModelChange}
3537 />,
3638 { container: host },
......@@ -156,4 +158,24 @@ describe("ProviderModelPicker", () => {
156158 await mounted.cleanup();
157159 }
158160 });
161
162 it("accepts outline trigger styling", async () => {
163 const mounted = await mountPicker({
164 provider: "codex",
165 model: "gpt-5-codex",
166 lockedProvider: null,
167 triggerVariant: "outline",
168 });
169
170 try {
171 const button = document.querySelector("button");
172 if (!(button instanceof HTMLButtonElement)) {
173 throw new Error("Expected picker trigger button to be rendered.");
174 }
175 expect(button.className).toContain("border-input");
176 expect(button.className).toContain("bg-popover");
177 } finally {
178 await mounted.cleanup();
179 }
180 });
159181});
apps/web/src/components/chat/ProviderModelPicker.tsx+6-2
......@@ -1,9 +1,10 @@
11import { type ModelSlug, type ProviderKind } from "@t3tools/contracts";
22import { resolveSelectableModel } from "@t3tools/shared/model";
33import { memo, useState } from "react";
4import type { VariantProps } from "class-variance-authority";
45import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic";
56import { ChevronDownIcon } from "lucide-react";
6import { Button } from "../ui/button";
7import { Button, buttonVariants } from "../ui/button";
78import {
89 Menu,
910 MenuGroup,
......@@ -56,6 +57,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
5657 activeProviderIconClassName?: string;
5758 compact?: boolean;
5859 disabled?: boolean;
60 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
61 triggerClassName?: string;
5962 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
6063}) {
6164 const [isMenuOpen, setIsMenuOpen] = useState(false);
......@@ -92,10 +95,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
9295 render={
9396 <Button
9497 size="sm"
95 variant="ghost"
98 variant={props.triggerVariant ?? "ghost"}
9699 className={cn(
97100 "min-w-0 justify-start overflow-hidden whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 [&_svg]:mx-0",
98101 props.compact ? "max-w-42 shrink-0" : "max-w-48 shrink sm:max-w-56 sm:px-3",
102 props.triggerClassName,
99103 )}
100104 disabled={props.disabled}
101105 />
apps/web/src/components/chat/TraitsPicker.browser.tsx+21-1
......@@ -29,6 +29,7 @@ const CLAUDE_THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits");
2929function ClaudeTraitsPickerHarness(props: {
3030 model: string;
3131 fallbackModelSelection: ModelSelection | null;
32 triggerVariant?: "ghost" | "outline";
3233}) {
3334 const prompt = useComposerThreadDraft(CLAUDE_THREAD_ID).prompt;
3435 const setPrompt = useComposerDraftStore((store) => store.setPrompt);
......@@ -54,6 +55,7 @@ function ClaudeTraitsPickerHarness(props: {
5455 prompt={prompt}
5556 modelOptions={modelOptions?.claudeAgent}
5657 onPromptChange={handlePromptChange}
58 triggerVariant={props.triggerVariant}
5759 />
5860 );
5961}
......@@ -68,6 +70,7 @@ async function mountClaudePicker(props?: {
6870 fastMode?: boolean;
6971 } | null;
7072 skipDraftModelOptions?: boolean;
73 triggerVariant?: "ghost" | "outline";
7174}) {
7275 const model = props?.model ?? "claude-opus-4-6";
7376 const claudeOptions = !props?.skipDraftModelOptions ? props?.options : undefined;
......@@ -110,7 +113,11 @@ async function mountClaudePicker(props?: {
110113 } satisfies ModelSelection)
111114 : null;
112115 const screen = await render(
113 <ClaudeTraitsPickerHarness model={model} fallbackModelSelection={fallbackModelSelection} />,
116 <ClaudeTraitsPickerHarness
117 model={model}
118 fallbackModelSelection={fallbackModelSelection}
119 {...(props?.triggerVariant ? { triggerVariant: props.triggerVariant } : {})}
120 />,
114121 { container: host },
115122 );
116123
......@@ -234,6 +241,19 @@ describe("TraitsPicker (Claude)", () => {
234241 },
235242 });
236243 });
244
245 it("accepts outline trigger styling", async () => {
246 await using _ = await mountClaudePicker({
247 triggerVariant: "outline",
248 });
249
250 const button = document.querySelector("button");
251 if (!(button instanceof HTMLButtonElement)) {
252 throw new Error("Expected traits trigger button to be rendered.");
253 }
254 expect(button.className).toContain("border-input");
255 expect(button.className).toContain("bg-popover");
256 });
237257});
238258
239259// ── Codex TraitsPicker tests ──────────────────────────────────────────
apps/web/src/components/chat/TraitsPicker.tsx+55-29
......@@ -14,8 +14,9 @@ import {
1414 hasEffortLevel,
1515} from "@t3tools/shared/model";
1616import { memo, useCallback, useState } from "react";
17import type { VariantProps } from "class-variance-authority";
1718import { ChevronDownIcon } from "lucide-react";
18import { Button } from "../ui/button";
19import { Button, buttonVariants } from "../ui/button";
1920import {
2021 Menu,
2122 MenuGroup,
......@@ -26,8 +27,18 @@ import {
2627 MenuTrigger,
2728} from "../ui/menu";
2829import { useComposerDraftStore } from "../../composerDraftStore";
30import { cn } from "~/lib/utils";
2931
3032type ProviderOptions = ProviderModelOptions[ProviderKind];
33type TraitsPersistence =
34 | {
35 threadId: ThreadId;
36 onModelOptionsChange?: never;
37 }
38 | {
39 threadId?: undefined;
40 onModelOptionsChange: (nextOptions: ProviderOptions | undefined) => void;
41 };
3142
3243const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n";
3344
......@@ -57,9 +68,14 @@ function getSelectedTraits(
5768 model: string | null | undefined,
5869 prompt: string,
5970 modelOptions: ProviderOptions | null | undefined,
71 allowPromptInjectedEffort: boolean,
6072) {
6173 const caps = getModelCapabilities(provider, model);
62 const effortLevels = caps.reasoningEffortLevels;
74 const effortLevels = allowPromptInjectedEffort
75 ? caps.reasoningEffortLevels
76 : caps.reasoningEffortLevels.filter(
77 (option) => !caps.promptInjectedEffortLevels.includes(option.value),
78 );
6379 const defaultEffort = getDefaultEffort(caps);
6480
6581 // Resolve effort from options (provider-specific key)
......@@ -88,7 +104,9 @@ function getSelectedTraits(
88104
89105 // Prompt-controlled effort (e.g. ultrathink in prompt text)
90106 const ultrathinkPromptControlled =
91 caps.promptInjectedEffortLevels.length > 0 && isClaudeUltrathinkPrompt(prompt);
107 allowPromptInjectedEffort &&
108 caps.promptInjectedEffortLevels.length > 0 &&
109 isClaudeUltrathinkPrompt(prompt);
92110
93111 return {
94112 caps,
......@@ -102,22 +120,35 @@ function getSelectedTraits(
102120
103121export interface TraitsMenuContentProps {
104122 provider: ProviderKind;
105 threadId: ThreadId;
106123 model: string | null | undefined;
107124 prompt: string;
108125 onPromptChange: (prompt: string) => void;
109126 modelOptions?: ProviderOptions | null | undefined;
127 allowPromptInjectedEffort?: boolean;
128 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
129 triggerClassName?: string;
110130}
111131
112132export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
113133 provider,
114 threadId,
115134 model,
116135 prompt,
117136 onPromptChange,
118137 modelOptions,
119}: TraitsMenuContentProps) {
138 allowPromptInjectedEffort = true,
139 ...persistence
140}: TraitsMenuContentProps & TraitsPersistence) {
120141 const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions);
142 const updateModelOptions = useCallback(
143 (nextOptions: ProviderOptions | undefined) => {
144 if ("onModelOptionsChange" in persistence) {
145 persistence.onModelOptionsChange(nextOptions);
146 return;
147 }
148 setProviderModelOptions(persistence.threadId, provider, nextOptions, { persistSticky: true });
149 },
150 [persistence, provider, setProviderModelOptions],
151 );
121152 const {
122153 caps,
123154 effort,
......@@ -125,7 +156,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
125156 thinkingEnabled,
126157 fastModeEnabled,
127158 ultrathinkPromptControlled,
128 } = getSelectedTraits(provider, model, prompt, modelOptions);
159 } = getSelectedTraits(provider, model, prompt, modelOptions, allowPromptInjectedEffort);
129160 const defaultEffort = getDefaultEffort(caps);
130161
131162 const handleEffortChange = useCallback(
......@@ -143,19 +174,15 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
143174 return;
144175 }
145176 const effortKey = provider === "codex" ? "reasoningEffort" : "effort";
146 setProviderModelOptions(
147 threadId,
148 provider,
177 updateModelOptions(
149178 buildNextOptions(provider, modelOptions, { [effortKey]: nextOption.value }),
150 { persistSticky: true },
151179 );
152180 },
153181 [
154182 ultrathinkPromptControlled,
155183 modelOptions,
156184 onPromptChange,
157 threadId,
158 setProviderModelOptions,
185 updateModelOptions,
159186 effortLevels,
160187 prompt,
161188 caps.promptInjectedEffortLevels,
......@@ -198,11 +225,8 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
198225 <MenuRadioGroup
199226 value={thinkingEnabled ? "on" : "off"}
200227 onValueChange={(value) => {
201 setProviderModelOptions(
202 threadId,
203 provider,
228 updateModelOptions(
204229 buildNextOptions(provider, modelOptions, { thinking: value === "on" }),
205 { persistSticky: true },
206230 );
207231 }}
208232 >
......@@ -219,11 +243,8 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
219243 <MenuRadioGroup
220244 value={fastModeEnabled ? "on" : "off"}
221245 onValueChange={(value) => {
222 setProviderModelOptions(
223 threadId,
224 provider,
246 updateModelOptions(
225247 buildNextOptions(provider, modelOptions, { fastMode: value === "on" }),
226 { persistSticky: true },
227248 );
228249 }}
229250 >
......@@ -239,12 +260,15 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
239260
240261export const TraitsPicker = memo(function TraitsPicker({
241262 provider,
242 threadId,
243263 model,
244264 prompt,
245265 onPromptChange,
246266 modelOptions,
247}: TraitsMenuContentProps) {
267 allowPromptInjectedEffort = true,
268 triggerVariant,
269 triggerClassName,
270 ...persistence
271}: TraitsMenuContentProps & TraitsPersistence) {
248272 const [isMenuOpen, setIsMenuOpen] = useState(false);
249273 const {
250274 caps,
......@@ -253,7 +277,7 @@ export const TraitsPicker = memo(function TraitsPicker({
253277 thinkingEnabled,
254278 fastModeEnabled,
255279 ultrathinkPromptControlled,
256 } = getSelectedTraits(provider, model, prompt, modelOptions);
280 } = getSelectedTraits(provider, model, prompt, modelOptions, allowPromptInjectedEffort);
257281
258282 const effortLabel = effort
259283 ? (effortLevels.find((l) => l.value === effort)?.label ?? effort)
......@@ -284,12 +308,13 @@ export const TraitsPicker = memo(function TraitsPicker({
284308 render={
285309 <Button
286310 size="sm"
287 variant="ghost"
288 className={
311 variant={triggerVariant ?? "ghost"}
312 className={cn(
289313 isCodexStyle
290314 ? "min-w-0 max-w-40 shrink justify-start overflow-hidden whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:max-w-48 sm:px-3 [&_svg]:mx-0"
291 : "shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
292 }
315 : "shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3",
316 triggerClassName,
317 )}
293318 />
294319 }
295320 >
......@@ -308,11 +333,12 @@ export const TraitsPicker = memo(function TraitsPicker({
308333 <MenuPopup align="start">
309334 <TraitsMenuContent
310335 provider={provider}
311 threadId={threadId}
312336 model={model}
313337 prompt={prompt}
314338 onPromptChange={onPromptChange}
315339 modelOptions={modelOptions}
340 allowPromptInjectedEffort={allowPromptInjectedEffort}
341 {...persistence}
316342 />
317343 </MenuPopup>
318344 </Menu>
apps/web/src/composerDraftStore.ts+1-1
......@@ -21,7 +21,7 @@ import {
2121} from "@t3tools/shared/model";
2222import { useMemo } from "react";
2323import { getLocalStorageItem } from "./hooks/useLocalStorage";
24import { resolveAppModelSelection } from "./appSettings";
24import { resolveAppModelSelection } from "./modelSelection";
2525import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types";
2626import {
2727 type TerminalContextDraft,
apps/web/src/lib/gitReactQuery.test.ts+8-1
......@@ -29,7 +29,14 @@ describe("git mutation options", () => {
2929 const queryClient = new QueryClient();
3030
3131 it("attaches cwd-scoped mutation key for runStackedAction", () => {
32 const options = gitRunStackedActionMutationOptions({ cwd: "/repo/a", queryClient });
32 const options = gitRunStackedActionMutationOptions({
33 cwd: "/repo/a",
34 queryClient,
35 modelSelection: {
36 provider: "codex",
37 model: "gpt-5.4",
38 },
39 });
3340 expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction("/repo/a"));
3441 });
3542
apps/web/src/lib/gitReactQuery.ts+3-3
......@@ -1,4 +1,4 @@
1import type { GitStackedAction } from "@t3tools/contracts";
1import { type GitStackedAction, type ModelSelection } from "@t3tools/contracts";
22import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";
33import { ensureNativeApi } from "../nativeApi";
44
......@@ -112,7 +112,7 @@ export function gitCheckoutMutationOptions(input: {
112112export function gitRunStackedActionMutationOptions(input: {
113113 cwd: string | null;
114114 queryClient: QueryClient;
115 model?: string | null;
115 modelSelection: ModelSelection;
116116}) {
117117 return mutationOptions({
118118 mutationKey: gitMutationKeys.runStackedAction(input.cwd),
......@@ -134,11 +134,11 @@ export function gitRunStackedActionMutationOptions(input: {
134134 return api.git.runStackedAction({
135135 actionId,
136136 cwd: input.cwd,
137 modelSelection: input.modelSelection,
137138 action,
138139 ...(commitMessage ? { commitMessage } : {}),
139140 ...(featureBranch ? { featureBranch } : {}),
140141 ...(filePaths ? { filePaths } : {}),
141 ...(input.model ? { model: input.model } : {}),
142142 });
143143 },
144144 onSettled: async () => {
apps/web/src/modelSelection.ts created+228
......@@ -0,0 +1,228 @@
1import {
2 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
3 type ModelSelection,
4 type ProviderKind,
5} from "@t3tools/contracts";
6import {
7 getDefaultModel,
8 getModelOptions,
9 normalizeModelSlug,
10 resolveSelectableModel,
11} from "@t3tools/shared/model";
12import { getComposerProviderState } from "./components/chat/composerProviderRegistry";
13
14const MAX_CUSTOM_MODEL_COUNT = 32;
15export const MAX_CUSTOM_MODEL_LENGTH = 256;
16
17export type CustomModelSettings = {
18 customCodexModels: readonly string[];
19 customClaudeModels: readonly string[];
20};
21
22export type ProviderCustomModelConfig = {
23 provider: ProviderKind;
24 settingsKey: keyof CustomModelSettings;
25 defaultSettingsKey: keyof CustomModelSettings;
26 title: string;
27 description: string;
28 placeholder: string;
29 example: string;
30};
31
32export interface AppModelOption {
33 slug: string;
34 name: string;
35 isCustom: boolean;
36}
37
38const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConfig> = {
39 codex: {
40 provider: "codex",
41 settingsKey: "customCodexModels",
42 defaultSettingsKey: "customCodexModels",
43 title: "Codex",
44 description: "Save additional Codex model slugs for the picker and `/model` command.",
45 placeholder: "your-codex-model-slug",
46 example: "gpt-6.7-codex-ultra-preview",
47 },
48 claudeAgent: {
49 provider: "claudeAgent",
50 settingsKey: "customClaudeModels",
51 defaultSettingsKey: "customClaudeModels",
52 title: "Claude",
53 description: "Save additional Claude model slugs for the picker and `/model` command.",
54 placeholder: "your-claude-model-slug",
55 example: "claude-sonnet-5-0",
56 },
57};
58
59const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {
60 codex: new Set(getModelOptions("codex").map((option) => option.slug)),
61 claudeAgent: new Set(getModelOptions("claudeAgent").map((option) => option.slug)),
62};
63
64export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG);
65
66export function normalizeCustomModelSlugs(
67 models: Iterable<string | null | undefined>,
68 provider: ProviderKind = "codex",
69): string[] {
70 const normalizedModels: string[] = [];
71 const seen = new Set<string>();
72 const builtInModelSlugs = BUILT_IN_MODEL_SLUGS_BY_PROVIDER[provider];
73
74 for (const candidate of models) {
75 const normalized = normalizeModelSlug(candidate, provider);
76 if (
77 !normalized ||
78 normalized.length > MAX_CUSTOM_MODEL_LENGTH ||
79 builtInModelSlugs.has(normalized) ||
80 seen.has(normalized)
81 ) {
82 continue;
83 }
84
85 seen.add(normalized);
86 normalizedModels.push(normalized);
87 if (normalizedModels.length >= MAX_CUSTOM_MODEL_COUNT) {
88 break;
89 }
90 }
91
92 return normalizedModels;
93}
94
95export function getCustomModelsForProvider(
96 settings: CustomModelSettings,
97 provider: ProviderKind,
98): readonly string[] {
99 return settings[PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey];
100}
101
102export function getDefaultCustomModelsForProvider(
103 defaults: CustomModelSettings,
104 provider: ProviderKind,
105): readonly string[] {
106 return defaults[PROVIDER_CUSTOM_MODEL_CONFIG[provider].defaultSettingsKey];
107}
108
109export function patchCustomModels(
110 provider: ProviderKind,
111 models: string[],
112): Partial<CustomModelSettings> {
113 return {
114 [PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]: models,
115 };
116}
117
118export function getCustomModelsByProvider(
119 settings: CustomModelSettings,
120): Record<ProviderKind, readonly string[]> {
121 return {
122 codex: getCustomModelsForProvider(settings, "codex"),
123 claudeAgent: getCustomModelsForProvider(settings, "claudeAgent"),
124 };
125}
126
127export function getAppModelOptions(
128 provider: ProviderKind,
129 customModels: readonly string[],
130 selectedModel?: string | null,
131): AppModelOption[] {
132 const options: AppModelOption[] = getModelOptions(provider).map(({ slug, name }) => ({
133 slug,
134 name,
135 isCustom: false,
136 }));
137 const seen = new Set(options.map((option) => option.slug));
138 const trimmedSelectedModel = selectedModel?.trim().toLowerCase();
139
140 for (const slug of normalizeCustomModelSlugs(customModels, provider)) {
141 if (seen.has(slug)) {
142 continue;
143 }
144
145 seen.add(slug);
146 options.push({
147 slug,
148 name: slug,
149 isCustom: true,
150 });
151 }
152
153 const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider);
154 const selectedModelMatchesExistingName =
155 typeof trimmedSelectedModel === "string" &&
156 options.some((option) => option.name.toLowerCase() === trimmedSelectedModel);
157 if (
158 normalizedSelectedModel &&
159 !seen.has(normalizedSelectedModel) &&
160 !selectedModelMatchesExistingName
161 ) {
162 options.push({
163 slug: normalizedSelectedModel,
164 name: normalizedSelectedModel,
165 isCustom: true,
166 });
167 }
168
169 return options;
170}
171
172export function resolveAppModelSelection(
173 provider: ProviderKind,
174 customModels: Record<ProviderKind, readonly string[]>,
175 selectedModel: string | null | undefined,
176): string {
177 const customModelsForProvider = customModels[provider];
178 const options = getAppModelOptions(provider, customModelsForProvider, selectedModel);
179 return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider);
180}
181
182export function getCustomModelOptionsByProvider(
183 settings: CustomModelSettings,
184 selectedProvider?: ProviderKind | null,
185 selectedModel?: string | null,
186): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> {
187 const customModelsByProvider = getCustomModelsByProvider(settings);
188 return {
189 codex: getAppModelOptions(
190 "codex",
191 customModelsByProvider.codex,
192 selectedProvider === "codex" ? selectedModel : undefined,
193 ),
194 claudeAgent: getAppModelOptions(
195 "claudeAgent",
196 customModelsByProvider.claudeAgent,
197 selectedProvider === "claudeAgent" ? selectedModel : undefined,
198 ),
199 };
200}
201
202export function resolveAppModelSelectionState(
203 settings: CustomModelSettings & {
204 textGenerationModelSelection: ModelSelection | undefined;
205 },
206): ModelSelection {
207 const selection = settings.textGenerationModelSelection ?? {
208 provider: "codex" as const,
209 model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex,
210 };
211 const provider = selection.provider;
212 const customModelsByProvider = getCustomModelsByProvider(settings);
213 const model = resolveAppModelSelection(provider, customModelsByProvider, selection.model);
214 const { modelOptionsForDispatch } = getComposerProviderState({
215 provider,
216 model,
217 prompt: "",
218 modelOptions: {
219 [provider]: selection.options,
220 },
221 });
222
223 return {
224 provider,
225 model,
226 ...(modelOptionsForDispatch ? { options: modelOptionsForDispatch } : {}),
227 };
228}
apps/web/src/routes/_chat.settings.tsx+66-47
......@@ -2,16 +2,17 @@ import { createFileRoute } from "@tanstack/react-router";
22import { useQuery } from "@tanstack/react-query";
33import { ChevronDownIcon, PlusIcon, RotateCcwIcon, Undo2Icon, XIcon } from "lucide-react";
44import { type ReactNode, useCallback, useState } from "react";
5import { type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts";
5import { type ProviderKind } from "@t3tools/contracts";
66import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
7import { useAppSettings } from "../appSettings";
78import {
8 getAppModelOptions,
9 getCustomModelOptionsByProvider,
910 getCustomModelsForProvider,
1011 MAX_CUSTOM_MODEL_LENGTH,
1112 MODEL_PROVIDER_SETTINGS,
1213 patchCustomModels,
13 useAppSettings,
14} from "../appSettings";
14 resolveAppModelSelectionState,
15} from "../modelSelection";
1516import { APP_VERSION } from "../branding";
1617import { Button } from "../components/ui/button";
1718import { Collapsible, CollapsibleContent } from "../components/ui/collapsible";
......@@ -25,6 +26,8 @@ import {
2526} from "../components/ui/select";
2627import { SidebarTrigger } from "../components/ui/sidebar";
2728import { Switch } from "../components/ui/switch";
29import { ProviderModelPicker } from "../components/chat/ProviderModelPicker";
30import { TraitsPicker } from "../components/chat/TraitsPicker";
2831import { SidebarInset } from "../components/ui/sidebar";
2932import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";
3033import { resolveAndPersistPreferredEditor } from "../editorPreferences";
......@@ -214,20 +217,15 @@ function SettingsRouteView() {
214217 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
215218 const availableEditors = serverConfigQuery.data?.availableEditors;
216219
217 const gitTextGenerationModelOptions = getAppModelOptions(
218 "codex",
219 settings.customCodexModels,
220 settings.textGenerationModel,
220 const textGenerationModelSelection = resolveAppModelSelectionState(settings);
221 const textGenProvider = textGenerationModelSelection.provider;
222 const textGenModel = textGenerationModelSelection.model;
223 const textGenModelOptions = textGenerationModelSelection.options;
224 const gitModelOptionsByProvider = getCustomModelOptionsByProvider(
225 settings,
226 textGenProvider,
227 textGenModel,
221228 );
222 const currentGitTextGenerationModel =
223 settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL;
224 const defaultGitTextGenerationModel =
225 defaults.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL;
226 const isGitTextGenerationModelDirty =
227 currentGitTextGenerationModel !== defaultGitTextGenerationModel;
228 const selectedGitTextGenerationModelLabel =
229 gitTextGenerationModelOptions.find((option) => option.slug === currentGitTextGenerationModel)
230 ?.name ?? currentGitTextGenerationModel;
231229 const selectedCustomModelProviderSettings = MODEL_PROVIDER_SETTINGS.find(
232230 (providerSettings) => providerSettings.provider === selectedCustomModelProvider,
233231 )!;
......@@ -260,7 +258,10 @@ function SettingsRouteView() {
260258 ...(settings.confirmThreadDelete !== defaults.confirmThreadDelete
261259 ? ["Delete confirmation"]
262260 : []),
263 ...(isGitTextGenerationModelDirty ? ["Git writing model"] : []),
261 ...(JSON.stringify(settings.textGenerationModelSelection ?? null) !==
262 JSON.stringify(defaults.textGenerationModelSelection ?? null)
263 ? ["Git writing model"]
264 : []),
264265 ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0
265266 ? ["Custom models"]
266267 : []),
......@@ -631,43 +632,61 @@ function SettingsRouteView() {
631632 <SettingsSection title="Models">
632633 <SettingsRow
633634 title="Git writing model"
634 description="Used for generated commit messages, PR titles, and branch names."
635 description="Provider and model used for auto-generated git content."
635636 resetAction={
636 isGitTextGenerationModelDirty ? (
637 JSON.stringify(settings.textGenerationModelSelection ?? null) !==
638 JSON.stringify(defaults.textGenerationModelSelection ?? null) ? (
637639 <SettingResetButton
638640 label="git writing model"
639 onClick={() =>
641 onClick={() => {
640642 updateSettings({
641 textGenerationModel: defaults.textGenerationModel,
642 })
643 }
643 textGenerationModelSelection: defaults.textGenerationModelSelection,
644 });
645 }}
644646 />
645647 ) : null
646648 }
647649 control={
648 <Select
649 value={currentGitTextGenerationModel}
650 onValueChange={(value) => {
651 if (!value) return;
652 updateSettings({
653 textGenerationModel: value,
654 });
655 }}
656 >
657 <SelectTrigger
658 className="w-full sm:w-52"
659 aria-label="Git text generation model"
660 >
661 <SelectValue>{selectedGitTextGenerationModelLabel}</SelectValue>
662 </SelectTrigger>
663 <SelectPopup align="end" alignItemWithTrigger={false}>
664 {gitTextGenerationModelOptions.map((option) => (
665 <SelectItem hideIndicator key={option.slug} value={option.slug}>
666 {option.name}
667 </SelectItem>
668 ))}
669 </SelectPopup>
670 </Select>
650 <div className="flex flex-wrap items-center justify-end gap-1.5">
651 <ProviderModelPicker
652 provider={textGenProvider}
653 model={textGenModel}
654 lockedProvider={null}
655 modelOptionsByProvider={gitModelOptionsByProvider}
656 triggerVariant="outline"
657 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
658 onProviderModelChange={(provider, model) => {
659 updateSettings({
660 textGenerationModelSelection: resolveAppModelSelectionState({
661 ...settings,
662 textGenerationModelSelection: { provider, model },
663 }),
664 });
665 }}
666 />
667 <TraitsPicker
668 provider={textGenProvider}
669 model={textGenModel}
670 prompt=""
671 onPromptChange={() => {}}
672 modelOptions={textGenModelOptions}
673 allowPromptInjectedEffort={false}
674 triggerVariant="outline"
675 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
676 onModelOptionsChange={(nextOptions) => {
677 updateSettings({
678 textGenerationModelSelection: resolveAppModelSelectionState({
679 ...settings,
680 textGenerationModelSelection: {
681 provider: textGenProvider,
682 model: textGenModel,
683 ...(nextOptions ? { options: nextOptions } : {}),
684 },
685 }),
686 });
687 }}
688 />
689 </div>
671690 }
672691 />
673692
apps/web/src/wsNativeApi.test.ts+18-2
......@@ -353,11 +353,27 @@ describe("wsNativeApi", () => {
353353 const { createWsNativeApi } = await import("./wsNativeApi");
354354
355355 const api = createWsNativeApi();
356 await api.git.runStackedAction({ actionId: "action-1", cwd: "/repo", action: "commit" });
356 await api.git.runStackedAction({
357 actionId: "action-1",
358 cwd: "/repo",
359 action: "commit",
360 modelSelection: {
361 provider: "codex",
362 model: "gpt-5.4-mini",
363 },
364 });
357365
358366 expect(requestMock).toHaveBeenCalledWith(
359367 WS_METHODS.gitRunStackedAction,
360 { actionId: "action-1", cwd: "/repo", action: "commit" },
368 {
369 actionId: "action-1",
370 cwd: "/repo",
371 action: "commit",
372 modelSelection: {
373 provider: "codex",
374 model: "gpt-5.4-mini",
375 },
376 },
361377 { timeoutMs: null },
362378 );
363379 });
packages/contracts/src/git.test.ts+19
......@@ -65,9 +65,28 @@ describe("GitRunStackedActionInput", () => {
6565 actionId: "action-1",
6666 cwd: "/repo",
6767 action: "commit",
68 modelSelection: {
69 provider: "codex",
70 model: "gpt-5.4-mini",
71 },
6872 });
6973
7074 expect(parsed.actionId).toBe("action-1");
7175 expect(parsed.action).toBe("commit");
7276 });
77
78 it("accepts git text generation as a modelSelection", () => {
79 const parsed = decodeRunStackedActionInput({
80 actionId: "action-1",
81 cwd: "/repo",
82 action: "commit_push_pr",
83 modelSelection: {
84 provider: "claudeAgent",
85 model: "claude-haiku-4-5",
86 },
87 });
88
89 expect(parsed.modelSelection?.provider).toBe("claudeAgent");
90 expect(parsed.modelSelection?.model).toBe("claude-haiku-4-5");
91 });
7392});
packages/contracts/src/git.ts+3-5
......@@ -1,6 +1,6 @@
1import { Option, Schema } from "effect";
1import { Schema } from "effect";
22import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";
3import { DEFAULT_GIT_TEXT_GENERATION_MODEL } from "./model";
3import { ModelSelection } from "./orchestration";
44
55const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
66
......@@ -80,9 +80,7 @@ export const GitRunStackedActionInput = Schema.Struct({
8080 filePaths: Schema.optional(
8181 Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)),
8282 ),
83 textGenerationModel: Schema.optional(TrimmedNonEmptyStringSchema).pipe(
84 Schema.withConstructorDefault(() => Option.some(DEFAULT_GIT_TEXT_GENERATION_MODEL)),
85 ),
83 modelSelection: ModelSelection,
8684});
8785export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;
8886
packages/contracts/src/model.ts+6-1
......@@ -199,7 +199,12 @@ export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, ModelSlug> = {
199199// Backward compatibility for existing Codex-only call sites.
200200export const MODEL_OPTIONS = MODEL_OPTIONS_BY_PROVIDER.codex;
201201export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex;
202export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini" as const;
202
203/** Per-provider text generation model defaults. */
204export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record<ProviderKind, string> = {
205 codex: "gpt-5.4-mini",
206 claudeAgent: "claude-haiku-4-5",
207};
203208
204209export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, ModelSlug>> = {
205210 codex: {