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