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 @@...@@ -1,6 +1,6 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it } from "@effect/vitest";2import { it } from "@effect/vitest";
3import { Effect, FileSystem, Layer, Path } from "effect";3import { Effect, FileSystem, Layer, Path, Result } from "effect";
4import { expect } from "vitest";4import { expect } from "vitest";
55
6import { ServerConfig } from "../../config.ts";6import { ServerConfig } from "../../config.ts";
...@@ -8,6 +8,11 @@ import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";...@@ -8,6 +8,11 @@ import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
8import { TextGenerationError } from "../Errors.ts";8import { TextGenerationError } from "../Errors.ts";
9import { TextGeneration } from "../Services/TextGeneration.ts";9import { TextGeneration } from "../Services/TextGeneration.ts";
1010
11const DEFAULT_TEST_MODEL_SELECTION = {
12 provider: "codex" as const,
13 model: "gpt-5.4-mini",
14};
15
11const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe(16const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe(
12 Layer.provideMerge(17 Layer.provideMerge(
13 ServerConfig.layerTest(process.cwd(), {18 ServerConfig.layerTest(process.cwd(), {
...@@ -38,6 +43,18 @@ function makeFakeCodexBinary(dir: string) {...@@ -38,6 +43,18 @@ function makeFakeCodexBinary(dir: string) {
38 " fi",43 " fi",
39 " continue",44 " continue",
40 " fi",45 " 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",
41 ' if [ "$1" = "--output-last-message" ]; then',58 ' if [ "$1" = "--output-last-message" ]; then',
42 " shift",59 " shift",
43 ' output_path="$1"',60 ' output_path="$1"',
...@@ -49,6 +66,18 @@ function makeFakeCodexBinary(dir: string) {...@@ -49,6 +66,18 @@ function makeFakeCodexBinary(dir: string) {
49 ' printf "%s\\n" "missing --image input" >&2',66 ' printf "%s\\n" "missing --image input" >&2',
50 " exit 2",67 " exit 2",
51 "fi",68 "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",
52 'if [ -n "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" ]; then',81 'if [ -n "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" ]; then',
53 ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" >/dev/null || {',82 ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" >/dev/null || {',
54 ' printf "%s\\n" "stdin missing expected content" >&2',83 ' printf "%s\\n" "stdin missing expected content" >&2',
...@@ -82,6 +111,9 @@ function withFakeCodexEnv<A, E, R>(...@@ -82,6 +111,9 @@ function withFakeCodexEnv<A, E, R>(
82 exitCode?: number;111 exitCode?: number;
83 stderr?: string;112 stderr?: string;
84 requireImage?: boolean;113 requireImage?: boolean;
114 requireFastServiceTier?: boolean;
115 requireReasoningEffort?: string;
116 forbidReasoningEffort?: boolean;
85 stdinMustContain?: string;117 stdinMustContain?: string;
86 stdinMustNotContain?: string;118 stdinMustNotContain?: string;
87 },119 },
...@@ -97,6 +129,9 @@ function withFakeCodexEnv<A, E, R>(...@@ -97,6 +129,9 @@ function withFakeCodexEnv<A, E, R>(
97 const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE;129 const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE;
98 const previousStderr = process.env.T3_FAKE_CODEX_STDERR;130 const previousStderr = process.env.T3_FAKE_CODEX_STDERR;
99 const previousRequireImage = process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;131 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;
100 const previousStdinMustContain = process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN;135 const previousStdinMustContain = process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN;
101 const previousStdinMustNotContain = process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN;136 const previousStdinMustNotContain = process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN;
102137
...@@ -122,6 +157,24 @@ function withFakeCodexEnv<A, E, R>(...@@ -122,6 +157,24 @@ function withFakeCodexEnv<A, E, R>(
122 delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;157 delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;
123 }158 }
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
125 if (input.stdinMustContain !== undefined) {178 if (input.stdinMustContain !== undefined) {
126 process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN = input.stdinMustContain;179 process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN = input.stdinMustContain;
127 } else {180 } else {
...@@ -141,6 +194,9 @@ function withFakeCodexEnv<A, E, R>(...@@ -141,6 +194,9 @@ function withFakeCodexEnv<A, E, R>(
141 previousExitCode,194 previousExitCode,
142 previousStderr,195 previousStderr,
143 previousRequireImage,196 previousRequireImage,
197 previousRequireFastServiceTier,
198 previousRequireReasoningEffort,
199 previousForbidReasoningEffort,
144 previousStdinMustContain,200 previousStdinMustContain,
145 previousStdinMustNotContain,201 previousStdinMustNotContain,
146 };202 };
...@@ -174,6 +230,27 @@ function withFakeCodexEnv<A, E, R>(...@@ -174,6 +230,27 @@ function withFakeCodexEnv<A, E, R>(
174 process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = previous.previousRequireImage;230 process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = previous.previousRequireImage;
175 }231 }
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
177 if (previous.previousStdinMustContain === undefined) {254 if (previous.previousStdinMustContain === undefined) {
178 delete process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN;255 delete process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN;
179 } else {256 } else {
...@@ -208,6 +285,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {...@@ -208,6 +285,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
208 branch: "feature/codex-effect",285 branch: "feature/codex-effect",
209 stagedSummary: "M README.md",286 stagedSummary: "M README.md",
210 stagedPatch: "diff --git a/README.md b/README.md",287 stagedPatch: "diff --git a/README.md b/README.md",
288 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
211 });289 });
212290
213 expect(generated.subject.length).toBeLessThanOrEqual(72);291 expect(generated.subject.length).toBeLessThanOrEqual(72);
...@@ -218,6 +296,63 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {...@@ -218,6 +296,63 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
218 ),296 ),
219 );297 );
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
221 it.effect("generates commit message with branch when includeBranch is true", () =>356 it.effect("generates commit message with branch when includeBranch is true", () =>
222 withFakeCodexEnv(357 withFakeCodexEnv(
223 {358 {
...@@ -237,6 +372,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {...@@ -237,6 +372,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
237 stagedSummary: "M README.md",372 stagedSummary: "M README.md",
238 stagedPatch: "diff --git a/README.md b/README.md",373 stagedPatch: "diff --git a/README.md b/README.md",
239 includeBranch: true,374 includeBranch: true,
375 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
240 });376 });
241377
242 expect(generated.subject).toBe("Add important change");378 expect(generated.subject).toBe("Add important change");
...@@ -263,6 +399,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {...@@ -263,6 +399,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
263 commitSummary: "feat: improve orchestration flow",399 commitSummary: "feat: improve orchestration flow",
264 diffSummary: "2 files changed",400 diffSummary: "2 files changed",
265 diffPatch: "diff --git a/a.ts b/a.ts",401 diffPatch: "diff --git a/a.ts b/a.ts",
402 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
266 });403 });
267404
268 expect(generated.title).toBe("Improve orchestration flow");405 expect(generated.title).toBe("Improve orchestration flow");
...@@ -286,6 +423,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {...@@ -286,6 +423,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
286 const generated = yield* textGeneration.generateBranchName({423 const generated = yield* textGeneration.generateBranchName({
287 cwd: process.cwd(),424 cwd: process.cwd(),
288 message: "Please update session handling.",425 message: "Please update session handling.",
426 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
289 });427 });
290428
291 expect(generated.branch).toBe("feat/session");429 expect(generated.branch).toBe("feat/session");
...@@ -307,6 +445,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {...@@ -307,6 +445,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
307 const generated = yield* textGeneration.generateBranchName({445 const generated = yield* textGeneration.generateBranchName({
308 cwd: process.cwd(),446 cwd: process.cwd(),
309 message: "Fix timeout behavior.",447 message: "Fix timeout behavior.",
448 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
310 });449 });
311450
312 expect(generated.branch).toBe("fix/session-timeout");451 expect(generated.branch).toBe("fix/session-timeout");
...@@ -333,21 +472,20 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {...@@ -333,21 +472,20 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
333 yield* fs.writeFile(attachmentPath, Buffer.from("hello"));472 yield* fs.writeFile(attachmentPath, Buffer.from("hello"));
334473
335 const textGeneration = yield* TextGeneration;474 const textGeneration = yield* TextGeneration;
336 const generated = yield* textGeneration475 const generated = yield* textGeneration.generateBranchName({
337 .generateBranchName({476 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
338 cwd: process.cwd(),477 cwd: process.cwd(),
339 message: "Fix layout bug from screenshot.",478 message: "Fix layout bug from screenshot.",
340 attachments: [479 attachments: [
341 {480 {
342 type: "image",481 type: "image",
343 id: attachmentId,482 id: attachmentId,
344 name: "bug.png",483 name: "bug.png",
345 mimeType: "image/png",484 mimeType: "image/png",
346 sizeBytes: 5,485 sizeBytes: 5,
347 },486 },
348 ],487 ],
349 })488 });
350 .pipe(Effect.ensuring(fs.remove(attachmentPath).pipe(Effect.catch(() => Effect.void))));
351489
352 expect(generated.branch).toBe("fix/ui-regression");490 expect(generated.branch).toBe("fix/ui-regression");
353 }),491 }),
...@@ -374,6 +512,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {...@@ -374,6 +512,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
374 const textGeneration = yield* TextGeneration;512 const textGeneration = yield* TextGeneration;
375 const generated = yield* textGeneration513 const generated = yield* textGeneration
376 .generateBranchName({514 .generateBranchName({
515 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
377 cwd: process.cwd(),516 cwd: process.cwd(),
378 message: "Fix layout bug from screenshot.",517 message: "Fix layout bug from screenshot.",
379 attachments: [518 attachments: [
...@@ -421,6 +560,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {...@@ -421,6 +560,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
421 const textGeneration = yield* TextGeneration;560 const textGeneration = yield* TextGeneration;
422 const result = yield* textGeneration561 const result = yield* textGeneration
423 .generateBranchName({562 .generateBranchName({
563 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
424 cwd: process.cwd(),564 cwd: process.cwd(),
425 message: "Fix layout bug from screenshot.",565 message: "Fix layout bug from screenshot.",
426 attachments: [566 attachments: [
...@@ -433,17 +573,12 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {...@@ -433,17 +573,12 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
433 },573 },
434 ],574 ],
435 })575 })
436 .pipe(576 .pipe(Effect.result);
437 Effect.match({
438 onFailure: (error) => ({ _tag: "Left" as const, left: error }),
439 onSuccess: (value) => ({ _tag: "Right" as const, right: value }),
440 }),
441 );
442577
443 expect(result._tag).toBe("Left");578 expect(Result.isFailure(result)).toBe(true);
444 if (result._tag === "Left") {579 if (Result.isFailure(result)) {
445 expect(result.left).toBeInstanceOf(TextGenerationError);580 expect(result.failure).toBeInstanceOf(TextGenerationError);
446 expect(result.left.message).toContain("missing --image input");581 expect(result.failure.message).toContain("missing --image input");
447 }582 }
448 }),583 }),
449 ),584 ),
...@@ -465,18 +600,14 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {...@@ -465,18 +600,14 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
465 .generateBranchName({600 .generateBranchName({
466 cwd: process.cwd(),601 cwd: process.cwd(),
467 message: "Fix websocket reconnect flake",602 message: "Fix websocket reconnect flake",
603 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
468 })604 })
469 .pipe(605 .pipe(Effect.result);
470 Effect.match({606
471 onFailure: (error) => ({ _tag: "Left" as const, left: error }),607 expect(Result.isFailure(result)).toBe(true);
472 onSuccess: (value) => ({ _tag: "Right" as const, right: value }),608 if (Result.isFailure(result)) {
473 }),609 expect(result.failure).toBeInstanceOf(TextGenerationError);
474 );610 expect(result.failure.message).toContain("Codex returned invalid structured output");
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");
480 }611 }
481 }),612 }),
482 ),613 ),
...@@ -498,18 +629,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {...@@ -498,18 +629,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
498 branch: "feature/codex-error",629 branch: "feature/codex-error",
499 stagedSummary: "M README.md",630 stagedSummary: "M README.md",
500 stagedPatch: "diff --git a/README.md b/README.md",631 stagedPatch: "diff --git a/README.md b/README.md",
632 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
501 })633 })
502 .pipe(634 .pipe(Effect.result);
503 Effect.match({
504 onFailure: (error) => ({ _tag: "Left" as const, left: error }),
505 onSuccess: (value) => ({ _tag: "Right" as const, right: value }),
506 }),
507 );
508635
509 expect(result._tag).toBe("Left");636 expect(Result.isFailure(result)).toBe(true);
510 if (result._tag === "Left") {637 if (Result.isFailure(result)) {
511 expect(result.left).toBeInstanceOf(TextGenerationError);638 expect(result.failure).toBeInstanceOf(TextGenerationError);
512 expect(result.left.message).toContain("Codex CLI command failed: codex execution failed");639 expect(result.failure.message).toContain(
640 "Codex CLI command failed: codex execution failed",
641 );
513 }642 }
514 }),643 }),
515 ),644 ),
apps/server/src/git/Layers/CodexTextGeneration.ts+139-249
...@@ -1,9 +1,10 @@...@@ -1,9 +1,10 @@
1import { randomUUID } from "node:crypto";1import { 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";
4import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";4import { 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";
7import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";8import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
89
9import { resolveAttachmentPath } from "../../attachmentStore.ts";10import { resolveAttachmentPath } from "../../attachmentStore.ts";
...@@ -11,90 +12,24 @@ import { ServerConfig } from "../../config.ts";...@@ -11,90 +12,24 @@ import { ServerConfig } from "../../config.ts";
11import { TextGenerationError } from "../Errors.ts";12import { TextGenerationError } from "../Errors.ts";
12import {13import {
13 type BranchNameGenerationInput,14 type BranchNameGenerationInput,
14 type BranchNameGenerationResult,
15 type CommitMessageGenerationResult,
16 type PrContentGenerationResult,
17 type TextGenerationShape,15 type TextGenerationShape,
18 TextGeneration,16 TextGeneration,
19} from "../Services/TextGeneration.ts";17} 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";
22const CODEX_TIMEOUT_MS = 180_000;31const 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
98const makeCodexTextGeneration = Effect.gen(function* () {33const makeCodexTextGeneration = Effect.gen(function* () {
99 const fileSystem = yield* FileSystem.FileSystem;34 const fileSystem = yield* FileSystem.FileSystem;
100 const path = yield* Path.Path;35 const path = yield* Path.Path;
...@@ -109,39 +44,37 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -109,39 +44,37 @@ const makeCodexTextGeneration = Effect.gen(function* () {
109 operation: string,44 operation: string,
110 stream: Stream.Stream<Uint8Array, E>,45 stream: Stream.Stream<Uint8Array, E>,
111 ): Effect.Effect<string, TextGenerationError> =>46 ): Effect.Effect<string, TextGenerationError> =>
112 Effect.gen(function* () {47 stream.pipe(
113 let text = "";48 Stream.decodeText(),
114 yield* Stream.runForEach(stream, (chunk) =>49 Stream.runFold(
115 Effect.sync(() => {50 () => "",
116 text += Buffer.from(chunk).toString("utf8");51 (acc, chunk) => acc + chunk,
117 }),52 ),
118 ).pipe(53 Effect.mapError((cause) =>
119 Effect.mapError((cause) =>54 normalizeCliError("codex", operation, cause, "Failed to collect process output"),
120 normalizeCodexError(operation, cause, "Failed to collect process output"),55 ),
121 ),56 );
122 );
123 return text;
124 });
125
126 const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";
12757
128 const writeTempFile = (58 const writeTempFile = (
129 operation: string,59 operation: string,
130 prefix: string,60 prefix: string,
131 content: string,61 content: string,
132 ): Effect.Effect<string, TextGenerationError> => {62 ): Effect.Effect<string, TextGenerationError, Scope.Scope> => {
133 const filePath = path.join(tempDir, `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`);63 return fileSystem
134 return fileSystem.writeFileString(filePath, content).pipe(64 .makeTempFileScoped({
135 Effect.mapError(65 prefix: `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`,
136 (cause) =>66 })
137 new TextGenerationError({67 .pipe(
138 operation,68 Effect.tap((filePath) => fileSystem.writeFileString(filePath, content)),
139 detail: `Failed to write temp file at ${filePath}.`,69 Effect.mapError(
140 cause,70 (cause) =>
141 }),71 new TextGenerationError({
142 ),72 operation,
143 Effect.as(filePath),73 detail: `Failed to write temp file`,
144 );74 cause,
75 }),
76 ),
77 );
145 };78 };
14679
147 const safeUnlink = (filePath: string): Effect.Effect<void, never> =>80 const safeUnlink = (filePath: string): Effect.Effect<void, never> =>
...@@ -187,7 +120,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -187,7 +120,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
187 outputSchemaJson,120 outputSchemaJson,
188 imagePaths = [],121 imagePaths = [],
189 cleanupPaths = [],122 cleanupPaths = [],
190 model,123 modelSelection,
191 }: {124 }: {
192 operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";125 operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";
193 cwd: string;126 cwd: string;
...@@ -195,17 +128,23 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -195,17 +128,23 @@ const makeCodexTextGeneration = Effect.gen(function* () {
195 outputSchemaJson: S;128 outputSchemaJson: S;
196 imagePaths?: ReadonlyArray<string>;129 imagePaths?: ReadonlyArray<string>;
197 cleanupPaths?: ReadonlyArray<string>;130 cleanupPaths?: ReadonlyArray<string>;
198 model?: string;131 modelSelection: CodexModelSelection;
199 }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>132 }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>
200 Effect.gen(function* () {133 Effect.gen(function* () {
201 const schemaPath = yield* writeTempFile(134 const schemaPath = yield* writeTempFile(
202 operation,135 operation,
203 "codex-schema",136 "codex-schema",
204 JSON.stringify(toCodexOutputJsonSchema(outputSchemaJson)),137 JSON.stringify(toJsonSchemaObject(outputSchemaJson)),
205 );138 );
206 const outputPath = yield* writeTempFile(operation, "codex-output", "");139 const outputPath = yield* writeTempFile(operation, "codex-output", "");
207140
208 const runCodexCommand = Effect.gen(function* () {141 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;
209 const command = ChildProcess.make(148 const command = ChildProcess.make(
210 "codex",149 "codex",
211 [150 [
...@@ -214,9 +153,10 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -214,9 +153,10 @@ const makeCodexTextGeneration = Effect.gen(function* () {
214 "-s",153 "-s",
215 "read-only",154 "read-only",
216 "--model",155 "--model",
217 model ?? DEFAULT_GIT_TEXT_GENERATION_MODEL,156 modelSelection.model,
218 "--config",157 "--config",
219 `model_reasoning_effort="${CODEX_REASONING_EFFORT}"`,158 `model_reasoning_effort="${reasoningEffort}"`,
159 ...(normalizedOptions?.fastMode ? ["--config", `service_tier="fast"`] : []),
220 "--output-schema",160 "--output-schema",
221 schemaPath,161 schemaPath,
222 "--output-last-message",162 "--output-last-message",
...@@ -228,7 +168,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -228,7 +168,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
228 cwd,168 cwd,
229 shell: process.platform === "win32",169 shell: process.platform === "win32",
230 stdin: {170 stdin: {
231 stream: Stream.make(new TextEncoder().encode(prompt)),171 stream: Stream.encodeText(Stream.make(prompt)),
232 },172 },
233 },173 },
234 );174 );
...@@ -237,7 +177,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -237,7 +177,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
237 .spawn(command)177 .spawn(command)
238 .pipe(178 .pipe(
239 Effect.mapError((cause) =>179 Effect.mapError((cause) =>
240 normalizeCodexError(operation, cause, "Failed to spawn Codex CLI process"),180 normalizeCliError("codex", operation, cause, "Failed to spawn Codex CLI process"),
241 ),181 ),
242 );182 );
243183
...@@ -246,9 +186,8 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -246,9 +186,8 @@ const makeCodexTextGeneration = Effect.gen(function* () {
246 readStreamAsString(operation, child.stdout),186 readStreamAsString(operation, child.stdout),
247 readStreamAsString(operation, child.stderr),187 readStreamAsString(operation, child.stderr),
248 child.exitCode.pipe(188 child.exitCode.pipe(
249 Effect.map((value) => Number(value)),
250 Effect.mapError((cause) =>189 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"),
252 ),191 ),
253 ),192 ),
254 ],193 ],
...@@ -314,153 +253,104 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -314,153 +253,104 @@ const makeCodexTextGeneration = Effect.gen(function* () {
314 }).pipe(Effect.ensuring(cleanup));253 }).pipe(Effect.ensuring(cleanup));
315 });254 });
316255
317 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => {256 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(
318 const wantsBranch = input.includeBranch === true;257 "CodexTextGeneration.generateCommitMessage",
319258 )(function* (input) {
320 const prompt = [259 const { prompt, outputSchema } = buildCommitMessagePrompt({
321 "You write concise git commit messages.",260 branch: input.branch,
322 wantsBranch261 stagedSummary: input.stagedSummary,
323 ? "Return a JSON object with keys: subject, body, branch."262 stagedPatch: input.stagedPatch,
324 : "Return a JSON object with keys: subject, body.",263 includeBranch: input.includeBranch === true,
325 "Rules:",264 });
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 });
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({
354 operation: "generateCommitMessage",274 operation: "generateCommitMessage",
355 cwd: input.cwd,275 cwd: input.cwd,
356 prompt,276 prompt,
357 outputSchemaJson,277 outputSchemaJson: outputSchema,
358 ...(input.model ? { model: input.model } : {}),278 modelSelection: input.modelSelection,
359 }).pipe(279 });
360 Effect.map(280
361 (generated) =>281 return {
362 ({282 subject: sanitizeCommitSubject(generated.subject),
363 subject: sanitizeCommitSubject(generated.subject),283 body: generated.body.trim(),
364 body: generated.body.trim(),284 ...("branch" in generated && typeof generated.branch === "string"
365 ...("branch" in generated && typeof generated.branch === "string"285 ? { branch: sanitizeFeatureBranchName(generated.branch) }
366 ? { branch: sanitizeFeatureBranchName(generated.branch) }286 : {}),
367 : {}),287 };
368 }) satisfies CommitMessageGenerationResult,288 });
369 ),289
370 );290 const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
371 };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) => {301 if (input.modelSelection.provider !== "codex") {
374 const prompt = [302 return yield* new TextGenerationError({
375 "You write GitHub pull request content.",303 operation: "generatePrContent",
376 "Return a JSON object with keys: title, body.",304 detail: "Invalid model selection.",
377 "Rules:",305 });
378 "- title should be concise and specific",306 }
379 "- body must be markdown and include headings '## Summary' and '## Testing'",307
380 "- under Summary, provide short bullet points",308 const generated = yield* runCodexJson({
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({
397 operation: "generatePrContent",309 operation: "generatePrContent",
398 cwd: input.cwd,310 cwd: input.cwd,
399 prompt,311 prompt,
400 outputSchemaJson: Schema.Struct({312 outputSchemaJson: outputSchema,
401 title: Schema.String,313 modelSelection: input.modelSelection,
402 body: Schema.String,314 });
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 };
415315
416 const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => {316 return {
417 return Effect.gen(function* () {317 title: sanitizePrTitle(generated.title),
418 const { imagePaths } = yield* materializeImageAttachments(318 body: generated.body.trim(),
419 "generateBranchName",319 };
420 input.attachments,320 });
421 );
422 const attachmentLines = (input.attachments ?? []).map(
423 (attachment) =>
424 `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
425 );
426321
427 const promptSections = [322 const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(
428 "You generate concise git branch names.",323 "CodexTextGeneration.generateBranchName",
429 "Return a JSON object with key: branch.",324 )(function* (input) {
430 "Rules:",325 const { imagePaths } = yield* materializeImageAttachments(
431 "- Branch should describe the requested work from the user message.",326 "generateBranchName",
432 "- Keep it short and specific (2-6 words).",327 input.attachments,
433 "- Use plain words only, no issue prefixes and no punctuation-heavy text.",328 );
434 "- If images are attached, use them as primary context for visual/UI issues.",329 const { prompt, outputSchema } = buildBranchNamePrompt({
435 "",330 message: input.message,
436 "User message:",331 attachments: input.attachments,
437 limitSection(input.message, 8_000),332 });
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");
447333
448 const generated = yield* runCodexJson({334 if (input.modelSelection.provider !== "codex") {
335 return yield* new TextGenerationError({
449 operation: "generateBranchName",336 operation: "generateBranchName",
450 cwd: input.cwd,337 detail: "Invalid model selection.",
451 prompt,
452 outputSchemaJson: Schema.Struct({
453 branch: Schema.String,
454 }),
455 imagePaths,
456 ...(input.model ? { model: input.model } : {}),
457 });338 });
339 }
458340
459 return {341 const generated = yield* runCodexJson({
460 branch: sanitizeBranchFragment(generated.branch),342 operation: "generateBranchName",
461 } satisfies BranchNameGenerationResult;343 cwd: input.cwd,
344 prompt,
345 outputSchemaJson: outputSchema,
346 imagePaths,
347 modelSelection: input.modelSelection,
462 });348 });
463 };349
350 return {
351 branch: sanitizeBranchFragment(generated.branch),
352 };
353 });
464354
465 return {355 return {
466 generateCommitMessage,356 generateCommitMessage,
apps/server/src/git/Layers/GitManager.test.ts+6
...@@ -21,6 +21,11 @@ import { GitCore } from "../Services/GitCore.ts";...@@ -21,6 +21,11 @@ import { GitCore } from "../Services/GitCore.ts";
21import { makeGitManager } from "./GitManager.ts";21import { makeGitManager } from "./GitManager.ts";
22import { ServerConfig } from "../../config.ts";22import { ServerConfig } from "../../config.ts";
2323
24const DEFAULT_TEST_MODEL_SELECTION = {
25 provider: "codex",
26 model: "gpt-5.4-mini",
27} as const;
28
24interface FakeGhScenario {29interface FakeGhScenario {
25 prListSequence?: string[];30 prListSequence?: string[];
26 prListByHeadSelector?: Record<string, string>;31 prListByHeadSelector?: Record<string, string>;
...@@ -461,6 +466,7 @@ function runStackedAction(...@@ -461,6 +466,7 @@ function runStackedAction(
461 {466 {
462 ...input,467 ...input,
463 actionId: input.actionId ?? "test-action-id",468 actionId: input.actionId ?? "test-action-id",
469 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
464 },470 },
465 options,471 options,
466 );472 );
apps/server/src/git/Layers/GitManager.ts+12-12
...@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";...@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2import { realpathSync } from "node:fs";2import { realpathSync } from "node:fs";
33
4import { Effect, FileSystem, Layer, Path } from "effect";4import { Effect, FileSystem, Layer, Path } from "effect";
5import type { GitActionProgressEvent, GitActionProgressPhase } from "@t3tools/contracts";5import { GitActionProgressEvent, GitActionProgressPhase, ModelSelection } from "@t3tools/contracts";
6import {6import {
7 resolveAutoFeatureBranchName,7 resolveAutoFeatureBranchName,
8 sanitizeBranchFragment,8 sanitizeBranchFragment,
...@@ -684,7 +684,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -684,7 +684,7 @@ export const makeGitManager = Effect.gen(function* () {
684 /** When true, also produce a semantic feature branch name. */684 /** When true, also produce a semantic feature branch name. */
685 includeBranch?: boolean;685 includeBranch?: boolean;
686 filePaths?: readonly string[];686 filePaths?: readonly string[];
687 model?: string;687 modelSelection: ModelSelection;
688 }) =>688 }) =>
689 Effect.gen(function* () {689 Effect.gen(function* () {
690 const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths);690 const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths);
...@@ -711,7 +711,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -711,7 +711,7 @@ export const makeGitManager = Effect.gen(function* () {
711 stagedSummary: limitContext(context.stagedSummary, 8_000),711 stagedSummary: limitContext(context.stagedSummary, 8_000),
712 stagedPatch: limitContext(context.stagedPatch, 50_000),712 stagedPatch: limitContext(context.stagedPatch, 50_000),
713 ...(input.includeBranch ? { includeBranch: true } : {}),713 ...(input.includeBranch ? { includeBranch: true } : {}),
714 ...(input.model ? { model: input.model } : {}),714 modelSelection: input.modelSelection,
715 })715 })
716 .pipe(Effect.map((result) => sanitizeCommitMessage(result)));716 .pipe(Effect.map((result) => sanitizeCommitMessage(result)));
717717
...@@ -724,13 +724,13 @@ export const makeGitManager = Effect.gen(function* () {...@@ -724,13 +724,13 @@ export const makeGitManager = Effect.gen(function* () {
724 });724 });
725725
726 const runCommitStep = (726 const runCommitStep = (
727 modelSelection: ModelSelection,
727 cwd: string,728 cwd: string,
728 action: "commit" | "commit_push" | "commit_push_pr",729 action: "commit" | "commit_push" | "commit_push_pr",
729 branch: string | null,730 branch: string | null,
730 commitMessage?: string,731 commitMessage?: string,
731 preResolvedSuggestion?: CommitAndBranchSuggestion,732 preResolvedSuggestion?: CommitAndBranchSuggestion,
732 filePaths?: readonly string[],733 filePaths?: readonly string[],
733 model?: string,
734 progressReporter?: GitActionProgressReporter,734 progressReporter?: GitActionProgressReporter,
735 actionId?: string,735 actionId?: string,
736 ) =>736 ) =>
...@@ -760,7 +760,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -760,7 +760,7 @@ export const makeGitManager = Effect.gen(function* () {
760 branch,760 branch,
761 ...(commitMessage ? { commitMessage } : {}),761 ...(commitMessage ? { commitMessage } : {}),
762 ...(filePaths ? { filePaths } : {}),762 ...(filePaths ? { filePaths } : {}),
763 ...(model ? { model } : {}),763 modelSelection,
764 });764 });
765 }765 }
766 if (!suggestion) {766 if (!suggestion) {
...@@ -837,7 +837,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -837,7 +837,7 @@ export const makeGitManager = Effect.gen(function* () {
837 };837 };
838 });838 });
839839
840 const runPrStep = (cwd: string, fallbackBranch: string | null, model?: string) =>840 const runPrStep = (modelSelection: ModelSelection, cwd: string, fallbackBranch: string | null) =>
841 Effect.gen(function* () {841 Effect.gen(function* () {
842 const details = yield* gitCore.statusDetails(cwd);842 const details = yield* gitCore.statusDetails(cwd);
843 const branch = details.branch ?? fallbackBranch;843 const branch = details.branch ?? fallbackBranch;
...@@ -881,7 +881,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -881,7 +881,7 @@ export const makeGitManager = Effect.gen(function* () {
881 commitSummary: limitContext(rangeContext.commitSummary, 20_000),881 commitSummary: limitContext(rangeContext.commitSummary, 20_000),
882 diffSummary: limitContext(rangeContext.diffSummary, 20_000),882 diffSummary: limitContext(rangeContext.diffSummary, 20_000),
883 diffPatch: limitContext(rangeContext.diffPatch, 60_000),883 diffPatch: limitContext(rangeContext.diffPatch, 60_000),
884 ...(model ? { model } : {}),884 modelSelection,
885 });885 });
886886
887 const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`);887 const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`);
...@@ -1102,11 +1102,11 @@ export const makeGitManager = Effect.gen(function* () {...@@ -1102,11 +1102,11 @@ export const makeGitManager = Effect.gen(function* () {
1102 );1102 );
11031103
1104 const runFeatureBranchStep = (1104 const runFeatureBranchStep = (
1105 modelSelection: ModelSelection,
1105 cwd: string,1106 cwd: string,
1106 branch: string | null,1107 branch: string | null,
1107 commitMessage?: string,1108 commitMessage?: string,
1108 filePaths?: readonly string[],1109 filePaths?: readonly string[],
1109 model?: string,
1110 ) =>1110 ) =>
1111 Effect.gen(function* () {1111 Effect.gen(function* () {
1112 const suggestion = yield* resolveCommitAndBranchSuggestion({1112 const suggestion = yield* resolveCommitAndBranchSuggestion({
...@@ -1115,7 +1115,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -1115,7 +1115,7 @@ export const makeGitManager = Effect.gen(function* () {
1115 ...(commitMessage ? { commitMessage } : {}),1115 ...(commitMessage ? { commitMessage } : {}),
1116 ...(filePaths ? { filePaths } : {}),1116 ...(filePaths ? { filePaths } : {}),
1117 includeBranch: true,1117 includeBranch: true,
1118 ...(model ? { model } : {}),1118 modelSelection,
1119 });1119 });
1120 if (!suggestion) {1120 if (!suggestion) {
1121 return yield* gitManagerError(1121 return yield* gitManagerError(
...@@ -1181,11 +1181,11 @@ export const makeGitManager = Effect.gen(function* () {...@@ -1181,11 +1181,11 @@ export const makeGitManager = Effect.gen(function* () {
1181 label: "Preparing feature branch...",1181 label: "Preparing feature branch...",
1182 });1182 });
1183 const result = yield* runFeatureBranchStep(1183 const result = yield* runFeatureBranchStep(
1184 input.modelSelection,
1184 input.cwd,1185 input.cwd,
1185 initialStatus.branch,1186 initialStatus.branch,
1186 input.commitMessage,1187 input.commitMessage,
1187 input.filePaths,1188 input.filePaths,
1188 input.textGenerationModel,
1189 );1189 );
1190 branchStep = result.branchStep;1190 branchStep = result.branchStep;
1191 commitMessageForStep = result.resolvedCommitMessage;1191 commitMessageForStep = result.resolvedCommitMessage;
...@@ -1198,13 +1198,13 @@ export const makeGitManager = Effect.gen(function* () {...@@ -1198,13 +1198,13 @@ export const makeGitManager = Effect.gen(function* () {
11981198
1199 currentPhase = "commit";1199 currentPhase = "commit";
1200 const commit = yield* runCommitStep(1200 const commit = yield* runCommitStep(
1201 input.modelSelection,
1201 input.cwd,1202 input.cwd,
1202 input.action,1203 input.action,
1203 currentBranch,1204 currentBranch,
1204 commitMessageForStep,1205 commitMessageForStep,
1205 preResolvedCommitSuggestion,1206 preResolvedCommitSuggestion,
1206 input.filePaths,1207 input.filePaths,
1207 input.textGenerationModel,
1208 options?.progressReporter,1208 options?.progressReporter,
1209 progress.actionId,1209 progress.actionId,
1210 );1210 );
...@@ -1237,7 +1237,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -1237,7 +1237,7 @@ export const makeGitManager = Effect.gen(function* () {
1237 Effect.flatMap(() =>1237 Effect.flatMap(() =>
1238 Effect.gen(function* () {1238 Effect.gen(function* () {
1239 currentPhase = "pr";1239 currentPhase = "pr";
1240 return yield* runPrStep(input.cwd, currentBranch, input.textGenerationModel);1240 return yield* runPrStep(input.modelSelection, input.cwd, currentBranch);
1241 }),1241 }),
1242 ),1242 ),
1243 )1243 )
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 @@...@@ -8,10 +8,13 @@
8 */8 */
9import { ServiceMap } from "effect";9import { ServiceMap } from "effect";
10import type { Effect } from "effect";10import type { Effect } from "effect";
11import type { ChatAttachment } from "@t3tools/contracts";11import type { ChatAttachment, ModelSelection } from "@t3tools/contracts";
1212
13import type { TextGenerationError } from "../Errors.ts";13import 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
15export interface CommitMessageGenerationInput {18export interface CommitMessageGenerationInput {
16 cwd: string;19 cwd: string;
17 branch: string | null;20 branch: string | null;
...@@ -19,8 +22,8 @@ export interface CommitMessageGenerationInput {...@@ -19,8 +22,8 @@ export interface CommitMessageGenerationInput {
19 stagedPatch: string;22 stagedPatch: string;
20 /** When true, the model also returns a semantic branch name for the change. */23 /** When true, the model also returns a semantic branch name for the change. */
21 includeBranch?: boolean;24 includeBranch?: boolean;
22 /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */25 /** What model and provider to use for generation. */
23 model?: string;26 modelSelection: ModelSelection;
24}27}
2528
26export interface CommitMessageGenerationResult {29export interface CommitMessageGenerationResult {
...@@ -37,8 +40,8 @@ export interface PrContentGenerationInput {...@@ -37,8 +40,8 @@ export interface PrContentGenerationInput {
37 commitSummary: string;40 commitSummary: string;
38 diffSummary: string;41 diffSummary: string;
39 diffPatch: string;42 diffPatch: string;
40 /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */43 /** What model and provider to use for generation. */
41 model?: string;44 modelSelection: ModelSelection;
42}45}
4346
44export interface PrContentGenerationResult {47export interface PrContentGenerationResult {
...@@ -50,8 +53,8 @@ export interface BranchNameGenerationInput {...@@ -50,8 +53,8 @@ export interface BranchNameGenerationInput {
50 cwd: string;53 cwd: string;
51 message: string;54 message: string;
52 attachments?: ReadonlyArray<ChatAttachment> | undefined;55 attachments?: ReadonlyArray<ChatAttachment> | undefined;
53 /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */56 /** What model and provider to use for generation. */
54 model?: string;57 modelSelection: ModelSelection;
55}58}
5659
57export interface BranchNameGenerationResult {60export 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";...@@ -24,7 +24,7 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
24import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts";24import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts";
25import { CheckpointStoreError } from "../../checkpointing/Errors.ts";25import { CheckpointStoreError } from "../../checkpointing/Errors.ts";
26import { OrchestrationDispatchError } from "../Errors.ts";26import { OrchestrationDispatchError } from "../Errors.ts";
27import { isGitRepository } from "../../git/isRepo.ts";27import { isGitRepository } from "../../git/Utils.ts";
2828
29type ReactorInput =29type ReactorInput =
30 | {30 | {
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+5-2
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import {1import {
2 type ChatAttachment,2 type ChatAttachment,
3 CommandId,3 CommandId,
4 DEFAULT_GIT_TEXT_GENERATION_MODEL,4 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
5 EventId,5 EventId,
6 type ModelSelection,6 type ModelSelection,
7 type OrchestrationEvent,7 type OrchestrationEvent,
...@@ -437,7 +437,10 @@ const make = Effect.gen(function* () {...@@ -437,7 +437,10 @@ const make = Effect.gen(function* () {
437 cwd,437 cwd,
438 message: input.messageText,438 message: input.messageText,
439 ...(attachments.length > 0 ? { attachments } : {}),439 ...(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 },
441 })444 })
442 .pipe(445 .pipe(
443 Effect.catch((error) =>446 Effect.catch((error) =>
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+1-1
...@@ -20,7 +20,7 @@ import { ProviderService } from "../../provider/Services/ProviderService.ts";...@@ -20,7 +20,7 @@ import { ProviderService } from "../../provider/Services/ProviderService.ts";
20import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";20import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";
21import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";21import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";
22import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";22import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
23import { isGitRepository } from "../../git/isRepo.ts";23import { isGitRepository } from "../../git/Utils.ts";
24import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";24import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
25import {25import {
26 ProviderRuntimeIngestionService,26 ProviderRuntimeIngestionService,
apps/server/src/serverLayers.ts+2-2
...@@ -30,7 +30,7 @@ import { KeybindingsLive } from "./keybindings";...@@ -30,7 +30,7 @@ import { KeybindingsLive } from "./keybindings";
30import { GitManagerLive } from "./git/Layers/GitManager";30import { GitManagerLive } from "./git/Layers/GitManager";
31import { GitCoreLive } from "./git/Layers/GitCore";31import { GitCoreLive } from "./git/Layers/GitCore";
32import { GitHubCliLive } from "./git/Layers/GitHubCli";32import { GitHubCliLive } from "./git/Layers/GitHubCli";
33import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration";33import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration";
34import { PtyAdapter } from "./terminal/Services/PTY";34import { PtyAdapter } from "./terminal/Services/PTY";
35import { AnalyticsService } from "./telemetry/Services/AnalyticsService";35import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
3636
...@@ -85,7 +85,7 @@ export function makeServerProviderLayer(): Layer.Layer<...@@ -85,7 +85,7 @@ export function makeServerProviderLayer(): Layer.Layer<
85}85}
8686
87export function makeServerRuntimeServicesLayer() {87export function makeServerRuntimeServicesLayer() {
88 const textGenerationLayer = CodexTextGenerationLive;88 const textGenerationLayer = RoutingTextGenerationLive;
89 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive));89 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive));
9090
91 const orchestrationLayer = OrchestrationEngineLive.pipe(91 const orchestrationLayer = OrchestrationEngineLive.pipe(
apps/server/src/wsServer.test.ts+12
...@@ -1838,6 +1838,10 @@ describe("WebSocket Server", () => {...@@ -1838,6 +1838,10 @@ describe("WebSocket Server", () => {
1838 actionId: "client-action-1",1838 actionId: "client-action-1",
1839 cwd: "/test",1839 cwd: "/test",
1840 action: "commit_push",1840 action: "commit_push",
1841 modelSelection: {
1842 provider: "codex",
1843 model: "gpt-5.4-mini",
1844 },
1841 });1845 });
1842 expect(response.result).toBeUndefined();1846 expect(response.result).toBeUndefined();
1843 expect(response.error?.message).toContain("detached HEAD");1847 expect(response.error?.message).toContain("detached HEAD");
...@@ -1846,6 +1850,10 @@ describe("WebSocket Server", () => {...@@ -1846,6 +1850,10 @@ describe("WebSocket Server", () => {
1846 actionId: "client-action-1",1850 actionId: "client-action-1",
1847 cwd: "/test",1851 cwd: "/test",
1848 action: "commit_push",1852 action: "commit_push",
1853 modelSelection: {
1854 provider: "codex",
1855 model: "gpt-5.4-mini",
1856 },
1849 },1857 },
1850 expect.objectContaining({1858 expect.objectContaining({
1851 actionId: "client-action-1",1859 actionId: "client-action-1",
...@@ -1901,6 +1909,10 @@ describe("WebSocket Server", () => {...@@ -1901,6 +1909,10 @@ describe("WebSocket Server", () => {
1901 actionId: "client-action-2",1909 actionId: "client-action-2",
1902 cwd: "/test",1910 cwd: "/test",
1903 action: "commit",1911 action: "commit",
1912 modelSelection: {
1913 provider: "codex",
1914 model: "gpt-5.4-mini",
1915 },
1904 });1916 });
1905 const progressPush = await waitForPush(initiatingWs, WS_CHANNELS.gitActionProgress);1917 const progressPush = await waitForPush(initiatingWs, WS_CHANNELS.gitActionProgress);
19061918
apps/web/src/appSettings.test.ts+60-2
...@@ -6,17 +6,20 @@ import {...@@ -6,17 +6,20 @@ import {
6 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,6 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
7 DEFAULT_SIDEBAR_THREAD_SORT_ORDER,7 DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
8 DEFAULT_TIMESTAMP_FORMAT,8 DEFAULT_TIMESTAMP_FORMAT,
9 getProviderStartOptions,
10} from "./appSettings";
11import {
9 getAppModelOptions,12 getAppModelOptions,
10 getCustomModelOptionsByProvider,13 getCustomModelOptionsByProvider,
11 getCustomModelsByProvider,14 getCustomModelsByProvider,
12 getCustomModelsForProvider,15 getCustomModelsForProvider,
13 getDefaultCustomModelsForProvider,16 getDefaultCustomModelsForProvider,
14 getProviderStartOptions,
15 MODEL_PROVIDER_SETTINGS,17 MODEL_PROVIDER_SETTINGS,
16 normalizeCustomModelSlugs,18 normalizeCustomModelSlugs,
17 patchCustomModels,19 patchCustomModels,
20 resolveAppModelSelectionState,
18 resolveAppModelSelection,21 resolveAppModelSelection,
19} from "./appSettings";22} from "./modelSelection";
2023
21describe("normalizeCustomModelSlugs", () => {24describe("normalizeCustomModelSlugs", () => {
22 it("normalizes aliases, removes built-ins, and deduplicates values", () => {25 it("normalizes aliases, removes built-ins, and deduplicates values", () => {
...@@ -265,3 +268,58 @@ describe("AppSettingsSchema", () => {...@@ -265,3 +268,58 @@ describe("AppSettingsSchema", () => {
265 });268 });
266 });269 });
267});270});
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 @@...@@ -1,22 +1,15 @@
1import { useCallback } from "react";1import { useCallback } from "react";
2import { Option, Schema } from "effect";2import { Option, Schema } from "effect";
3import {3import {
4 TrimmedNonEmptyString,4 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
5 type ProviderKind,5 ModelSelection,
6 type ProviderStartOptions,6 type ProviderStartOptions,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import {
9 getDefaultModel,
10 getModelOptions,
11 normalizeModelSlug,
12 resolveSelectableModel,
13} from "@t3tools/shared/model";
14import { useLocalStorage } from "./hooks/useLocalStorage";8import { useLocalStorage } from "./hooks/useLocalStorage";
15import { EnvMode } from "./components/BranchToolbar.logic";9import { EnvMode } from "./components/BranchToolbar.logic";
10import { normalizeCustomModelSlugs } from "./modelSelection";
1611
17const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";12const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
18const MAX_CUSTOM_MODEL_COUNT = 32;
19export const MAX_CUSTOM_MODEL_LENGTH = 256;
2013
21export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]);14export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]);
22export type TimestampFormat = typeof TimestampFormat.Type;15export type TimestampFormat = typeof TimestampFormat.Type;
...@@ -27,21 +20,6 @@ export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "upda...@@ -27,21 +20,6 @@ export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "upda
27export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]);20export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]);
28export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type;21export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type;
29export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at";22export 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
46const withDefaults =24const withDefaults =
47 <25 <
...@@ -73,66 +51,16 @@ export const AppSettingsSchema = Schema.Struct({...@@ -73,66 +51,16 @@ export const AppSettingsSchema = Schema.Struct({
73 timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)),51 timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)),
74 customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])),52 customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])),
75 customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])),53 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 ),
77});60});
78export type AppSettings = typeof AppSettingsSchema.Type;61export type AppSettings = typeof AppSettingsSchema.Type;
79export interface AppModelOption {
80 slug: string;
81 name: string;
82 isCustom: boolean;
83}
8462
85const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});63const 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
137function normalizeAppSettings(settings: AppSettings): AppSettings {65function normalizeAppSettings(settings: AppSettings): AppSettings {
138 return {66 return {
...@@ -142,103 +70,6 @@ function normalizeAppSettings(settings: AppSettings): AppSettings {...@@ -142,103 +70,6 @@ function normalizeAppSettings(settings: AppSettings): AppSettings {
142 };70 };
143}71}
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
242export function getProviderStartOptions(73export function getProviderStartOptions(
243 settings: Pick<AppSettings, "claudeBinaryPath" | "codexBinaryPath" | "codexHomePath">,74 settings: Pick<AppSettings, "claudeBinaryPath" | "codexBinaryPath" | "codexHomePath">,
244): ProviderStartOptions | undefined {75): ProviderStartOptions | undefined {
apps/web/src/components/ChatView.tsx+4-5
...@@ -120,13 +120,12 @@ import {...@@ -120,13 +120,12 @@ import {
120import { SidebarTrigger } from "./ui/sidebar";120import { SidebarTrigger } from "./ui/sidebar";
121import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";121import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
122import { readNativeApi } from "~/nativeApi";122import { readNativeApi } from "~/nativeApi";
123import { getProviderStartOptions, useAppSettings } from "../appSettings";
123import {124import {
124 getCustomModelOptionsByProvider,125 getCustomModelOptionsByProvider,
125 getCustomModelsByProvider,126 getCustomModelsByProvider,
126 getProviderStartOptions,
127 resolveAppModelSelection,127 resolveAppModelSelection,
128 useAppSettings,128} from "../modelSelection";
129} from "../appSettings";
130import { isTerminalFocused } from "../lib/terminalFocus";129import { isTerminalFocused } from "../lib/terminalFocus";
131import {130import {
132 type ComposerImageAttachment,131 type ComposerImageAttachment,
...@@ -642,8 +641,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -642,8 +641,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
642 const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]);641 const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]);
643 const selectedModelForPicker = selectedModel;642 const selectedModelForPicker = selectedModel;
644 const modelOptionsByProvider = useMemo(643 const modelOptionsByProvider = useMemo(
645 () => getCustomModelOptionsByProvider(settings),644 () => getCustomModelOptionsByProvider(settings, selectedProvider, selectedModel),
646 [settings],645 [settings, selectedProvider, selectedModel],
647 );646 );
648 const selectedModelForPickerWithCustomFallback = useMemo(() => {647 const selectedModelForPickerWithCustomFallback = useMemo(() => {
649 const currentOptions = modelOptionsByProvider[selectedProvider];648 const currentOptions = modelOptionsByProvider[selectedProvider];
apps/web/src/components/GitActionsControl.tsx+1-1
...@@ -261,7 +261,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -261,7 +261,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
261 gitRunStackedActionMutationOptions({261 gitRunStackedActionMutationOptions({
262 cwd: gitCwd,262 cwd: gitCwd,
263 queryClient,263 queryClient,
264 model: settings.textGenerationModel ?? null,264 modelSelection: settings.textGenerationModelSelection,
265 }),265 }),
266 );266 );
267 const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));267 const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));
apps/web/src/components/chat/ProviderModelPicker.browser.tsx+22
...@@ -21,6 +21,7 @@ async function mountPicker(props: {...@@ -21,6 +21,7 @@ async function mountPicker(props: {
21 provider: ProviderKind;21 provider: ProviderKind;
22 model: ModelSlug;22 model: ModelSlug;
23 lockedProvider: ProviderKind | null;23 lockedProvider: ProviderKind | null;
24 triggerVariant?: "ghost" | "outline";
24}) {25}) {
25 const host = document.createElement("div");26 const host = document.createElement("div");
26 document.body.append(host);27 document.body.append(host);
...@@ -31,6 +32,7 @@ async function mountPicker(props: {...@@ -31,6 +32,7 @@ async function mountPicker(props: {
31 model={props.model}32 model={props.model}
32 lockedProvider={props.lockedProvider}33 lockedProvider={props.lockedProvider}
33 modelOptionsByProvider={MODEL_OPTIONS_BY_PROVIDER}34 modelOptionsByProvider={MODEL_OPTIONS_BY_PROVIDER}
35 triggerVariant={props.triggerVariant}
34 onProviderModelChange={onProviderModelChange}36 onProviderModelChange={onProviderModelChange}
35 />,37 />,
36 { container: host },38 { container: host },
...@@ -156,4 +158,24 @@ describe("ProviderModelPicker", () => {...@@ -156,4 +158,24 @@ describe("ProviderModelPicker", () => {
156 await mounted.cleanup();158 await mounted.cleanup();
157 }159 }
158 });160 });
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 });
159});181});
apps/web/src/components/chat/ProviderModelPicker.tsx+6-2
...@@ -1,9 +1,10 @@...@@ -1,9 +1,10 @@
1import { type ModelSlug, type ProviderKind } from "@t3tools/contracts";1import { type ModelSlug, type ProviderKind } from "@t3tools/contracts";
2import { resolveSelectableModel } from "@t3tools/shared/model";2import { resolveSelectableModel } from "@t3tools/shared/model";
3import { memo, useState } from "react";3import { memo, useState } from "react";
4import type { VariantProps } from "class-variance-authority";
4import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic";5import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic";
5import { ChevronDownIcon } from "lucide-react";6import { ChevronDownIcon } from "lucide-react";
6import { Button } from "../ui/button";7import { Button, buttonVariants } from "../ui/button";
7import {8import {
8 Menu,9 Menu,
9 MenuGroup,10 MenuGroup,
...@@ -56,6 +57,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -56,6 +57,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
56 activeProviderIconClassName?: string;57 activeProviderIconClassName?: string;
57 compact?: boolean;58 compact?: boolean;
58 disabled?: boolean;59 disabled?: boolean;
60 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
61 triggerClassName?: string;
59 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;62 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
60}) {63}) {
61 const [isMenuOpen, setIsMenuOpen] = useState(false);64 const [isMenuOpen, setIsMenuOpen] = useState(false);
...@@ -92,10 +95,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -92,10 +95,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
92 render={95 render={
93 <Button96 <Button
94 size="sm"97 size="sm"
95 variant="ghost"98 variant={props.triggerVariant ?? "ghost"}
96 className={cn(99 className={cn(
97 "min-w-0 justify-start overflow-hidden whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 [&_svg]:mx-0",100 "min-w-0 justify-start overflow-hidden whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 [&_svg]:mx-0",
98 props.compact ? "max-w-42 shrink-0" : "max-w-48 shrink sm:max-w-56 sm:px-3",101 props.compact ? "max-w-42 shrink-0" : "max-w-48 shrink sm:max-w-56 sm:px-3",
102 props.triggerClassName,
99 )}103 )}
100 disabled={props.disabled}104 disabled={props.disabled}
101 />105 />
apps/web/src/components/chat/TraitsPicker.browser.tsx+21-1
...@@ -29,6 +29,7 @@ const CLAUDE_THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits");...@@ -29,6 +29,7 @@ const CLAUDE_THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits");
29function ClaudeTraitsPickerHarness(props: {29function ClaudeTraitsPickerHarness(props: {
30 model: string;30 model: string;
31 fallbackModelSelection: ModelSelection | null;31 fallbackModelSelection: ModelSelection | null;
32 triggerVariant?: "ghost" | "outline";
32}) {33}) {
33 const prompt = useComposerThreadDraft(CLAUDE_THREAD_ID).prompt;34 const prompt = useComposerThreadDraft(CLAUDE_THREAD_ID).prompt;
34 const setPrompt = useComposerDraftStore((store) => store.setPrompt);35 const setPrompt = useComposerDraftStore((store) => store.setPrompt);
...@@ -54,6 +55,7 @@ function ClaudeTraitsPickerHarness(props: {...@@ -54,6 +55,7 @@ function ClaudeTraitsPickerHarness(props: {
54 prompt={prompt}55 prompt={prompt}
55 modelOptions={modelOptions?.claudeAgent}56 modelOptions={modelOptions?.claudeAgent}
56 onPromptChange={handlePromptChange}57 onPromptChange={handlePromptChange}
58 triggerVariant={props.triggerVariant}
57 />59 />
58 );60 );
59}61}
...@@ -68,6 +70,7 @@ async function mountClaudePicker(props?: {...@@ -68,6 +70,7 @@ async function mountClaudePicker(props?: {
68 fastMode?: boolean;70 fastMode?: boolean;
69 } | null;71 } | null;
70 skipDraftModelOptions?: boolean;72 skipDraftModelOptions?: boolean;
73 triggerVariant?: "ghost" | "outline";
71}) {74}) {
72 const model = props?.model ?? "claude-opus-4-6";75 const model = props?.model ?? "claude-opus-4-6";
73 const claudeOptions = !props?.skipDraftModelOptions ? props?.options : undefined;76 const claudeOptions = !props?.skipDraftModelOptions ? props?.options : undefined;
...@@ -110,7 +113,11 @@ async function mountClaudePicker(props?: {...@@ -110,7 +113,11 @@ async function mountClaudePicker(props?: {
110 } satisfies ModelSelection)113 } satisfies ModelSelection)
111 : null;114 : null;
112 const screen = await render(115 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 />,
114 { container: host },121 { container: host },
115 );122 );
116123
...@@ -234,6 +241,19 @@ describe("TraitsPicker (Claude)", () => {...@@ -234,6 +241,19 @@ describe("TraitsPicker (Claude)", () => {
234 },241 },
235 });242 });
236 });243 });
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 });
237});257});
238258
239// ── Codex TraitsPicker tests ──────────────────────────────────────────259// ── Codex TraitsPicker tests ──────────────────────────────────────────
apps/web/src/components/chat/TraitsPicker.tsx+55-29
...@@ -14,8 +14,9 @@ import {...@@ -14,8 +14,9 @@ import {
14 hasEffortLevel,14 hasEffortLevel,
15} from "@t3tools/shared/model";15} from "@t3tools/shared/model";
16import { memo, useCallback, useState } from "react";16import { memo, useCallback, useState } from "react";
17import type { VariantProps } from "class-variance-authority";
17import { ChevronDownIcon } from "lucide-react";18import { ChevronDownIcon } from "lucide-react";
18import { Button } from "../ui/button";19import { Button, buttonVariants } from "../ui/button";
19import {20import {
20 Menu,21 Menu,
21 MenuGroup,22 MenuGroup,
...@@ -26,8 +27,18 @@ import {...@@ -26,8 +27,18 @@ import {
26 MenuTrigger,27 MenuTrigger,
27} from "../ui/menu";28} from "../ui/menu";
28import { useComposerDraftStore } from "../../composerDraftStore";29import { useComposerDraftStore } from "../../composerDraftStore";
30import { cn } from "~/lib/utils";
2931
30type ProviderOptions = ProviderModelOptions[ProviderKind];32type 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
32const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n";43const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n";
3344
...@@ -57,9 +68,14 @@ function getSelectedTraits(...@@ -57,9 +68,14 @@ function getSelectedTraits(
57 model: string | null | undefined,68 model: string | null | undefined,
58 prompt: string,69 prompt: string,
59 modelOptions: ProviderOptions | null | undefined,70 modelOptions: ProviderOptions | null | undefined,
71 allowPromptInjectedEffort: boolean,
60) {72) {
61 const caps = getModelCapabilities(provider, model);73 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 );
63 const defaultEffort = getDefaultEffort(caps);79 const defaultEffort = getDefaultEffort(caps);
6480
65 // Resolve effort from options (provider-specific key)81 // Resolve effort from options (provider-specific key)
...@@ -88,7 +104,9 @@ function getSelectedTraits(...@@ -88,7 +104,9 @@ function getSelectedTraits(
88104
89 // Prompt-controlled effort (e.g. ultrathink in prompt text)105 // Prompt-controlled effort (e.g. ultrathink in prompt text)
90 const ultrathinkPromptControlled =106 const ultrathinkPromptControlled =
91 caps.promptInjectedEffortLevels.length > 0 && isClaudeUltrathinkPrompt(prompt);107 allowPromptInjectedEffort &&
108 caps.promptInjectedEffortLevels.length > 0 &&
109 isClaudeUltrathinkPrompt(prompt);
92110
93 return {111 return {
94 caps,112 caps,
...@@ -102,22 +120,35 @@ function getSelectedTraits(...@@ -102,22 +120,35 @@ function getSelectedTraits(
102120
103export interface TraitsMenuContentProps {121export interface TraitsMenuContentProps {
104 provider: ProviderKind;122 provider: ProviderKind;
105 threadId: ThreadId;
106 model: string | null | undefined;123 model: string | null | undefined;
107 prompt: string;124 prompt: string;
108 onPromptChange: (prompt: string) => void;125 onPromptChange: (prompt: string) => void;
109 modelOptions?: ProviderOptions | null | undefined;126 modelOptions?: ProviderOptions | null | undefined;
127 allowPromptInjectedEffort?: boolean;
128 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
129 triggerClassName?: string;
110}130}
111131
112export const TraitsMenuContent = memo(function TraitsMenuContentImpl({132export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
113 provider,133 provider,
114 threadId,
115 model,134 model,
116 prompt,135 prompt,
117 onPromptChange,136 onPromptChange,
118 modelOptions,137 modelOptions,
119}: TraitsMenuContentProps) {138 allowPromptInjectedEffort = true,
139 ...persistence
140}: TraitsMenuContentProps & TraitsPersistence) {
120 const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions);141 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 );
121 const {152 const {
122 caps,153 caps,
123 effort,154 effort,
...@@ -125,7 +156,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -125,7 +156,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
125 thinkingEnabled,156 thinkingEnabled,
126 fastModeEnabled,157 fastModeEnabled,
127 ultrathinkPromptControlled,158 ultrathinkPromptControlled,
128 } = getSelectedTraits(provider, model, prompt, modelOptions);159 } = getSelectedTraits(provider, model, prompt, modelOptions, allowPromptInjectedEffort);
129 const defaultEffort = getDefaultEffort(caps);160 const defaultEffort = getDefaultEffort(caps);
130161
131 const handleEffortChange = useCallback(162 const handleEffortChange = useCallback(
...@@ -143,19 +174,15 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -143,19 +174,15 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
143 return;174 return;
144 }175 }
145 const effortKey = provider === "codex" ? "reasoningEffort" : "effort";176 const effortKey = provider === "codex" ? "reasoningEffort" : "effort";
146 setProviderModelOptions(177 updateModelOptions(
147 threadId,
148 provider,
149 buildNextOptions(provider, modelOptions, { [effortKey]: nextOption.value }),178 buildNextOptions(provider, modelOptions, { [effortKey]: nextOption.value }),
150 { persistSticky: true },
151 );179 );
152 },180 },
153 [181 [
154 ultrathinkPromptControlled,182 ultrathinkPromptControlled,
155 modelOptions,183 modelOptions,
156 onPromptChange,184 onPromptChange,
157 threadId,185 updateModelOptions,
158 setProviderModelOptions,
159 effortLevels,186 effortLevels,
160 prompt,187 prompt,
161 caps.promptInjectedEffortLevels,188 caps.promptInjectedEffortLevels,
...@@ -198,11 +225,8 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -198,11 +225,8 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
198 <MenuRadioGroup225 <MenuRadioGroup
199 value={thinkingEnabled ? "on" : "off"}226 value={thinkingEnabled ? "on" : "off"}
200 onValueChange={(value) => {227 onValueChange={(value) => {
201 setProviderModelOptions(228 updateModelOptions(
202 threadId,
203 provider,
204 buildNextOptions(provider, modelOptions, { thinking: value === "on" }),229 buildNextOptions(provider, modelOptions, { thinking: value === "on" }),
205 { persistSticky: true },
206 );230 );
207 }}231 }}
208 >232 >
...@@ -219,11 +243,8 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -219,11 +243,8 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
219 <MenuRadioGroup243 <MenuRadioGroup
220 value={fastModeEnabled ? "on" : "off"}244 value={fastModeEnabled ? "on" : "off"}
221 onValueChange={(value) => {245 onValueChange={(value) => {
222 setProviderModelOptions(246 updateModelOptions(
223 threadId,
224 provider,
225 buildNextOptions(provider, modelOptions, { fastMode: value === "on" }),247 buildNextOptions(provider, modelOptions, { fastMode: value === "on" }),
226 { persistSticky: true },
227 );248 );
228 }}249 }}
229 >250 >
...@@ -239,12 +260,15 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -239,12 +260,15 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
239260
240export const TraitsPicker = memo(function TraitsPicker({261export const TraitsPicker = memo(function TraitsPicker({
241 provider,262 provider,
242 threadId,
243 model,263 model,
244 prompt,264 prompt,
245 onPromptChange,265 onPromptChange,
246 modelOptions,266 modelOptions,
247}: TraitsMenuContentProps) {267 allowPromptInjectedEffort = true,
268 triggerVariant,
269 triggerClassName,
270 ...persistence
271}: TraitsMenuContentProps & TraitsPersistence) {
248 const [isMenuOpen, setIsMenuOpen] = useState(false);272 const [isMenuOpen, setIsMenuOpen] = useState(false);
249 const {273 const {
250 caps,274 caps,
...@@ -253,7 +277,7 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -253,7 +277,7 @@ export const TraitsPicker = memo(function TraitsPicker({
253 thinkingEnabled,277 thinkingEnabled,
254 fastModeEnabled,278 fastModeEnabled,
255 ultrathinkPromptControlled,279 ultrathinkPromptControlled,
256 } = getSelectedTraits(provider, model, prompt, modelOptions);280 } = getSelectedTraits(provider, model, prompt, modelOptions, allowPromptInjectedEffort);
257281
258 const effortLabel = effort282 const effortLabel = effort
259 ? (effortLevels.find((l) => l.value === effort)?.label ?? effort)283 ? (effortLevels.find((l) => l.value === effort)?.label ?? effort)
...@@ -284,12 +308,13 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -284,12 +308,13 @@ export const TraitsPicker = memo(function TraitsPicker({
284 render={308 render={
285 <Button309 <Button
286 size="sm"310 size="sm"
287 variant="ghost"311 variant={triggerVariant ?? "ghost"}
288 className={312 className={cn(
289 isCodexStyle313 isCodexStyle
290 ? "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"314 ? "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"315 : "shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3",
292 }316 triggerClassName,
317 )}
293 />318 />
294 }319 }
295 >320 >
...@@ -308,11 +333,12 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -308,11 +333,12 @@ export const TraitsPicker = memo(function TraitsPicker({
308 <MenuPopup align="start">333 <MenuPopup align="start">
309 <TraitsMenuContent334 <TraitsMenuContent
310 provider={provider}335 provider={provider}
311 threadId={threadId}
312 model={model}336 model={model}
313 prompt={prompt}337 prompt={prompt}
314 onPromptChange={onPromptChange}338 onPromptChange={onPromptChange}
315 modelOptions={modelOptions}339 modelOptions={modelOptions}
340 allowPromptInjectedEffort={allowPromptInjectedEffort}
341 {...persistence}
316 />342 />
317 </MenuPopup>343 </MenuPopup>
318 </Menu>344 </Menu>
apps/web/src/composerDraftStore.ts+1-1
...@@ -21,7 +21,7 @@ import {...@@ -21,7 +21,7 @@ import {
21} from "@t3tools/shared/model";21} from "@t3tools/shared/model";
22import { useMemo } from "react";22import { useMemo } from "react";
23import { getLocalStorageItem } from "./hooks/useLocalStorage";23import { getLocalStorageItem } from "./hooks/useLocalStorage";
24import { resolveAppModelSelection } from "./appSettings";24import { resolveAppModelSelection } from "./modelSelection";
25import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types";25import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types";
26import {26import {
27 type TerminalContextDraft,27 type TerminalContextDraft,
apps/web/src/lib/gitReactQuery.test.ts+8-1
...@@ -29,7 +29,14 @@ describe("git mutation options", () => {...@@ -29,7 +29,14 @@ describe("git mutation options", () => {
29 const queryClient = new QueryClient();29 const queryClient = new QueryClient();
3030
31 it("attaches cwd-scoped mutation key for runStackedAction", () => {31 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 });
33 expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction("/repo/a"));40 expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction("/repo/a"));
34 });41 });
3542
apps/web/src/lib/gitReactQuery.ts+3-3
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { GitStackedAction } from "@t3tools/contracts";1import { type GitStackedAction, type ModelSelection } from "@t3tools/contracts";
2import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";2import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";
3import { ensureNativeApi } from "../nativeApi";3import { ensureNativeApi } from "../nativeApi";
44
...@@ -112,7 +112,7 @@ export function gitCheckoutMutationOptions(input: {...@@ -112,7 +112,7 @@ export function gitCheckoutMutationOptions(input: {
112export function gitRunStackedActionMutationOptions(input: {112export function gitRunStackedActionMutationOptions(input: {
113 cwd: string | null;113 cwd: string | null;
114 queryClient: QueryClient;114 queryClient: QueryClient;
115 model?: string | null;115 modelSelection: ModelSelection;
116}) {116}) {
117 return mutationOptions({117 return mutationOptions({
118 mutationKey: gitMutationKeys.runStackedAction(input.cwd),118 mutationKey: gitMutationKeys.runStackedAction(input.cwd),
...@@ -134,11 +134,11 @@ export function gitRunStackedActionMutationOptions(input: {...@@ -134,11 +134,11 @@ export function gitRunStackedActionMutationOptions(input: {
134 return api.git.runStackedAction({134 return api.git.runStackedAction({
135 actionId,135 actionId,
136 cwd: input.cwd,136 cwd: input.cwd,
137 modelSelection: input.modelSelection,
137 action,138 action,
138 ...(commitMessage ? { commitMessage } : {}),139 ...(commitMessage ? { commitMessage } : {}),
139 ...(featureBranch ? { featureBranch } : {}),140 ...(featureBranch ? { featureBranch } : {}),
140 ...(filePaths ? { filePaths } : {}),141 ...(filePaths ? { filePaths } : {}),
141 ...(input.model ? { model: input.model } : {}),
142 });142 });
143 },143 },
144 onSettled: async () => {144 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";...@@ -2,16 +2,17 @@ import { createFileRoute } from "@tanstack/react-router";
2import { useQuery } from "@tanstack/react-query";2import { useQuery } from "@tanstack/react-query";
3import { ChevronDownIcon, PlusIcon, RotateCcwIcon, Undo2Icon, XIcon } from "lucide-react";3import { ChevronDownIcon, PlusIcon, RotateCcwIcon, Undo2Icon, XIcon } from "lucide-react";
4import { type ReactNode, useCallback, useState } from "react";4import { type ReactNode, useCallback, useState } from "react";
5import { type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts";5import { type ProviderKind } from "@t3tools/contracts";
6import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";6import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
7import { useAppSettings } from "../appSettings";
7import {8import {
8 getAppModelOptions,9 getCustomModelOptionsByProvider,
9 getCustomModelsForProvider,10 getCustomModelsForProvider,
10 MAX_CUSTOM_MODEL_LENGTH,11 MAX_CUSTOM_MODEL_LENGTH,
11 MODEL_PROVIDER_SETTINGS,12 MODEL_PROVIDER_SETTINGS,
12 patchCustomModels,13 patchCustomModels,
13 useAppSettings,14 resolveAppModelSelectionState,
14} from "../appSettings";15} from "../modelSelection";
15import { APP_VERSION } from "../branding";16import { APP_VERSION } from "../branding";
16import { Button } from "../components/ui/button";17import { Button } from "../components/ui/button";
17import { Collapsible, CollapsibleContent } from "../components/ui/collapsible";18import { Collapsible, CollapsibleContent } from "../components/ui/collapsible";
...@@ -25,6 +26,8 @@ import {...@@ -25,6 +26,8 @@ import {
25} from "../components/ui/select";26} from "../components/ui/select";
26import { SidebarTrigger } from "../components/ui/sidebar";27import { SidebarTrigger } from "../components/ui/sidebar";
27import { Switch } from "../components/ui/switch";28import { Switch } from "../components/ui/switch";
29import { ProviderModelPicker } from "../components/chat/ProviderModelPicker";
30import { TraitsPicker } from "../components/chat/TraitsPicker";
28import { SidebarInset } from "../components/ui/sidebar";31import { SidebarInset } from "../components/ui/sidebar";
29import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";32import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";
30import { resolveAndPersistPreferredEditor } from "../editorPreferences";33import { resolveAndPersistPreferredEditor } from "../editorPreferences";
...@@ -214,20 +217,15 @@ function SettingsRouteView() {...@@ -214,20 +217,15 @@ function SettingsRouteView() {
214 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;217 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
215 const availableEditors = serverConfigQuery.data?.availableEditors;218 const availableEditors = serverConfigQuery.data?.availableEditors;
216219
217 const gitTextGenerationModelOptions = getAppModelOptions(220 const textGenerationModelSelection = resolveAppModelSelectionState(settings);
218 "codex",221 const textGenProvider = textGenerationModelSelection.provider;
219 settings.customCodexModels,222 const textGenModel = textGenerationModelSelection.model;
220 settings.textGenerationModel,223 const textGenModelOptions = textGenerationModelSelection.options;
224 const gitModelOptionsByProvider = getCustomModelOptionsByProvider(
225 settings,
226 textGenProvider,
227 textGenModel,
221 );228 );
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;
231 const selectedCustomModelProviderSettings = MODEL_PROVIDER_SETTINGS.find(229 const selectedCustomModelProviderSettings = MODEL_PROVIDER_SETTINGS.find(
232 (providerSettings) => providerSettings.provider === selectedCustomModelProvider,230 (providerSettings) => providerSettings.provider === selectedCustomModelProvider,
233 )!;231 )!;
...@@ -260,7 +258,10 @@ function SettingsRouteView() {...@@ -260,7 +258,10 @@ function SettingsRouteView() {
260 ...(settings.confirmThreadDelete !== defaults.confirmThreadDelete258 ...(settings.confirmThreadDelete !== defaults.confirmThreadDelete
261 ? ["Delete confirmation"]259 ? ["Delete confirmation"]
262 : []),260 : []),
263 ...(isGitTextGenerationModelDirty ? ["Git writing model"] : []),261 ...(JSON.stringify(settings.textGenerationModelSelection ?? null) !==
262 JSON.stringify(defaults.textGenerationModelSelection ?? null)
263 ? ["Git writing model"]
264 : []),
264 ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0265 ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0
265 ? ["Custom models"]266 ? ["Custom models"]
266 : []),267 : []),
...@@ -631,43 +632,61 @@ function SettingsRouteView() {...@@ -631,43 +632,61 @@ function SettingsRouteView() {
631 <SettingsSection title="Models">632 <SettingsSection title="Models">
632 <SettingsRow633 <SettingsRow
633 title="Git writing model"634 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."
635 resetAction={636 resetAction={
636 isGitTextGenerationModelDirty ? (637 JSON.stringify(settings.textGenerationModelSelection ?? null) !==
638 JSON.stringify(defaults.textGenerationModelSelection ?? null) ? (
637 <SettingResetButton639 <SettingResetButton
638 label="git writing model"640 label="git writing model"
639 onClick={() =>641 onClick={() => {
640 updateSettings({642 updateSettings({
641 textGenerationModel: defaults.textGenerationModel,643 textGenerationModelSelection: defaults.textGenerationModelSelection,
642 })644 });
643 }645 }}
644 />646 />
645 ) : null647 ) : null
646 }648 }
647 control={649 control={
648 <Select650 <div className="flex flex-wrap items-center justify-end gap-1.5">
649 value={currentGitTextGenerationModel}651 <ProviderModelPicker
650 onValueChange={(value) => {652 provider={textGenProvider}
651 if (!value) return;653 model={textGenModel}
652 updateSettings({654 lockedProvider={null}
653 textGenerationModel: value,655 modelOptionsByProvider={gitModelOptionsByProvider}
654 });656 triggerVariant="outline"
655 }}657 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
656 >658 onProviderModelChange={(provider, model) => {
657 <SelectTrigger659 updateSettings({
658 className="w-full sm:w-52"660 textGenerationModelSelection: resolveAppModelSelectionState({
659 aria-label="Git text generation model"661 ...settings,
660 >662 textGenerationModelSelection: { provider, model },
661 <SelectValue>{selectedGitTextGenerationModelLabel}</SelectValue>663 }),
662 </SelectTrigger>664 });
663 <SelectPopup align="end" alignItemWithTrigger={false}>665 }}
664 {gitTextGenerationModelOptions.map((option) => (666 />
665 <SelectItem hideIndicator key={option.slug} value={option.slug}>667 <TraitsPicker
666 {option.name}668 provider={textGenProvider}
667 </SelectItem>669 model={textGenModel}
668 ))}670 prompt=""
669 </SelectPopup>671 onPromptChange={() => {}}
670 </Select>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>
671 }690 }
672 />691 />
673692
apps/web/src/wsNativeApi.test.ts+18-2
...@@ -353,11 +353,27 @@ describe("wsNativeApi", () => {...@@ -353,11 +353,27 @@ describe("wsNativeApi", () => {
353 const { createWsNativeApi } = await import("./wsNativeApi");353 const { createWsNativeApi } = await import("./wsNativeApi");
354354
355 const api = createWsNativeApi();355 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
358 expect(requestMock).toHaveBeenCalledWith(366 expect(requestMock).toHaveBeenCalledWith(
359 WS_METHODS.gitRunStackedAction,367 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 },
361 { timeoutMs: null },377 { timeoutMs: null },
362 );378 );
363 });379 });
packages/contracts/src/git.test.ts+19
...@@ -65,9 +65,28 @@ describe("GitRunStackedActionInput", () => {...@@ -65,9 +65,28 @@ describe("GitRunStackedActionInput", () => {
65 actionId: "action-1",65 actionId: "action-1",
66 cwd: "/repo",66 cwd: "/repo",
67 action: "commit",67 action: "commit",
68 modelSelection: {
69 provider: "codex",
70 model: "gpt-5.4-mini",
71 },
68 });72 });
6973
70 expect(parsed.actionId).toBe("action-1");74 expect(parsed.actionId).toBe("action-1");
71 expect(parsed.action).toBe("commit");75 expect(parsed.action).toBe("commit");
72 });76 });
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 });
73});92});
packages/contracts/src/git.ts+3-5
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { Option, Schema } from "effect";1import { Schema } from "effect";
2import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";2import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";
3import { DEFAULT_GIT_TEXT_GENERATION_MODEL } from "./model";3import { ModelSelection } from "./orchestration";
44
5const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;5const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
66
...@@ -80,9 +80,7 @@ export const GitRunStackedActionInput = Schema.Struct({...@@ -80,9 +80,7 @@ export const GitRunStackedActionInput = Schema.Struct({
80 filePaths: Schema.optional(80 filePaths: Schema.optional(
81 Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)),81 Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)),
82 ),82 ),
83 textGenerationModel: Schema.optional(TrimmedNonEmptyStringSchema).pipe(83 modelSelection: ModelSelection,
84 Schema.withConstructorDefault(() => Option.some(DEFAULT_GIT_TEXT_GENERATION_MODEL)),
85 ),
86});84});
87export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;85export 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> = {...@@ -199,7 +199,12 @@ export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, ModelSlug> = {
199// Backward compatibility for existing Codex-only call sites.199// Backward compatibility for existing Codex-only call sites.
200export const MODEL_OPTIONS = MODEL_OPTIONS_BY_PROVIDER.codex;200export const MODEL_OPTIONS = MODEL_OPTIONS_BY_PROVIDER.codex;
201export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex;201export 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
204export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, ModelSlug>> = {209export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, ModelSlug>> = {
205 codex: {210 codex: {