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