Unified · Split
| @@ -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 | +}); | |
| @@ -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); | |
| @@ -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"; · 1 unmodified line | ||
| 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) { · 24 unmodified lines | ||
| 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) { · 5 unmodified lines | ||
| 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>( · 27 unmodified lines | ||
| 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>( · 9 unmodified lines | ||
| 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>( · 19 unmodified lines | ||
| 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>( · 13 unmodified lines | ||
| 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>( · 27 unmodified lines | ||
| 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) => { · 28 unmodified lines | ||
| 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) => { · 4 unmodified lines | ||
| 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) => { · 13 unmodified lines | ||
| 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) => { · 20 unmodified lines | ||
| 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) => { · 17 unmodified lines | ||
| 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) => { · 15 unmodified lines | ||
| 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) => { · 20 unmodified lines | ||
| 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) => { · 20 unmodified lines | ||
| 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) => { · 41 unmodified lines | ||
| 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) => { · 6 unmodified lines | ||
| 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) => { · 15 unmodified lines | ||
| 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) => { · 15 unmodified lines | ||
| 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 | ), |
| @@ -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"; · 1 unmodified line | ||
| 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* () { · 8 unmodified lines | ||
| 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* () { · 39 unmodified lines | ||
| 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* () { · 1 unmodified line | ||
| 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* () { · 2 unmodified lines | ||
| 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* () { · 5 unmodified lines | ||
| 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* () { · 2 unmodified lines | ||
| 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* () { · 2 unmodified lines | ||
| 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* () { · 59 unmodified lines | ||
| 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, |
| @@ -21,6 +21,11 @@ import { GitCore } from "../Services/GitCore.ts"; · 20 unmodified lines | ||
| 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( · 434 unmodified lines | ||
| 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 | ); |
| @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; · 1 unmodified line | ||
| 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* () { · 675 unmodified lines | ||
| 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* () { · 20 unmodified lines | ||
| 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* () { · 6 unmodified lines | ||
| 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* () { · 23 unmodified lines | ||
| 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* () { · 70 unmodified lines | ||
| 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* () { · 37 unmodified lines | ||
| 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* () { · 214 unmodified lines | ||
| 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* () { · 2 unmodified lines | ||
| 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* () { · 59 unmodified lines | ||
| 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* () { · 6 unmodified lines | ||
| 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* () { · 26 unmodified lines | ||
| 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 | ) |
| @@ -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)); | |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -8,10 +8,13 @@ · 7 unmodified lines | ||
| 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 { · 1 unmodified line | ||
| 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 { · 10 unmodified lines | ||
| 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 { · 5 unmodified lines | ||
| 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 { |
| @@ -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 | +} | |
| @@ -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 | −} | |
| @@ -24,7 +24,7 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; · 23 unmodified lines | ||
| 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 | | { |
| @@ -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* () { · 429 unmodified lines | ||
| 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) => |
| @@ -20,7 +20,7 @@ import { ProviderService } from "../../provider/Services/ProviderService.ts"; · 19 unmodified lines | ||
| 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, |
| @@ -30,7 +30,7 @@ import { KeybindingsLive } from "./keybindings"; · 29 unmodified lines | ||
| 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< · 48 unmodified lines | ||
| 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( |
| @@ -1838,6 +1838,10 @@ describe("WebSocket Server", () => { · 1837 unmodified lines | ||
| 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", () => { · 2 unmodified lines | ||
| 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", () => { · 49 unmodified lines | ||
| 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 | |
| @@ -6,17 +6,20 @@ import { · 5 unmodified lines | ||
| 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", () => { · 242 unmodified lines | ||
| 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 | +}); | |
| @@ -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 · 4 unmodified lines | ||
| 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({ · 25 unmodified lines | ||
| 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 { · 3 unmodified lines | ||
| 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 { |
| @@ -120,13 +120,12 @@ import { · 119 unmodified lines | ||
| 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) { · 509 unmodified lines | ||
| 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]; |
| @@ -261,7 +261,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 260 unmodified lines | ||
| 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 })); |
| @@ -21,6 +21,7 @@ async function mountPicker(props: { · 20 unmodified lines | ||
| 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: { · 4 unmodified lines | ||
| 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", () => { · 119 unmodified lines | ||
| 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 | }); |
| @@ -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: { · 46 unmodified lines | ||
| 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: { · 30 unmodified lines | ||
| 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 | /> |
| @@ -29,6 +29,7 @@ const CLAUDE_THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits"); · 28 unmodified lines | ||
| 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: { · 19 unmodified lines | ||
| 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?: { · 8 unmodified lines | ||
| 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?: { · 36 unmodified lines | ||
| 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)", () => { · 117 unmodified lines | ||
| 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 ────────────────────────────────────────── |
| @@ -14,8 +14,9 @@ import { · 13 unmodified lines | ||
| 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 { · 4 unmodified lines | ||
| 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( · 23 unmodified lines | ||
| 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( · 22 unmodified lines | ||
| 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( · 7 unmodified lines | ||
| 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({ · 1 unmodified line | ||
| 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({ · 11 unmodified lines | ||
| 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({ · 36 unmodified lines | ||
| 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({ · 10 unmodified lines | ||
| 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({ · 9 unmodified lines | ||
| 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({ · 2 unmodified lines | ||
| 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({ · 24 unmodified lines | ||
| 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({ · 12 unmodified lines | ||
| 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> |
| @@ -21,7 +21,7 @@ import { · 20 unmodified lines | ||
| 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, |
| @@ -29,7 +29,14 @@ describe("git mutation options", () => { · 28 unmodified lines | ||
| 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 | |
| @@ -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: { · 107 unmodified lines | ||
| 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: { · 15 unmodified lines | ||
| 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 () => { |
| @@ -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 | +} | |
| @@ -2,16 +2,17 @@ import { createFileRoute } from "@tanstack/react-router"; · 1 unmodified line | ||
| 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 { · 7 unmodified lines | ||
| 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() { · 183 unmodified lines | ||
| 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() { · 26 unmodified lines | ||
| 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() { · 364 unmodified lines | ||
| 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 | |
| @@ -353,11 +353,27 @@ describe("wsNativeApi", () => { · 352 unmodified lines | ||
| 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 | }); |
| @@ -65,9 +65,28 @@ describe("GitRunStackedActionInput", () => { · 64 unmodified lines | ||
| 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 | }); |
| @@ -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({ · 73 unmodified lines | ||
| 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 | |
| @@ -199,7 +199,12 @@ export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, ModelSlug> = { · 198 unmodified lines | ||
| 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: { |