diff --git a/CLAUDE.md b/CLAUDE.md index c3170642553f..47dc3e3d863c 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md +AGENTS.md \ No newline at end of file diff --git a/REMOTE.md b/REMOTE.md index 5eed2f803e2c..a08948b9131c 100644 --- a/REMOTE.md +++ b/REMOTE.md @@ -6,21 +6,24 @@ Use this when you want to open T3 Code from another device (phone, tablet, anoth The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables: -| CLI flag | Env var | Notes | -| ----------------------- | --------------------- | ---------------------------------- | -| `--mode ` | `T3CODE_MODE` | Runtime mode. | -| `--port ` | `T3CODE_PORT` | HTTP/WebSocket port. | -| `--host
` | `T3CODE_HOST` | Bind interface/address. | -| `--base-dir ` | `T3CODE_HOME` | Base directory. | -| `--dev-url ` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. | -| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. | -| `--auth-token ` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. | +| CLI flag | Env var | Notes | +| ----------------------- | --------------------- | ------------------------------------------------------------------------------------ | +| `--mode ` | `T3CODE_MODE` | Runtime mode. | +| `--port ` | `T3CODE_PORT` | HTTP/WebSocket port. | +| `--host
` | `T3CODE_HOST` | Bind interface/address. | +| `--base-dir ` | `T3CODE_HOME` | Base directory. | +| `--dev-url ` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. | +| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. | +| `--auth-token ` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. Use this for standard CLI and remote-server flows. | +| `--bootstrap-fd ` | `T3CODE_BOOTSTRAP_FD` | Read a one-shot bootstrap envelope from an inherited file descriptor during startup. | > TIP: Use the `--help` flag to see all available options and their descriptions. ## Security First - Always set `--auth-token` before exposing the server outside localhost. + - When you control the process launcher, prefer sending the auth token in a JSON envelope via `--bootstrap-fd `. + With `--bootstrap-fd `, the launcher starts the server first, then sends a one-shot JSON envelope over the inherited file descriptor. This allows the auth token to be delivered without putting it in process environment or command-line arguments. - Treat the token like a password. - Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed. diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 6e7b9434156c..65083f035afb 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -61,6 +61,7 @@ const LOG_DIR_CHANNEL = "desktop:log-dir"; const LOG_LIST_CHANNEL = "desktop:log-list"; const LOG_READ_CHANNEL = "desktop:log-read"; const LOG_OPEN_DIR_CHANNEL = "desktop:log-open-dir"; +const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); const STATE_DIR = Path.join(BASE_DIR, "userdata"); const DESKTOP_SCHEME = "t3"; @@ -118,6 +119,17 @@ function sanitizeLogValue(value: string): string { return value.replace(/\s+/g, " ").trim(); } +function backendChildEnv(): NodeJS.ProcessEnv { + const env = { ...process.env }; + delete env.T3CODE_PORT; + delete env.T3CODE_AUTH_TOKEN; + delete env.T3CODE_MODE; + delete env.T3CODE_NO_BROWSER; + delete env.T3CODE_HOST; + delete env.T3CODE_DESKTOP_WS_URL; + return env; +} + function writeDesktopLogHeader(message: string): void { if (!desktopLogSink) return; desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`); @@ -923,17 +935,6 @@ function configureAutoUpdater(): void { }, AUTO_UPDATE_POLL_INTERVAL_MS); updatePollTimer.unref(); } -function backendEnv(): NodeJS.ProcessEnv { - return { - ...process.env, - T3CODE_MODE: "desktop", - T3CODE_NO_BROWSER: "1", - T3CODE_PORT: String(backendPort), - T3CODE_HOME: BASE_DIR, - T3CODE_AUTH_TOKEN: backendAuthToken, - }; -} - function scheduleBackendRestart(reason: string): void { if (isQuitting || restartTimer) return; @@ -957,16 +958,35 @@ function startBackend(): void { } const captureBackendLogs = app.isPackaged && backendLogSink !== null; - const child = ChildProcess.spawn(process.execPath, [backendEntry], { + const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], { cwd: resolveBackendCwd(), // In Electron main, process.execPath points to the Electron binary. // Run the child in Node mode so this backend process does not become a GUI app instance. env: { - ...backendEnv(), + ...backendChildEnv(), ELECTRON_RUN_AS_NODE: "1", }, - stdio: captureBackendLogs ? ["ignore", "pipe", "pipe"] : "inherit", + stdio: captureBackendLogs + ? ["ignore", "pipe", "pipe", "pipe"] + : ["ignore", "inherit", "inherit", "pipe"], }); + const bootstrapStream = child.stdio[3]; + if (bootstrapStream && "write" in bootstrapStream) { + bootstrapStream.write( + `${JSON.stringify({ + mode: "desktop", + noBrowser: true, + port: backendPort, + t3Home: BASE_DIR, + authToken: backendAuthToken, + })}\n`, + ); + bootstrapStream.end(); + } else { + child.kill("SIGTERM"); + scheduleBackendRestart("missing desktop bootstrap pipe"); + return; + } backendProcess = child; let backendSessionClosed = false; const closeBackendSession = (details: string) => { @@ -1077,6 +1097,11 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise { } function registerIpcHandlers(): void { + ipcMain.removeAllListeners(GET_WS_URL_CHANNEL); + ipcMain.on(GET_WS_URL_CHANNEL, (event) => { + event.returnValue = backendWsUrl; + }); + ipcMain.removeHandler(PICK_FOLDER_CHANNEL); ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; @@ -1368,9 +1393,9 @@ async function bootstrap(): Promise { ); writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`); backendAuthToken = Crypto.randomBytes(24).toString("hex"); - backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`; - process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl; - writeDesktopLogHeader(`bootstrap resolved websocket url=${backendWsUrl}`); + const baseUrl = `ws://127.0.0.1:${backendPort}`; + backendWsUrl = `${baseUrl}/?token=${encodeURIComponent(backendAuthToken)}`; + writeDesktopLogHeader(`bootstrap resolved websocket endpoint baseUrl=${baseUrl}`); registerIpcHandlers(); writeDesktopLogHeader("bootstrap ipc handlers registered"); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 860e3f00a8e6..f348ea1a7931 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -16,10 +16,13 @@ const LOG_DIR_CHANNEL = "desktop:log-dir"; const LOG_LIST_CHANNEL = "desktop:log-list"; const LOG_READ_CHANNEL = "desktop:log-read"; const LOG_OPEN_DIR_CHANNEL = "desktop:log-open-dir"; -const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; +const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; contextBridge.exposeInMainWorld("desktopBridge", { - getWsUrl: () => wsUrl, + getWsUrl: () => { + const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL); + return typeof result === "string" && result !== "" ? result : null; + }, pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts new file mode 100644 index 000000000000..804f2440a918 --- /dev/null +++ b/apps/server/src/bootstrap.test.ts @@ -0,0 +1,96 @@ +import * as NFS from "node:fs"; +import * as path from "node:path"; +import { execFileSync, spawn } from "node:child_process"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { FileSystem, Schema } from "effect"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import { TestClock } from "effect/testing"; + +import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap"; +import { assertNone, assertSome } from "@effect/vitest/utils"; + +const TestEnvelopeSchema = Schema.Struct({ mode: Schema.String }); + +it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { + it.effect("uses platform-specific fd paths", () => + Effect.sync(() => { + assert.equal(resolveFdPath(3, "linux"), "/proc/self/fd/3"); + assert.equal(resolveFdPath(3, "darwin"), "/dev/fd/3"); + assert.equal(resolveFdPath(3, "win32"), undefined); + }), + ); + + it.effect("reads a bootstrap envelope from a provided fd", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); + + yield* fs.writeFileString( + filePath, + `${yield* Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema))({ + mode: "desktop", + })}\n`, + ); + + const fd = yield* Effect.acquireRelease( + Effect.sync(() => NFS.openSync(filePath, "r")), + (fd) => Effect.sync(() => NFS.closeSync(fd)), + ); + + const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); + assertSome(payload, { + mode: "desktop", + }); + }), + ); + + it.effect("returns none when the fd is unavailable", () => + Effect.gen(function* () { + const fd = NFS.openSync("/dev/null", "r"); + NFS.closeSync(fd); + + const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); + assertNone(payload); + }), + ); + + it.effect("returns none when the bootstrap read times out before any value arrives", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bootstrap-" }); + const fifoPath = path.join(tempDir, "bootstrap.pipe"); + + yield* Effect.sync(() => execFileSync("mkfifo", [fifoPath])); + + const _writer = yield* Effect.acquireRelease( + Effect.sync(() => + spawn("sh", ["-c", 'exec 3>"$1"; sleep 60', "sh", fifoPath], { + stdio: ["ignore", "ignore", "ignore"], + }), + ), + (writer) => + Effect.sync(() => { + writer.kill("SIGKILL"); + }), + ); + + const fd = yield* Effect.acquireRelease( + Effect.sync(() => NFS.openSync(fifoPath, "r")), + (fd) => Effect.sync(() => NFS.closeSync(fd)), + ); + + const fiber = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { + timeoutMs: 100, + }).pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(100)); + + const payload = yield* Fiber.join(fiber); + assertNone(payload); + }).pipe(Effect.provide(TestClock.layer())), + ); +}); diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts new file mode 100644 index 000000000000..b837ac6c1849 --- /dev/null +++ b/apps/server/src/bootstrap.ts @@ -0,0 +1,145 @@ +import * as NFS from "node:fs"; +import * as Net from "node:net"; +import * as readline from "node:readline"; +import type { Readable } from "node:stream"; + +import { Data, Effect, Option, Predicate, Result, Schema } from "effect"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +class BootstrapError extends Data.TaggedError("BootstrapError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function* ( + schema: Schema.Codec, + fd: number, + options?: { + timeoutMs?: number; + }, +): Effect.fn.Return, BootstrapError> { + const fdReady = yield* isFdReady(fd); + if (!fdReady) return Option.none(); + + const stream = yield* makeBootstrapInputStream(fd); + + const timeoutMs = options?.timeoutMs ?? 1000; + + return yield* Effect.callback, BootstrapError>((resume) => { + const input = readline.createInterface({ + input: stream, + crlfDelay: Infinity, + }); + + const cleanup = () => { + stream.removeListener("error", handleError); + input.removeListener("line", handleLine); + input.removeListener("close", handleClose); + input.close(); + stream.destroy(); + }; + + const handleError = (error: Error) => { + if (isUnavailableBootstrapFdError(error)) { + resume(Effect.succeedNone); + return; + } + resume( + Effect.fail( + new BootstrapError({ + message: "Failed to read bootstrap envelope.", + cause: error, + }), + ), + ); + }; + + const handleLine = (line: string) => { + const parsed = decodeJsonResult(schema)(line); + if (Result.isSuccess(parsed)) { + resume(Effect.succeedSome(parsed.success)); + } else { + resume( + Effect.fail( + new BootstrapError({ + message: "Failed to decode bootstrap envelope.", + cause: parsed.failure, + }), + ), + ); + } + }; + + const handleClose = () => { + resume(Effect.succeedNone); + }; + + stream.once("error", handleError); + input.once("line", handleLine); + input.once("close", handleClose); + + return Effect.sync(cleanup); + }).pipe(Effect.timeoutOption(timeoutMs), Effect.map(Option.flatten)); +}); + +const isUnavailableBootstrapFdError = Predicate.compose( + Predicate.hasProperty("code"), + (_) => _.code === "EBADF" || _.code === "ENOENT", +); + +const isFdReady = (fd: number) => + Effect.try({ + try: () => NFS.fstatSync(fd), + catch: (error) => + new BootstrapError({ + message: "Failed to stat bootstrap fd.", + cause: error, + }), + }).pipe( + Effect.as(true), + Effect.catchIf( + (error) => isUnavailableBootstrapFdError(error.cause), + () => Effect.succeed(false), + ), + ); + +const makeBootstrapInputStream = (fd: number) => + Effect.try({ + try: () => { + const fdPath = resolveFdPath(fd); + if (fdPath === undefined) { + const stream = new Net.Socket({ + fd, + readable: true, + writable: false, + }); + stream.setEncoding("utf8"); + return stream; + } + + const streamFd = NFS.openSync(fdPath, "r"); + return NFS.createReadStream("", { + fd: streamFd, + encoding: "utf8", + autoClose: true, + }); + }, + catch: (error) => + new BootstrapError({ + message: "Failed to duplicate bootstrap fd.", + cause: error, + }), + }); + +export function resolveFdPath( + fd: number, + platform: NodeJS.Platform = process.platform, +): string | undefined { + if (platform === "linux") { + return `/proc/self/fd/${fd}`; + } + if (platform === "win32") { + return undefined; + } + return `/dev/fd/${fd}`; +} 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 000000000000..0a3829798e18 --- /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 000000000000..9f48a07c5100 --- /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 0170d207fee8..b53d7f15bd59 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 3ab20c22a63c..afe972ab4a69 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -1,8 +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 { CodexModelSelection } from "@t3tools/contracts"; +import { normalizeCodexModelOptions } from "@t3tools/shared/model"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -10,91 +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_MODEL = "gpt-5.3-codex"; -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,6 +120,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { outputSchemaJson, imagePaths = [], cleanupPaths = [], + modelSelection, }: { operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; cwd: string; @@ -194,16 +128,23 @@ const makeCodexTextGeneration = Effect.gen(function* () { outputSchemaJson: S; imagePaths?: ReadonlyArray; cleanupPaths?: ReadonlyArray; + 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", [ @@ -212,9 +153,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { "-s", "read-only", "--model", - CODEX_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", @@ -226,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)), }, }, ); @@ -235,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"), ), ); @@ -244,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"), ), ), ], @@ -312,150 +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, - }).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, - }), - }).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, + 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 3f35f7125052..6dfc2744c5b3 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -34,6 +34,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; @@ -471,6 +476,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 fb6b6dec6e8f..98d3b648c69a 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, @@ -706,6 +706,7 @@ export const makeGitManager = Effect.gen(function* () { /** Provider model to use for text generation. */ model?: string | undefined; filePaths?: readonly string[]; + modelSelection: ModelSelection; }) => Effect.gen(function* () { const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths); @@ -735,6 +736,7 @@ export const makeGitManager = Effect.gen(function* () { ...(input.provider ? { provider: input.provider } : {}), ...(input.model ? { model: input.model } : {}), ...(input.includeBranch ? { includeBranch: true } : {}), + modelSelection: input.modelSelection, }) .pipe(Effect.map((result) => sanitizeCommitMessage(result))); @@ -747,6 +749,7 @@ export const makeGitManager = Effect.gen(function* () { }); const runCommitStep = ( + modelSelection: ModelSelection, cwd: string, action: "commit" | "commit_push" | "commit_push_pr", branch: string | null, @@ -786,6 +789,7 @@ export const makeGitManager = Effect.gen(function* () { provider, model, ...(filePaths ? { filePaths } : {}), + modelSelection, }); } if (!suggestion) { @@ -863,6 +867,7 @@ export const makeGitManager = Effect.gen(function* () { }); const runPrStep = ( + modelSelection: ModelSelection, cwd: string, fallbackBranch: string | null, provider?: ProviderKind | undefined, @@ -914,6 +919,7 @@ export const makeGitManager = Effect.gen(function* () { diffPatch: limitContext(rangeContext.diffPatch, 60_000), ...(provider ? { provider } : {}), ...(model ? { model } : {}), + modelSelection, }); const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`); @@ -1135,6 +1141,7 @@ export const makeGitManager = Effect.gen(function* () { ); const runFeatureBranchStep = ( + modelSelection: ModelSelection, cwd: string, branch: string | null, commitMessage?: string, @@ -1151,6 +1158,7 @@ export const makeGitManager = Effect.gen(function* () { includeBranch: true, provider, model, + modelSelection, }); if (!suggestion) { return yield* gitManagerError( @@ -1216,6 +1224,7 @@ export const makeGitManager = Effect.gen(function* () { label: "Preparing feature branch...", }); const result = yield* runFeatureBranchStep( + input.modelSelection, input.cwd, initialStatus.branch, input.commitMessage, @@ -1234,6 +1243,7 @@ export const makeGitManager = Effect.gen(function* () { currentPhase = "commit"; const commit = yield* runCommitStep( + input.modelSelection, input.cwd, input.action, currentBranch, @@ -1274,7 +1284,13 @@ export const makeGitManager = Effect.gen(function* () { Effect.flatMap(() => Effect.gen(function* () { currentPhase = "pr"; - return yield* runPrStep(input.cwd, currentBranch, input.provider, input.model); + return yield* runPrStep( + input.modelSelection, + input.cwd, + currentBranch, + input.provider, + input.model, + ); }), ), ) diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts new file mode 100644 index 000000000000..48ac9fcbd3d1 --- /dev/null +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -0,0 +1,78 @@ +/** + * RoutingTextGeneration – Dispatches text generation requests to the + * appropriate CLI implementation based on the provider in each request input. + * + * Currently supported providers: + * - `"claudeAgent"` → Claude CLI layer + * - `"codex"` → Codex CLI layer (also the default fallback) + * + * Providers without a dedicated CLI text-generation layer (copilot, cursor, + * opencode, geminiCli, amp, kilo) fall back to Codex. When a dedicated + * layer is added for one of those providers, add a route here. + * + * @module RoutingTextGeneration + */ +import { Effect, Layer, ServiceMap } from "effect"; + +import type { ProviderKind } from "@t3tools/contracts"; +import { TextGeneration, type TextGenerationShape } from "../Services/TextGeneration.ts"; +import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; +import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; + +// --------------------------------------------------------------------------- +// Supported git text-generation providers. Providers not in this set fall +// back to codex (the most broadly compatible CLI implementation). +// --------------------------------------------------------------------------- + +const GIT_TEXT_GEN_PROVIDERS = new Set(["codex", "claudeAgent"]); + +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?: ProviderKind): TextGenerationShape => { + if (!provider || !GIT_TEXT_GEN_PROVIDERS.has(provider)) return codex; + if (provider === "claudeAgent") return claude; + return 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 000000000000..23c3eca557d5 --- /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 000000000000..2eacf370ebb3 --- /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 22942ff79ad8..6139696f338a 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, ProviderKind } from "@t3tools/contracts"; +import type { ChatAttachment, ModelSelection, ProviderKind } 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; @@ -21,6 +24,8 @@ export interface CommitMessageGenerationInput { model?: string | undefined; /** When true, the model also returns a semantic branch name for the change. */ includeBranch?: boolean; + /** What model and provider to use for generation. */ + modelSelection: ModelSelection; } export interface CommitMessageGenerationResult { @@ -39,6 +44,8 @@ export interface PrContentGenerationInput { diffPatch: string; provider?: ProviderKind | undefined; model?: string | undefined; + /** What model and provider to use for generation. */ + modelSelection: ModelSelection; } export interface PrContentGenerationResult { @@ -52,6 +59,8 @@ export interface BranchNameGenerationInput { provider?: ProviderKind | undefined; model?: string | undefined; attachments?: ReadonlyArray | undefined; + /** 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 000000000000..eb208deccbbb --- /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 6faf3e99c77b..000000000000 --- 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/main.test.ts b/apps/server/src/main.test.ts index dc90a44bb2c9..2d54bbbd72b2 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -4,6 +4,7 @@ import { assert, it, vi } from "@effect/vitest"; import type { OrchestrationReadModel } from "@t3tools/contracts"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Command from "effect/unstable/cli/Command"; import { FetchHttpClient } from "effect/unstable/http"; @@ -154,6 +155,99 @@ it.layer(testLayer)("server CLI command", (it) => { }), ); + const openBootstrapFd = Effect.fn(function* (payload: Record) { + const fs = yield* FileSystem.FileSystem; + const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); + yield* fs.writeFileString(filePath, `${JSON.stringify(payload)}\n`); + const { fd } = yield* fs.open(filePath, { flag: "r" }); + return fd; + }); + + it.effect("recognizes bootstrap fd from environment config", () => + Effect.gen(function* () { + const fd = yield* openBootstrapFd({ authToken: "bootstrap-token" }); + + yield* runCli([], { + T3CODE_MODE: "web", + T3CODE_BOOTSTRAP_FD: String(fd), + T3CODE_AUTH_TOKEN: "env-token", + T3CODE_NO_BROWSER: "true", + }); + + assert.equal(start.mock.calls.length, 1); + assert.equal(resolvedConfig?.mode, "web"); + assert.equal(resolvedConfig?.authToken, "env-token"); + }), + ); + + it.effect("uses bootstrap envelope values as fallbacks when CLI and env are absent", () => + Effect.gen(function* () { + const fd = yield* openBootstrapFd({ + mode: "desktop", + port: 4888, + host: "127.0.0.2", + t3Home: "/tmp/t3-bootstrap-home", + devUrl: "http://127.0.0.1:5173", + noBrowser: true, + authToken: "bootstrap-token", + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: true, + }); + + yield* runCli([], { + T3CODE_BOOTSTRAP_FD: String(fd), + }); + + assert.equal(start.mock.calls.length, 1); + assert.equal(resolvedConfig?.mode, "desktop"); + assert.equal(resolvedConfig?.port, 4888); + assert.equal(resolvedConfig?.host, "127.0.0.2"); + assert.equal(resolvedConfig?.baseDir, "/tmp/t3-bootstrap-home"); + assert.equal(resolvedConfig?.stateDir, "/tmp/t3-bootstrap-home/dev"); + assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/"); + assert.equal(resolvedConfig?.noBrowser, true); + assert.equal(resolvedConfig?.authToken, "bootstrap-token"); + assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false); + assert.equal(resolvedConfig?.logWebSocketEvents, true); + }), + ); + + it.effect("applies CLI then env precedence over bootstrap envelope values", () => + Effect.gen(function* () { + const fd = yield* openBootstrapFd({ + mode: "desktop", + port: 4888, + host: "127.0.0.2", + t3Home: "/tmp/t3-bootstrap-home", + devUrl: "http://127.0.0.1:5173", + noBrowser: false, + authToken: "bootstrap-token", + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + }); + + yield* runCli(["--port", "4999", "--host", "0.0.0.0", "--auth-token", "cli-token"], { + T3CODE_MODE: "web", + T3CODE_BOOTSTRAP_FD: String(fd), + T3CODE_HOME: "/tmp/t3-env-home", + T3CODE_NO_BROWSER: "true", + T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true", + T3CODE_LOG_WS_EVENTS: "true", + }); + + assert.equal(start.mock.calls.length, 1); + assert.equal(resolvedConfig?.mode, "web"); + assert.equal(resolvedConfig?.port, 4999); + assert.equal(resolvedConfig?.host, "0.0.0.0"); + assert.equal(resolvedConfig?.baseDir, "/tmp/t3-env-home"); + assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/"); + assert.equal(resolvedConfig?.noBrowser, true); + assert.equal(resolvedConfig?.authToken, "cli-token"); + assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, true); + assert.equal(resolvedConfig?.logWebSocketEvents, true); + }), + ); + it.effect("prefers --mode over T3CODE_MODE", () => Effect.gen(function* () { findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(4666)); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 17bf7f32f7d3..5b212528846e 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -27,12 +27,27 @@ import { Server } from "./wsServer"; import { ServerLoggerLive } from "./serverLogger"; import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; +import { readBootstrapEnvelope } from "./bootstrap"; export class StartupError extends Data.TaggedError("StartupError")<{ readonly message: string; readonly cause?: unknown; }> {} +const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); + +const BootstrapEnvelopeSchema = Schema.Struct({ + mode: Schema.optional(Schema.String), + port: Schema.optional(PortSchema), + host: Schema.optional(Schema.String), + t3Home: Schema.optional(Schema.String), + devUrl: Schema.optional(Schema.URLFromString), + noBrowser: Schema.optional(Schema.Boolean), + authToken: Schema.optional(Schema.String), + autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), + logWebSocketEvents: Schema.optional(Schema.Boolean), +}); + interface CliInput { readonly mode: Option.Option; readonly port: Option.Option; @@ -41,6 +56,7 @@ interface CliInput { readonly devUrl: Option.Option; readonly noBrowser: Option.Option; readonly authToken: Option.Option; + readonly bootstrapFd: Option.Option; readonly autoBootstrapProjectFromCwd: Option.Option; readonly logWebSocketEvents: Option.Option; } @@ -91,12 +107,8 @@ export class CliConfig extends ServiceMap.Service()( const CliEnvConfig = Config.all({ mode: Config.string("T3CODE_MODE").pipe( Config.option, - Config.map( - Option.match({ - onNone: () => "web", - onSome: (value) => (value === "desktop" ? "desktop" : "web"), - }), - ), + Config.map(Option.map((value) => (value === "desktop" ? "desktop" : "web"))), + Config.map(Option.getOrUndefined), ), port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), @@ -110,6 +122,10 @@ const CliEnvConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), + bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -123,6 +139,14 @@ const CliEnvConfig = Config.all({ const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) => Option.getOrElse(Option.filter(flag, Boolean), () => envValue); +const resolveOptionPrecedence = ( + ...values: ReadonlyArray> +): Option.Option => Option.firstSomeOf(values); + +const isValidPort = (value: number): boolean => value >= 1 && value <= 65_535; +const isRuntimeMode = (value: string): value is RuntimeMode => + value === "web" || value === "desktop"; + const ServerConfigLive = (input: CliInput) => Layer.effect( ServerConfig, @@ -136,39 +160,115 @@ const ServerConfigLive = (input: CliInput) => ), ); - const mode = Option.getOrElse(input.mode, () => env.mode); - - const port = yield* Option.match(input.port, { - onSome: (value) => Effect.succeed(value), - onNone: () => { - if (env.port) { - return Effect.succeed(env.port); - } - if (mode === "desktop") { - return Effect.succeed(DEFAULT_PORT); - } - return findAvailablePort(DEFAULT_PORT); + const bootstrapFd = Option.getOrUndefined(input.bootstrapFd) ?? env.bootstrapFd; + const bootstrapEnvelope = + bootstrapFd !== undefined + ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd) + : Option.none(); + + const mode: RuntimeMode = Option.getOrElse( + resolveOptionPrecedence( + input.mode, + Option.fromUndefinedOr(env.mode), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.filter(Option.fromUndefinedOr(bootstrap.mode), isRuntimeMode), + ), + ), + () => "web", + ); + const port = yield* Option.match( + resolveOptionPrecedence( + input.port, + Option.fromUndefinedOr(env.port), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.filter(Option.fromUndefinedOr(bootstrap.port), isValidPort), + ), + ), + { + onSome: (value) => Effect.succeed(value), + onNone: () => { + if (mode === "desktop") { + return Effect.succeed(DEFAULT_PORT); + } + return findAvailablePort(DEFAULT_PORT); + }, }, - }); + ); - const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl); - const baseDir = yield* resolveBaseDir(Option.getOrUndefined(input.t3Home) ?? env.t3Home); + const devUrl = Option.getOrElse( + resolveOptionPrecedence( + input.devUrl, + Option.fromUndefinedOr(env.devUrl), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.devUrl), + ), + ), + () => undefined, + ); + const baseDir = yield* resolveBaseDir( + Option.getOrUndefined( + resolveOptionPrecedence( + input.t3Home, + Option.fromUndefinedOr(env.t3Home), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.t3Home), + ), + ), + ), + ); const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); - const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop"); - const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; + const noBrowser = resolveBooleanFlag( + input.noBrowser, + Option.getOrElse( + resolveOptionPrecedence( + Option.fromUndefinedOr(env.noBrowser), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.noBrowser), + ), + ), + () => mode === "desktop", + ), + ); + const authToken = resolveOptionPrecedence( + input.authToken, + Option.fromUndefinedOr(env.authToken), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.authToken), + ), + ); const autoBootstrapProjectFromCwd = resolveBooleanFlag( input.autoBootstrapProjectFromCwd, - env.autoBootstrapProjectFromCwd ?? mode === "web", + Option.getOrElse( + resolveOptionPrecedence( + Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.autoBootstrapProjectFromCwd), + ), + ), + () => mode === "web", + ), ); const logWebSocketEvents = resolveBooleanFlag( input.logWebSocketEvents, - env.logWebSocketEvents ?? Boolean(devUrl), + Option.getOrElse( + resolveOptionPrecedence( + Option.fromUndefinedOr(env.logWebSocketEvents), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.logWebSocketEvents), + ), + ), + () => Boolean(devUrl), + ), ); const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; - const host = - Option.getOrUndefined(input.host) ?? - env.host ?? - (mode === "desktop" ? "127.0.0.1" : undefined); + const host = Option.getOrElse( + resolveOptionPrecedence( + input.host, + Option.fromUndefinedOr(env.host), + Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.host)), + ), + () => (mode === "desktop" ? "127.0.0.1" : undefined), + ); const config: ServerConfigShape = { mode, @@ -180,7 +280,7 @@ const ServerConfigLive = (input: CliInput) => staticDir, devUrl, noBrowser, - authToken, + authToken: Option.getOrUndefined(authToken), autoBootstrapProjectFromCwd, logWebSocketEvents, } satisfies ServerConfigShape; @@ -287,7 +387,7 @@ const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe( Flag.optional, ); const portFlag = Flag.integer("port").pipe( - Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), + Flag.withSchema(PortSchema), Flag.withDescription("Port for the HTTP/WebSocket server."), Flag.optional, ); @@ -313,6 +413,11 @@ const authTokenFlag = Flag.string("auth-token").pipe( Flag.withAlias("token"), Flag.optional, ); +const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( + Flag.withSchema(Schema.Int), + Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), + Flag.optional, +); const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( Flag.withDescription( "Create a project for the current working directory on startup when missing.", @@ -335,6 +440,7 @@ export const t3Cli = Command.make("t3", { devUrl: devUrlFlag, noBrowser: noBrowserFlag, authToken: authTokenFlag, + bootstrapFd: bootstrapFdFlag, autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, logWebSocketEvents: logWebSocketEventsFlag, }).pipe( diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index ab38c103328d..e4a673342c59 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 4489cadc71ee..ed4908305494 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1,6 +1,7 @@ import { type ChatAttachment, CommandId, + DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, EventId, type ModelSelection, type OrchestrationEvent, @@ -458,6 +459,10 @@ const make = Effect.gen(function* () { cwd, message: input.messageText, ...(attachments.length > 0 ? { attachments } : {}), + 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 6d9674452ea9..388adfee3548 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 cd508880b162..9fc87977af6b 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -36,7 +36,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 { SessionTextGenerationLive } from "./git/Layers/SessionTextGeneration"; import { PtyAdapter } from "./terminal/Services/PTY"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; @@ -108,7 +108,7 @@ export function makeServerProviderLayer(): Layer.Layer< } export function makeServerRuntimeServicesLayer() { - const textGenerationLayer = CodexTextGenerationLive; + const textGenerationLayer = RoutingTextGenerationLive; const sessionTextGenerationLayer = SessionTextGenerationLive; const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 721be687d819..5953f5a6b228 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1839,6 +1839,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"); @@ -1847,6 +1851,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", @@ -1902,6 +1910,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 9ac421f49ead..cf3548beeff4 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -6,13 +6,15 @@ import { DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, DEFAULT_SIDEBAR_THREAD_SORT_ORDER, DEFAULT_TIMESTAMP_FORMAT, + getProviderStartOptions, +} from "./appSettings"; +import { getAppModelOptions, getAppSettingsSnapshot, getCustomModelOptionsByProvider, getCustomModelsByProvider, getCustomModelsForProvider, getDefaultCustomModelsForProvider, - getProviderStartOptions, MODEL_PROVIDER_SETTINGS, normalizeCustomModelSlugs, patchCustomModels, @@ -431,3 +433,8 @@ describe("AppSettingsSchema", () => { }); }); }); + +// Note: upstream's resolveAppModelSelectionState tests removed — the fork +// uses resolveGitTextGenerationModelSelection with per-provider overrides +// instead of a single textGenerationModelSelection field. Equivalent +// coverage lives in the resolveGitTextGenerationModelSelection tests above. diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 10f4a08337ba..a69c36691dae 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -2,7 +2,6 @@ import { useCallback, useMemo } from "react"; import { Option, Schema } from "effect"; import { DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, - TrimmedNonEmptyString, type ProviderKind, type ProviderStartOptions, } from "@t3tools/contracts"; @@ -14,7 +13,6 @@ import { } from "@t3tools/shared/model"; import { DEFAULT_ACCENT_COLOR, isValidAccentColor, normalizeAccentColor } from "./accentColor"; import { useLocalStorage } from "./hooks/useLocalStorage"; -import { EnvMode } from "./components/BranchToolbar.logic"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3ceedf9dfe05..2f5864a5bc91 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -128,12 +128,8 @@ import { import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -import { - getCustomModelsByProvider, - getProviderStartOptions, - resolveAppModelSelection, - useAppSettings, -} from "../appSettings"; +import { getProviderStartOptions, useAppSettings } from "../appSettings"; +import { getCustomModelsByProvider, resolveAppModelSelection } from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; import { type ComposerImageAttachment, @@ -3736,7 +3732,7 @@ export default function ChatView({ threadId }: ChatViewProps) { >
) : ( - + {quickActionDisabledReason ? ( , { container: host }, @@ -77,6 +79,51 @@ describe("ProviderModelPicker", () => { } }); + it("opens provider submenus with a visible gap from the parent menu", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: null, + }); + + try { + await page.getByRole("button").click(); + const providerTrigger = page.getByRole("menuitem", { name: "Codex" }); + await providerTrigger.hover(); + + await vi.waitFor(() => { + expect(document.body.textContent ?? "").toContain("GPT-5 Codex"); + }); + + const providerTriggerElement = Array.from( + document.querySelectorAll('[role="menuitem"]'), + ).find((element) => element.textContent?.includes("Codex")); + if (!providerTriggerElement) { + throw new Error("Expected the Codex provider trigger to be mounted."); + } + + const providerTriggerRect = providerTriggerElement.getBoundingClientRect(); + const modelElement = Array.from( + document.querySelectorAll('[role="menuitemradio"]'), + ).find((element) => element.textContent?.includes("GPT-5 Codex")); + if (!modelElement) { + throw new Error("Expected the submenu model option to be mounted."); + } + + const submenuPopup = modelElement.closest('[data-slot="menu-sub-content"]'); + if (!(submenuPopup instanceof HTMLElement)) { + throw new Error("Expected submenu popup to be mounted."); + } + + const submenuRect = submenuPopup.getBoundingClientRect(); + + expect(submenuRect.left).toBeGreaterThanOrEqual(providerTriggerRect.right); + expect(submenuRect.left - providerTriggerRect.right).toBeGreaterThanOrEqual(2); + } finally { + await mounted.cleanup(); + } + }); + it("disables non-locked providers when provider is locked mid-thread", async () => { const mounted = await mountPicker({ provider: "claudeAgent", @@ -119,4 +166,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 188bd07b9bfe..cf7f004922c6 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -6,9 +6,10 @@ import { resolveCursorPickerModelSlug, } from "@t3tools/shared/model"; import { memo, useState } from "react"; +import type { VariantProps } from "class-variance-authority"; import { PROVIDER_OPTIONS, type ProviderPickerKind } from "../../session-logic"; import { ChevronDownIcon } from "lucide-react"; -import { Button } from "../ui/button"; +import { Button, buttonVariants } from "../ui/button"; import { Menu, MenuGroup, @@ -178,14 +179,6 @@ function groupModelsBySubProvider( return result; } -function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { - value: ProviderKind; - label: string; - available: true; -} { - return option.available; -} - function resolveModelForProviderPicker( provider: ProviderKind, value: string, @@ -259,6 +252,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); @@ -268,18 +263,6 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const selectedModelLabel = selectedModelOption?.name ?? props.model; const selectedPricingTier = selectedModelOption?.pricingTier; const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[activeProvider]; - const handleModelChange = (provider: ProviderKind, value: string) => { - if (props.disabled) return; - if (!value) return; - const resolvedModel = resolveSelectableModel( - provider, - value, - props.modelOptionsByProvider[provider], - ); - if (!resolvedModel) return; - props.onProviderModelChange(provider, resolvedModel); - setIsMenuOpen(false); - }; return ( @@ -359,7 +343,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const renderSubProviderGroup = (group: GroupedModelEntry) => ( {group.subProvider} - + {option.label} - + {groups.length === 0 ? ( No models discovered @@ -414,7 +398,10 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { All Providers - + {disconnectedGroups.map(renderSubProviderGroup)} @@ -442,7 +429,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { /> {option.label} - + store.setPrompt); @@ -63,6 +64,7 @@ function ClaudeTraitsPickerHarness(props: { prompt={prompt} modelOptions={modelOptions?.claudeAgent} onPromptChange={handlePromptChange} + triggerVariant={props.triggerVariant} /> ); } @@ -77,6 +79,7 @@ async function mountClaudePicker(props?: { fastMode?: boolean; } | null; skipDraftModelOptions?: boolean; + triggerVariant?: "ghost" | "outline"; }) { const model = props?.model ?? "claude-opus-4-6"; const claudeOptions = !props?.skipDraftModelOptions ? props?.options : undefined; @@ -119,7 +122,11 @@ async function mountClaudePicker(props?: { } satisfies ModelSelection) : null; const screen = await render( - , + , { container: host }, ); @@ -244,6 +251,19 @@ describe("TraitsPicker (Claude)", () => { }, }); }); + + it("accepts outline trigger styling", async () => { + await using _ = await mountClaudePicker({ + triggerVariant: "outline", + }); + + const button = document.querySelector("button"); + if (!(button instanceof HTMLButtonElement)) { + throw new Error("Expected traits trigger button to be rendered."); + } + expect(button.className).toContain("border-input"); + expect(button.className).toContain("bg-popover"); + }); }); // ── Codex TraitsPicker tests ────────────────────────────────────────── diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index e43c0942834a..f48d525d0276 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -14,8 +14,9 @@ import { hasEffortLevel, } from "@t3tools/shared/model"; import { memo, useCallback, useState } from "react"; +import type { VariantProps } from "class-variance-authority"; import { ChevronDownIcon } from "lucide-react"; -import { Button } from "../ui/button"; +import { Button, buttonVariants } from "../ui/button"; import { Menu, MenuGroup, @@ -26,8 +27,18 @@ import { MenuTrigger, } from "../ui/menu"; import { useComposerDraftStore } from "../../composerDraftStore"; +import { cn } from "~/lib/utils"; type ProviderOptions = ProviderModelOptions[ProviderKind]; +type TraitsPersistence = + | { + threadId: ThreadId; + onModelOptionsChange?: never; + } + | { + threadId?: undefined; + onModelOptionsChange: (nextOptions: ProviderOptions | undefined) => void; + }; const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n"; @@ -57,9 +68,14 @@ function getSelectedTraits( model: string | null | undefined, prompt: string, modelOptions: ProviderOptions | null | undefined, + allowPromptInjectedEffort: boolean, ) { const caps = getModelCapabilities(provider, model); - const effortLevels = caps.reasoningEffortLevels; + const effortLevels = allowPromptInjectedEffort + ? caps.reasoningEffortLevels + : caps.reasoningEffortLevels.filter( + (option) => !caps.promptInjectedEffortLevels.includes(option.value), + ); const defaultEffort = getDefaultEffort(caps); // Resolve effort from options (provider-specific key) @@ -88,7 +104,9 @@ function getSelectedTraits( // Prompt-controlled effort (e.g. ultrathink in prompt text) const ultrathinkPromptControlled = - caps.promptInjectedEffortLevels.length > 0 && isClaudeUltrathinkPrompt(prompt); + allowPromptInjectedEffort && + caps.promptInjectedEffortLevels.length > 0 && + isClaudeUltrathinkPrompt(prompt); return { caps, @@ -102,22 +120,35 @@ function getSelectedTraits( export interface TraitsMenuContentProps { provider: ProviderKind; - threadId: ThreadId; model: string | null | undefined; prompt: string; onPromptChange: (prompt: string) => void; modelOptions?: ProviderOptions | null | undefined; + allowPromptInjectedEffort?: boolean; + triggerVariant?: VariantProps["variant"]; + triggerClassName?: string; } export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ provider, - threadId, model, prompt, onPromptChange, modelOptions, -}: TraitsMenuContentProps) { + allowPromptInjectedEffort = true, + ...persistence +}: TraitsMenuContentProps & TraitsPersistence) { const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); + const updateModelOptions = useCallback( + (nextOptions: ProviderOptions | undefined) => { + if ("onModelOptionsChange" in persistence) { + persistence.onModelOptionsChange(nextOptions); + return; + } + setProviderModelOptions(persistence.threadId, provider, nextOptions, { persistSticky: true }); + }, + [persistence, provider, setProviderModelOptions], + ); const { caps, effort, @@ -125,7 +156,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ thinkingEnabled, fastModeEnabled, ultrathinkPromptControlled, - } = getSelectedTraits(provider, model, prompt, modelOptions); + } = getSelectedTraits(provider, model, prompt, modelOptions, allowPromptInjectedEffort); const defaultEffort = getDefaultEffort(caps); const handleEffortChange = useCallback( @@ -143,19 +174,15 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ return; } const effortKey = provider === "codex" ? "reasoningEffort" : "effort"; - setProviderModelOptions( - threadId, - provider, + updateModelOptions( buildNextOptions(provider, modelOptions, { [effortKey]: nextOption.value }), - { persistSticky: true }, ); }, [ ultrathinkPromptControlled, modelOptions, onPromptChange, - threadId, - setProviderModelOptions, + updateModelOptions, effortLevels, prompt, caps.promptInjectedEffortLevels, @@ -198,11 +225,8 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ { - setProviderModelOptions( - threadId, - provider, + updateModelOptions( buildNextOptions(provider, modelOptions, { thinking: value === "on" }), - { persistSticky: true }, ); }} > @@ -219,11 +243,8 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ { - setProviderModelOptions( - threadId, - provider, + updateModelOptions( buildNextOptions(provider, modelOptions, { fastMode: value === "on" }), - { persistSticky: true }, ); }} > @@ -239,12 +260,15 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ export const TraitsPicker = memo(function TraitsPicker({ provider, - threadId, model, prompt, onPromptChange, modelOptions, -}: TraitsMenuContentProps) { + allowPromptInjectedEffort = true, + triggerVariant, + triggerClassName, + ...persistence +}: TraitsMenuContentProps & TraitsPersistence) { const [isMenuOpen, setIsMenuOpen] = useState(false); const { caps, @@ -253,7 +277,7 @@ export const TraitsPicker = memo(function TraitsPicker({ thinkingEnabled, fastModeEnabled, ultrathinkPromptControlled, - } = getSelectedTraits(provider, model, prompt, modelOptions); + } = getSelectedTraits(provider, model, prompt, modelOptions, allowPromptInjectedEffort); const effortLabel = effort ? (effortLevels.find((l) => l.value === effort)?.label ?? effort) @@ -284,12 +308,13 @@ export const TraitsPicker = memo(function TraitsPicker({ render={ diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 768a083e2e05..e06d50bfbcb1 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -5,8 +5,10 @@ import { useEffect, type CSSProperties } from "react"; import { useParams } from "@tanstack/react-router"; import { ThreadId } from "@t3tools/contracts"; import { + CheckIcon, CircleAlertIcon, CircleCheckIcon, + CopyIcon, InfoIcon, LoaderCircleIcon, TriangleAlertIcon, @@ -14,6 +16,7 @@ import { import { cn } from "~/lib/utils"; import { buttonVariants } from "~/components/ui/button"; +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic"; type ThreadToastData = { @@ -35,6 +38,26 @@ const TOAST_ICONS = { warning: TriangleAlertIcon, } as const; +function CopyErrorButton({ text }: { text: string }) { + const { copyToClipboard, isCopied } = useCopyToClipboard(); + + return ( + + ); +} + type ToastPosition = | "top-left" | "top-center" @@ -284,12 +307,17 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { )}
- +
+ + {toast.type === "error" && typeof toast.description === "string" && ( + + )} +
@@ -373,12 +401,17 @@ function AnchoredToasts() { )}
- +
+ + {toast.type === "error" && typeof toast.description === "string" && ( + + )} +
diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 28e12efbc652..3c95a9af9e1f 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -21,7 +21,7 @@ import { } from "@t3tools/shared/model"; import { useMemo } from "react"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; -import { resolveAppModelSelection } from "./appSettings"; +import { resolveAppModelSelection } from "./modelSelection"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; import { type TerminalContextDraft, diff --git a/apps/web/src/lib/gitReactQuery.test.ts b/apps/web/src/lib/gitReactQuery.test.ts index 964d14fb8be0..b5d75f743c33 100644 --- a/apps/web/src/lib/gitReactQuery.test.ts +++ b/apps/web/src/lib/gitReactQuery.test.ts @@ -29,7 +29,14 @@ describe("git mutation options", () => { const queryClient = new QueryClient(); it("attaches cwd-scoped mutation key for runStackedAction", () => { - const options = gitRunStackedActionMutationOptions({ cwd: "/repo/a", queryClient }); + const options = gitRunStackedActionMutationOptions({ + cwd: "/repo/a", + queryClient, + modelSelection: { + provider: "codex", + model: "gpt-5.4", + }, + }); expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction("/repo/a")); }); diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index be6f3e436c7f..6367cf624756 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -1,4 +1,4 @@ -import type { GitStackedAction, ProviderKind } from "@t3tools/contracts"; +import type { GitStackedAction, ModelSelection, ProviderKind } from "@t3tools/contracts"; import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; import { ensureNativeApi } from "../nativeApi"; @@ -114,6 +114,7 @@ export function gitCheckoutMutationOptions(input: { export function gitRunStackedActionMutationOptions(input: { cwd: string | null; queryClient: QueryClient; + modelSelection: ModelSelection; }) { return mutationOptions({ mutationKey: gitMutationKeys.runStackedAction(input.cwd), @@ -139,6 +140,7 @@ export function gitRunStackedActionMutationOptions(input: { return api.git.runStackedAction({ actionId, cwd: input.cwd, + modelSelection: input.modelSelection, action, ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts new file mode 100644 index 000000000000..fede97f78d73 --- /dev/null +++ b/apps/web/src/modelSelection.ts @@ -0,0 +1,18 @@ +// Re-export model selection utilities from appSettings where the fork +// maintains the canonical 8-provider implementations. Upstream introduced +// this module with only codex + claudeAgent; the fork keeps the full set in +// appSettings.ts to avoid duplication. +export { + type AppModelOption, + type ProviderCustomModelConfig, + MAX_CUSTOM_MODEL_LENGTH, + MODEL_PROVIDER_SETTINGS, + normalizeCustomModelSlugs, + getCustomModelsForProvider, + getDefaultCustomModelsForProvider, + patchCustomModels, + getCustomModelsByProvider, + getAppModelOptions, + resolveAppModelSelection, + getCustomModelOptionsByProvider, +} from "./appSettings"; diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index e500b5779119..86ac4e9ba6ca 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -353,11 +353,27 @@ describe("wsNativeApi", () => { const { createWsNativeApi } = await import("./wsNativeApi"); const api = createWsNativeApi(); - await api.git.runStackedAction({ actionId: "action-1", cwd: "/repo", action: "commit" }); + await api.git.runStackedAction({ + actionId: "action-1", + cwd: "/repo", + action: "commit", + modelSelection: { + provider: "codex", + model: "gpt-5.4-mini", + }, + }); expect(requestMock).toHaveBeenCalledWith( WS_METHODS.gitRunStackedAction, - { actionId: "action-1", cwd: "/repo", action: "commit" }, + { + actionId: "action-1", + cwd: "/repo", + action: "commit", + modelSelection: { + provider: "codex", + model: "gpt-5.4-mini", + }, + }, { timeoutMs: null }, ); }); diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index afb79d3a7c5b..e9446b540a31 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -65,9 +65,28 @@ describe("GitRunStackedActionInput", () => { actionId: "action-1", cwd: "/repo", action: "commit", + modelSelection: { + provider: "codex", + model: "gpt-5.4-mini", + }, }); expect(parsed.actionId).toBe("action-1"); expect(parsed.action).toBe("commit"); }); + + it("accepts git text generation as a modelSelection", () => { + const parsed = decodeRunStackedActionInput({ + actionId: "action-1", + cwd: "/repo", + action: "commit_push_pr", + modelSelection: { + provider: "claudeAgent", + model: "claude-haiku-4-5", + }, + }); + + expect(parsed.modelSelection?.provider).toBe("claudeAgent"); + expect(parsed.modelSelection?.model).toBe("claude-haiku-4-5"); + }); }); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 71b60cc393ee..2903a2f91d98 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,6 +1,6 @@ import { Schema } from "effect"; import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; -import { ProviderKind } from "./orchestration"; +import { ModelSelection, ProviderKind } from "./orchestration"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; @@ -82,6 +82,7 @@ export const GitRunStackedActionInput = Schema.Struct({ filePaths: Schema.optional( Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)), ), + modelSelection: ModelSelection, }); export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 394affd323d9..e5cbd5d81e20 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -379,6 +379,7 @@ export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini" as const; export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER = { ...DEFAULT_MODEL_BY_PROVIDER, codex: "gpt-5.4-mini", + claudeAgent: "claude-haiku-4-5", } as const satisfies Record; export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record> = { diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index e76d18718841..d3e19e55c238 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -160,6 +160,43 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(env.T3CODE_HOME, resolve("/tmp/my-t3")); }), ); + + it.effect("does not export backend bootstrap env for dev:desktop", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev:desktop", + baseEnv: { + T3CODE_PORT: "3773", + T3CODE_AUTH_TOKEN: "stale-token", + T3CODE_MODE: "web", + T3CODE_NO_BROWSER: "0", + T3CODE_HOST: "0.0.0.0", + VITE_WS_URL: "ws://localhost:3773", + }, + serverOffset: 0, + webOffset: 0, + t3Home: "/tmp/my-t3", + authToken: "fresh-token", + noBrowser: true, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: "127.0.0.1", + port: 4222, + devUrl: undefined, + }); + + assert.equal(env.T3CODE_HOME, resolve("/tmp/my-t3")); + assert.equal(env.PORT, "5733"); + assert.equal(env.ELECTRON_RENDERER_PORT, "5733"); + assert.equal(env.VITE_DEV_SERVER_URL, "http://localhost:5733"); + assert.equal(env.T3CODE_PORT, undefined); + assert.equal(env.T3CODE_AUTH_TOKEN, undefined); + assert.equal(env.T3CODE_MODE, undefined); + assert.equal(env.T3CODE_NO_BROWSER, undefined); + assert.equal(env.T3CODE_HOST, undefined); + assert.equal(env.VITE_WS_URL, undefined); + }), + ); }); describe("findFirstAvailableOffset", () => { diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 241ce3537b16..74474b8efecf 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -151,30 +151,45 @@ export function createDevRunnerEnv({ const serverPort = port ?? BASE_SERVER_PORT + serverOffset; const webPort = BASE_WEB_PORT + webOffset; const resolvedBaseDir = yield* resolveBaseDir(t3Home); + const isDesktopMode = mode === "dev:desktop"; const output: NodeJS.ProcessEnv = { ...baseEnv, - T3CODE_PORT: String(serverPort), PORT: String(webPort), ELECTRON_RENDERER_PORT: String(webPort), - VITE_WS_URL: `ws://localhost:${serverPort}`, VITE_DEV_SERVER_URL: devUrl?.toString() ?? `http://localhost:${webPort}`, T3CODE_HOME: resolvedBaseDir, }; - if (host !== undefined) { + // Always strip bootstrap fd — it refers to the parent's descriptor and + // must never leak into turbo/child processes regardless of mode. + delete output.T3CODE_BOOTSTRAP_FD; + + if (!isDesktopMode) { + output.T3CODE_PORT = String(serverPort); + output.VITE_WS_URL = `ws://localhost:${serverPort}`; + } else { + delete output.T3CODE_PORT; + delete output.VITE_WS_URL; + delete output.T3CODE_AUTH_TOKEN; + delete output.T3CODE_MODE; + delete output.T3CODE_NO_BROWSER; + delete output.T3CODE_HOST; + } + + if (!isDesktopMode && host !== undefined) { output.T3CODE_HOST = host; } - if (authToken !== undefined) { + if (!isDesktopMode && authToken !== undefined) { output.T3CODE_AUTH_TOKEN = authToken; - } else { + } else if (!isDesktopMode) { delete output.T3CODE_AUTH_TOKEN; } - if (noBrowser !== undefined) { + if (!isDesktopMode && noBrowser !== undefined) { output.T3CODE_NO_BROWSER = noBrowser ? "1" : "0"; - } else { + } else if (!isDesktopMode) { delete output.T3CODE_NO_BROWSER; } @@ -200,6 +215,10 @@ export function createDevRunnerEnv({ delete output.T3CODE_DESKTOP_WS_URL; } + if (isDesktopMode) { + delete output.T3CODE_DESKTOP_WS_URL; + } + return output; }); }