diff --git a/CLAUDE.md b/CLAUDE.md index 47dc3e3d863c..c3170642553f 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md \ No newline at end of file +AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b734a99bbb0..495fc3605f96 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,65 +1,36 @@ -# Contributing +# Contributing to T3 Code (Gemini Fork) -## Read This First +First off, thank you for considering contributing to this fork! It's people like you that make the open-source community such an amazing place to learn, inspire, and create. -We are not actively accepting contributions right now. +This fork is maintained by [mtdewwolf](https://github.com/mtdewwolf) and focuses on enhancing Gemini CLI support and improving the core orchestration and persistence layers. -You can still open an issue or PR, but please do so knowing there is a high chance we close it, defer it forever, or never look at it. +## How Can I Contribute? -If that sounds annoying, that is because it is. This project is still early and we are trying to keep scope, quality, and direction under control. +### Reporting Bugs +Before creating bug reports, please check the existing issues to see if the problem has already been reported. When you are creating a bug report, please include as many details as possible. -PRs are automatically labeled with a `vouch:*` trust status and a `size:*` diff size based on changed lines. +### Suggesting Enhancements +Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, please: +* Use a clear and descriptive title. +* Provide a step-by-step description of the suggested enhancement. +* Explain why this enhancement would be useful. -If you are an external contributor, expect `vouch:unvouched` until we explicitly add you to [.github/VOUCHED.td](.github/VOUCHED.td). +### Pull Requests +We welcome pull requests! To help us review your PR effectively, please: +1. **Keep it focused:** Try to keep PRs small and focused on a single change. +2. **Test your changes:** Ensure that your changes don't break existing functionality. Add new tests if possible. +3. **Document your changes:** Update documentation (like the README) if your changes introduce new features or change existing ones. +4. **Describe your PR:** Use the pull request template to describe what changed and why. -## What We Are Most Likely To Accept +## Development Setup -Small, focused bug fixes. +1. Clone the repository: `git clone https://github.com/mtdewwolf/t3code-gemini.git` +2. Install dependencies: `bun install` +3. Run the development server: `bun run dev` -Small reliability fixes. +## Code Style +Please try to match the existing code style. We use `oxlint` for linting and `oxfmt` for formatting. -Small performance improvements. +--- -Tightly scoped maintenance work that clearly improves the project without changing its direction. - -## What We Are Least Likely To Accept - -Large PRs. - -Drive-by feature work. - -Opinionated rewrites. - -Anything that expands product scope without us asking for it first. - -If you open a 1,000+ line PR full of new features, we will probably close it quickly and remember that you ignored the clearly written instructions. - -## If You Still Want To Open A PR - -Keep it small. - -Explain exactly what changed. - -Explain exactly why the change should exist. - -Do not mix unrelated fixes together. - -If the PR makes anything resembling a UI change, include clear before/after images. - -If the change depends on motion, timing, transitions, or interaction details, include a short video. - -If we have to guess what changed, we are much less likely to review it. - -## Issues First - -If you are thinking about a non-trivial change, open an issue first. - -That still does not mean we will want the PR, but it gives you a chance to avoid wasting your time. - -## Be Realistic - -Opening a PR does not create an obligation on our side. - -We may close it. We may ignore it. We may ask you to shrink it. We may reimplement the idea ourselves later. - -If you are fine with that, proceed. +Special thanks to the original creators at [Pingdotgg](https://github.com/pingdotgg) and the maintainers of the [aaditagrawal fork](https://github.com/aaditagrawal/t3code) for the solid foundation. diff --git a/README.md b/README.md index 856540311ec2..51c23d035a7a 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,37 @@ -# T3 Code +# T3 Code (Gemini Fork) -T3 Code is a minimal web GUI for coding agents made by [Pingdotgg](https://github.com/pingdotgg). This project is a downstream fork of [T3 Code](https://github.com/pingdotgg/t3code) customised to my utility and includes various PRs/feature additions from the upstream repo. Thanks to the team and its maintainers for keeping it OSS and an upstream to look up to. +T3 Code is a minimal web GUI for coding agents made by [Pingdotgg](https://github.com/pingdotgg). This project is a downstream fork of [aaditagrawal's T3 Code](https://github.com/aaditagrawal/t3code), which itself is a fork of the original [T3 Code](https://github.com/pingdotgg/t3code). + +This fork by [mtdewwolf](https://github.com/mtdewwolf) focuses on enhancing the Gemini CLI integration, improving persistence layers, and refining the provider management system. It supports Codex, Claude Code, Cursor, Copilot, Gemini CLI, Amp, Kilo, and OpenCode. (NOTE: Amp /mode free is not supported, as Amp Code doesn't support it in headless mode - since they need to show ads for that business model to work.) -## Why the fork? +## Why this fork? -This fork is designed to keep up a faster rate of development customised to my needs (and if you want, _yours_ as well -> Submit an issue and I'll make a PR for it). There's certain features which will (rightly) remain out of scope/priority for the project at its scale, but might be required for someone like me. +This fork aims to provide a more robust and feature-rich experience for Gemini CLI users, with improved server management and more reliable persistence of orchestration events. It also includes several UI refinements for settings and model selection. -### Multi-provider support +### Multi-provider support (Enhanced) Adds full provider adapters (server managers, service layers, runtime layers) for agents that are not yet on the upstream roadmap: | Provider | What's included | | ----------- | ------------------------------------------------------------------------- | +| Gemini CLI | **Enhanced:** Adapter + `geminiCliServerManager` with full test coverage | | Amp | Adapter + `ampServerManager` for headless Amp sessions | | Copilot | Adapter + CLI binary resolution + text generation layer | | Cursor | Adapter + ACP probe integration + usage tracking | -| Gemini CLI | Adapter + `geminiCliServerManager` with full test coverage | | Kilo | Adapter + `kiloServerManager` + OpenCode-style server URL config | | OpenCode | Adapter + `opencodeServerManager` with hostname/port/workspace config | | Claude Code | Full adapter with permission mode, thinking token limits, and SDK typings | +### Persistence & Orchestration Improvements + +- **Normalized Provider Kinds:** Migration added to handle legacy provider kind naming consistently. +- **Improved Event Store:** Robust persistence layer for orchestration events with better error handling. +- **Session Management:** refined `ProviderSessionDirectory` for better tracking of active sessions. + ### UX enhancements | Feature | Description | @@ -36,19 +44,6 @@ Adds full provider adapters (server managers, service layers, runtime layers) fo | Plan sidebar | Dedicated panel for reviewing, downloading, or saving proposed agent plans | | Terminal drawer | Theme-aware integrated terminal with accent color styling | -### Branding & build - -- Custom abstract-mark app icon with macOS icon composer support -- Centralized branding constants for easy identity swaps -- Desktop icon asset generation pipeline from SVG source - -### Developer tooling - -- `sync-upstream-pr-tracks` script for tracking cherry-picked upstream PRs -- `cursor-acp-probe` for testing Cursor Agent Communication Protocol -- Custom alpha workflow playbook (`docs/custom-alpha-workflow.md`) -- Upstream PR tracking config (`config/upstream-pr-tracks.json`) - ## Getting started ### Quick install (recommended) @@ -57,16 +52,14 @@ Run the interactive installer — it detects your OS, checks prerequisites (git, ```bash # macOS / Linux / WSL -bash <(curl -fsSL https://raw.githubusercontent.com/aaditagrawal/t3code/main/scripts/install.sh) +bash <(curl -fsSL https://raw.githubusercontent.com/mtdewwolf/t3code-gemini/main/scripts/install.sh) ``` ```powershell # Windows (Git Bash, MSYS2, or WSL) -bash <(curl -fsSL https://raw.githubusercontent.com/aaditagrawal/t3code/main/scripts/install.sh) +bash <(curl -fsSL https://raw.githubusercontent.com/mtdewwolf/t3code-gemini/main/scripts/install.sh) ``` -The installer supports **npm, yarn, pnpm, bun, and deno** detection, and will auto-install bun if no suitable package manager is found. It provides OS-specific install instructions for any missing prerequisites (Homebrew on macOS, apt/dnf/pacman on Linux, winget on Windows). - ### Manual build > [!WARNING] @@ -74,25 +67,25 @@ The installer supports **npm, yarn, pnpm, bun, and deno** detection, and will au ```bash # Prerequisites: Bun >=1.3.9, Node >=24.13.1 -git clone https://github.com/aaditagrawal/t3code.git -cd t3code +git clone https://github.com/mtdewwolf/t3code-gemini.git +cd t3code-gemini bun install bun run dev ``` ## Supported agents -- [Codex CLI](https://github.com/openai/codex) (requires v0.37.0 or later) +- [Gemini CLI](https://github.com/google-gemini/gemini-cli) - [Claude Code](https://github.com/anthropics/claude-code) - [Cursor](https://cursor.sh) +- [Codex CLI](https://github.com/openai/codex) (requires v0.37.0 or later) - [Copilot](https://github.com/features/copilot) -- [Gemini CLI](https://github.com/google-gemini/gemini-cli) - [Amp](https://ampcode.com) - [Kilo](https://kilo.dev) - [OpenCode](https://opencode.ai) ## Notes -- This project is very early in development. Expect bugs. (Especially with my fork) +- This project is very early in development. Expect bugs. - Interested in contributing? See [CONTRIBUTING.md](CONTRIBUTING.md). -- Maintaining a custom fork or alpha branch? See [docs/custom-alpha-workflow.md](docs/custom-alpha-workflow.md). +- Special thanks to [Pingdotgg](https://github.com/pingdotgg) for the original project and [aaditagrawal](https://github.com/aaditagrawal) for the foundational fork. diff --git a/apps/server/src/commandPath.ts b/apps/server/src/commandPath.ts new file mode 100644 index 000000000000..e116f466ad6f --- /dev/null +++ b/apps/server/src/commandPath.ts @@ -0,0 +1,119 @@ +import { accessSync, constants, statSync } from "node:fs"; +import { extname, join } from "node:path"; + +interface CommandPathOptions { + readonly platform?: NodeJS.Platform; + readonly env?: NodeJS.ProcessEnv; +} + +function stripWrappingQuotes(value: string): string { + return value.replace(/^"+|"+$/g, ""); +} + +function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { + return env.PATH ?? env.Path ?? env.path ?? ""; +} + +function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray { + const rawValue = env.PATHEXT; + const fallback = [".COM", ".EXE", ".BAT", ".CMD"]; + if (!rawValue) return fallback; + + const parsed = rawValue + .split(";") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`)); + return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback; +} + +function resolvePathDelimiter(platform: NodeJS.Platform): string { + return platform === "win32" ? ";" : ":"; +} + +function resolveCommandCandidates( + command: string, + platform: NodeJS.Platform, + windowsPathExtensions: ReadonlyArray, +): ReadonlyArray { + if (platform !== "win32") return [command]; + const extension = extname(command); + const normalizedExtension = extension.toUpperCase(); + + if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) { + const commandWithoutExtension = command.slice(0, -extension.length); + return Array.from( + new Set([ + command, + `${commandWithoutExtension}${normalizedExtension}`, + `${commandWithoutExtension}${normalizedExtension.toLowerCase()}`, + ]), + ); + } + + const candidates: string[] = []; + for (const windowsPathExtension of windowsPathExtensions) { + candidates.push(`${command}${windowsPathExtension}`); + candidates.push(`${command}${windowsPathExtension.toLowerCase()}`); + } + return Array.from(new Set(candidates)); +} + +function isExecutableFile( + filePath: string, + platform: NodeJS.Platform, + windowsPathExtensions: ReadonlyArray, +): boolean { + try { + const stat = statSync(filePath); + if (!stat.isFile()) return false; + if (platform === "win32") { + const extension = extname(filePath); + if (extension.length === 0) return false; + return windowsPathExtensions.includes(extension.toUpperCase()); + } + accessSync(filePath, constants.X_OK); + return true; + } catch { + return false; + } +} + +export function resolveCommandPath( + command: string, + options: CommandPathOptions = {}, +): string | undefined { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; + const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); + + if (command.includes("/") || command.includes("\\")) { + return commandCandidates.find((candidate) => + isExecutableFile(candidate, platform, windowsPathExtensions), + ); + } + + const pathValue = resolvePathEnvironmentVariable(env); + if (pathValue.length === 0) return undefined; + + const pathEntries = pathValue + .split(resolvePathDelimiter(platform)) + .map((entry) => stripWrappingQuotes(entry.trim())) + .filter((entry) => entry.length > 0); + + for (const pathEntry of pathEntries) { + for (const candidate of commandCandidates) { + const resolvedPath = join(pathEntry, candidate); + if (isExecutableFile(resolvedPath, platform, windowsPathExtensions)) { + return resolvedPath; + } + } + } + + return undefined; +} + +export function isCommandAvailable(command: string, options: CommandPathOptions = {}): boolean { + return resolveCommandPath(command, options) !== undefined; +} diff --git a/apps/server/src/geminiCliServerManager.test.ts b/apps/server/src/geminiCliServerManager.test.ts index 817799ae6569..5e3419b17a39 100644 --- a/apps/server/src/geminiCliServerManager.test.ts +++ b/apps/server/src/geminiCliServerManager.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { ThreadId, TurnId, type ProviderRuntimeEvent } from "@t3tools/contracts"; -import { GeminiCliServerManager } from "./geminiCliServerManager"; +import { + buildGeminiSpawnOptions, + GeminiCliServerManager, + resolveGeminiSpawnPlan, +} from "./geminiCliServerManager"; const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); @@ -14,6 +18,58 @@ const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); // --------------------------------------------------------------------------- describe("GeminiCliServerManager", () => { + describe("buildGeminiSpawnOptions", () => { + it("uses piped stdio without a shell", () => { + const options = buildGeminiSpawnOptions({ + cwd: "/tmp", + env: {}, + }); + + expect(options.cwd).toBe("/tmp"); + expect(options.stdio).toEqual(["pipe", "pipe", "pipe"]); + }); + }); + + describe("resolveGeminiSpawnPlan", () => { + it("rewrites Windows npm shim launches to node gemini.js", () => { + const env = { + PATH: "C:\\Users\\user\\AppData\\Roaming\\npm;C:\\Program Files\\nodejs", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }; + + const plan = resolveGeminiSpawnPlan( + { + binaryPath: "gemini", + args: ["-p", "Reply with exactly PONG"], + cwd: "C:\\repo", + env, + }, + "win32", + ); + + expect(plan.command.toLowerCase()).toContain("node"); + expect(plan.args[0]?.replace(/\\/g, "/")).toContain( + "/AppData/Roaming/npm/node_modules/@google/gemini-cli/bundle/gemini.js", + ); + expect(plan.args.slice(1)).toEqual(["-p", "Reply with exactly PONG"]); + }); + + it("spawns directly on non-Windows platforms", () => { + const plan = resolveGeminiSpawnPlan( + { + binaryPath: "gemini", + args: ["-p", "Reply with exactly PONG"], + cwd: "/tmp", + env: {}, + }, + "linux", + ); + + expect(plan.command).toBe("gemini"); + expect(plan.args).toEqual(["-p", "Reply with exactly PONG"]); + }); + }); + describe("startSession", () => { it("creates a session and returns a ready ProviderSession", async () => { const manager = new GeminiCliServerManager(); diff --git a/apps/server/src/geminiCliServerManager.ts b/apps/server/src/geminiCliServerManager.ts index f5179464424c..a7a85d1fdea1 100644 --- a/apps/server/src/geminiCliServerManager.ts +++ b/apps/server/src/geminiCliServerManager.ts @@ -1,6 +1,13 @@ import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; -import { spawn, type ChildProcess } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, extname, join } from "node:path"; +import { + spawn, + spawnSync, + type ChildProcess, + type ChildProcessWithoutNullStreams, +} from "node:child_process"; import readline from "node:readline"; import { @@ -19,6 +26,7 @@ import { } from "@t3tools/contracts"; import type { ProviderSessionUsage, ProviderUsageResult } from "@t3tools/contracts"; import type { ProviderThreadSnapshot } from "./provider/Services/ProviderAdapter.ts"; +import { resolveCommandPath } from "./commandPath.ts"; const PROVIDER = "geminiCli" as const; @@ -58,10 +66,6 @@ export function fetchGeminiCliUsage(): ProviderUsageResult { }; } -type GeminiCliProviderOptions = { - readonly binaryPath?: string; -}; - function readGeminiResumeSessionId(resumeCursor: unknown): string | undefined { if (typeof resumeCursor === "string" && resumeCursor.trim().length > 0) { return resumeCursor.trim(); @@ -149,6 +153,7 @@ interface GeminiCliSession { geminiSessionId: string | undefined; activeTurnId: TurnId | undefined; activeProcess: ChildProcess | undefined; + interruptedTurnId: TurnId | undefined; /** Stable itemId for the current turn's assistant message (reused across content.delta events). */ activeAssistantItemId: RuntimeItemId | undefined; /** Track active tool items by tool_id → { itemId, toolName, paramSummary } for item lifecycle events. */ @@ -164,6 +169,117 @@ function defaultBinaryPath(): string { return "gemini"; } +export function buildGeminiSpawnOptions(input: { + readonly cwd: string; + readonly env: NodeJS.ProcessEnv; +}): { + readonly cwd: string; + readonly stdio: ["pipe", "pipe", "pipe"]; + readonly env: NodeJS.ProcessEnv; +} { + return { + cwd: input.cwd, + stdio: ["pipe", "pipe", "pipe"], + env: input.env, + }; +} + +interface GeminiSpawnPlan { + readonly command: string; + readonly args: ReadonlyArray; + readonly options: ReturnType; +} + +function resolveGeminiShimEntryPoint(binaryPath: string): string | undefined { + if (![".cmd", ".bat"].includes(extname(binaryPath).toLowerCase())) { + return undefined; + } + + const shimDirectory = dirname(binaryPath); + const bundledEntryPoint = join( + shimDirectory, + "node_modules", + "@google", + "gemini-cli", + "bundle", + "gemini.js", + ); + + return existsSync(bundledEntryPoint) ? bundledEntryPoint : undefined; +} + +function resolveNodeCommand(env: NodeJS.ProcessEnv): string { + if (process.platform === "win32") { + return resolveCommandPath("node", { platform: "win32", env }) ?? "node"; + } + return "node"; +} + +export function resolveGeminiSpawnPlan( + input: { + readonly binaryPath: string; + readonly args: ReadonlyArray; + readonly cwd: string; + readonly env: NodeJS.ProcessEnv; + }, + platform: NodeJS.Platform = process.platform, +): GeminiSpawnPlan { + const options = buildGeminiSpawnOptions({ + cwd: input.cwd, + env: input.env, + }); + + if (platform !== "win32") { + return { + command: input.binaryPath, + args: [...input.args], + options, + }; + } + + const resolvedBinaryPath = + resolveCommandPath(input.binaryPath, { + platform, + env: input.env, + }) ?? input.binaryPath; + + if (extname(resolvedBinaryPath).toLowerCase() === ".js") { + return { + command: resolveNodeCommand(input.env), + args: [resolvedBinaryPath, ...input.args], + options, + }; + } + + const bundledEntryPoint = resolveGeminiShimEntryPoint(resolvedBinaryPath); + if (bundledEntryPoint) { + return { + command: resolveNodeCommand(input.env), + args: [bundledEntryPoint, ...input.args], + options, + }; + } + + return { + command: resolvedBinaryPath, + args: [...input.args], + options, + }; +} + +function killGeminiChildProcess(child: ChildProcess, signal: NodeJS.Signals = "SIGTERM"): void { + if (process.platform === "win32" && child.pid !== undefined) { + try { + spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); + return; + } catch { + // Fall back to direct kill when taskkill is unavailable. + } + } + + child.kill(signal); +} + /** Extract a short description from tool parameters for display. */ function summarizeToolCall(toolName: string, parameters: unknown): string { if (!parameters || typeof parameters !== "object") { @@ -233,6 +349,7 @@ export class GeminiCliServerManager extends EventEmitter<{ geminiSessionId: resumeSessionId, activeTurnId: undefined, activeProcess: undefined, + interruptedTurnId: undefined, activeAssistantItemId: undefined, activeToolItems: new Map(), createdAt: now, @@ -293,6 +410,7 @@ export class GeminiCliServerManager extends EventEmitter<{ session.updatedAt = new Date().toISOString(); session.activeToolItems.clear(); session.activeAssistantItemId = undefined; + session.interruptedTurnId = undefined; const prompt = input.input ?? ""; @@ -318,12 +436,21 @@ export class GeminiCliServerManager extends EventEmitter<{ args.push("--resume", session.geminiSessionId); } - const child = spawn(session.binaryPath, args, { + const spawnPlan = resolveGeminiSpawnPlan({ + binaryPath: session.binaryPath, + args, cwd: session.cwd, - stdio: ["pipe", "pipe", "pipe"], env: { ...process.env }, }); + const child: ChildProcessWithoutNullStreams = spawn( + spawnPlan.command, + [...spawnPlan.args], + spawnPlan.options, + ); + + child.stdin.end(); + session.activeProcess = child; // Emit turn.started immediately. @@ -332,6 +459,7 @@ export class GeminiCliServerManager extends EventEmitter<{ payload: { model: effectiveModel }, }); + let stderrSummary = ""; const rl = readline.createInterface({ input: child.stdout }); rl.on("line", (line) => { @@ -341,6 +469,10 @@ export class GeminiCliServerManager extends EventEmitter<{ child.stderr.on("data", (chunk: Buffer) => { const text = chunk.toString().trim(); if (!text) return; + if (stderrSummary.length < 2_000) { + const nextSummary = stderrSummary.length > 0 ? `${stderrSummary}\n${text}` : text; + stderrSummary = nextSummary.slice(0, 2_000); + } const normalized = text.toLowerCase(); if (normalized.includes("trusted folder") || normalized.includes("untrusted folder")) { this.emitEvent(input.threadId, turnId, { @@ -365,21 +497,25 @@ export class GeminiCliServerManager extends EventEmitter<{ if (s.status === "running" && s.activeTurnId === turnId) { s.status = "ready"; s.updatedAt = new Date().toISOString(); + const wasInterrupted = s.interruptedTurnId === turnId || signal === "SIGINT"; + s.interruptedTurnId = undefined; // Flush any open assistant message or tool items that never received completion. this.finalizeOpenItems(input.threadId, turnId, s); this.emitEvent(input.threadId, turnId, { type: "turn.completed", - payload: - signal === "SIGINT" - ? { state: "interrupted" } - : code === 0 - ? { state: "completed" } - : { - state: "failed", - errorMessage: `Gemini CLI exited with code ${code}`, - }, + payload: wasInterrupted + ? { state: "interrupted" } + : code === 0 + ? { state: "completed" } + : { + state: "failed", + errorMessage: + stderrSummary.length > 0 + ? `Gemini CLI exited with code ${code}: ${stderrSummary}` + : `Gemini CLI exited with code ${code}`, + }, }); } }); @@ -390,6 +526,7 @@ export class GeminiCliServerManager extends EventEmitter<{ s.activeProcess = undefined; s.status = "ready"; s.updatedAt = new Date().toISOString(); + s.interruptedTurnId = undefined; // Flush any open assistant message or tool items that never received completion. this.finalizeOpenItems(input.threadId, turnId, s); @@ -419,7 +556,8 @@ export class GeminiCliServerManager extends EventEmitter<{ throw new Error(`Unknown Gemini CLI session: ${threadId}`); } if (session.status === "running" && session.activeProcess) { - session.activeProcess.kill("SIGINT"); + session.interruptedTurnId = session.activeTurnId; + killGeminiChildProcess(session.activeProcess, "SIGINT"); } return Promise.resolve(); } @@ -445,7 +583,7 @@ export class GeminiCliServerManager extends EventEmitter<{ if (!session) return; if (session.activeProcess) { try { - session.activeProcess.kill(); + killGeminiChildProcess(session.activeProcess); } catch { // Process may already be dead. } diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index ef8b00d61583..1b781e06a046 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -7,12 +7,13 @@ * @module Open */ import { spawn } from "node:child_process"; -import { accessSync, constants, existsSync, statSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import os from "node:os"; -import { dirname, extname, join } from "node:path"; +import { dirname } from "node:path"; import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts"; import { ServiceMap, Effect, Layer } from "effect"; +import { isCommandAvailable } from "./commandPath.ts"; // ============================== // Definitions @@ -30,11 +31,6 @@ interface EditorLaunch { readonly args: ReadonlyArray; } -interface CommandAvailabilityOptions { - readonly platform?: NodeJS.Platform; - readonly env?: NodeJS.ProcessEnv; -} - const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; function parseTargetPathAndPosition(target: string): { @@ -137,110 +133,7 @@ function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { } } -function stripWrappingQuotes(value: string): string { - return value.replace(/^"+|"+$/g, ""); -} - -function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { - return env.PATH ?? env.Path ?? env.path ?? ""; -} - -function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray { - const rawValue = env.PATHEXT; - const fallback = [".COM", ".EXE", ".BAT", ".CMD"]; - if (!rawValue) return fallback; - - const parsed = rawValue - .split(";") - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0) - .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`)); - return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback; -} - -function resolveCommandCandidates( - command: string, - platform: NodeJS.Platform, - windowsPathExtensions: ReadonlyArray, -): ReadonlyArray { - if (platform !== "win32") return [command]; - const extension = extname(command); - const normalizedExtension = extension.toUpperCase(); - - if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) { - const commandWithoutExtension = command.slice(0, -extension.length); - return Array.from( - new Set([ - command, - `${commandWithoutExtension}${normalizedExtension}`, - `${commandWithoutExtension}${normalizedExtension.toLowerCase()}`, - ]), - ); - } - - const candidates: string[] = []; - for (const extension of windowsPathExtensions) { - candidates.push(`${command}${extension}`); - candidates.push(`${command}${extension.toLowerCase()}`); - } - return Array.from(new Set(candidates)); -} - -function isExecutableFile( - filePath: string, - platform: NodeJS.Platform, - windowsPathExtensions: ReadonlyArray, -): boolean { - try { - const stat = statSync(filePath); - if (!stat.isFile()) return false; - if (platform === "win32") { - const extension = extname(filePath); - if (extension.length === 0) return false; - return windowsPathExtensions.includes(extension.toUpperCase()); - } - accessSync(filePath, constants.X_OK); - return true; - } catch { - return false; - } -} - -function resolvePathDelimiter(platform: NodeJS.Platform): string { - return platform === "win32" ? ";" : ":"; -} - -export function isCommandAvailable( - command: string, - options: CommandAvailabilityOptions = {}, -): boolean { - const platform = options.platform ?? process.platform; - const env = options.env ?? process.env; - const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; - const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); - - if (command.includes("/") || command.includes("\\")) { - return commandCandidates.some((candidate) => - isExecutableFile(candidate, platform, windowsPathExtensions), - ); - } - - const pathValue = resolvePathEnvironmentVariable(env); - if (pathValue.length === 0) return false; - const pathEntries = pathValue - .split(resolvePathDelimiter(platform)) - .map((entry) => stripWrappingQuotes(entry.trim())) - .filter((entry) => entry.length > 0); - - for (const pathEntry of pathEntries) { - for (const candidate of commandCandidates) { - if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) { - return true; - } - } - } - return false; -} +export { isCommandAvailable }; export function resolveAvailableEditors( platform: NodeJS.Platform = process.platform, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 419e3f3bf23b..1406bfb48aa8 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -165,7 +165,11 @@ const make = Effect.gen(function* () { ), ); - const threadModelSelections = new Map(); + const threadModelSelections = yield* Cache.make({ + capacity: 10_000, + timeToLive: Duration.hours(24), + lookup: () => Effect.fail(new Error("not found")), + }); const appendProviderFailureActivity = (input: { readonly threadId: ThreadId; @@ -306,7 +310,9 @@ const make = Effect.gen(function* () { requestedModelSelection !== undefined && requestedModelSelection.model !== activeSession?.model; const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session"; - const previousModelSelection = threadModelSelections.get(threadId); + const previousModelSelection = Option.getOrUndefined( + yield* Cache.getOption(threadModelSelections, threadId), + ); const shouldRestartForModelSelectionChange = currentProvider === "claudeAgent" && requestedModelSelection !== undefined && @@ -376,7 +382,7 @@ const make = Effect.gen(function* () { input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}, ); if (input.modelSelection !== undefined) { - threadModelSelections.set(input.threadId, input.modelSelection); + yield* Cache.set(threadModelSelections, input.threadId, input.modelSelection); } const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; @@ -390,7 +396,9 @@ const make = Effect.gen(function* () { ? "in-session" : (yield* providerService.getCapabilities(activeSession.provider)).sessionModelSwitch; const requestedModelSelection = - input.modelSelection ?? threadModelSelections.get(input.threadId) ?? thread.modelSelection; + input.modelSelection ?? + Option.getOrUndefined(yield* Cache.getOption(threadModelSelections, input.threadId)) ?? + thread.modelSelection; const modelForTurn = sessionModelSwitch === "unsupported" ? activeSession?.model !== undefined @@ -748,7 +756,9 @@ const make = Effect.gen(function* () { if (!thread?.session || thread.session.status === "stopped") { return; } - const cachedModelSelection = threadModelSelections.get(event.payload.threadId); + const cachedModelSelection = Option.getOrUndefined( + yield* Cache.getOption(threadModelSelections, event.payload.threadId), + ); yield* ensureSessionForThread( event.payload.threadId, event.occurredAt, diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts index 249e9d1e368d..88e6cbc66cbd 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts @@ -116,4 +116,78 @@ layer("OrchestrationEventStore", (it) => { } }), ); + + it.effect("normalizes legacy provider names while replaying stored events", () => + Effect.gen(function* () { + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = new Date().toISOString(); + const existingRows = yield* sql<{ readonly maxSequence: number }>` + SELECT COALESCE(MAX(sequence), 0) AS "maxSequence" + FROM orchestration_events + `; + const maxSequence = existingRows[0]?.maxSequence ?? 0; + + yield* sql` + INSERT INTO orchestration_events ( + event_id, + aggregate_kind, + stream_id, + stream_version, + event_type, + occurred_at, + command_id, + causation_event_id, + correlation_id, + actor_kind, + payload_json, + metadata_json + ) + VALUES ( + ${EventId.makeUnsafe("evt-store-legacy-provider")}, + ${"thread"}, + ${"thread-legacy-provider"}, + ${0}, + ${"thread.created"}, + ${now}, + ${CommandId.makeUnsafe("cmd-store-legacy-provider")}, + ${null}, + ${null}, + ${"server"}, + ${JSON.stringify({ + threadId: "thread-legacy-provider", + projectId: "project-legacy-provider", + title: "Legacy Provider Thread", + modelSelection: { + provider: "gemini", + model: "gemini-2.5-pro", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + })}, + ${"{}"} + ) + `; + + const replayed = yield* Stream.runCollect(eventStore.readFromSequence(maxSequence, 10)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + + assert.equal(replayed.length, 1); + const replayedEvent = replayed[0]; + assert.isDefined(replayedEvent); + assert.equal(replayedEvent?.type, "thread.created"); + if (!replayedEvent || replayedEvent.type !== "thread.created") { + return; + } + assert.deepStrictEqual(replayedEvent.payload.modelSelection, { + provider: "geminiCli", + model: "gemini-2.5-pro", + }); + }), + ); }); diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index fc1b143f06ba..2e08d32ea812 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -16,6 +16,7 @@ import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { Effect, Layer, Schema, Stream } from "effect"; import { redactEventForBoundary } from "../../orchestration/redactEvent.ts"; +import { normalizePersistedProviderKindName } from "../../provider/providerKind.ts"; import { toPersistenceDecodeError, toPersistenceSqlError, @@ -29,9 +30,9 @@ import { const decodeEventRaw = Schema.decodeUnknownEffect(OrchestrationEvent); /** - * Normalize legacy "claudeCode" provider name to "claudeAgent" in event - * payloads before Schema decoding. This handles data written before the - * provider rename that migrations may not have caught. + * Normalize legacy provider names in event payloads before Schema decoding. + * This handles data written before provider renames that migrations may not + * have caught. */ function normalizeLegacyProviderNames(row: unknown): unknown { if (typeof row !== "object" || row === null) return row; @@ -40,19 +41,45 @@ function normalizeLegacyProviderNames(row: unknown): unknown { if (typeof payload !== "object" || payload === null) return row; const p = payload as Record; let patched = false; + const patchStringProvider = (field: string) => { + const value = p[field]; + if (typeof value !== "string") { + return; + } + const normalizedProvider = normalizePersistedProviderKindName(value); + if (normalizedProvider === null) { + return; + } + p[field] = normalizedProvider; + patched = true; + }; const patchProvider = (field: string) => { const sel = p[field] as Record | undefined; - if (sel && typeof sel === "object" && sel.provider === "claudeCode") { - p[field] = { ...sel, provider: "claudeAgent" }; + if (!sel || typeof sel !== "object" || typeof sel.provider !== "string") { + return; + } + const normalizedProvider = normalizePersistedProviderKindName(sel.provider); + if (normalizedProvider === null) { + return; + } + if (normalizedProvider !== sel.provider) { + p[field] = { ...sel, provider: normalizedProvider }; patched = true; } }; patchProvider("modelSelection"); patchProvider("defaultModelSelection"); - if ((p as Record).provider === "claudeCode") { - (p as Record).provider = "claudeAgent"; - patched = true; - } + patchStringProvider("provider"); + patchStringProvider("defaultProvider"); + patchStringProvider("providerName"); + patchStringProvider("defaultProviderName"); + patchStringProvider("providerKind"); + patchStringProvider("defaultProviderKind"); + patchStringProvider("sessionProvider"); + patchStringProvider("selectedProvider"); + patchStringProvider("activeProvider"); + patchStringProvider("stickyProvider"); + patchStringProvider("stickyActiveProvider"); return patched ? { ...obj, payload: { ...p } } : row; } diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index a03c3c2d1868..2d211dd212a0 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -32,6 +32,8 @@ import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts"; import Migration0017 from "./Migrations/017_ProjectionThreadsArchivedAt.ts"; import Migration0018 from "./Migrations/018_ProjectionThreadsArchivedAtIndex.ts"; import Migration0019 from "./Migrations/019_ProjectionSnapshotLookupIndexes.ts"; +import Migration0020 from "./Migrations/020_NormalizeLegacyProviderKinds.ts"; +import Migration0021 from "./Migrations/021_RepairProjectionThreadProposedPlanImplementationColumns.ts"; /** * Migration loader with all migrations defined inline. @@ -63,6 +65,8 @@ export const migrationEntries = [ [17, "ProjectionThreadsArchivedAt", Migration0017], [18, "ProjectionThreadsArchivedAtIndex", Migration0018], [19, "ProjectionSnapshotLookupIndexes", Migration0019], + [20, "NormalizeLegacyProviderKinds", Migration0020], + [21, "RepairProjectionThreadProposedPlanImplementationColumns", Migration0021], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/020_NormalizeLegacyProviderKinds.test.ts b/apps/server/src/persistence/Migrations/020_NormalizeLegacyProviderKinds.test.ts new file mode 100644 index 000000000000..3ec795c00e1e --- /dev/null +++ b/apps/server/src/persistence/Migrations/020_NormalizeLegacyProviderKinds.test.ts @@ -0,0 +1,206 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("020_NormalizeLegacyProviderKinds", (it) => { + it.effect("rewrites legacy provider names across persisted tables", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 19 }); + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + scripts_json, + created_at, + updated_at, + deleted_at, + default_model_selection_json + ) + VALUES ( + 'project-legacy-provider', + 'Legacy provider project', + '/tmp/project-legacy-provider', + '[]', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL, + '{"provider":"gemini","model":"gemini-2.5-pro"}' + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + archived_at, + deleted_at + ) + VALUES ( + 'thread-legacy-provider', + 'project-legacy-provider', + 'Legacy provider thread', + '{"provider":"claudeCode","model":"claude-sonnet-4-6"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL, + NULL + ) + `; + + yield* sql` + INSERT INTO projection_thread_sessions ( + thread_id, + status, + provider_name, + provider_session_id, + provider_thread_id, + active_turn_id, + last_error, + updated_at, + runtime_mode + ) + VALUES ( + 'thread-legacy-provider', + 'running', + 'gemini', + NULL, + NULL, + NULL, + NULL, + '2026-01-01T00:00:00.000Z', + 'full-access' + ) + `; + + yield* sql` + INSERT INTO provider_session_runtime ( + thread_id, + provider_name, + adapter_key, + runtime_mode, + status, + last_seen_at, + resume_cursor_json, + runtime_payload_json + ) + VALUES ( + 'thread-legacy-provider', + 'claudeCode', + 'claudeCode', + 'full-access', + 'running', + '2026-01-01T00:00:00.000Z', + NULL, + NULL + ) + `; + + yield* sql` + INSERT INTO orchestration_events ( + event_id, + aggregate_kind, + stream_id, + stream_version, + event_type, + occurred_at, + command_id, + causation_event_id, + correlation_id, + actor_kind, + payload_json, + metadata_json + ) + VALUES ( + 'event-legacy-provider', + 'thread', + 'thread-legacy-provider', + 1, + 'thread.created', + '2026-01-01T00:00:00.000Z', + 'command-legacy-provider', + NULL, + NULL, + 'server', + '{"provider":"gemini","defaultProvider":"claudeCode","modelSelection":{"provider":"gemini","model":"gemini-2.5-pro"},"defaultModelSelection":{"provider":"claudeCode","model":"claude-sonnet-4-6"}}', + '{}' + ) + `; + + yield* runMigrations(); + + const normalizedProject = yield* sql<{ readonly provider: string }>` + SELECT json_extract(default_model_selection_json, '$.provider') AS "provider" + FROM projection_projects + WHERE project_id = 'project-legacy-provider' + `; + assert.deepStrictEqual(normalizedProject, [{ provider: "geminiCli" }]); + + const normalizedThread = yield* sql<{ readonly provider: string }>` + SELECT json_extract(model_selection_json, '$.provider') AS "provider" + FROM projection_threads + WHERE thread_id = 'thread-legacy-provider' + `; + assert.deepStrictEqual(normalizedThread, [{ provider: "claudeAgent" }]); + + const normalizedThreadSession = yield* sql<{ readonly providerName: string }>` + SELECT provider_name AS "providerName" + FROM projection_thread_sessions + WHERE thread_id = 'thread-legacy-provider' + `; + assert.deepStrictEqual(normalizedThreadSession, [{ providerName: "geminiCli" }]); + + const normalizedRuntime = yield* sql<{ readonly providerName: string }>` + SELECT provider_name AS "providerName" + FROM provider_session_runtime + WHERE thread_id = 'thread-legacy-provider' + `; + assert.deepStrictEqual(normalizedRuntime, [{ providerName: "claudeAgent" }]); + + const normalizedEvent = yield* sql<{ + readonly provider: string; + readonly defaultProvider: string; + readonly modelSelectionProvider: string; + readonly defaultModelSelectionProvider: string; + }>` + SELECT + json_extract(payload_json, '$.provider') AS "provider", + json_extract(payload_json, '$.defaultProvider') AS "defaultProvider", + json_extract(payload_json, '$.modelSelection.provider') AS "modelSelectionProvider", + json_extract(payload_json, '$.defaultModelSelection.provider') AS "defaultModelSelectionProvider" + FROM orchestration_events + WHERE event_id = 'event-legacy-provider' + `; + assert.deepStrictEqual(normalizedEvent, [ + { + provider: "geminiCli", + defaultProvider: "claudeAgent", + modelSelectionProvider: "geminiCli", + defaultModelSelectionProvider: "claudeAgent", + }, + ]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/020_NormalizeLegacyProviderKinds.ts b/apps/server/src/persistence/Migrations/020_NormalizeLegacyProviderKinds.ts new file mode 100644 index 000000000000..3f8ef4e8de70 --- /dev/null +++ b/apps/server/src/persistence/Migrations/020_NormalizeLegacyProviderKinds.ts @@ -0,0 +1,68 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Normalize legacy provider names in persisted data. + * + * Historical rows may still contain: + * - "claudeCode" before upstream standardized on "claudeAgent" + * - "gemini" before the fork standardized on "geminiCli" + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const rewrites = [ + ["claudeCode", "claudeAgent"], + ["gemini", "geminiCli"], + ] as const; + + for (const [legacyProvider, canonicalProvider] of rewrites) { + yield* sql` + UPDATE orchestration_events + SET payload_json = json_set(payload_json, '$.modelSelection.provider', ${canonicalProvider}) + WHERE json_extract(payload_json, '$.modelSelection.provider') = ${legacyProvider} + `; + + yield* sql` + UPDATE orchestration_events + SET payload_json = json_set(payload_json, '$.defaultModelSelection.provider', ${canonicalProvider}) + WHERE json_extract(payload_json, '$.defaultModelSelection.provider') = ${legacyProvider} + `; + + yield* sql` + UPDATE orchestration_events + SET payload_json = json_set(payload_json, '$.provider', ${canonicalProvider}) + WHERE json_extract(payload_json, '$.provider') = ${legacyProvider} + `; + + yield* sql` + UPDATE orchestration_events + SET payload_json = json_set(payload_json, '$.defaultProvider', ${canonicalProvider}) + WHERE json_extract(payload_json, '$.defaultProvider') = ${legacyProvider} + `; + + yield* sql` + UPDATE projection_threads + SET model_selection_json = json_set(model_selection_json, '$.provider', ${canonicalProvider}) + WHERE json_extract(model_selection_json, '$.provider') = ${legacyProvider} + `; + + yield* sql` + UPDATE projection_projects + SET default_model_selection_json = json_set(default_model_selection_json, '$.provider', ${canonicalProvider}) + WHERE json_extract(default_model_selection_json, '$.provider') = ${legacyProvider} + `; + + yield* sql` + UPDATE projection_thread_sessions + SET provider_name = ${canonicalProvider} + WHERE provider_name = ${legacyProvider} + `; + + yield* sql` + UPDATE provider_session_runtime + SET provider_name = ${canonicalProvider} + WHERE provider_name = ${legacyProvider} + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/021_RepairProjectionThreadProposedPlanImplementationColumns.test.ts b/apps/server/src/persistence/Migrations/021_RepairProjectionThreadProposedPlanImplementationColumns.test.ts new file mode 100644 index 000000000000..72368604d076 --- /dev/null +++ b/apps/server/src/persistence/Migrations/021_RepairProjectionThreadProposedPlanImplementationColumns.test.ts @@ -0,0 +1,48 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("021_RepairProjectionThreadProposedPlanImplementationColumns", (it) => { + it.effect( + "repairs missing proposed plan implementation columns when migration history is ahead", + () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 13 }); + + yield* sql` + INSERT INTO effect_sql_migrations (migration_id, name) + VALUES + (14, 'ProjectionThreadProposedPlanImplementation'), + (15, 'ProjectionTurnsSourceProposedPlan'), + (16, 'CanonicalizeModelSelections'), + (17, 'ProjectionThreadsArchivedAt'), + (18, 'ProjectionThreadsArchivedAtIndex'), + (19, 'ProjectionSnapshotLookupIndexes'), + (20, 'NormalizeLegacyProviderKinds') + `; + + const columnsBeforeRepair = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_proposed_plans) + `; + assert.ok(!columnsBeforeRepair.some((column) => column.name === "implemented_at")); + assert.ok( + !columnsBeforeRepair.some((column) => column.name === "implementation_thread_id"), + ); + + yield* runMigrations(); + + const columnsAfterRepair = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_proposed_plans) + `; + assert.ok(columnsAfterRepair.some((column) => column.name === "implemented_at")); + assert.ok(columnsAfterRepair.some((column) => column.name === "implementation_thread_id")); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/021_RepairProjectionThreadProposedPlanImplementationColumns.ts b/apps/server/src/persistence/Migrations/021_RepairProjectionThreadProposedPlanImplementationColumns.ts new file mode 100644 index 000000000000..5cfeae8923af --- /dev/null +++ b/apps/server/src/persistence/Migrations/021_RepairProjectionThreadProposedPlanImplementationColumns.ts @@ -0,0 +1,23 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_proposed_plans) + `; + + if (!columns.some((column) => column.name === "implemented_at")) { + yield* sql` + ALTER TABLE projection_thread_proposed_plans + ADD COLUMN implemented_at TEXT + `; + } + + if (!columns.some((column) => column.name === "implementation_thread_id")) { + yield* sql` + ALTER TABLE projection_thread_proposed_plans + ADD COLUMN implementation_thread_id TEXT + `; + } +}); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 694dbbd11ddc..60b87edad7d2 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -1612,12 +1612,15 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { if (!cancelResult.ok) { yield* emitRuntimeWarning( context, - "Cursor ACP session/cancel is unavailable; marking turn as interrupted.", + "Cursor ACP session/cancel failed or is unavailable; terminating process as fallback.", cancelResult.error, ); + // Terminate the process to ensure the turn actually stops. + // This will trigger the 'exit' handler which cleans up the session. + yield* stopSessionInternal(context, { emitExitEvent: true }); + } else { + yield* completeTurn(context, "interrupted", "Turn interrupted by user.", "cancelled"); } - - yield* completeTurn(context, "interrupted", "Turn interrupted by user.", "cancelled"); }); const readThread: CursorAdapterShape["readThread"] = (threadId) => diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 9afe27dda6cb..4d31525cafda 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -226,4 +226,43 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL assert.equal(resolvedBinding.value.threadId, threadId); } })); + + it("normalizes legacy gemini provider bindings on read", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.makeUnsafe("thread-gemini-legacy"); + + yield* sql` + INSERT INTO provider_session_runtime ( + thread_id, + provider_name, + adapter_key, + runtime_mode, + status, + last_seen_at, + resume_cursor_json, + runtime_payload_json + ) + VALUES ( + ${threadId}, + ${"gemini"}, + ${"gemini"}, + ${"full-access"}, + ${"running"}, + ${new Date().toISOString()}, + ${null}, + ${null} + ) + `; + + const provider = yield* directory.getProvider(threadId); + assert.equal(provider, "geminiCli"); + + const binding = yield* directory.getBinding(threadId); + assertSome(binding, { + threadId, + provider: "geminiCli", + }); + })); }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index cea1c34f093b..7f06004b0020 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -1,8 +1,10 @@ import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; -import { Effect, Layer, Option } from "effect"; +import { Cache, Duration, Effect, Layer, Option } from "effect"; +import * as Semaphore from "effect/Semaphore"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts"; +import { normalizePersistedProviderKindName } from "../providerKind.ts"; import { ProviderSessionDirectory, type ProviderRuntimeBinding, @@ -22,17 +24,9 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if ( - providerName === "codex" || - providerName === "copilot" || - providerName === "claudeAgent" || - providerName === "cursor" || - providerName === "opencode" || - providerName === "geminiCli" || - providerName === "amp" || - providerName === "kilo" - ) { - return Effect.succeed(providerName); + const normalizedProvider = normalizePersistedProviderKindName(providerName); + if (normalizedProvider !== null) { + return Effect.succeed(normalizedProvider); } return Effect.fail( new ProviderSessionDirectoryPersistenceError({ @@ -62,6 +56,12 @@ function mergeRuntimePayload( const makeProviderSessionDirectory = Effect.gen(function* () { const repository = yield* ProviderSessionRuntimeRepository; + const upsertLocks = yield* Cache.make({ + capacity: 10_000, + timeToLive: Duration.minutes(60), + lookup: () => Semaphore.make(1), + }); + const getBinding = (threadId: ThreadId) => repository.getByThreadId({ threadId }).pipe( Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getByThreadId")), @@ -89,42 +89,51 @@ const makeProviderSessionDirectory = Effect.gen(function* () { ); const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) { - const existing = yield* repository - .getByThreadId({ threadId: binding.threadId }) - .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId"))); - - const existingRuntime = Option.getOrUndefined(existing); - const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId; - if (!resolvedThreadId) { + const threadId = binding.threadId; + if (!threadId) { return yield* new ProviderValidationError({ operation: "ProviderSessionDirectory.upsert", issue: "threadId must be a non-empty string.", }); } - const now = new Date().toISOString(); - const providerChanged = - existingRuntime !== undefined && existingRuntime.providerName !== binding.provider; - yield* repository - .upsert({ - threadId: resolvedThreadId, - providerName: binding.provider, - adapterKey: - binding.adapterKey ?? - (providerChanged ? binding.provider : (existingRuntime?.adapterKey ?? binding.provider)), - runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", - status: binding.status ?? existingRuntime?.status ?? "running", - lastSeenAt: now, - resumeCursor: - binding.resumeCursor !== undefined - ? binding.resumeCursor - : (existingRuntime?.resumeCursor ?? null), - runtimePayload: mergeRuntimePayload( - existingRuntime?.runtimePayload ?? null, - binding.runtimePayload, - ), - }) - .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); + const lock = yield* Cache.get(upsertLocks, threadId); + yield* Semaphore.withPermit(lock)( + Effect.gen(function* () { + const existing = yield* repository + .getByThreadId({ threadId }) + .pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId")), + ); + + const existingRuntime = Option.getOrUndefined(existing); + const now = new Date().toISOString(); + const providerChanged = + existingRuntime !== undefined && existingRuntime.providerName !== binding.provider; + yield* repository + .upsert({ + threadId, + providerName: binding.provider, + adapterKey: + binding.adapterKey ?? + (providerChanged + ? binding.provider + : (existingRuntime?.adapterKey ?? binding.provider)), + runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", + status: binding.status ?? existingRuntime?.status ?? "running", + lastSeenAt: now, + resumeCursor: + binding.resumeCursor !== undefined + ? binding.resumeCursor + : (existingRuntime?.resumeCursor ?? null), + runtimePayload: mergeRuntimePayload( + existingRuntime?.runtimePayload ?? null, + binding.runtimePayload, + ), + }) + .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); + }), + ); }); const getProvider: ProviderSessionDirectoryShape["getProvider"] = (threadId) => diff --git a/apps/server/src/provider/providerKind.ts b/apps/server/src/provider/providerKind.ts new file mode 100644 index 000000000000..14f54075553e --- /dev/null +++ b/apps/server/src/provider/providerKind.ts @@ -0,0 +1,27 @@ +import type { ProviderKind } from "@t3tools/contracts"; + +const PROVIDER_KINDS = [ + "codex", + "copilot", + "claudeAgent", + "cursor", + "opencode", + "geminiCli", + "amp", + "kilo", +] as const satisfies ReadonlyArray; + +const LEGACY_PROVIDER_KIND_ALIASES = { + claudeCode: "claudeAgent", + gemini: "geminiCli", +} as const satisfies Record; + +const PROVIDER_KIND_SET = new Set(PROVIDER_KINDS); + +export function normalizePersistedProviderKindName(providerName: string): ProviderKind | null { + const normalized = + LEGACY_PROVIDER_KIND_ALIASES[providerName as keyof typeof LEGACY_PROVIDER_KIND_ALIASES] ?? + providerName; + + return PROVIDER_KIND_SET.has(normalized as ProviderKind) ? (normalized as ProviderKind) : null; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5c29cdfa0063..fe7136f2edf8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -156,6 +156,7 @@ import { ChatHeader } from "./chat/ChatHeader"; import { ContextWindowMeter } from "./chat/ContextWindowMeter"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; +import { resolveModelOptionsByProvider } from "../providerModelOptions"; import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; @@ -1417,19 +1418,29 @@ export default function ChatView({ threadId }: ChatViewProps) { const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); const modelOptionsByProvider = useMemo( - () => ({ - codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], - claudeAgent: - providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [], - copilot: providerStatuses.find((provider) => provider.provider === "copilot")?.models ?? [], - cursor: providerStatuses.find((provider) => provider.provider === "cursor")?.models ?? [], - opencode: providerStatuses.find((provider) => provider.provider === "opencode")?.models ?? [], - geminiCli: - providerStatuses.find((provider) => provider.provider === "geminiCli")?.models ?? [], - amp: providerStatuses.find((provider) => provider.provider === "amp")?.models ?? [], - kilo: providerStatuses.find((provider) => provider.provider === "kilo")?.models ?? [], - }), - [providerStatuses], + () => + resolveModelOptionsByProvider({ + customCodexModels: settings.providers.codex.customModels, + customCopilotModels: settings.providers.copilot.customModels, + customClaudeModels: settings.providers.claudeAgent.customModels, + customCursorModels: settings.providers.cursor.customModels, + customOpencodeModels: settings.providers.opencode.customModels, + customGeminiCliModels: settings.providers.geminiCli.customModels, + customAmpModels: settings.providers.amp.customModels, + customKiloModels: settings.providers.kilo.customModels, + discovered: { + codex: providerStatuses.find((provider) => provider.provider === "codex")?.models, + claudeAgent: providerStatuses.find((provider) => provider.provider === "claudeAgent") + ?.models, + copilot: providerStatuses.find((provider) => provider.provider === "copilot")?.models, + cursor: providerStatuses.find((provider) => provider.provider === "cursor")?.models, + opencode: providerStatuses.find((provider) => provider.provider === "opencode")?.models, + geminiCli: providerStatuses.find((provider) => provider.provider === "geminiCli")?.models, + amp: providerStatuses.find((provider) => provider.provider === "amp")?.models, + kilo: providerStatuses.find((provider) => provider.provider === "kilo")?.models, + }, + }), + [providerStatuses, settings.providers], ); const selectedModelForPickerWithCustomFallback = useMemo(() => { const currentOptions = modelOptionsByProvider[selectedProvider]; diff --git a/apps/web/src/components/chat/ProviderModelPicker.logic.test.ts b/apps/web/src/components/chat/ProviderModelPicker.logic.test.ts new file mode 100644 index 000000000000..be0588cb7db2 --- /dev/null +++ b/apps/web/src/components/chat/ProviderModelPicker.logic.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { resolveModelOptionsByProvider } from "../../providerModelOptions"; + +const EMPTY_CUSTOM_MODELS = { + customCodexModels: [], + customCopilotModels: [], + customClaudeModels: [], + customCursorModels: [], + customOpencodeModels: [], + customGeminiCliModels: [], + customAmpModels: [], + customKiloModels: [], +} as const; + +describe("resolveModelOptionsByProvider", () => { + it("keeps built-in model catalogs when the server has no snapshot for a provider", () => { + const modelOptions = resolveModelOptionsByProvider(EMPTY_CUSTOM_MODELS); + + expect(modelOptions.copilot.length).toBeGreaterThan(0); + expect(modelOptions.cursor.length).toBeGreaterThan(0); + expect(modelOptions.opencode.length).toBeGreaterThan(0); + expect(modelOptions.geminiCli.length).toBeGreaterThan(0); + expect(modelOptions.amp.length).toBeGreaterThan(0); + expect(modelOptions.kilo.length).toBeGreaterThan(0); + expect(modelOptions.copilot.some((option) => option.slug === "claude-sonnet-4.6")).toBe(true); + expect(modelOptions.cursor.some((option) => option.slug === "gpt-5.3-codex")).toBe(true); + expect(modelOptions.opencode.some((option) => option.slug === "openai/gpt-5")).toBe(true); + expect(modelOptions.geminiCli.some((option) => option.slug === "gemini-2.5-pro")).toBe(true); + expect(modelOptions.amp.some((option) => option.slug === "smart")).toBe(true); + expect(modelOptions.kilo.some((option) => option.slug === "openai/gpt-5")).toBe(true); + }); + + it("merges discovered provider models on top of the built-in fallback list", () => { + const modelOptions = resolveModelOptionsByProvider({ + ...EMPTY_CUSTOM_MODELS, + discovered: { + opencode: [ + { slug: "openai/gpt-5", name: "OpenAI / GPT-5", connected: true }, + { slug: "anthropic/sonnet", name: "Anthropic / Sonnet", connected: false }, + ], + kilo: [{ slug: "openai/gpt-5", name: "OpenAI / GPT-5", connected: true }], + copilot: [{ slug: "gpt-5.4", name: "GPT-5.4", pricingTier: "1x" }], + }, + }); + + expect(modelOptions.opencode[0]).toEqual({ + slug: "anthropic/sonnet", + name: "Anthropic / Sonnet", + connected: false, + }); + expect(modelOptions.opencode).toContainEqual({ + slug: "openai/gpt-5", + name: "OpenAI / GPT-5", + connected: true, + isCustom: false, + }); + expect(modelOptions.kilo).toContainEqual({ + slug: "openai/gpt-5", + name: "OpenAI / GPT-5", + connected: true, + isCustom: false, + }); + expect(modelOptions.copilot.find((option) => option.slug === "gpt-5.4")?.pricingTier).toBe( + "1x", + ); + }); +}); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 923bbe975954..549782d3b42d 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,10 +1,5 @@ import { type ModelSlug, type ProviderKind } from "@t3tools/contracts"; -import { - normalizeModelSlug, - parseCursorModelSelection, - resolveSelectableModel, - resolveCursorPickerModelSlug, -} from "@t3tools/shared/model"; +import { normalizeModelSlug, parseCursorModelSelection } from "@t3tools/shared/model"; import { memo, useState } from "react"; import type { VariantProps } from "class-variance-authority"; import { PROVIDER_OPTIONS, type ProviderPickerKind } from "../../session-logic"; @@ -35,16 +30,12 @@ import { OpenCodeIcon, } from "../Icons"; import { cn } from "~/lib/utils"; -import { getAppModelOptions } from "../../appSettings"; -import { getCursorModelFamilyOptions } from "@t3tools/shared/model"; - -export type ModelOptionEntry = { - slug: string; - name: string; - pricingTier?: string; - isCustom?: boolean; - connected?: boolean; -}; +import { type ModelOptionEntry } from "../../providerModelOptions"; +export { + buildModelOptionsByProvider, + mergeDiscoveredModels, + resolveModelOptionsByProvider, +} from "../../providerModelOptions"; type GroupedModelEntry = { readonly subProvider: string; @@ -52,79 +43,6 @@ type GroupedModelEntry = { readonly connected: boolean; }; -export function buildModelOptionsByProvider(settings: { - customCodexModels: readonly string[]; - customCopilotModels: readonly string[]; - customClaudeModels: readonly string[]; - customCursorModels: readonly string[]; - customOpencodeModels: readonly string[]; - customGeminiCliModels: readonly string[]; - customAmpModels: readonly string[]; - customKiloModels: readonly string[]; -}): Record> { - const cursorFamilyOptions = getCursorModelFamilyOptions(); - return { - codex: getAppModelOptions("codex", settings.customCodexModels), - copilot: getAppModelOptions("copilot", settings.customCopilotModels), - claudeAgent: getAppModelOptions("claudeAgent", settings.customClaudeModels), - cursor: [ - ...cursorFamilyOptions, - ...getAppModelOptions("cursor", settings.customCursorModels).filter( - (option) => - option.isCustom && !cursorFamilyOptions.some((family) => family.slug === option.slug), - ), - ], - opencode: getAppModelOptions("opencode", settings.customOpencodeModels), - geminiCli: getAppModelOptions("geminiCli", settings.customGeminiCliModels), - amp: getAppModelOptions("amp", settings.customAmpModels), - kilo: getAppModelOptions("kilo", settings.customKiloModels), - }; -} - -export function mergeDiscoveredModels( - base: Record>, - discovered: Partial | undefined>>, -): Record> { - const result = { ...base }; - for (const [provider, models] of Object.entries(discovered) as Array< - [ProviderKind, ReadonlyArray | undefined] - >) { - if (!models || models.length === 0) continue; - const normalizedModels = - provider === "cursor" - ? models.filter((model) => resolveCursorPickerModelSlug(model.slug) === model.slug) - : models; - const dedupedModels = Array.from(new Map(normalizedModels.map((m) => [m.slug, m])).values()); - const existing = new Set(base[provider]?.map((m) => m.slug)); - // For copilot, discovered models replace the static list but inherit - // pricingTier from the static entries when the SDK doesn't provide it. - if (provider === "copilot") { - const baseTiers = new Map((base[provider] ?? []).map((m) => [m.slug, m.pricingTier])); - const enriched = dedupedModels.map((m) => { - if (m.pricingTier) return m; - const tier = baseTiers.get(m.slug); - return tier ? { ...m, pricingTier: tier } : m; - }); - const customOnly = (base[provider] ?? []).filter( - (m) => m.isCustom && !dedupedModels.some((d) => d.slug === m.slug), - ); - result[provider] = [...enriched, ...customOnly]; - continue; - } - // Build a lookup of discovered models by slug so we can merge metadata - // (e.g. pricingTier) into base entries and also add truly-new models. - const discoveredBySlug = new Map(dedupedModels.map((m) => [m.slug, m])); - const merged = (base[provider] ?? []).map((m) => { - const disc = discoveredBySlug.get(m.slug); - return disc ? Object.assign({}, m, disc) : m; - }); - // Append any discovered models that weren't already in the base list. - const additions = dedupedModels.filter((m) => !existing.has(m.slug)); - result[provider] = [...additions, ...merged]; - } - return result; -} - function groupModelsBySubProvider( models: ReadonlyArray, ): ReadonlyArray { diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index f0ea32d4be6c..675da2ff37e8 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -67,6 +67,29 @@ describe("GeneralSettingsPanel observability", () => { .toBeInTheDocument(); }); + it("renders the same provider lineup as the chat picker", async () => { + setServerConfigSnapshot(createBaseServerConfig()); + + await render( + + + , + ); + + for (const label of [ + "Codex", + "GitHub Copilot", + "Claude Code", + "Cursor Agent", + "OpenCode", + "Gemini CLI", + "AMPcode", + "Kilo", + ]) { + await expect.element(page.getByText(label, { exact: true })).toBeInTheDocument(); + } + }); + it("opens the logs folder in the preferred editor", async () => { const openInEditor = vi.fn().mockResolvedValue(undefined); window.nativeApi = { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 4451cf2b9fab..584e3ea89b72 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -13,7 +13,6 @@ import { useQueryClient } from "@tanstack/react-query"; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type ModelSelection, - PROVIDER_DISPLAY_NAMES, type ProviderKind, type ServerProvider, type ServerProviderModel, @@ -30,7 +29,11 @@ import { isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, } from "../../components/desktopUpdate.logic"; -import { ProviderModelPicker } from "../chat/ProviderModelPicker"; +import { + ProviderModelPicker, + buildModelOptionsByProvider, + mergeDiscoveredModels, +} from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; import { isElectron } from "../../env"; @@ -47,6 +50,7 @@ import { resolveAppModelSelectionState, } from "../../modelSelection"; import { ensureNativeApi, readNativeApi } from "../../nativeApi"; +import { PROVIDER_OPTIONS } from "../../session-logic"; import { useStore } from "../../store"; import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; import { cn } from "../../lib/utils"; @@ -89,32 +93,79 @@ const TIMESTAMP_FORMAT_LABELS = { type InstallProviderSettings = { provider: ProviderKind; - title: string; + label: string; binaryPlaceholder: string; binaryDescription: ReactNode; - homePathKey?: "codexHomePath"; homePlaceholder?: string; homeDescription?: ReactNode; + customModelExample: string; }; const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ { provider: "codex", - title: "Codex", + label: "Codex", binaryPlaceholder: "Codex binary path", binaryDescription: "Path to the Codex binary", - homePathKey: "codexHomePath", homePlaceholder: "CODEX_HOME", homeDescription: "Optional custom Codex home and config directory.", + customModelExample: "gpt-6.7-codex-ultra-preview", + }, + { + provider: "copilot", + label: "GitHub Copilot", + binaryPlaceholder: "Copilot binary path", + binaryDescription: "Path to the Copilot CLI binary", + customModelExample: "gpt-4o-copilot", }, { provider: "claudeAgent", - title: "Claude", + label: "Claude Code", binaryPlaceholder: "Claude binary path", binaryDescription: "Path to the Claude binary", + customModelExample: "claude-sonnet-5-0", + }, + { + provider: "cursor", + label: "Cursor Agent", + binaryPlaceholder: "Cursor binary path", + binaryDescription: "Path to the Cursor Agent binary", + customModelExample: "cursor-fast", + }, + { + provider: "opencode", + label: "OpenCode", + binaryPlaceholder: "OpenCode binary path", + binaryDescription: "Path to the OpenCode binary", + customModelExample: "opencode-pro", + }, + { + provider: "geminiCli", + label: "Gemini CLI", + binaryPlaceholder: "Gemini CLI binary path", + binaryDescription: "Path to the Gemini CLI binary", + customModelExample: "gemini-2.0-ultra", + }, + { + provider: "amp", + label: "AMPcode", + binaryPlaceholder: "AMPcode binary path", + binaryDescription: "Path to the AMPcode binary", + customModelExample: "amp-pro", + }, + { + provider: "kilo", + label: "Kilo", + binaryPlaceholder: "Kilo binary path", + binaryDescription: "Path to the Kilo binary", + customModelExample: "kilo-advanced", }, ] as const; +const PROVIDER_LABEL_BY_PROVIDER = Object.fromEntries( + PROVIDER_OPTIONS.map((option) => [option.value, option.label] as const), +) as Record; + const PROVIDER_STATUS_STYLES = { disabled: { dot: "bg-amber-400", @@ -130,11 +181,18 @@ const PROVIDER_STATUS_STYLES = { }, } as const; -function getProviderSummary(provider: ServerProvider | undefined) { +function getProviderSummary(provider: ServerProvider | undefined, enabled: boolean) { if (!provider) { + if (!enabled) { + return { + headline: "Disabled", + detail: "This provider is disabled for new sessions in T3 Code.", + }; + } return { - headline: "Checking provider status", - detail: "Waiting for the server to report installation and authentication details.", + headline: "Status unavailable", + detail: + "Live installation and authentication status has not been published for this provider yet.", }; } if (!provider.enabled) { @@ -599,6 +657,38 @@ export function GeneralSettingsPanel() { const textGenProvider = textGenerationModelSelection.provider; const textGenModel = textGenerationModelSelection.model; const textGenModelOptions = textGenerationModelSelection.options; + const staticProviderModelOptions = useMemo( + () => + buildModelOptionsByProvider({ + customCodexModels: settings.providers.codex.customModels, + customCopilotModels: settings.providers.copilot.customModels, + customClaudeModels: settings.providers.claudeAgent.customModels, + customCursorModels: settings.providers.cursor.customModels, + customOpencodeModels: settings.providers.opencode.customModels, + customGeminiCliModels: settings.providers.geminiCli.customModels, + customAmpModels: settings.providers.amp.customModels, + customKiloModels: settings.providers.kilo.customModels, + }), + [settings.providers], + ); + const discoveredProviderModelOptions = useMemo( + () => + Object.fromEntries( + serverProviders.map((provider) => [ + provider.provider, + provider.models.map((model) => ({ + slug: model.slug, + name: model.name, + isCustom: model.isCustom, + })), + ]), + ) as Partial>, + [serverProviders], + ); + const providerModelOptions = useMemo( + () => mergeDiscoveredModels(staticProviderModelOptions, discoveredProviderModelOptions), + [discoveredProviderModelOptions, staticProviderModelOptions], + ); const gitModelOptionsByProvider = getCustomModelOptionsByProvider( settings, serverProviders, @@ -752,24 +842,30 @@ export function GeneralSettingsPanel() { const providerConfig = settings.providers[providerSettings.provider]; const defaultProviderConfig = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider]; const statusKey = liveProvider?.status ?? (providerConfig.enabled ? "warning" : "disabled"); - const summary = getProviderSummary(liveProvider); - const models: ReadonlyArray = - liveProvider?.models ?? - providerConfig.customModels.map((slug) => ({ - slug, - name: slug, - isCustom: true, - capabilities: null, - })); + const summary = getProviderSummary(liveProvider, providerConfig.enabled); + const liveModelsBySlug = new Map( + (liveProvider?.models ?? []).map((model) => [model.slug, model]), + ); + const models: ReadonlyArray = providerModelOptions[ + providerSettings.provider + ].map( + (modelOption) => + liveModelsBySlug.get(modelOption.slug) ?? { + slug: modelOption.slug, + name: modelOption.name, + isCustom: modelOption.isCustom ?? false, + capabilities: null, + }, + ); return { provider: providerSettings.provider, - title: providerSettings.title, + title: providerSettings.label, binaryPlaceholder: providerSettings.binaryPlaceholder, binaryDescription: providerSettings.binaryDescription, - homePathKey: providerSettings.homePathKey, homePlaceholder: providerSettings.homePlaceholder, homeDescription: providerSettings.homeDescription, + customModelExample: providerSettings.customModelExample, binaryPathValue: providerConfig.binaryPath, isDirty: !Equal.equals(providerConfig, defaultProviderConfig), liveProvider, @@ -1114,7 +1210,7 @@ export function GeneralSettingsPanel() { const customModelInput = customModelInputByProvider[providerCard.provider]; const customModelError = customModelErrorByProvider[providerCard.provider] ?? null; const providerDisplayName = - PROVIDER_DISPLAY_NAMES[providerCard.provider] ?? providerCard.title; + PROVIDER_LABEL_BY_PROVIDER[providerCard.provider] ?? providerCard.title; return (
@@ -1248,17 +1344,17 @@ export function GeneralSettingsPanel() {
- {providerCard.homePathKey ? ( + {providerCard.provider === "codex" ? (