diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts new file mode 100644 index 0000000000..0a3829798e --- /dev/null +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts @@ -0,0 +1,248 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path } from "effect"; +import { expect } from "vitest"; + +import { ServerConfig } from "../../config.ts"; +import { TextGeneration } from "../Services/TextGeneration.ts"; +import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; + +const ClaudeTextGenerationTestLayer = ClaudeTextGenerationLive.pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-claude-text-generation-test-", + }), + ), + Layer.provideMerge(NodeServices.layer), +); + +function makeFakeClaudeBinary(dir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = path.join(dir, "bin"); + const claudePath = path.join(binDir, "claude"); + yield* fs.makeDirectory(binDir, { recursive: true }); + + yield* fs.writeFileString( + claudePath, + [ + "#!/bin/sh", + 'args="$*"', + 'stdin_content="$(cat)"', + 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" ]; then', + ' printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" >/dev/null || {', + ' printf "%s\\n" "args missing expected content" >&2', + " exit 2", + " }", + "fi", + 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" ]; then', + ' if printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" >/dev/null; then', + ' printf "%s\\n" "args contained forbidden content" >&2', + " exit 3", + " fi", + "fi", + 'if [ -n "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" ]; then', + ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" >/dev/null || {', + ' printf "%s\\n" "stdin missing expected content" >&2', + " exit 4", + " }", + "fi", + 'if [ -n "$T3_FAKE_CLAUDE_STDERR" ]; then', + ' printf "%s\\n" "$T3_FAKE_CLAUDE_STDERR" >&2', + "fi", + 'printf "%s" "$T3_FAKE_CLAUDE_OUTPUT"', + 'exit "${T3_FAKE_CLAUDE_EXIT_CODE:-0}"', + "", + ].join("\n"), + ); + yield* fs.chmod(claudePath, 0o755); + return binDir; + }); +} + +function withFakeClaudeEnv( + input: { + output: string; + exitCode?: number; + stderr?: string; + argsMustContain?: string; + argsMustNotContain?: string; + stdinMustContain?: string; + }, + effect: Effect.Effect, +) { + return Effect.acquireUseRelease( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" }); + const binDir = yield* makeFakeClaudeBinary(tempDir); + const previousPath = process.env.PATH; + const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT; + const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE; + const previousStderr = process.env.T3_FAKE_CLAUDE_STDERR; + const previousArgsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; + const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; + const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; + + yield* Effect.sync(() => { + process.env.PATH = `${binDir}:${previousPath ?? ""}`; + process.env.T3_FAKE_CLAUDE_OUTPUT = input.output; + + if (input.exitCode !== undefined) { + process.env.T3_FAKE_CLAUDE_EXIT_CODE = String(input.exitCode); + } else { + delete process.env.T3_FAKE_CLAUDE_EXIT_CODE; + } + + if (input.stderr !== undefined) { + process.env.T3_FAKE_CLAUDE_STDERR = input.stderr; + } else { + delete process.env.T3_FAKE_CLAUDE_STDERR; + } + + if (input.argsMustContain !== undefined) { + process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = input.argsMustContain; + } else { + delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; + } + + if (input.argsMustNotContain !== undefined) { + process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = input.argsMustNotContain; + } else { + delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; + } + + if (input.stdinMustContain !== undefined) { + process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = input.stdinMustContain; + } else { + delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; + } + }); + + return { + previousPath, + previousOutput, + previousExitCode, + previousStderr, + previousArgsMustContain, + previousArgsMustNotContain, + previousStdinMustContain, + }; + }), + () => effect, + (previous) => + Effect.sync(() => { + process.env.PATH = previous.previousPath; + + if (previous.previousOutput === undefined) { + delete process.env.T3_FAKE_CLAUDE_OUTPUT; + } else { + process.env.T3_FAKE_CLAUDE_OUTPUT = previous.previousOutput; + } + + if (previous.previousExitCode === undefined) { + delete process.env.T3_FAKE_CLAUDE_EXIT_CODE; + } else { + process.env.T3_FAKE_CLAUDE_EXIT_CODE = previous.previousExitCode; + } + + if (previous.previousStderr === undefined) { + delete process.env.T3_FAKE_CLAUDE_STDERR; + } else { + process.env.T3_FAKE_CLAUDE_STDERR = previous.previousStderr; + } + + if (previous.previousArgsMustContain === undefined) { + delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; + } else { + process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = previous.previousArgsMustContain; + } + + if (previous.previousArgsMustNotContain === undefined) { + delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; + } else { + process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = previous.previousArgsMustNotContain; + } + + if (previous.previousStdinMustContain === undefined) { + delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; + } else { + process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previous.previousStdinMustContain; + } + }), + ); +} + +it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { + it.effect("forwards Claude thinking settings for Haiku without passing effort", () => + withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + subject: "Add important change", + body: "", + }, + }), + argsMustContain: '--settings {"alwaysThinkingEnabled":false}', + argsMustNotContain: "--effort", + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/claude-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: { + provider: "claudeAgent", + model: "claude-haiku-4-5", + options: { + thinking: false, + effort: "high", + }, + }, + }); + + expect(generated.subject).toBe("Add important change"); + }), + ), + ); + + it.effect("forwards Claude fast mode and supported effort", () => + withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + title: "Improve orchestration flow", + body: "Body", + }, + }), + argsMustContain: '--effort max --settings {"fastMode":true}', + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generatePrContent({ + cwd: process.cwd(), + baseBranch: "main", + headBranch: "feature/claude-effect", + commitSummary: "Improve orchestration", + diffSummary: "1 file changed", + diffPatch: "diff --git a/README.md b/README.md", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { + effort: "max", + fastMode: true, + }, + }, + }); + + expect(generated.title).toBe("Improve orchestration flow"); + }), + ), + ); +}); diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts new file mode 100644 index 0000000000..9f48a07c51 --- /dev/null +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -0,0 +1,301 @@ +/** + * ClaudeTextGeneration – Text generation layer using the Claude CLI. + * + * Implements the same TextGenerationShape contract as CodexTextGeneration but + * delegates to the `claude` CLI (`claude -p`) with structured JSON output + * instead of the `codex exec` CLI. + * + * @module ClaudeTextGeneration + */ +import { Effect, Layer, Option, Schema, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { ClaudeModelSelection } from "@t3tools/contracts"; +import { normalizeClaudeModelOptions } from "@t3tools/shared/model"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; + +import { TextGenerationError } from "../Errors.ts"; +import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, +} from "../Prompts.ts"; +import { + normalizeCliError, + sanitizeCommitSubject, + sanitizePrTitle, + toJsonSchemaObject, +} from "../Utils.ts"; + +const CLAUDE_TIMEOUT_MS = 180_000; + +/** + * Schema for the wrapper JSON returned by `claude -p --output-format json`. + * We only care about `structured_output`. + */ +const ClaudeOutputEnvelope = Schema.Struct({ + structured_output: Schema.Unknown, +}); + +const makeClaudeTextGeneration = Effect.gen(function* () { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const readStreamAsString = ( + operation: string, + stream: Stream.Stream, + ): Effect.Effect => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + Effect.mapError((cause) => + normalizeCliError("claude", operation, cause, "Failed to collect process output"), + ), + ); + + /** + * Spawn the Claude CLI with structured JSON output and return the parsed, + * schema-validated result. + */ + const runClaudeJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ClaudeModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson)); + const normalizedOptions = normalizeClaudeModelOptions( + modelSelection.model, + modelSelection.options, + ); + const settings = { + ...(typeof normalizedOptions?.thinking === "boolean" + ? { alwaysThinkingEnabled: normalizedOptions.thinking } + : {}), + ...(normalizedOptions?.fastMode ? { fastMode: true } : {}), + }; + + const runClaudeCommand = Effect.gen(function* () { + const command = ChildProcess.make( + "claude", + [ + "-p", + "--output-format", + "json", + "--json-schema", + jsonSchemaStr, + "--model", + modelSelection.model, + ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []), + ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []), + "--dangerously-skip-permissions", + ], + { + cwd, + shell: process.platform === "win32", + stdin: { + stream: Stream.encodeText(Stream.make(prompt)), + }, + }, + ); + + const child = yield* commandSpawner + .spawn(command) + .pipe( + Effect.mapError((cause) => + normalizeCliError("claude", operation, cause, "Failed to spawn Claude CLI process"), + ), + ); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + readStreamAsString(operation, child.stdout), + readStreamAsString(operation, child.stderr), + child.exitCode.pipe( + Effect.mapError((cause) => + normalizeCliError( + "claude", + operation, + cause, + "Failed to read Claude CLI exit code", + ), + ), + ), + ], + { concurrency: "unbounded" }, + ); + + if (exitCode !== 0) { + const stderrDetail = stderr.trim(); + const stdoutDetail = stdout.trim(); + const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; + return yield* new TextGenerationError({ + operation, + detail: + detail.length > 0 + ? `Claude CLI command failed: ${detail}` + : `Claude CLI command failed with code ${exitCode}.`, + }); + } + + return stdout; + }); + + const rawStdout = yield* runClaudeCommand.pipe( + Effect.scoped, + Effect.timeoutOption(CLAUDE_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ operation, detail: "Claude CLI request timed out." }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + ); + + const envelope = yield* Schema.decodeEffect(Schema.fromJsonString(ClaudeOutputEnvelope))( + rawStdout, + ).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Claude CLI returned unexpected output format.", + cause, + }), + ), + ), + ); + + return yield* Schema.decodeEffect(outputSchemaJson)(envelope.structured_output).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Claude returned invalid structured output.", + cause, + }), + ), + ), + ); + }); + + // --------------------------------------------------------------------------- + // TextGenerationShape methods + // --------------------------------------------------------------------------- + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + "ClaudeTextGeneration.generateCommitMessage", + )(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + + if (input.modelSelection.provider !== "claudeAgent") { + return yield* new TextGenerationError({ + operation: "generateCommitMessage", + detail: "Invalid model selection.", + }); + } + + const generated = yield* runClaudeJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( + "ClaudeTextGeneration.generatePrContent", + )(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + + if (input.modelSelection.provider !== "claudeAgent") { + return yield* new TextGenerationError({ + operation: "generatePrContent", + detail: "Invalid model selection.", + }); + } + + const generated = yield* runClaudeJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + "ClaudeTextGeneration.generateBranchName", + )(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + if (input.modelSelection.provider !== "claudeAgent") { + return yield* new TextGenerationError({ + operation: "generateBranchName", + detail: "Invalid model selection.", + }); + } + + const generated = yield* runClaudeJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + } satisfies TextGenerationShape; +}); + +export const ClaudeTextGenerationLive = Layer.effect(TextGeneration, makeClaudeTextGeneration); diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 0170d207fe..b53d7f15bd 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -1,6 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path } from "effect"; +import { Effect, FileSystem, Layer, Path, Result } from "effect"; import { expect } from "vitest"; import { ServerConfig } from "../../config.ts"; @@ -8,6 +8,11 @@ import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; import { TextGenerationError } from "../Errors.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; +const DEFAULT_TEST_MODEL_SELECTION = { + provider: "codex" as const, + model: "gpt-5.4-mini", +}; + const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe( Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { @@ -38,6 +43,18 @@ function makeFakeCodexBinary(dir: string) { " fi", " continue", " fi", + ' if [ "$1" = "--config" ]; then', + " shift", + ' if [ "$1" = "service_tier=\\"fast\\"" ]; then', + ' seen_fast_service_tier="1"', + " fi", + ' case "$1" in', + " model_reasoning_effort=*)", + ' seen_reasoning_effort="$1"', + " ;;", + " esac", + " continue", + " fi", ' if [ "$1" = "--output-last-message" ]; then', " shift", ' output_path="$1"', @@ -49,6 +66,18 @@ function makeFakeCodexBinary(dir: string) { ' printf "%s\\n" "missing --image input" >&2', " exit 2", "fi", + 'if [ "$T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER" = "1" ] && [ "$seen_fast_service_tier" != "1" ]; then', + ' printf "%s\\n" "missing fast service tier config" >&2', + " exit 5", + "fi", + 'if [ -n "$T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT" ] && [ "$seen_reasoning_effort" != "model_reasoning_effort=\\"$T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT\\"" ]; then', + ' printf "%s\\n" "unexpected reasoning effort config: $seen_reasoning_effort" >&2', + " exit 6", + "fi", + 'if [ "$T3_FAKE_CODEX_FORBID_REASONING_EFFORT" = "1" ] && [ -n "$seen_reasoning_effort" ]; then', + ' printf "%s\\n" "reasoning effort config should be omitted: $seen_reasoning_effort" >&2', + " exit 7", + "fi", 'if [ -n "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" ]; then', ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" >/dev/null || {', ' printf "%s\\n" "stdin missing expected content" >&2', @@ -82,6 +111,9 @@ function withFakeCodexEnv( exitCode?: number; stderr?: string; requireImage?: boolean; + requireFastServiceTier?: boolean; + requireReasoningEffort?: string; + forbidReasoningEffort?: boolean; stdinMustContain?: string; stdinMustNotContain?: string; }, @@ -97,6 +129,9 @@ function withFakeCodexEnv( const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE; const previousStderr = process.env.T3_FAKE_CODEX_STDERR; const previousRequireImage = process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; + const previousRequireFastServiceTier = process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER; + const previousRequireReasoningEffort = process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT; + const previousForbidReasoningEffort = process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT; const previousStdinMustContain = process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN; const previousStdinMustNotContain = process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN; @@ -122,6 +157,24 @@ function withFakeCodexEnv( delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; } + if (input.requireFastServiceTier) { + process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER = "1"; + } else { + delete process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER; + } + + if (input.requireReasoningEffort !== undefined) { + process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT = input.requireReasoningEffort; + } else { + delete process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT; + } + + if (input.forbidReasoningEffort) { + process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT = "1"; + } else { + delete process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT; + } + if (input.stdinMustContain !== undefined) { process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN = input.stdinMustContain; } else { @@ -141,6 +194,9 @@ function withFakeCodexEnv( previousExitCode, previousStderr, previousRequireImage, + previousRequireFastServiceTier, + previousRequireReasoningEffort, + previousForbidReasoningEffort, previousStdinMustContain, previousStdinMustNotContain, }; @@ -174,6 +230,27 @@ function withFakeCodexEnv( process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = previous.previousRequireImage; } + if (previous.previousRequireFastServiceTier === undefined) { + delete process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER; + } else { + process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER = + previous.previousRequireFastServiceTier; + } + + if (previous.previousRequireReasoningEffort === undefined) { + delete process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT; + } else { + process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT = + previous.previousRequireReasoningEffort; + } + + if (previous.previousForbidReasoningEffort === undefined) { + delete process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT; + } else { + process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT = + previous.previousForbidReasoningEffort; + } + if (previous.previousStdinMustContain === undefined) { delete process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN; } else { @@ -208,6 +285,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { branch: "feature/codex-effect", stagedSummary: "M README.md", stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, }); expect(generated.subject.length).toBeLessThanOrEqual(72); @@ -218,6 +296,63 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { ), ); + it.effect( + "forwards codex fast mode and non-default reasoning effort into codex exec config", + () => + withFakeCodexEnv( + { + output: JSON.stringify({ + subject: "Add important change", + body: "", + }), + requireFastServiceTier: true, + requireReasoningEffort: "xhigh", + stdinMustNotContain: "branch must be a short semantic git branch fragment", + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: { + provider: "codex", + model: "gpt-5.4", + options: { + reasoningEffort: "xhigh", + fastMode: true, + }, + }, + }); + }), + ), + ); + + it.effect("defaults git text generation codex effort to low", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + subject: "Add important change", + body: "", + }), + requireReasoningEffort: "low", + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + }), + ), + ); + it.effect("generates commit message with branch when includeBranch is true", () => withFakeCodexEnv( { @@ -237,6 +372,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { stagedSummary: "M README.md", stagedPatch: "diff --git a/README.md b/README.md", includeBranch: true, + modelSelection: DEFAULT_TEST_MODEL_SELECTION, }); expect(generated.subject).toBe("Add important change"); @@ -263,6 +399,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { commitSummary: "feat: improve orchestration flow", diffSummary: "2 files changed", diffPatch: "diff --git a/a.ts b/a.ts", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, }); expect(generated.title).toBe("Improve orchestration flow"); @@ -286,6 +423,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { const generated = yield* textGeneration.generateBranchName({ cwd: process.cwd(), message: "Please update session handling.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, }); expect(generated.branch).toBe("feat/session"); @@ -307,6 +445,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { const generated = yield* textGeneration.generateBranchName({ cwd: process.cwd(), message: "Fix timeout behavior.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, }); expect(generated.branch).toBe("fix/session-timeout"); @@ -333,21 +472,20 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { yield* fs.writeFile(attachmentPath, Buffer.from("hello")); const textGeneration = yield* TextGeneration; - const generated = yield* textGeneration - .generateBranchName({ - cwd: process.cwd(), - message: "Fix layout bug from screenshot.", - attachments: [ - { - type: "image", - id: attachmentId, - name: "bug.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - }) - .pipe(Effect.ensuring(fs.remove(attachmentPath).pipe(Effect.catch(() => Effect.void)))); + const generated = yield* textGeneration.generateBranchName({ + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + cwd: process.cwd(), + message: "Fix layout bug from screenshot.", + attachments: [ + { + type: "image", + id: attachmentId, + name: "bug.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + }); expect(generated.branch).toBe("fix/ui-regression"); }), @@ -374,6 +512,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { const textGeneration = yield* TextGeneration; const generated = yield* textGeneration .generateBranchName({ + modelSelection: DEFAULT_TEST_MODEL_SELECTION, cwd: process.cwd(), message: "Fix layout bug from screenshot.", attachments: [ @@ -421,6 +560,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { const textGeneration = yield* TextGeneration; const result = yield* textGeneration .generateBranchName({ + modelSelection: DEFAULT_TEST_MODEL_SELECTION, cwd: process.cwd(), message: "Fix layout bug from screenshot.", attachments: [ @@ -433,17 +573,12 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { }, ], }) - .pipe( - Effect.match({ - onFailure: (error) => ({ _tag: "Left" as const, left: error }), - onSuccess: (value) => ({ _tag: "Right" as const, right: value }), - }), - ); + .pipe(Effect.result); - expect(result._tag).toBe("Left"); - if (result._tag === "Left") { - expect(result.left).toBeInstanceOf(TextGenerationError); - expect(result.left.message).toContain("missing --image input"); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure).toBeInstanceOf(TextGenerationError); + expect(result.failure.message).toContain("missing --image input"); } }), ), @@ -465,18 +600,14 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { .generateBranchName({ cwd: process.cwd(), message: "Fix websocket reconnect flake", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, }) - .pipe( - Effect.match({ - onFailure: (error) => ({ _tag: "Left" as const, left: error }), - onSuccess: (value) => ({ _tag: "Right" as const, right: value }), - }), - ); - - expect(result._tag).toBe("Left"); - if (result._tag === "Left") { - expect(result.left).toBeInstanceOf(TextGenerationError); - expect(result.left.message).toContain("Codex returned invalid structured output"); + .pipe(Effect.result); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure).toBeInstanceOf(TextGenerationError); + expect(result.failure.message).toContain("Codex returned invalid structured output"); } }), ), @@ -498,18 +629,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { branch: "feature/codex-error", stagedSummary: "M README.md", stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, }) - .pipe( - Effect.match({ - onFailure: (error) => ({ _tag: "Left" as const, left: error }), - onSuccess: (value) => ({ _tag: "Right" as const, right: value }), - }), - ); + .pipe(Effect.result); - expect(result._tag).toBe("Left"); - if (result._tag === "Left") { - expect(result.left).toBeInstanceOf(TextGenerationError); - expect(result.left.message).toContain("Codex CLI command failed: codex execution failed"); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure).toBeInstanceOf(TextGenerationError); + expect(result.failure.message).toContain( + "Codex CLI command failed: codex execution failed", + ); } }), ), diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 373c191236..afe972ab4a 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -1,9 +1,10 @@ import { randomUUID } from "node:crypto"; -import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect"; +import { Effect, FileSystem, Layer, Option, Path, Schema, Scope, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts"; +import { CodexModelSelection } from "@t3tools/contracts"; +import { normalizeCodexModelOptions } from "@t3tools/shared/model"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -11,90 +12,24 @@ import { ServerConfig } from "../../config.ts"; import { TextGenerationError } from "../Errors.ts"; import { type BranchNameGenerationInput, - type BranchNameGenerationResult, - type CommitMessageGenerationResult, - type PrContentGenerationResult, type TextGenerationShape, TextGeneration, } from "../Services/TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, +} from "../Prompts.ts"; +import { + normalizeCliError, + sanitizeCommitSubject, + sanitizePrTitle, + toJsonSchemaObject, +} from "../Utils.ts"; -const CODEX_REASONING_EFFORT = "low"; +const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; -function toCodexOutputJsonSchema(schema: Schema.Top): unknown { - const document = Schema.toJsonSchemaDocument(schema); - if (document.definitions && Object.keys(document.definitions).length > 0) { - return { - ...document.schema, - $defs: document.definitions, - }; - } - return document.schema; -} - -function normalizeCodexError( - operation: string, - error: unknown, - fallback: string, -): TextGenerationError { - if (Schema.is(TextGenerationError)(error)) { - return error; - } - - if (error instanceof Error) { - const lower = error.message.toLowerCase(); - if ( - error.message.includes("Command not found: codex") || - lower.includes("spawn codex") || - lower.includes("enoent") - ) { - return new TextGenerationError({ - operation, - detail: "Codex CLI (`codex`) is required but not available on PATH.", - cause: error, - }); - } - return new TextGenerationError({ - operation, - detail: `${fallback}: ${error.message}`, - cause: error, - }); - } - - return new TextGenerationError({ - operation, - detail: fallback, - cause: error, - }); -} - -function limitSection(value: string, maxChars: number): string { - if (value.length <= maxChars) return value; - const truncated = value.slice(0, maxChars); - return `${truncated}\n\n[truncated]`; -} - -function sanitizeCommitSubject(raw: string): string { - const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; - const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); - if (withoutTrailingPeriod.length === 0) { - return "Update project files"; - } - - if (withoutTrailingPeriod.length <= 72) { - return withoutTrailingPeriod; - } - return withoutTrailingPeriod.slice(0, 72).trimEnd(); -} - -function sanitizePrTitle(raw: string): string { - const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; - if (singleLine.length > 0) { - return singleLine; - } - return "Update project changes"; -} - const makeCodexTextGeneration = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -109,39 +44,37 @@ const makeCodexTextGeneration = Effect.gen(function* () { operation: string, stream: Stream.Stream, ): Effect.Effect => - Effect.gen(function* () { - let text = ""; - yield* Stream.runForEach(stream, (chunk) => - Effect.sync(() => { - text += Buffer.from(chunk).toString("utf8"); - }), - ).pipe( - Effect.mapError((cause) => - normalizeCodexError(operation, cause, "Failed to collect process output"), - ), - ); - return text; - }); - - const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + Effect.mapError((cause) => + normalizeCliError("codex", operation, cause, "Failed to collect process output"), + ), + ); const writeTempFile = ( operation: string, prefix: string, content: string, - ): Effect.Effect => { - const filePath = path.join(tempDir, `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`); - return fileSystem.writeFileString(filePath, content).pipe( - Effect.mapError( - (cause) => - new TextGenerationError({ - operation, - detail: `Failed to write temp file at ${filePath}.`, - cause, - }), - ), - Effect.as(filePath), - ); + ): Effect.Effect => { + return fileSystem + .makeTempFileScoped({ + prefix: `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`, + }) + .pipe( + Effect.tap((filePath) => fileSystem.writeFileString(filePath, content)), + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: `Failed to write temp file`, + cause, + }), + ), + ); }; const safeUnlink = (filePath: string): Effect.Effect => @@ -187,7 +120,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { outputSchemaJson, imagePaths = [], cleanupPaths = [], - model, + modelSelection, }: { operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; cwd: string; @@ -195,17 +128,23 @@ const makeCodexTextGeneration = Effect.gen(function* () { outputSchemaJson: S; imagePaths?: ReadonlyArray; cleanupPaths?: ReadonlyArray; - model?: string; + modelSelection: CodexModelSelection; }): Effect.Effect => Effect.gen(function* () { const schemaPath = yield* writeTempFile( operation, "codex-schema", - JSON.stringify(toCodexOutputJsonSchema(outputSchemaJson)), + JSON.stringify(toJsonSchemaObject(outputSchemaJson)), ); const outputPath = yield* writeTempFile(operation, "codex-output", ""); const runCodexCommand = Effect.gen(function* () { + const normalizedOptions = normalizeCodexModelOptions( + modelSelection.model, + modelSelection.options, + ); + const reasoningEffort = + modelSelection.options?.reasoningEffort ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; const command = ChildProcess.make( "codex", [ @@ -214,9 +153,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { "-s", "read-only", "--model", - model ?? DEFAULT_GIT_TEXT_GENERATION_MODEL, + modelSelection.model, "--config", - `model_reasoning_effort="${CODEX_REASONING_EFFORT}"`, + `model_reasoning_effort="${reasoningEffort}"`, + ...(normalizedOptions?.fastMode ? ["--config", `service_tier="fast"`] : []), "--output-schema", schemaPath, "--output-last-message", @@ -228,7 +168,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { cwd, shell: process.platform === "win32", stdin: { - stream: Stream.make(new TextEncoder().encode(prompt)), + stream: Stream.encodeText(Stream.make(prompt)), }, }, ); @@ -237,7 +177,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { .spawn(command) .pipe( Effect.mapError((cause) => - normalizeCodexError(operation, cause, "Failed to spawn Codex CLI process"), + normalizeCliError("codex", operation, cause, "Failed to spawn Codex CLI process"), ), ); @@ -246,9 +186,8 @@ const makeCodexTextGeneration = Effect.gen(function* () { readStreamAsString(operation, child.stdout), readStreamAsString(operation, child.stderr), child.exitCode.pipe( - Effect.map((value) => Number(value)), Effect.mapError((cause) => - normalizeCodexError(operation, cause, "Failed to read Codex CLI exit code"), + normalizeCliError("codex", operation, cause, "Failed to read Codex CLI exit code"), ), ), ], @@ -314,153 +253,104 @@ const makeCodexTextGeneration = Effect.gen(function* () { }).pipe(Effect.ensuring(cleanup)); }); - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => { - const wantsBranch = input.includeBranch === true; - - const prompt = [ - "You write concise git commit messages.", - wantsBranch - ? "Return a JSON object with keys: subject, body, branch." - : "Return a JSON object with keys: subject, body.", - "Rules:", - "- subject must be imperative, <= 72 chars, and no trailing period", - "- body can be empty string or short bullet points", - ...(wantsBranch - ? ["- branch must be a short semantic git branch fragment for this change"] - : []), - "- capture the primary user-visible or developer-visible change", - "", - `Branch: ${input.branch ?? "(detached)"}`, - "", - "Staged files:", - limitSection(input.stagedSummary, 6_000), - "", - "Staged patch:", - limitSection(input.stagedPatch, 40_000), - ].join("\n"); - - const outputSchemaJson = wantsBranch - ? Schema.Struct({ - subject: Schema.String, - body: Schema.String, - branch: Schema.String, - }) - : Schema.Struct({ - subject: Schema.String, - body: Schema.String, - }); + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + "CodexTextGeneration.generateCommitMessage", + )(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); - return runCodexJson({ + if (input.modelSelection.provider !== "codex") { + return yield* new TextGenerationError({ + operation: "generateCommitMessage", + detail: "Invalid model selection.", + }); + } + + const generated = yield* runCodexJson({ operation: "generateCommitMessage", cwd: input.cwd, prompt, - outputSchemaJson, - ...(input.model ? { model: input.model } : {}), - }).pipe( - Effect.map( - (generated) => - ({ - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }) satisfies CommitMessageGenerationResult, - ), - ); - }; + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( + "CodexTextGeneration.generatePrContent", + )(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); - const generatePrContent: TextGenerationShape["generatePrContent"] = (input) => { - const prompt = [ - "You write GitHub pull request content.", - "Return a JSON object with keys: title, body.", - "Rules:", - "- title should be concise and specific", - "- body must be markdown and include headings '## Summary' and '## Testing'", - "- under Summary, provide short bullet points", - "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", - "", - `Base branch: ${input.baseBranch}`, - `Head branch: ${input.headBranch}`, - "", - "Commits:", - limitSection(input.commitSummary, 12_000), - "", - "Diff stat:", - limitSection(input.diffSummary, 12_000), - "", - "Diff patch:", - limitSection(input.diffPatch, 40_000), - ].join("\n"); - - return runCodexJson({ + if (input.modelSelection.provider !== "codex") { + return yield* new TextGenerationError({ + operation: "generatePrContent", + detail: "Invalid model selection.", + }); + } + + const generated = yield* runCodexJson({ operation: "generatePrContent", cwd: input.cwd, prompt, - outputSchemaJson: Schema.Struct({ - title: Schema.String, - body: Schema.String, - }), - ...(input.model ? { model: input.model } : {}), - }).pipe( - Effect.map( - (generated) => - ({ - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }) satisfies PrContentGenerationResult, - ), - ); - }; + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => { - return Effect.gen(function* () { - const { imagePaths } = yield* materializeImageAttachments( - "generateBranchName", - input.attachments, - ); - const attachmentLines = (input.attachments ?? []).map( - (attachment) => - `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, - ); + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); - const promptSections = [ - "You generate concise git branch names.", - "Return a JSON object with key: branch.", - "Rules:", - "- Branch should describe the requested work from the user message.", - "- Keep it short and specific (2-6 words).", - "- Use plain words only, no issue prefixes and no punctuation-heavy text.", - "- If images are attached, use them as primary context for visual/UI issues.", - "", - "User message:", - limitSection(input.message, 8_000), - ]; - if (attachmentLines.length > 0) { - promptSections.push( - "", - "Attachment metadata:", - limitSection(attachmentLines.join("\n"), 4_000), - ); - } - const prompt = promptSections.join("\n"); + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + "CodexTextGeneration.generateBranchName", + )(function* (input) { + const { imagePaths } = yield* materializeImageAttachments( + "generateBranchName", + input.attachments, + ); + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); - const generated = yield* runCodexJson({ + if (input.modelSelection.provider !== "codex") { + return yield* new TextGenerationError({ operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: Schema.Struct({ - branch: Schema.String, - }), - imagePaths, - ...(input.model ? { model: input.model } : {}), + detail: "Invalid model selection.", }); + } - return { - branch: sanitizeBranchFragment(generated.branch), - } satisfies BranchNameGenerationResult; + const generated = yield* runCodexJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + imagePaths, + modelSelection: input.modelSelection, }); - }; + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); return { generateCommitMessage, diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index ce252f739f..9302c562e8 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -21,6 +21,11 @@ import { GitCore } from "../Services/GitCore.ts"; import { makeGitManager } from "./GitManager.ts"; import { ServerConfig } from "../../config.ts"; +const DEFAULT_TEST_MODEL_SELECTION = { + provider: "codex", + model: "gpt-5.4-mini", +} as const; + interface FakeGhScenario { prListSequence?: string[]; prListByHeadSelector?: Record; @@ -461,6 +466,7 @@ function runStackedAction( { ...input, actionId: input.actionId ?? "test-action-id", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, }, options, ); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 4f240e0044..e2ae56a90c 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { realpathSync } from "node:fs"; import { Effect, FileSystem, Layer, Path } from "effect"; -import type { GitActionProgressEvent, GitActionProgressPhase } from "@t3tools/contracts"; +import { GitActionProgressEvent, GitActionProgressPhase, ModelSelection } from "@t3tools/contracts"; import { resolveAutoFeatureBranchName, sanitizeBranchFragment, @@ -684,7 +684,7 @@ export const makeGitManager = Effect.gen(function* () { /** When true, also produce a semantic feature branch name. */ includeBranch?: boolean; filePaths?: readonly string[]; - model?: string; + modelSelection: ModelSelection; }) => Effect.gen(function* () { const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths); @@ -711,7 +711,7 @@ export const makeGitManager = Effect.gen(function* () { stagedSummary: limitContext(context.stagedSummary, 8_000), stagedPatch: limitContext(context.stagedPatch, 50_000), ...(input.includeBranch ? { includeBranch: true } : {}), - ...(input.model ? { model: input.model } : {}), + modelSelection: input.modelSelection, }) .pipe(Effect.map((result) => sanitizeCommitMessage(result))); @@ -724,13 +724,13 @@ export const makeGitManager = Effect.gen(function* () { }); const runCommitStep = ( + modelSelection: ModelSelection, cwd: string, action: "commit" | "commit_push" | "commit_push_pr", branch: string | null, commitMessage?: string, preResolvedSuggestion?: CommitAndBranchSuggestion, filePaths?: readonly string[], - model?: string, progressReporter?: GitActionProgressReporter, actionId?: string, ) => @@ -760,7 +760,7 @@ export const makeGitManager = Effect.gen(function* () { branch, ...(commitMessage ? { commitMessage } : {}), ...(filePaths ? { filePaths } : {}), - ...(model ? { model } : {}), + modelSelection, }); } if (!suggestion) { @@ -837,7 +837,7 @@ export const makeGitManager = Effect.gen(function* () { }; }); - const runPrStep = (cwd: string, fallbackBranch: string | null, model?: string) => + const runPrStep = (modelSelection: ModelSelection, cwd: string, fallbackBranch: string | null) => Effect.gen(function* () { const details = yield* gitCore.statusDetails(cwd); const branch = details.branch ?? fallbackBranch; @@ -881,7 +881,7 @@ export const makeGitManager = Effect.gen(function* () { commitSummary: limitContext(rangeContext.commitSummary, 20_000), diffSummary: limitContext(rangeContext.diffSummary, 20_000), diffPatch: limitContext(rangeContext.diffPatch, 60_000), - ...(model ? { model } : {}), + modelSelection, }); const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`); @@ -1102,11 +1102,11 @@ export const makeGitManager = Effect.gen(function* () { ); const runFeatureBranchStep = ( + modelSelection: ModelSelection, cwd: string, branch: string | null, commitMessage?: string, filePaths?: readonly string[], - model?: string, ) => Effect.gen(function* () { const suggestion = yield* resolveCommitAndBranchSuggestion({ @@ -1115,7 +1115,7 @@ export const makeGitManager = Effect.gen(function* () { ...(commitMessage ? { commitMessage } : {}), ...(filePaths ? { filePaths } : {}), includeBranch: true, - ...(model ? { model } : {}), + modelSelection, }); if (!suggestion) { return yield* gitManagerError( @@ -1181,11 +1181,11 @@ export const makeGitManager = Effect.gen(function* () { label: "Preparing feature branch...", }); const result = yield* runFeatureBranchStep( + input.modelSelection, input.cwd, initialStatus.branch, input.commitMessage, input.filePaths, - input.textGenerationModel, ); branchStep = result.branchStep; commitMessageForStep = result.resolvedCommitMessage; @@ -1198,13 +1198,13 @@ export const makeGitManager = Effect.gen(function* () { currentPhase = "commit"; const commit = yield* runCommitStep( + input.modelSelection, input.cwd, input.action, currentBranch, commitMessageForStep, preResolvedCommitSuggestion, input.filePaths, - input.textGenerationModel, options?.progressReporter, progress.actionId, ); @@ -1237,7 +1237,7 @@ export const makeGitManager = Effect.gen(function* () { Effect.flatMap(() => Effect.gen(function* () { currentPhase = "pr"; - return yield* runPrStep(input.cwd, currentBranch, input.textGenerationModel); + return yield* runPrStep(input.modelSelection, input.cwd, currentBranch); }), ), ) diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts new file mode 100644 index 0000000000..7915131385 --- /dev/null +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -0,0 +1,72 @@ +/** + * RoutingTextGeneration – Dispatches text generation requests to either the + * Codex CLI or Claude CLI implementation based on the provider in each + * request input. + * + * When `modelSelection.provider` is `"claudeAgent"` the request is forwarded to + * the Claude layer; for any other value (including the default `undefined`) it + * falls through to the Codex layer. + * + * @module RoutingTextGeneration + */ +import { Effect, Layer, ServiceMap } from "effect"; + +import { + TextGeneration, + type TextGenerationProvider, + type TextGenerationShape, +} from "../Services/TextGeneration.ts"; +import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; +import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; + +// --------------------------------------------------------------------------- +// Internal service tags so both concrete layers can coexist. +// --------------------------------------------------------------------------- + +class CodexTextGen extends ServiceMap.Service()( + "t3/git/Layers/RoutingTextGeneration/CodexTextGen", +) {} + +class ClaudeTextGen extends ServiceMap.Service()( + "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen", +) {} + +// --------------------------------------------------------------------------- +// Routing implementation +// --------------------------------------------------------------------------- + +const makeRoutingTextGeneration = Effect.gen(function* () { + const codex = yield* CodexTextGen; + const claude = yield* ClaudeTextGen; + + const route = (provider?: TextGenerationProvider): TextGenerationShape => + provider === "claudeAgent" ? claude : codex; + + return { + generateCommitMessage: (input) => + route(input.modelSelection.provider).generateCommitMessage(input), + generatePrContent: (input) => route(input.modelSelection.provider).generatePrContent(input), + generateBranchName: (input) => route(input.modelSelection.provider).generateBranchName(input), + } satisfies TextGenerationShape; +}); + +const InternalCodexLayer = Layer.effect( + CodexTextGen, + Effect.gen(function* () { + const svc = yield* TextGeneration; + return svc; + }), +).pipe(Layer.provide(CodexTextGenerationLive)); + +const InternalClaudeLayer = Layer.effect( + ClaudeTextGen, + Effect.gen(function* () { + const svc = yield* TextGeneration; + return svc; + }), +).pipe(Layer.provide(ClaudeTextGenerationLive)); + +export const RoutingTextGenerationLive = Layer.effect( + TextGeneration, + makeRoutingTextGeneration, +).pipe(Layer.provide(InternalCodexLayer), Layer.provide(InternalClaudeLayer)); diff --git a/apps/server/src/git/Prompts.test.ts b/apps/server/src/git/Prompts.test.ts new file mode 100644 index 0000000000..23c3eca557 --- /dev/null +++ b/apps/server/src/git/Prompts.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; + +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, +} from "./Prompts.ts"; +import { normalizeCliError } from "./Utils.ts"; +import { TextGenerationError } from "./Errors.ts"; + +describe("buildCommitMessagePrompt", () => { + it("includes staged patch and summary in the prompt", () => { + const result = buildCommitMessagePrompt({ + branch: "main", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md\n+hello", + includeBranch: false, + }); + + expect(result.prompt).toContain("Staged files:"); + expect(result.prompt).toContain("M README.md"); + expect(result.prompt).toContain("Staged patch:"); + expect(result.prompt).toContain("diff --git a/README.md b/README.md"); + expect(result.prompt).toContain("Branch: main"); + // Should NOT include the branch generation instruction + expect(result.prompt).not.toContain("branch must be a short semantic git branch fragment"); + }); + + it("includes branch generation instruction when includeBranch is true", () => { + const result = buildCommitMessagePrompt({ + branch: "feature/foo", + stagedSummary: "M README.md", + stagedPatch: "diff", + includeBranch: true, + }); + + expect(result.prompt).toContain("branch must be a short semantic git branch fragment"); + expect(result.prompt).toContain("Return a JSON object with keys: subject, body, branch."); + }); + + it("shows (detached) when branch is null", () => { + const result = buildCommitMessagePrompt({ + branch: null, + stagedSummary: "M a.ts", + stagedPatch: "diff", + includeBranch: false, + }); + + expect(result.prompt).toContain("Branch: (detached)"); + }); +}); + +describe("buildPrContentPrompt", () => { + it("includes branch names, commits, and diff in the prompt", () => { + const result = buildPrContentPrompt({ + baseBranch: "main", + headBranch: "feature/auth", + commitSummary: "feat: add login page", + diffSummary: "3 files changed", + diffPatch: "diff --git a/auth.ts b/auth.ts\n+export function login()", + }); + + expect(result.prompt).toContain("Base branch: main"); + expect(result.prompt).toContain("Head branch: feature/auth"); + expect(result.prompt).toContain("Commits:"); + expect(result.prompt).toContain("feat: add login page"); + expect(result.prompt).toContain("Diff stat:"); + expect(result.prompt).toContain("3 files changed"); + expect(result.prompt).toContain("Diff patch:"); + expect(result.prompt).toContain("export function login()"); + }); +}); + +describe("buildBranchNamePrompt", () => { + it("includes the user message in the prompt", () => { + const result = buildBranchNamePrompt({ + message: "Fix the login timeout bug", + }); + + expect(result.prompt).toContain("User message:"); + expect(result.prompt).toContain("Fix the login timeout bug"); + expect(result.prompt).not.toContain("Attachment metadata:"); + }); + + it("includes attachment metadata when attachments are provided", () => { + const result = buildBranchNamePrompt({ + message: "Fix the layout from screenshot", + attachments: [ + { + type: "image" as const, + id: "att-123", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 12345, + }, + ], + }); + + expect(result.prompt).toContain("Attachment metadata:"); + expect(result.prompt).toContain("screenshot.png"); + expect(result.prompt).toContain("image/png"); + expect(result.prompt).toContain("12345 bytes"); + }); +}); + +describe("normalizeCliError", () => { + it("detects 'Command not found' and includes CLI name in the message", () => { + const error = normalizeCliError( + "claude", + "generateCommitMessage", + new Error("Command not found: claude"), + "Something went wrong", + ); + + expect(error).toBeInstanceOf(TextGenerationError); + expect(error.detail).toContain("Claude CLI"); + expect(error.detail).toContain("not available on PATH"); + }); + + it("uses the CLI name from the first argument for codex", () => { + const error = normalizeCliError( + "codex", + "generateBranchName", + new Error("Command not found: codex"), + "Something went wrong", + ); + + expect(error).toBeInstanceOf(TextGenerationError); + expect(error.detail).toContain("Codex CLI"); + expect(error.detail).toContain("not available on PATH"); + }); + + it("returns the error as-is if it is already a TextGenerationError", () => { + const existing = new TextGenerationError({ + operation: "generatePrContent", + detail: "Already wrapped", + }); + + const result = normalizeCliError("claude", "generatePrContent", existing, "fallback"); + + expect(result).toBe(existing); + }); + + it("wraps unknown non-Error values with the fallback message", () => { + const result = normalizeCliError("codex", "generateCommitMessage", "string error", "fallback"); + + expect(result).toBeInstanceOf(TextGenerationError); + expect(result.detail).toBe("fallback"); + }); +}); diff --git a/apps/server/src/git/Prompts.ts b/apps/server/src/git/Prompts.ts new file mode 100644 index 0000000000..2eacf370eb --- /dev/null +++ b/apps/server/src/git/Prompts.ts @@ -0,0 +1,153 @@ +/** + * Shared prompt builders for text generation providers. + * + * Extracts the prompt construction logic that is identical across + * Codex, Claude, and any future CLI-based text generation backends. + * + * @module textGenerationPrompts + */ +import { Schema } from "effect"; +import type { ChatAttachment } from "@t3tools/contracts"; + +import { limitSection } from "./Utils.ts"; + +// --------------------------------------------------------------------------- +// Commit message +// --------------------------------------------------------------------------- + +export interface CommitMessagePromptInput { + branch: string | null; + stagedSummary: string; + stagedPatch: string; + includeBranch: boolean; +} + +export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { + const wantsBranch = input.includeBranch; + + const prompt = [ + "You write concise git commit messages.", + wantsBranch + ? "Return a JSON object with keys: subject, body, branch." + : "Return a JSON object with keys: subject, body.", + "Rules:", + "- subject must be imperative, <= 72 chars, and no trailing period", + "- body can be empty string or short bullet points", + ...(wantsBranch + ? ["- branch must be a short semantic git branch fragment for this change"] + : []), + "- capture the primary user-visible or developer-visible change", + "", + `Branch: ${input.branch ?? "(detached)"}`, + "", + "Staged files:", + limitSection(input.stagedSummary, 6_000), + "", + "Staged patch:", + limitSection(input.stagedPatch, 40_000), + ].join("\n"); + + if (wantsBranch) { + return { + prompt, + outputSchema: Schema.Struct({ + subject: Schema.String, + body: Schema.String, + branch: Schema.String, + }), + }; + } + + return { + prompt, + outputSchema: Schema.Struct({ + subject: Schema.String, + body: Schema.String, + }), + }; +} + +// --------------------------------------------------------------------------- +// PR content +// --------------------------------------------------------------------------- + +export interface PrContentPromptInput { + baseBranch: string; + headBranch: string; + commitSummary: string; + diffSummary: string; + diffPatch: string; +} + +export function buildPrContentPrompt(input: PrContentPromptInput) { + const prompt = [ + "You write GitHub pull request content.", + "Return a JSON object with keys: title, body.", + "Rules:", + "- title should be concise and specific", + "- body must be markdown and include headings '## Summary' and '## Testing'", + "- under Summary, provide short bullet points", + "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", + "", + `Base branch: ${input.baseBranch}`, + `Head branch: ${input.headBranch}`, + "", + "Commits:", + limitSection(input.commitSummary, 12_000), + "", + "Diff stat:", + limitSection(input.diffSummary, 12_000), + "", + "Diff patch:", + limitSection(input.diffPatch, 40_000), + ].join("\n"); + + const outputSchema = Schema.Struct({ + title: Schema.String, + body: Schema.String, + }); + + return { prompt, outputSchema }; +} + +// --------------------------------------------------------------------------- +// Branch name +// --------------------------------------------------------------------------- + +export interface BranchNamePromptInput { + message: string; + attachments?: ReadonlyArray | undefined; +} + +export function buildBranchNamePrompt(input: BranchNamePromptInput) { + const attachmentLines = (input.attachments ?? []).map( + (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, + ); + + const promptSections = [ + "You generate concise git branch names.", + "Return a JSON object with key: branch.", + "Rules:", + "- Branch should describe the requested work from the user message.", + "- Keep it short and specific (2-6 words).", + "- Use plain words only, no issue prefixes and no punctuation-heavy text.", + "- If images are attached, use them as primary context for visual/UI issues.", + "", + "User message:", + limitSection(input.message, 8_000), + ]; + if (attachmentLines.length > 0) { + promptSections.push( + "", + "Attachment metadata:", + limitSection(attachmentLines.join("\n"), 4_000), + ); + } + + const prompt = promptSections.join("\n"); + const outputSchema = Schema.Struct({ + branch: Schema.String, + }); + + return { prompt, outputSchema }; +} diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index b4650ed570..e9f2230f43 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -8,10 +8,13 @@ */ import { ServiceMap } from "effect"; import type { Effect } from "effect"; -import type { ChatAttachment } from "@t3tools/contracts"; +import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; import type { TextGenerationError } from "../Errors.ts"; +/** Providers that support git text generation (commit messages, PR content, branch names). */ +export type TextGenerationProvider = "codex" | "claudeAgent"; + export interface CommitMessageGenerationInput { cwd: string; branch: string | null; @@ -19,8 +22,8 @@ export interface CommitMessageGenerationInput { stagedPatch: string; /** When true, the model also returns a semantic branch name for the change. */ includeBranch?: boolean; - /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */ - model?: string; + /** What model and provider to use for generation. */ + modelSelection: ModelSelection; } export interface CommitMessageGenerationResult { @@ -37,8 +40,8 @@ export interface PrContentGenerationInput { commitSummary: string; diffSummary: string; diffPatch: string; - /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */ - model?: string; + /** What model and provider to use for generation. */ + modelSelection: ModelSelection; } export interface PrContentGenerationResult { @@ -50,8 +53,8 @@ export interface BranchNameGenerationInput { cwd: string; message: string; attachments?: ReadonlyArray | undefined; - /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */ - model?: string; + /** What model and provider to use for generation. */ + modelSelection: ModelSelection; } export interface BranchNameGenerationResult { diff --git a/apps/server/src/git/Utils.ts b/apps/server/src/git/Utils.ts new file mode 100644 index 0000000000..eb208deccb --- /dev/null +++ b/apps/server/src/git/Utils.ts @@ -0,0 +1,102 @@ +/** + * Shared utilities for text generation layers (Codex, Claude, etc.). + * + * @module textGenerationUtils + */ +import { Schema } from "effect"; + +import { TextGenerationError } from "./Errors.ts"; + +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +export function isGitRepository(cwd: string): boolean { + return existsSync(join(cwd, ".git")); +} + +/** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */ +export function toJsonSchemaObject(schema: Schema.Top): unknown { + const document = Schema.toJsonSchemaDocument(schema); + if (document.definitions && Object.keys(document.definitions).length > 0) { + return { ...document.schema, $defs: document.definitions }; + } + return document.schema; +} + +/** Truncate a text section to `maxChars`, appending a `[truncated]` marker when needed. */ +export function limitSection(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + const truncated = value.slice(0, maxChars); + return `${truncated}\n\n[truncated]`; +} + +/** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */ +export function sanitizeCommitSubject(raw: string): string { + const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; + const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); + if (withoutTrailingPeriod.length === 0) { + return "Update project files"; + } + + if (withoutTrailingPeriod.length <= 72) { + return withoutTrailingPeriod; + } + return withoutTrailingPeriod.slice(0, 72).trimEnd(); +} + +/** Normalise a raw PR title to a single line with a sensible fallback. */ +export function sanitizePrTitle(raw: string): string { + const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; + if (singleLine.length > 0) { + return singleLine; + } + return "Update project changes"; +} + +/** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */ +function cliLabel(cliName: string): string { + const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1); + return `${capitalized} CLI (\`${cliName}\`)`; +} + +/** + * Normalize an unknown error from a CLI text generation process into a + * typed `TextGenerationError`. Parameterized by CLI name so both Codex + * and Claude (and future providers) can share the same logic. + */ +export function normalizeCliError( + cliName: string, + operation: string, + error: unknown, + fallback: string, +): TextGenerationError { + if (Schema.is(TextGenerationError)(error)) { + return error; + } + + if (error instanceof Error) { + const lower = error.message.toLowerCase(); + if ( + error.message.includes(`Command not found: ${cliName}`) || + lower.includes(`spawn ${cliName}`) || + lower.includes("enoent") + ) { + return new TextGenerationError({ + operation, + detail: `${cliLabel(cliName)} is required but not available on PATH.`, + cause: error, + }); + } + return new TextGenerationError({ + operation, + detail: `${fallback}: ${error.message}`, + cause: error, + }); + } + + return new TextGenerationError({ + operation, + detail: fallback, + cause: error, + }); +} diff --git a/apps/server/src/git/isRepo.ts b/apps/server/src/git/isRepo.ts deleted file mode 100644 index 6faf3e99c7..0000000000 --- a/apps/server/src/git/isRepo.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { existsSync } from "node:fs"; -import { join } from "node:path"; - -export function isGitRepository(cwd: string): boolean { - return existsSync(join(cwd, ".git")); -} diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index ab38c10332..e4a673342c 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -24,7 +24,7 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts"; import { CheckpointStoreError } from "../../checkpointing/Errors.ts"; import { OrchestrationDispatchError } from "../Errors.ts"; -import { isGitRepository } from "../../git/isRepo.ts"; +import { isGitRepository } from "../../git/Utils.ts"; type ReactorInput = | { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 9399bcc280..737ad665d7 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1,7 +1,7 @@ import { type ChatAttachment, CommandId, - DEFAULT_GIT_TEXT_GENERATION_MODEL, + DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, EventId, type ModelSelection, type OrchestrationEvent, @@ -437,7 +437,10 @@ const make = Effect.gen(function* () { cwd, message: input.messageText, ...(attachments.length > 0 ? { attachments } : {}), - model: DEFAULT_GIT_TEXT_GENERATION_MODEL, + modelSelection: { + provider: "codex", + model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, + }, }) .pipe( Effect.catch((error) => diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 170b5bc717..0b7af8bd4b 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -20,7 +20,7 @@ import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; -import { isGitRepository } from "../../git/isRepo.ts"; +import { isGitRepository } from "../../git/Utils.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService, diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 1cd8edac26..68fa9e8708 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -30,7 +30,7 @@ import { KeybindingsLive } from "./keybindings"; import { GitManagerLive } from "./git/Layers/GitManager"; import { GitCoreLive } from "./git/Layers/GitCore"; import { GitHubCliLive } from "./git/Layers/GitHubCli"; -import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration"; +import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; import { PtyAdapter } from "./terminal/Services/PTY"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; @@ -85,7 +85,7 @@ export function makeServerProviderLayer(): Layer.Layer< } export function makeServerRuntimeServicesLayer() { - const textGenerationLayer = CodexTextGenerationLive; + const textGenerationLayer = RoutingTextGenerationLive; const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); const orchestrationLayer = OrchestrationEngineLive.pipe( diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index ff95b54112..7dc4a59e7c 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1838,6 +1838,10 @@ describe("WebSocket Server", () => { actionId: "client-action-1", cwd: "/test", action: "commit_push", + modelSelection: { + provider: "codex", + model: "gpt-5.4-mini", + }, }); expect(response.result).toBeUndefined(); expect(response.error?.message).toContain("detached HEAD"); @@ -1846,6 +1850,10 @@ describe("WebSocket Server", () => { actionId: "client-action-1", cwd: "/test", action: "commit_push", + modelSelection: { + provider: "codex", + model: "gpt-5.4-mini", + }, }, expect.objectContaining({ actionId: "client-action-1", @@ -1901,6 +1909,10 @@ describe("WebSocket Server", () => { actionId: "client-action-2", cwd: "/test", action: "commit", + modelSelection: { + provider: "codex", + model: "gpt-5.4-mini", + }, }); const progressPush = await waitForPush(initiatingWs, WS_CHANNELS.gitActionProgress); diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 74d5b29df2..fea74edd72 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -6,17 +6,20 @@ import { DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, DEFAULT_SIDEBAR_THREAD_SORT_ORDER, DEFAULT_TIMESTAMP_FORMAT, + getProviderStartOptions, +} from "./appSettings"; +import { getAppModelOptions, getCustomModelOptionsByProvider, getCustomModelsByProvider, getCustomModelsForProvider, getDefaultCustomModelsForProvider, - getProviderStartOptions, MODEL_PROVIDER_SETTINGS, normalizeCustomModelSlugs, patchCustomModels, + resolveAppModelSelectionState, resolveAppModelSelection, -} from "./appSettings"; +} from "./modelSelection"; describe("normalizeCustomModelSlugs", () => { it("normalizes aliases, removes built-ins, and deduplicates values", () => { @@ -265,3 +268,58 @@ describe("AppSettingsSchema", () => { }); }); }); + +describe("resolveAppModelSelectionState", () => { + it("falls back to the default git-writing codex selection", () => { + expect( + resolveAppModelSelectionState({ + customCodexModels: [], + customClaudeModels: [], + textGenerationModelSelection: undefined, + }), + ).toEqual({ + provider: "codex", + model: "gpt-5.4-mini", + }); + }); + + it("preserves the selected provider and resolves saved custom models", () => { + expect( + resolveAppModelSelectionState({ + customCodexModels: [], + customClaudeModels: ["claude/custom-haiku"], + textGenerationModelSelection: { + provider: "claudeAgent", + model: "claude/custom-haiku", + }, + }), + ).toEqual({ + provider: "claudeAgent", + model: "claude/custom-haiku", + }); + }); + + it("normalizes provider options against the resolved model capabilities", () => { + expect( + resolveAppModelSelectionState({ + customCodexModels: [], + customClaudeModels: [], + textGenerationModelSelection: { + provider: "claudeAgent", + model: "claude-haiku-4-5", + options: { + effort: "max", + thinking: false, + fastMode: true, + }, + }, + }), + ).toEqual({ + provider: "claudeAgent", + model: "claude-haiku-4-5", + options: { + thinking: false, + }, + }); + }); +}); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index be9c376989..e2aac52a84 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,22 +1,15 @@ import { useCallback } from "react"; import { Option, Schema } from "effect"; import { - TrimmedNonEmptyString, - type ProviderKind, + DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, + ModelSelection, type ProviderStartOptions, } from "@t3tools/contracts"; -import { - getDefaultModel, - getModelOptions, - normalizeModelSlug, - resolveSelectableModel, -} from "@t3tools/shared/model"; import { useLocalStorage } from "./hooks/useLocalStorage"; import { EnvMode } from "./components/BranchToolbar.logic"; +import { normalizeCustomModelSlugs } from "./modelSelection"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; -const MAX_CUSTOM_MODEL_COUNT = 32; -export const MAX_CUSTOM_MODEL_LENGTH = 256; export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); export type TimestampFormat = typeof TimestampFormat.Type; @@ -27,21 +20,6 @@ export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "upda export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]); export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; -type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels"; -export type ProviderCustomModelConfig = { - provider: ProviderKind; - settingsKey: CustomModelSettingsKey; - defaultSettingsKey: CustomModelSettingsKey; - title: string; - description: string; - placeholder: string; - example: string; -}; - -const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { - codex: new Set(getModelOptions("codex").map((option) => option.slug)), - claudeAgent: new Set(getModelOptions("claudeAgent").map((option) => option.slug)), -}; const withDefaults = < @@ -73,66 +51,16 @@ export const AppSettingsSchema = Schema.Struct({ timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)), customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), - textGenerationModel: Schema.optional(TrimmedNonEmptyString), + textGenerationModelSelection: ModelSelection.pipe( + withDefaults(() => ({ + provider: "codex" as const, + model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, + })), + ), }); export type AppSettings = typeof AppSettingsSchema.Type; -export interface AppModelOption { - slug: string; - name: string; - isCustom: boolean; -} const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); -const PROVIDER_CUSTOM_MODEL_CONFIG: Record = { - codex: { - provider: "codex", - settingsKey: "customCodexModels", - defaultSettingsKey: "customCodexModels", - title: "Codex", - description: "Save additional Codex model slugs for the picker and `/model` command.", - placeholder: "your-codex-model-slug", - example: "gpt-6.7-codex-ultra-preview", - }, - claudeAgent: { - provider: "claudeAgent", - settingsKey: "customClaudeModels", - defaultSettingsKey: "customClaudeModels", - title: "Claude", - description: "Save additional Claude model slugs for the picker and `/model` command.", - placeholder: "your-claude-model-slug", - example: "claude-sonnet-5-0", - }, -}; -export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG); - -export function normalizeCustomModelSlugs( - models: Iterable, - provider: ProviderKind = "codex", -): string[] { - const normalizedModels: string[] = []; - const seen = new Set(); - const builtInModelSlugs = BUILT_IN_MODEL_SLUGS_BY_PROVIDER[provider]; - - for (const candidate of models) { - const normalized = normalizeModelSlug(candidate, provider); - if ( - !normalized || - normalized.length > MAX_CUSTOM_MODEL_LENGTH || - builtInModelSlugs.has(normalized) || - seen.has(normalized) - ) { - continue; - } - - seen.add(normalized); - normalizedModels.push(normalized); - if (normalizedModels.length >= MAX_CUSTOM_MODEL_COUNT) { - break; - } - } - - return normalizedModels; -} function normalizeAppSettings(settings: AppSettings): AppSettings { return { @@ -142,103 +70,6 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { }; } -export function getCustomModelsForProvider( - settings: Pick, - provider: ProviderKind, -): readonly string[] { - return settings[PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]; -} - -export function getDefaultCustomModelsForProvider( - defaults: Pick, - provider: ProviderKind, -): readonly string[] { - return defaults[PROVIDER_CUSTOM_MODEL_CONFIG[provider].defaultSettingsKey]; -} - -export function patchCustomModels( - provider: ProviderKind, - models: string[], -): Partial> { - return { - [PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]: models, - }; -} - -export function getCustomModelsByProvider( - settings: Pick, -): Record { - return { - codex: getCustomModelsForProvider(settings, "codex"), - claudeAgent: getCustomModelsForProvider(settings, "claudeAgent"), - }; -} - -export function getAppModelOptions( - provider: ProviderKind, - customModels: readonly string[], - selectedModel?: string | null, -): AppModelOption[] { - const options: AppModelOption[] = getModelOptions(provider).map(({ slug, name }) => ({ - slug, - name, - isCustom: false, - })); - const seen = new Set(options.map((option) => option.slug)); - const trimmedSelectedModel = selectedModel?.trim().toLowerCase(); - - for (const slug of normalizeCustomModelSlugs(customModels, provider)) { - if (seen.has(slug)) { - continue; - } - - seen.add(slug); - options.push({ - slug, - name: slug, - isCustom: true, - }); - } - - const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider); - const selectedModelMatchesExistingName = - typeof trimmedSelectedModel === "string" && - options.some((option) => option.name.toLowerCase() === trimmedSelectedModel); - if ( - normalizedSelectedModel && - !seen.has(normalizedSelectedModel) && - !selectedModelMatchesExistingName - ) { - options.push({ - slug: normalizedSelectedModel, - name: normalizedSelectedModel, - isCustom: true, - }); - } - - return options; -} - -export function resolveAppModelSelection( - provider: ProviderKind, - customModels: Record, - selectedModel: string | null | undefined, -): string { - const customModelsForProvider = customModels[provider]; - const options = getAppModelOptions(provider, customModelsForProvider, selectedModel); - return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider); -} - -export function getCustomModelOptionsByProvider( - settings: Pick, -): Record> { - const customModelsByProvider = getCustomModelsByProvider(settings); - return { - codex: getAppModelOptions("codex", customModelsByProvider.codex), - claudeAgent: getAppModelOptions("claudeAgent", customModelsByProvider.claudeAgent), - }; -} - export function getProviderStartOptions( settings: Pick, ): ProviderStartOptions | undefined { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fbc887bf62..ace74a5cc8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -120,13 +120,12 @@ import { import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; +import { getProviderStartOptions, useAppSettings } from "../appSettings"; import { getCustomModelOptionsByProvider, getCustomModelsByProvider, - getProviderStartOptions, resolveAppModelSelection, - useAppSettings, -} from "../appSettings"; +} from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; import { type ComposerImageAttachment, @@ -642,8 +641,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]); const selectedModelForPicker = selectedModel; const modelOptionsByProvider = useMemo( - () => getCustomModelOptionsByProvider(settings), - [settings], + () => getCustomModelOptionsByProvider(settings, selectedProvider, selectedModel), + [settings, selectedProvider, selectedModel], ); const selectedModelForPickerWithCustomFallback = useMemo(() => { const currentOptions = modelOptionsByProvider[selectedProvider]; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 4d563d77fd..79fb1ff11e 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -261,7 +261,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient, - model: settings.textGenerationModel ?? null, + modelSelection: settings.textGenerationModelSelection, }), ); const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 1aef9c46c1..fad008eba7 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -21,6 +21,7 @@ async function mountPicker(props: { provider: ProviderKind; model: ModelSlug; lockedProvider: ProviderKind | null; + triggerVariant?: "ghost" | "outline"; }) { const host = document.createElement("div"); document.body.append(host); @@ -31,6 +32,7 @@ async function mountPicker(props: { model={props.model} lockedProvider={props.lockedProvider} modelOptionsByProvider={MODEL_OPTIONS_BY_PROVIDER} + triggerVariant={props.triggerVariant} onProviderModelChange={onProviderModelChange} />, { container: host }, @@ -156,4 +158,24 @@ describe("ProviderModelPicker", () => { await mounted.cleanup(); } }); + + it("accepts outline trigger styling", async () => { + const mounted = await mountPicker({ + provider: "codex", + model: "gpt-5-codex", + lockedProvider: null, + triggerVariant: "outline", + }); + + try { + const button = document.querySelector("button"); + if (!(button instanceof HTMLButtonElement)) { + throw new Error("Expected picker trigger button to be rendered."); + } + expect(button.className).toContain("border-input"); + expect(button.className).toContain("bg-popover"); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 2fc20a9731..ccf756fec6 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,9 +1,10 @@ import { type ModelSlug, type ProviderKind } from "@t3tools/contracts"; import { resolveSelectableModel } from "@t3tools/shared/model"; import { memo, useState } from "react"; +import type { VariantProps } from "class-variance-authority"; import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic"; import { ChevronDownIcon } from "lucide-react"; -import { Button } from "../ui/button"; +import { Button, buttonVariants } from "../ui/button"; import { Menu, MenuGroup, @@ -56,6 +57,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { activeProviderIconClassName?: string; compact?: boolean; disabled?: boolean; + triggerVariant?: VariantProps["variant"]; + triggerClassName?: string; onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; }) { const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -92,10 +95,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { render={