mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge branch 'main' into move-marketing
This commit is contained in:
8
.github/workflows/pr-check.yml
vendored
8
.github/workflows/pr-check.yml
vendored
@@ -29,5 +29,13 @@ jobs:
|
||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||
run: npm install
|
||||
|
||||
- name: Install Linux native bindings
|
||||
# Workaround for npm optional dependencies bug (npm/cli#4828)
|
||||
# Explicitly install Linux bindings needed for build tools
|
||||
run: |
|
||||
npm install --no-save --force \
|
||||
@rollup/rollup-linux-x64-gnu@4.53.3 \
|
||||
@tailwindcss/oxide-linux-x64-gnu@4.1.17
|
||||
|
||||
- name: Run build:electron
|
||||
run: npm run build:electron
|
||||
|
||||
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
@@ -48,6 +48,15 @@ jobs:
|
||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||
run: npm install
|
||||
|
||||
- name: Install Linux native bindings
|
||||
# Workaround for npm optional dependencies bug (npm/cli#4828)
|
||||
# Only needed on Linux - macOS and Windows get their bindings automatically
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
npm install --no-save --force \
|
||||
@rollup/rollup-linux-x64-gnu@4.53.3 \
|
||||
@tailwindcss/oxide-linux-x64-gnu@4.1.17
|
||||
|
||||
- name: Extract and set version
|
||||
id: version
|
||||
shell: bash
|
||||
|
||||
53
.github/workflows/test.yml
vendored
Normal file
53
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Test Suite
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||
run: npm install
|
||||
|
||||
- name: Install Linux native bindings
|
||||
# Workaround for npm optional dependencies bug (npm/cli#4828)
|
||||
# Explicitly install Linux bindings needed for build tools
|
||||
run: |
|
||||
npm install --no-save --force \
|
||||
@rollup/rollup-linux-x64-gnu@4.53.3 \
|
||||
@tailwindcss/oxide-linux-x64-gnu@4.1.17
|
||||
|
||||
- name: Run server tests with coverage
|
||||
run: npm run test:server:coverage
|
||||
env:
|
||||
NODE_ENV: test
|
||||
|
||||
# - name: Upload coverage reports
|
||||
# uses: codecov/codecov-action@v4
|
||||
# if: always()
|
||||
# with:
|
||||
# files: ./apps/server/coverage/coverage-final.json
|
||||
# flags: server
|
||||
# name: server-coverage
|
||||
# env:
|
||||
# CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,3 +11,5 @@ dist/
|
||||
.automaker/
|
||||
/.automaker/*
|
||||
/.automaker/
|
||||
|
||||
/old
|
||||
6
.npmrc
6
.npmrc
@@ -8,3 +8,9 @@
|
||||
#
|
||||
# In CI/CD: Use "npm install" instead of "npm ci" to allow npm to resolve
|
||||
# the correct platform-specific binaries at install time.
|
||||
|
||||
# Include bindings for all platforms in package-lock.json to support CI/CD
|
||||
# This ensures Linux, macOS, and Windows bindings are all present
|
||||
# NOTE: Only enable when regenerating package-lock.json, then comment out to keep installs fast
|
||||
# supportedArchitectures.os=linux,darwin,win32
|
||||
# supportedArchitectures.cpu=x64,arm64
|
||||
|
||||
1
apps/app/.gitignore
vendored
1
apps/app/.gitignore
vendored
@@ -48,3 +48,4 @@ next-env.d.ts
|
||||
|
||||
# Electron
|
||||
/dist/
|
||||
/server-bundle/
|
||||
|
||||
@@ -8,21 +8,114 @@
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
const fs = require("fs");
|
||||
|
||||
// Load environment variables from .env file
|
||||
require("dotenv").config({ path: path.join(__dirname, "../.env") });
|
||||
|
||||
const http = require("http");
|
||||
const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron");
|
||||
|
||||
// Load environment variables from .env file (development only)
|
||||
if (!app.isPackaged) {
|
||||
try {
|
||||
require("dotenv").config({ path: path.join(__dirname, "../.env") });
|
||||
} catch (error) {
|
||||
console.warn("[Electron] dotenv not available:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
let mainWindow = null;
|
||||
let serverProcess = null;
|
||||
let staticServer = null;
|
||||
const SERVER_PORT = 3008;
|
||||
const STATIC_PORT = 3007;
|
||||
|
||||
// Get icon path - works in both dev and production
|
||||
// Get icon path - works in both dev and production, cross-platform
|
||||
function getIconPath() {
|
||||
return app.isPackaged
|
||||
? path.join(process.resourcesPath, "app", "public", "logo.png")
|
||||
: path.join(__dirname, "../public/logo.png");
|
||||
// Different icon formats for different platforms
|
||||
let iconFile;
|
||||
if (process.platform === "win32") {
|
||||
iconFile = "icon.ico";
|
||||
} else if (process.platform === "darwin") {
|
||||
iconFile = "logo_larger.png";
|
||||
} else {
|
||||
// Linux
|
||||
iconFile = "logo_larger.png";
|
||||
}
|
||||
|
||||
const iconPath = path.join(__dirname, "../public", iconFile);
|
||||
|
||||
// Verify the icon exists
|
||||
if (!fs.existsSync(iconPath)) {
|
||||
console.warn(`[Electron] Icon not found at: ${iconPath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return iconPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start static file server for production builds
|
||||
*/
|
||||
async function startStaticServer() {
|
||||
const staticPath = path.join(__dirname, "../out");
|
||||
|
||||
staticServer = http.createServer((request, response) => {
|
||||
// Parse the URL and remove query string
|
||||
let filePath = path.join(staticPath, request.url.split("?")[0]);
|
||||
|
||||
// Default to index.html for directory requests
|
||||
if (filePath.endsWith("/")) {
|
||||
filePath = path.join(filePath, "index.html");
|
||||
} else if (!path.extname(filePath)) {
|
||||
filePath += ".html";
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
fs.stat(filePath, (err, stats) => {
|
||||
if (err || !stats.isFile()) {
|
||||
// Try index.html for SPA fallback
|
||||
filePath = path.join(staticPath, "index.html");
|
||||
}
|
||||
|
||||
// Read and serve the file
|
||||
fs.readFile(filePath, (error, content) => {
|
||||
if (error) {
|
||||
response.writeHead(500);
|
||||
response.end("Server Error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set content type based on file extension
|
||||
const ext = path.extname(filePath);
|
||||
const contentTypes = {
|
||||
".html": "text/html",
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".eot": "application/vnd.ms-fontobject",
|
||||
};
|
||||
|
||||
response.writeHead(200, { "Content-Type": contentTypes[ext] || "application/octet-stream" });
|
||||
response.end(content);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
staticServer.listen(STATIC_PORT, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
console.log(`[Electron] Static server running at http://localhost:${STATIC_PORT}`);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,14 +128,18 @@ async function startServer() {
|
||||
let command, args, serverPath;
|
||||
if (isDev) {
|
||||
// In development, use tsx to run TypeScript directly
|
||||
// Use the node executable that's running Electron
|
||||
command = process.execPath; // This is the path to node.exe
|
||||
// Use node from PATH (process.execPath in Electron points to Electron, not Node.js)
|
||||
// spawn() resolves "node" from PATH on all platforms (Windows, Linux, macOS)
|
||||
command = "node";
|
||||
serverPath = path.join(__dirname, "../../server/src/index.ts");
|
||||
|
||||
|
||||
// Find tsx CLI - check server node_modules first, then root
|
||||
const serverNodeModules = path.join(__dirname, "../../server/node_modules/tsx");
|
||||
const serverNodeModules = path.join(
|
||||
__dirname,
|
||||
"../../server/node_modules/tsx"
|
||||
);
|
||||
const rootNodeModules = path.join(__dirname, "../../../node_modules/tsx");
|
||||
|
||||
|
||||
let tsxCliPath;
|
||||
if (fs.existsSync(path.join(serverNodeModules, "dist/cli.mjs"))) {
|
||||
tsxCliPath = path.join(serverNodeModules, "dist/cli.mjs");
|
||||
@@ -51,30 +148,61 @@ async function startServer() {
|
||||
} else {
|
||||
// Last resort: try require.resolve
|
||||
try {
|
||||
tsxCliPath = require.resolve("tsx/cli.mjs", { paths: [path.join(__dirname, "../../server")] });
|
||||
tsxCliPath = require.resolve("tsx/cli.mjs", {
|
||||
paths: [path.join(__dirname, "../../server")],
|
||||
});
|
||||
} catch {
|
||||
throw new Error("Could not find tsx. Please run 'npm install' in the server directory.");
|
||||
throw new Error(
|
||||
"Could not find tsx. Please run 'npm install' in the server directory."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
args = [tsxCliPath, "watch", serverPath];
|
||||
} else {
|
||||
// In production, use compiled JavaScript
|
||||
command = "node";
|
||||
serverPath = path.join(process.resourcesPath, "server", "index.js");
|
||||
args = [serverPath];
|
||||
|
||||
// Verify server files exist
|
||||
if (!fs.existsSync(serverPath)) {
|
||||
throw new Error(`Server not found at: ${serverPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Set environment variables for server
|
||||
const serverNodeModules = app.isPackaged
|
||||
? path.join(process.resourcesPath, "server", "node_modules")
|
||||
: path.join(__dirname, "../../server/node_modules");
|
||||
|
||||
// Set default workspace directory to user's Documents/Automaker
|
||||
const defaultWorkspaceDir = path.join(app.getPath("documents"), "Automaker");
|
||||
|
||||
// Ensure workspace directory exists
|
||||
if (!fs.existsSync(defaultWorkspaceDir)) {
|
||||
try {
|
||||
fs.mkdirSync(defaultWorkspaceDir, { recursive: true });
|
||||
console.log("[Electron] Created workspace directory:", defaultWorkspaceDir);
|
||||
} catch (error) {
|
||||
console.error("[Electron] Failed to create workspace directory:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
PORT: SERVER_PORT.toString(),
|
||||
DATA_DIR: app.getPath("userData"),
|
||||
NODE_PATH: serverNodeModules,
|
||||
WORKSPACE_DIR: process.env.WORKSPACE_DIR || defaultWorkspaceDir,
|
||||
};
|
||||
|
||||
console.log("[Electron] Starting backend server...");
|
||||
console.log("[Electron] Server path:", serverPath);
|
||||
console.log("[Electron] NODE_PATH:", serverNodeModules);
|
||||
|
||||
serverProcess = spawn(command, args, {
|
||||
cwd: path.dirname(serverPath),
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
@@ -92,6 +220,11 @@ async function startServer() {
|
||||
serverProcess = null;
|
||||
});
|
||||
|
||||
serverProcess.on("error", (err) => {
|
||||
console.error(`[Server] Failed to start server process:`, err);
|
||||
serverProcess = null;
|
||||
});
|
||||
|
||||
// Wait for server to be ready
|
||||
await waitForServer();
|
||||
}
|
||||
@@ -105,13 +238,16 @@ async function waitForServer(maxAttempts = 30) {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const req = http.get(`http://localhost:${SERVER_PORT}/api/health`, (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Status: ${res.statusCode}`));
|
||||
const req = http.get(
|
||||
`http://localhost:${SERVER_PORT}/api/health`,
|
||||
(res) => {
|
||||
if (res.statusCode === 200) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Status: ${res.statusCode}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.setTimeout(1000, () => {
|
||||
req.destroy();
|
||||
@@ -132,12 +268,12 @@ async function waitForServer(maxAttempts = 30) {
|
||||
* Create the main window
|
||||
*/
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
const iconPath = getIconPath();
|
||||
const windowOptions = {
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 1024,
|
||||
minHeight: 700,
|
||||
icon: getIconPath(),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
contextIsolation: true,
|
||||
@@ -145,17 +281,20 @@ function createWindow() {
|
||||
},
|
||||
titleBarStyle: "hiddenInset",
|
||||
backgroundColor: "#0a0a0a",
|
||||
});
|
||||
};
|
||||
|
||||
// Load Next.js dev server in development or production build
|
||||
// Only set icon if it exists
|
||||
if (iconPath) {
|
||||
windowOptions.icon = iconPath;
|
||||
}
|
||||
|
||||
mainWindow = new BrowserWindow(windowOptions);
|
||||
|
||||
// Load Next.js dev server in development or static server in production
|
||||
const isDev = !app.isPackaged;
|
||||
if (isDev) {
|
||||
mainWindow.loadURL("http://localhost:3007");
|
||||
if (process.env.OPEN_DEVTOOLS === "true") {
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, "../.next/server/app/index.html"));
|
||||
mainWindow.loadURL(`http://localhost:${STATIC_PORT}`);
|
||||
if (isDev && process.env.OPEN_DEVTOOLS === "true") {
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
@@ -173,10 +312,22 @@ function createWindow() {
|
||||
app.whenReady().then(async () => {
|
||||
// Set app icon (dock icon on macOS)
|
||||
if (process.platform === "darwin" && app.dock) {
|
||||
app.dock.setIcon(getIconPath());
|
||||
const iconPath = getIconPath();
|
||||
if (iconPath) {
|
||||
try {
|
||||
app.dock.setIcon(iconPath);
|
||||
} catch (error) {
|
||||
console.warn("[Electron] Failed to set dock icon:", error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Start static file server in production
|
||||
if (app.isPackaged) {
|
||||
await startStaticServer();
|
||||
}
|
||||
|
||||
// Start backend server
|
||||
await startServer();
|
||||
|
||||
@@ -207,6 +358,13 @@ app.on("before-quit", () => {
|
||||
serverProcess.kill();
|
||||
serverProcess = null;
|
||||
}
|
||||
|
||||
// Close static server
|
||||
if (staticServer) {
|
||||
console.log("[Electron] Stopping static server...");
|
||||
staticServer.close();
|
||||
staticServer = null;
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "export",
|
||||
env: {
|
||||
CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN || "",
|
||||
},
|
||||
|
||||
@@ -20,7 +20,11 @@
|
||||
"dev:electron": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && electron .\"",
|
||||
"dev:electron:debug": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && OPEN_DEVTOOLS=true electron .\"",
|
||||
"build": "next build",
|
||||
"build:electron": "next build && electron-builder",
|
||||
"build:electron": "node scripts/prepare-server.js && next build && electron-builder",
|
||||
"build:electron:win": "node scripts/prepare-server.js && next build && electron-builder --win",
|
||||
"build:electron:mac": "node scripts/prepare-server.js && next build && electron-builder --mac",
|
||||
"build:electron:linux": "node scripts/prepare-server.js && next build && electron-builder --linux",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test": "playwright test",
|
||||
@@ -51,7 +55,7 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"geist": "^1.5.1",
|
||||
"lucide-react": "^0.556.0",
|
||||
"next": "16.0.7",
|
||||
"next": "^16.0.10",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -79,35 +83,46 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"concurrently": "^9.2.1",
|
||||
"electron": "^39.2.6",
|
||||
"electron": "39.2.7",
|
||||
"electron-builder": "^26.0.12",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.7",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5",
|
||||
"typescript": "5.9.3",
|
||||
"wait-on": "^9.0.3"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.automaker.app",
|
||||
"productName": "Automaker",
|
||||
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
||||
"afterPack": "./scripts/rebuild-server-natives.js",
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"files": [
|
||||
"electron/**/*",
|
||||
".next/**/*",
|
||||
"out/**/*",
|
||||
"public/**/*",
|
||||
"!node_modules/**/*"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": ".env",
|
||||
"from": "server-bundle/dist",
|
||||
"to": "server"
|
||||
},
|
||||
{
|
||||
"from": "server-bundle/node_modules",
|
||||
"to": "server/node_modules"
|
||||
},
|
||||
{
|
||||
"from": "server-bundle/package.json",
|
||||
"to": "server/package.json"
|
||||
},
|
||||
{
|
||||
"from": "../../.env",
|
||||
"to": ".env",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
"filter": ["**/*"]
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
@@ -139,7 +154,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "public/logo_larger.png"
|
||||
"icon": "public/icon.ico"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
|
||||
BIN
apps/app/public/icon.ico
Normal file
BIN
apps/app/public/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 279 KiB |
81
apps/app/scripts/prepare-server.js
Normal file
81
apps/app/scripts/prepare-server.js
Normal file
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* This script prepares the server for bundling with Electron.
|
||||
* It copies the server dist and installs production dependencies
|
||||
* in a way that works with npm workspaces.
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const APP_DIR = join(__dirname, '..');
|
||||
const SERVER_DIR = join(APP_DIR, '..', 'server');
|
||||
const BUNDLE_DIR = join(APP_DIR, 'server-bundle');
|
||||
|
||||
console.log('🔧 Preparing server for Electron bundling...\n');
|
||||
|
||||
// Step 1: Clean up previous bundle
|
||||
if (existsSync(BUNDLE_DIR)) {
|
||||
console.log('🗑️ Cleaning previous server-bundle...');
|
||||
rmSync(BUNDLE_DIR, { recursive: true });
|
||||
}
|
||||
mkdirSync(BUNDLE_DIR, { recursive: true });
|
||||
|
||||
// Step 2: Build the server TypeScript
|
||||
console.log('📦 Building server TypeScript...');
|
||||
execSync('npm run build', { cwd: SERVER_DIR, stdio: 'inherit' });
|
||||
|
||||
// Step 3: Copy server dist
|
||||
console.log('📋 Copying server dist...');
|
||||
cpSync(join(SERVER_DIR, 'dist'), join(BUNDLE_DIR, 'dist'), { recursive: true });
|
||||
|
||||
// Step 4: Create a minimal package.json for the server
|
||||
console.log('📝 Creating server package.json...');
|
||||
const serverPkg = JSON.parse(readFileSync(join(SERVER_DIR, 'package.json'), 'utf-8'));
|
||||
|
||||
const bundlePkg = {
|
||||
name: '@automaker/server-bundle',
|
||||
version: serverPkg.version,
|
||||
type: 'module',
|
||||
main: 'dist/index.js',
|
||||
dependencies: serverPkg.dependencies
|
||||
};
|
||||
|
||||
writeFileSync(
|
||||
join(BUNDLE_DIR, 'package.json'),
|
||||
JSON.stringify(bundlePkg, null, 2)
|
||||
);
|
||||
|
||||
// Step 5: Install production dependencies
|
||||
console.log('📥 Installing server production dependencies...');
|
||||
execSync('npm install --omit=dev', {
|
||||
cwd: BUNDLE_DIR,
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
// Prevent npm from using workspace resolution
|
||||
npm_config_workspace: ''
|
||||
}
|
||||
});
|
||||
|
||||
// Step 6: Rebuild native modules for current architecture
|
||||
// This is critical for modules like node-pty that have native bindings
|
||||
console.log('🔨 Rebuilding native modules for current architecture...');
|
||||
try {
|
||||
execSync('npm rebuild', {
|
||||
cwd: BUNDLE_DIR,
|
||||
stdio: 'inherit'
|
||||
});
|
||||
console.log('✅ Native modules rebuilt successfully');
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Warning: Failed to rebuild native modules. Terminal functionality may not work.');
|
||||
console.warn(' Error:', error.message);
|
||||
}
|
||||
|
||||
console.log('\n✅ Server prepared for bundling at:', BUNDLE_DIR);
|
||||
66
apps/app/scripts/rebuild-server-natives.js
Normal file
66
apps/app/scripts/rebuild-server-natives.js
Normal file
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Electron-builder afterPack hook
|
||||
* Rebuilds native modules in the server bundle for the target architecture
|
||||
*/
|
||||
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const path = require('path');
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
exports.default = async function(context) {
|
||||
const { appOutDir, electronPlatformName, arch, packager } = context;
|
||||
const electronVersion = packager.config.electronVersion;
|
||||
|
||||
// Convert arch to string if it's a number (electron-builder sometimes passes indices)
|
||||
const archNames = ['ia32', 'x64', 'armv7l', 'arm64', 'universal'];
|
||||
const archStr = typeof arch === 'number' ? archNames[arch] : arch;
|
||||
|
||||
console.log(`\n🔨 Rebuilding server native modules for ${electronPlatformName}-${archStr}...`);
|
||||
|
||||
// Path to server node_modules in the packaged app
|
||||
let serverNodeModulesPath;
|
||||
if (electronPlatformName === 'darwin') {
|
||||
serverNodeModulesPath = path.join(
|
||||
appOutDir,
|
||||
`${packager.appInfo.productName}.app`,
|
||||
'Contents',
|
||||
'Resources',
|
||||
'server',
|
||||
'node_modules'
|
||||
);
|
||||
} else if (electronPlatformName === 'win32') {
|
||||
serverNodeModulesPath = path.join(
|
||||
appOutDir,
|
||||
'resources',
|
||||
'server',
|
||||
'node_modules'
|
||||
);
|
||||
} else {
|
||||
serverNodeModulesPath = path.join(
|
||||
appOutDir,
|
||||
'resources',
|
||||
'server',
|
||||
'node_modules'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Rebuild native modules for the target architecture
|
||||
const rebuildCmd = `npx --yes @electron/rebuild --version=${electronVersion} --arch=${archStr} --force --module-dir="${serverNodeModulesPath}/.."`;
|
||||
|
||||
console.log(` Command: ${rebuildCmd}`);
|
||||
|
||||
const { stdout, stderr } = await execAsync(rebuildCmd);
|
||||
if (stdout) console.log(stdout);
|
||||
if (stderr) console.error(stderr);
|
||||
|
||||
console.log(`✅ Server native modules rebuilt successfully for ${archStr}\n`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to rebuild server native modules:`, error.message);
|
||||
// Don't fail the build, just warn
|
||||
}
|
||||
};
|
||||
@@ -113,8 +113,8 @@ export function FileBrowserDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-popover border-border max-w-2xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogContent className="bg-popover border-border max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader className="pb-2">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FolderOpen className="w-5 h-5 text-brand-500" />
|
||||
{title}
|
||||
@@ -124,7 +124,7 @@ export function FileBrowserDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3 min-h-[400px]">
|
||||
<div className="flex flex-col gap-3 min-h-[400px] flex-1 overflow-hidden py-2">
|
||||
{/* Drives selector (Windows only) */}
|
||||
{drives.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
|
||||
@@ -216,7 +216,7 @@ export function FileBrowserDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<DialogFooter className="border-t border-border pt-4 gap-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -238,6 +238,25 @@ export function Sidebar() {
|
||||
// Ref for project search input
|
||||
const projectSearchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Auto-collapse sidebar on small screens
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(max-width: 1024px)'); // lg breakpoint
|
||||
|
||||
const handleResize = () => {
|
||||
if (mediaQuery.matches && sidebarOpen) {
|
||||
// Auto-collapse on small screens
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
// Check on mount
|
||||
handleResize();
|
||||
|
||||
// Listen for changes
|
||||
mediaQuery.addEventListener('change', handleResize);
|
||||
return () => mediaQuery.removeEventListener('change', handleResize);
|
||||
}, [sidebarOpen, toggleSidebar]);
|
||||
|
||||
// Filtered projects based on search query
|
||||
const filteredProjects = useMemo(() => {
|
||||
if (!projectSearchQuery.trim()) {
|
||||
|
||||
@@ -198,7 +198,10 @@ export function NewProjectModal({
|
||||
}
|
||||
};
|
||||
|
||||
const projectPath = workspaceDir && projectName ? `${workspaceDir}/${projectName}` : "";
|
||||
// Use platform-specific path separator
|
||||
const pathSep = typeof window !== 'undefined' && (window as any).electronAPI ?
|
||||
(navigator.platform.indexOf('Win') !== -1 ? '\\' : '/') : '/';
|
||||
const projectPath = workspaceDir && projectName ? `${workspaceDir}${pathSep}${projectName}` : "";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
||||
@@ -121,7 +121,7 @@ type ModelOption = {
|
||||
label: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
provider: "claude" | "codex";
|
||||
provider: "claude";
|
||||
};
|
||||
|
||||
const CLAUDE_MODELS: ModelOption[] = [
|
||||
@@ -148,37 +148,6 @@ const CLAUDE_MODELS: ModelOption[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const CODEX_MODELS: ModelOption[] = [
|
||||
{
|
||||
id: "gpt-5.1-codex-max",
|
||||
label: "GPT-5.1 Codex Max",
|
||||
description: "Flagship Codex model tuned for deep coding tasks.",
|
||||
badge: "Flagship",
|
||||
provider: "codex",
|
||||
},
|
||||
{
|
||||
id: "gpt-5.1-codex",
|
||||
label: "GPT-5.1 Codex",
|
||||
description: "Strong coding performance with lower cost.",
|
||||
badge: "Standard",
|
||||
provider: "codex",
|
||||
},
|
||||
{
|
||||
id: "gpt-5.1-codex-mini",
|
||||
label: "GPT-5.1 Codex Mini",
|
||||
description: "Fastest Codex option for lightweight edits.",
|
||||
badge: "Fast",
|
||||
provider: "codex",
|
||||
},
|
||||
{
|
||||
id: "gpt-5.1",
|
||||
label: "GPT-5.1",
|
||||
description: "General-purpose reasoning with solid coding ability.",
|
||||
badge: "General",
|
||||
provider: "codex",
|
||||
},
|
||||
];
|
||||
|
||||
// Profile icon mapping
|
||||
const PROFILE_ICONS: Record<
|
||||
string,
|
||||
@@ -1693,12 +1662,8 @@ export function BoardView() {
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedModel === option.id;
|
||||
const isCodex = option.provider === "codex";
|
||||
// Shorter display names for compact view
|
||||
const shortName = option.label
|
||||
.replace("Claude ", "")
|
||||
.replace("GPT-5.1 Codex ", "")
|
||||
.replace("GPT-5.1 ", "");
|
||||
const shortName = option.label.replace("Claude ", "");
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
@@ -1708,9 +1673,7 @@ export function BoardView() {
|
||||
className={cn(
|
||||
"flex-1 min-w-[80px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
|
||||
isSelected
|
||||
? isCodex
|
||||
? "bg-emerald-600 text-white border-emerald-500"
|
||||
: "bg-primary text-primary-foreground border-primary"
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-background hover:bg-accent border-input"
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-${option.id}`}
|
||||
@@ -2270,7 +2233,6 @@ export function BoardView() {
|
||||
const IconComponent = profile.icon
|
||||
? PROFILE_ICONS[profile.icon]
|
||||
: Brain;
|
||||
const isCodex = profile.provider === "codex";
|
||||
const isSelected =
|
||||
newFeature.model === profile.model &&
|
||||
newFeature.thinkingLevel === profile.thinkingLevel;
|
||||
@@ -2284,13 +2246,6 @@ export function BoardView() {
|
||||
model: profile.model,
|
||||
thinkingLevel: profile.thinkingLevel,
|
||||
});
|
||||
if (profile.thinkingLevel === "ultrathink") {
|
||||
toast.warning("Ultrathink Selected", {
|
||||
description:
|
||||
"Ultrathink uses extensive reasoning (45-180s, ~$0.48/task).",
|
||||
duration: 4000,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-2 rounded-lg border text-left transition-all",
|
||||
@@ -2300,19 +2255,9 @@ export function BoardView() {
|
||||
)}
|
||||
data-testid={`profile-quick-select-${profile.id}`}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-7 h-7 rounded flex items-center justify-center flex-shrink-0",
|
||||
isCodex ? "bg-emerald-500/10" : "bg-primary/10"
|
||||
)}
|
||||
>
|
||||
<div className="w-7 h-7 rounded flex items-center justify-center flex-shrink-0 bg-primary/10">
|
||||
{IconComponent && (
|
||||
<IconComponent
|
||||
className={cn(
|
||||
"w-4 h-4",
|
||||
isCodex ? "text-emerald-500" : "text-primary"
|
||||
)}
|
||||
/>
|
||||
<IconComponent className="w-4 h-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -2401,13 +2346,6 @@ export function BoardView() {
|
||||
...newFeature,
|
||||
thinkingLevel: level,
|
||||
});
|
||||
if (level === "ultrathink") {
|
||||
toast.warning("Ultrathink Selected", {
|
||||
description:
|
||||
"Ultrathink uses extensive reasoning (45-180s, ~$0.48/task). Best for complex architecture, migrations, or debugging.",
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors min-w-[60px]",
|
||||
@@ -2433,36 +2371,6 @@ export function BoardView() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Separator */}
|
||||
{(!showProfilesOnly || showAdvancedOptions) && (
|
||||
<div className="border-t border-border" />
|
||||
)}
|
||||
|
||||
{/* Codex Models Section - Hidden when showProfilesOnly is true and showAdvancedOptions is false */}
|
||||
{(!showProfilesOnly || showAdvancedOptions) && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-emerald-500" />
|
||||
OpenAI via Codex CLI
|
||||
</Label>
|
||||
<span className="text-[11px] px-2 py-0.5 rounded-full border border-emerald-500/50 text-emerald-600 dark:text-emerald-300">
|
||||
CLI
|
||||
</span>
|
||||
</div>
|
||||
{renderModelOptions(CODEX_MODELS, newFeature.model, (model) =>
|
||||
setNewFeature({
|
||||
...newFeature,
|
||||
model,
|
||||
thinkingLevel: "none",
|
||||
})
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Codex models do not support thinking levels.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Testing Tab */}
|
||||
@@ -2688,7 +2596,6 @@ export function BoardView() {
|
||||
const IconComponent = profile.icon
|
||||
? PROFILE_ICONS[profile.icon]
|
||||
: Brain;
|
||||
const isCodex = profile.provider === "codex";
|
||||
const isSelected =
|
||||
editingFeature.model === profile.model &&
|
||||
editingFeature.thinkingLevel ===
|
||||
@@ -2703,13 +2610,6 @@ export function BoardView() {
|
||||
model: profile.model,
|
||||
thinkingLevel: profile.thinkingLevel,
|
||||
});
|
||||
if (profile.thinkingLevel === "ultrathink") {
|
||||
toast.warning("Ultrathink Selected", {
|
||||
description:
|
||||
"Ultrathink uses extensive reasoning (45-180s, ~$0.48/task).",
|
||||
duration: 4000,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-2 rounded-lg border text-left transition-all",
|
||||
@@ -2719,21 +2619,9 @@ export function BoardView() {
|
||||
)}
|
||||
data-testid={`edit-profile-quick-select-${profile.id}`}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-7 h-7 rounded flex items-center justify-center flex-shrink-0",
|
||||
isCodex ? "bg-emerald-500/10" : "bg-primary/10"
|
||||
)}
|
||||
>
|
||||
<div className="w-7 h-7 rounded flex items-center justify-center flex-shrink-0 bg-primary/10">
|
||||
{IconComponent && (
|
||||
<IconComponent
|
||||
className={cn(
|
||||
"w-4 h-4",
|
||||
isCodex
|
||||
? "text-emerald-500"
|
||||
: "text-primary"
|
||||
)}
|
||||
/>
|
||||
<IconComponent className="w-4 h-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -2813,13 +2701,6 @@ export function BoardView() {
|
||||
...editingFeature,
|
||||
thinkingLevel: level,
|
||||
});
|
||||
if (level === "ultrathink") {
|
||||
toast.warning("Ultrathink Selected", {
|
||||
description:
|
||||
"Ultrathink uses extensive reasoning (45-180s, ~$0.48/task). Best for complex architecture, migrations, or debugging.",
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors min-w-[60px]",
|
||||
@@ -2846,40 +2727,6 @@ export function BoardView() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Separator */}
|
||||
{(!showProfilesOnly || showEditAdvancedOptions) && (
|
||||
<div className="border-t border-border" />
|
||||
)}
|
||||
|
||||
{/* Codex Models Section - Hidden when showProfilesOnly is true and showEditAdvancedOptions is false */}
|
||||
{(!showProfilesOnly || showEditAdvancedOptions) && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-emerald-500" />
|
||||
OpenAI via Codex CLI
|
||||
</Label>
|
||||
<span className="text-[11px] px-2 py-0.5 rounded-full border border-emerald-500/50 text-emerald-600 dark:text-emerald-300">
|
||||
CLI
|
||||
</span>
|
||||
</div>
|
||||
{renderModelOptions(
|
||||
CODEX_MODELS,
|
||||
(editingFeature.model ?? "opus") as AgentModel,
|
||||
(model) =>
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
model,
|
||||
thinkingLevel: "none",
|
||||
}),
|
||||
"edit-model-select"
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Codex models do not support thinking levels.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Testing Tab */}
|
||||
|
||||
@@ -305,7 +305,10 @@ export function InterviewView() {
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const fullProjectPath = `${projectPath}/${projectName}`;
|
||||
// Use platform-specific path separator
|
||||
const pathSep = typeof window !== 'undefined' && (window as any).electronAPI ?
|
||||
(navigator.platform.indexOf('Win') !== -1 ? '\\' : '/') : '/';
|
||||
const fullProjectPath = `${projectPath}${pathSep}${projectName}`;
|
||||
|
||||
// Create project directory
|
||||
await api.mkdir(fullProjectPath);
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
GripVertical,
|
||||
Lock,
|
||||
Check,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -88,13 +89,6 @@ const CLAUDE_MODELS: { id: AgentModel; label: string }[] = [
|
||||
{ id: "opus", label: "Claude Opus" },
|
||||
];
|
||||
|
||||
const CODEX_MODELS: { id: AgentModel; label: string }[] = [
|
||||
{ id: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
|
||||
{ id: "gpt-5.1-codex", label: "GPT-5.1 Codex" },
|
||||
{ id: "gpt-5.1-codex-mini", label: "GPT-5.1 Codex Mini" },
|
||||
{ id: "gpt-5.1", label: "GPT-5.1" },
|
||||
];
|
||||
|
||||
const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [
|
||||
{ id: "none", label: "None" },
|
||||
{ id: "low", label: "Low" },
|
||||
@@ -105,9 +99,6 @@ const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [
|
||||
|
||||
// Helper to determine provider from model
|
||||
function getProviderFromModel(model: AgentModel): ModelProvider {
|
||||
if (model.startsWith("gpt")) {
|
||||
return "codex";
|
||||
}
|
||||
return "claude";
|
||||
}
|
||||
|
||||
@@ -137,7 +128,6 @@ function SortableProfileCard({
|
||||
};
|
||||
|
||||
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
|
||||
const isCodex = profile.provider === "codex";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -165,18 +155,10 @@ function SortableProfileCard({
|
||||
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center",
|
||||
isCodex ? "bg-emerald-500/10" : "bg-primary/10"
|
||||
)}
|
||||
className="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center bg-primary/10"
|
||||
>
|
||||
{IconComponent && (
|
||||
<IconComponent
|
||||
className={cn(
|
||||
"w-5 h-5",
|
||||
isCodex ? "text-emerald-500" : "text-primary"
|
||||
)}
|
||||
/>
|
||||
<IconComponent className="w-5 h-5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -196,12 +178,7 @@ function SortableProfileCard({
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded-full border",
|
||||
isCodex
|
||||
? "border-emerald-500/30 text-emerald-600 dark:text-emerald-400 bg-emerald-500/10"
|
||||
: "border-primary/30 text-primary bg-primary/10"
|
||||
)}
|
||||
className="text-xs px-2 py-0.5 rounded-full border border-primary/30 text-primary bg-primary/10"
|
||||
>
|
||||
{profile.model}
|
||||
</span>
|
||||
@@ -266,12 +243,9 @@ function ProfileForm({
|
||||
const supportsThinking = modelSupportsThinking(formData.model);
|
||||
|
||||
const handleModelChange = (model: AgentModel) => {
|
||||
const newProvider = getProviderFromModel(model);
|
||||
setFormData({
|
||||
...formData,
|
||||
model,
|
||||
// Reset thinking level when switching to Codex (doesn't support thinking)
|
||||
thinkingLevel: newProvider === "codex" ? "none" : formData.thinkingLevel,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -344,11 +318,11 @@ function ProfileForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Selection - Claude */}
|
||||
{/* Model Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Brain className="w-4 h-4 text-primary" />
|
||||
Claude Models
|
||||
Model
|
||||
</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{CLAUDE_MODELS.map(({ id, label }) => (
|
||||
@@ -370,33 +344,7 @@ function ProfileForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Selection - Codex */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-emerald-500" />
|
||||
Codex Models
|
||||
</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{CODEX_MODELS.map(({ id, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => handleModelChange(id)}
|
||||
className={cn(
|
||||
"flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
|
||||
formData.model === id
|
||||
? "bg-emerald-600 text-white border-emerald-500"
|
||||
: "bg-background hover:bg-accent border-input"
|
||||
)}
|
||||
data-testid={`model-select-${id}`}
|
||||
>
|
||||
{label.replace("GPT-5.1 ", "").replace("Codex ", "")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thinking Level - Only for Claude models */}
|
||||
{/* Thinking Level */}
|
||||
{supportsThinking && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
@@ -461,6 +409,7 @@ export function ProfilesView() {
|
||||
updateAIProfile,
|
||||
removeAIProfile,
|
||||
reorderAIProfiles,
|
||||
resetAIProfiles,
|
||||
} = useAppStore();
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
|
||||
@@ -529,6 +478,13 @@ export function ProfilesView() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleResetProfiles = () => {
|
||||
resetAIProfiles();
|
||||
toast.success("Profiles refreshed", {
|
||||
description: "Default profiles have been updated to the latest version",
|
||||
});
|
||||
};
|
||||
|
||||
// Build keyboard shortcuts for profiles view
|
||||
const profilesShortcuts: KeyboardShortcut[] = useMemo(() => {
|
||||
const shortcutsList: KeyboardShortcut[] = [];
|
||||
@@ -568,15 +524,26 @@ export function ProfilesView() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<HotkeyButton
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
hotkey={shortcuts.addProfile}
|
||||
hotkeyActive={false}
|
||||
data-testid="add-profile-button"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Profile
|
||||
</HotkeyButton>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleResetProfiles}
|
||||
data-testid="refresh-profiles-button"
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Refresh Defaults
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
hotkey={shortcuts.addProfile}
|
||||
hotkeyActive={false}
|
||||
data-testid="add-profile-button"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Profile
|
||||
</HotkeyButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Key,
|
||||
Palette,
|
||||
Terminal,
|
||||
Atom,
|
||||
FlaskConical,
|
||||
Trash2,
|
||||
Settings2,
|
||||
@@ -24,7 +23,6 @@ import { DeleteProjectDialog } from "./settings-view/components/delete-project-d
|
||||
import { SettingsNavigation } from "./settings-view/components/settings-navigation";
|
||||
import { ApiKeysSection } from "./settings-view/api-keys/api-keys-section";
|
||||
import { ClaudeCliStatus } from "./settings-view/cli-status/claude-cli-status";
|
||||
import { CodexCliStatus } from "./settings-view/cli-status/codex-cli-status";
|
||||
import { AppearanceSection } from "./settings-view/appearance/appearance-section";
|
||||
import { KeyboardShortcutsSection } from "./settings-view/keyboard-shortcuts/keyboard-shortcuts-section";
|
||||
import { FeatureDefaultsSection } from "./settings-view/feature-defaults/feature-defaults-section";
|
||||
@@ -39,7 +37,6 @@ import type { Project as ElectronProject } from "@/lib/electron";
|
||||
const NAV_ITEMS = [
|
||||
{ id: "api-keys", label: "API Keys", icon: Key },
|
||||
{ id: "claude", label: "Claude", icon: Terminal },
|
||||
{ id: "codex", label: "Codex", icon: Atom },
|
||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
||||
{ id: "audio", label: "Audio", icon: Volume2 },
|
||||
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
|
||||
@@ -96,11 +93,8 @@ export function SettingsView() {
|
||||
// Use CLI status hook
|
||||
const {
|
||||
claudeCliStatus,
|
||||
codexCliStatus,
|
||||
isCheckingClaudeCli,
|
||||
isCheckingCodexCli,
|
||||
handleRefreshClaudeCli,
|
||||
handleRefreshCodexCli,
|
||||
} = useCliStatus();
|
||||
|
||||
// Use scroll tracking hook
|
||||
@@ -147,15 +141,6 @@ export function SettingsView() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Codex CLI Status Section */}
|
||||
{codexCliStatus && (
|
||||
<CodexCliStatus
|
||||
status={codexCliStatus}
|
||||
isChecking={isCheckingCodexCli}
|
||||
onRefresh={handleRefreshCodexCli}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Appearance Section */}
|
||||
<AppearanceSection
|
||||
effectiveTheme={effectiveTheme}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useApiKeyManagement } from "./hooks/use-api-key-management";
|
||||
|
||||
export function ApiKeysSection() {
|
||||
const { apiKeys } = useAppStore();
|
||||
const { claudeAuthStatus, codexAuthStatus } = useSetupStore();
|
||||
const { claudeAuthStatus } = useSetupStore();
|
||||
|
||||
const { providerConfigParams, apiKeyStatus, handleSave, saved } =
|
||||
useApiKeyManagement();
|
||||
@@ -41,7 +41,6 @@ export function ApiKeysSection() {
|
||||
{/* Authentication Status Display */}
|
||||
<AuthenticationStatusDisplay
|
||||
claudeAuthStatus={claudeAuthStatus}
|
||||
codexAuthStatus={codexAuthStatus}
|
||||
apiKeyStatus={apiKeyStatus}
|
||||
apiKeys={apiKeys}
|
||||
/>
|
||||
|
||||
@@ -4,29 +4,24 @@ import {
|
||||
AlertCircle,
|
||||
Info,
|
||||
Terminal,
|
||||
Atom,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import type { ClaudeAuthStatus, CodexAuthStatus } from "@/store/setup-store";
|
||||
import type { ClaudeAuthStatus } from "@/store/setup-store";
|
||||
|
||||
interface AuthenticationStatusDisplayProps {
|
||||
claudeAuthStatus: ClaudeAuthStatus | null;
|
||||
codexAuthStatus: CodexAuthStatus | null;
|
||||
apiKeyStatus: {
|
||||
hasAnthropicKey: boolean;
|
||||
hasOpenAIKey: boolean;
|
||||
hasGoogleKey: boolean;
|
||||
} | null;
|
||||
apiKeys: {
|
||||
anthropic: string;
|
||||
google: string;
|
||||
openai: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function AuthenticationStatusDisplay({
|
||||
claudeAuthStatus,
|
||||
codexAuthStatus,
|
||||
apiKeyStatus,
|
||||
apiKeys,
|
||||
}: AuthenticationStatusDisplayProps) {
|
||||
@@ -93,56 +88,6 @@ export function AuthenticationStatusDisplay({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Codex/OpenAI Authentication Status */}
|
||||
<div className="p-3 rounded-lg bg-card border border-border">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Atom className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Codex (OpenAI)
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs min-h-12">
|
||||
{codexAuthStatus?.authenticated ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
|
||||
<span className="text-green-400 font-medium">Authenticated</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Info className="w-3 h-3 shrink-0" />
|
||||
<span>
|
||||
{codexAuthStatus.method === "subscription"
|
||||
? "Using Codex subscription (Plus/Team)"
|
||||
: codexAuthStatus.method === "cli_verified" ||
|
||||
codexAuthStatus.method === "cli_tokens"
|
||||
? "Using CLI login (OpenAI account)"
|
||||
: codexAuthStatus.method === "api_key"
|
||||
? "Using stored API key"
|
||||
: codexAuthStatus.method === "env"
|
||||
? "Using OPENAI_API_KEY"
|
||||
: `Using ${codexAuthStatus.method || "unknown"} authentication`}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : apiKeyStatus?.hasOpenAIKey ? (
|
||||
<div className="flex items-center gap-2 text-blue-400">
|
||||
<Info className="w-3 h-3 shrink-0" />
|
||||
<span>Using environment variable (OPENAI_API_KEY)</span>
|
||||
</div>
|
||||
) : apiKeys.openai ? (
|
||||
<div className="flex items-center gap-2 text-blue-400">
|
||||
<Info className="w-3 h-3 shrink-0" />
|
||||
<span>Using manual API key from settings</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-yellow-500 py-0.5">
|
||||
<AlertCircle className="w-3 h-3 shrink-0" />
|
||||
<span className="text-xs">Not configured</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Google/Gemini Authentication Status */}
|
||||
<div className="p-3 rounded-lg bg-card border border-border">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
|
||||
@@ -10,7 +10,6 @@ interface TestResult {
|
||||
|
||||
interface ApiKeyStatus {
|
||||
hasAnthropicKey: boolean;
|
||||
hasOpenAIKey: boolean;
|
||||
hasGoogleKey: boolean;
|
||||
}
|
||||
|
||||
@@ -24,12 +23,10 @@ export function useApiKeyManagement() {
|
||||
// API key values
|
||||
const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic);
|
||||
const [googleKey, setGoogleKey] = useState(apiKeys.google);
|
||||
const [openaiKey, setOpenaiKey] = useState(apiKeys.openai);
|
||||
|
||||
// Visibility toggles
|
||||
const [showAnthropicKey, setShowAnthropicKey] = useState(false);
|
||||
const [showGoogleKey, setShowGoogleKey] = useState(false);
|
||||
const [showOpenaiKey, setShowOpenaiKey] = useState(false);
|
||||
|
||||
// Test connection states
|
||||
const [testingConnection, setTestingConnection] = useState(false);
|
||||
@@ -38,10 +35,6 @@ export function useApiKeyManagement() {
|
||||
const [geminiTestResult, setGeminiTestResult] = useState<TestResult | null>(
|
||||
null
|
||||
);
|
||||
const [testingOpenaiConnection, setTestingOpenaiConnection] = useState(false);
|
||||
const [openaiTestResult, setOpenaiTestResult] = useState<TestResult | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// API key status from environment
|
||||
const [apiKeyStatus, setApiKeyStatus] = useState<ApiKeyStatus | null>(null);
|
||||
@@ -53,7 +46,6 @@ export function useApiKeyManagement() {
|
||||
useEffect(() => {
|
||||
setAnthropicKey(apiKeys.anthropic);
|
||||
setGoogleKey(apiKeys.google);
|
||||
setOpenaiKey(apiKeys.openai);
|
||||
}, [apiKeys]);
|
||||
|
||||
// Check API key status from environment on mount
|
||||
@@ -66,7 +58,6 @@ export function useApiKeyManagement() {
|
||||
if (status.success) {
|
||||
setApiKeyStatus({
|
||||
hasAnthropicKey: status.hasAnthropicKey,
|
||||
hasOpenAIKey: status.hasOpenAIKey,
|
||||
hasGoogleKey: status.hasGoogleKey,
|
||||
});
|
||||
}
|
||||
@@ -152,68 +143,11 @@ export function useApiKeyManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
// Test OpenAI connection
|
||||
const handleTestOpenaiConnection = async () => {
|
||||
setTestingOpenaiConnection(true);
|
||||
setOpenaiTestResult(null);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api?.testOpenAIConnection) {
|
||||
const result = await api.testOpenAIConnection(openaiKey);
|
||||
if (result.success) {
|
||||
setOpenaiTestResult({
|
||||
success: true,
|
||||
message:
|
||||
result.message || "Connection successful! OpenAI API responded.",
|
||||
});
|
||||
} else {
|
||||
setOpenaiTestResult({
|
||||
success: false,
|
||||
message: result.error || "Failed to connect to OpenAI API.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fallback to web API test
|
||||
const response = await fetch("/api/openai/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ apiKey: openaiKey }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
setOpenaiTestResult({
|
||||
success: true,
|
||||
message:
|
||||
data.message || "Connection successful! OpenAI API responded.",
|
||||
});
|
||||
} else {
|
||||
setOpenaiTestResult({
|
||||
success: false,
|
||||
message: data.error || "Failed to connect to OpenAI API.",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setOpenaiTestResult({
|
||||
success: false,
|
||||
message: "Network error. Please check your connection.",
|
||||
});
|
||||
} finally {
|
||||
setTestingOpenaiConnection(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Save API keys
|
||||
const handleSave = () => {
|
||||
setApiKeys({
|
||||
anthropic: anthropicKey,
|
||||
google: googleKey,
|
||||
openai: openaiKey,
|
||||
});
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
@@ -240,15 +174,6 @@ export function useApiKeyManagement() {
|
||||
onTest: handleTestGeminiConnection,
|
||||
result: geminiTestResult,
|
||||
},
|
||||
openai: {
|
||||
value: openaiKey,
|
||||
setValue: setOpenaiKey,
|
||||
show: showOpenaiKey,
|
||||
setShow: setShowOpenaiKey,
|
||||
testing: testingOpenaiConnection,
|
||||
onTest: handleTestOpenaiConnection,
|
||||
result: openaiTestResult,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Terminal,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import type { CliStatus } from "../shared/types";
|
||||
|
||||
interface CliStatusProps {
|
||||
status: CliStatus | null;
|
||||
isChecking: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function CodexCliStatus({
|
||||
status,
|
||||
isChecking,
|
||||
onRefresh,
|
||||
}: CliStatusProps) {
|
||||
if (!status) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
id="codex"
|
||||
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
||||
>
|
||||
<div className="p-6 border-b border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5 text-green-500" />
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
OpenAI Codex CLI
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRefresh}
|
||||
disabled={isChecking}
|
||||
data-testid="refresh-codex-cli"
|
||||
title="Refresh Codex CLI detection"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Codex CLI enables GPT-5.1 Codex models for autonomous coding tasks.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{status.success && status.status === "installed" ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-green-400">
|
||||
Codex CLI Installed
|
||||
</p>
|
||||
<div className="text-xs text-green-400/80 mt-1 space-y-1">
|
||||
{status.method && (
|
||||
<p>
|
||||
Method: <span className="font-mono">{status.method}</span>
|
||||
</p>
|
||||
)}
|
||||
{status.version && (
|
||||
<p>
|
||||
Version:{" "}
|
||||
<span className="font-mono">{status.version}</span>
|
||||
</p>
|
||||
)}
|
||||
{status.path && (
|
||||
<p className="truncate" title={status.path}>
|
||||
Path:{" "}
|
||||
<span className="font-mono text-[10px]">
|
||||
{status.path}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{status.recommendation && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{status.recommendation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : status.status === "api_key_only" ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||
<AlertCircle className="w-5 h-5 text-blue-500 mt-0.5 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-blue-400">
|
||||
API Key Detected - CLI Not Installed
|
||||
</p>
|
||||
<p className="text-xs text-blue-400/80 mt-1">
|
||||
{status.recommendation ||
|
||||
"OPENAI_API_KEY found but Codex CLI not installed. Install the CLI for full agentic capabilities."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{status.installCommands && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-foreground-secondary">
|
||||
Installation Commands:
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{status.installCommands.npm && (
|
||||
<div className="p-2 rounded bg-background border border-border-glass">
|
||||
<p className="text-xs text-muted-foreground mb-1">npm:</p>
|
||||
<code className="text-xs text-foreground-secondary font-mono break-all">
|
||||
{status.installCommands.npm}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-500 mt-0.5 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-yellow-400">
|
||||
Codex CLI Not Detected
|
||||
</p>
|
||||
<p className="text-xs text-yellow-400/80 mt-1">
|
||||
{status.recommendation ||
|
||||
"Install OpenAI Codex CLI to use GPT-5.1 Codex models for autonomous coding."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{status.installCommands && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-foreground-secondary">
|
||||
Installation Commands:
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{status.installCommands.npm && (
|
||||
<div className="p-2 rounded bg-background border border-border-glass">
|
||||
<p className="text-xs text-muted-foreground mb-1">npm:</p>
|
||||
<code className="text-xs text-foreground-secondary font-mono break-all">
|
||||
{status.installCommands.npm}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{status.installCommands.macos && (
|
||||
<div className="p-2 rounded bg-background border border-border-glass">
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
macOS (Homebrew):
|
||||
</p>
|
||||
<code className="text-xs text-foreground-secondary font-mono break-all">
|
||||
{status.installCommands.macos}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Key,
|
||||
Terminal,
|
||||
Atom,
|
||||
Palette,
|
||||
LayoutGrid,
|
||||
Settings2,
|
||||
@@ -20,7 +19,6 @@ export interface NavigationItem {
|
||||
export const NAV_ITEMS: NavigationItem[] = [
|
||||
{ id: "api-keys", label: "API Keys", icon: Key },
|
||||
{ id: "claude", label: "Claude", icon: Terminal },
|
||||
{ id: "codex", label: "Codex", icon: Atom },
|
||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
||||
{ id: "kanban", label: "Kanban Display", icon: LayoutGrid },
|
||||
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
|
||||
|
||||
@@ -59,7 +59,7 @@ export function FeatureDefaultsSection({
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, the Add Feature dialog will show only AI profiles
|
||||
and hide advanced model tweaking options (Claude SDK, thinking
|
||||
levels, and OpenAI Codex CLI). This creates a cleaner, less
|
||||
levels). This creates a cleaner, less
|
||||
overwhelming UI. You can always disable this to access advanced
|
||||
settings.
|
||||
</p>
|
||||
|
||||
@@ -18,25 +18,17 @@ interface CliStatusResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface CodexCliStatusResult extends CliStatusResult {
|
||||
hasApiKey?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for managing Claude and Codex CLI status
|
||||
* Custom hook for managing Claude CLI status
|
||||
* Handles checking CLI installation, authentication, and refresh functionality
|
||||
*/
|
||||
export function useCliStatus() {
|
||||
const { setClaudeAuthStatus, setCodexAuthStatus } = useSetupStore();
|
||||
const { setClaudeAuthStatus } = useSetupStore();
|
||||
|
||||
const [claudeCliStatus, setClaudeCliStatus] =
|
||||
useState<CliStatusResult | null>(null);
|
||||
|
||||
const [codexCliStatus, setCodexCliStatus] =
|
||||
useState<CodexCliStatusResult | null>(null);
|
||||
|
||||
const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false);
|
||||
const [isCheckingCodexCli, setIsCheckingCodexCli] = useState(false);
|
||||
|
||||
// Check CLI status on mount
|
||||
useEffect(() => {
|
||||
@@ -53,16 +45,6 @@ export function useCliStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
// Check Codex CLI
|
||||
if (api?.checkCodexCli) {
|
||||
try {
|
||||
const status = await api.checkCodexCli();
|
||||
setCodexCliStatus(status);
|
||||
} catch (error) {
|
||||
console.error("Failed to check Codex CLI status:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Check Claude auth status (re-fetch on mount to ensure persistence)
|
||||
if (api?.setup?.getClaudeStatus) {
|
||||
try {
|
||||
@@ -95,47 +77,10 @@ export function useCliStatus() {
|
||||
console.error("Failed to check Claude auth status:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Check Codex auth status (re-fetch on mount to ensure persistence)
|
||||
if (api?.setup?.getCodexStatus) {
|
||||
try {
|
||||
const result = await api.setup.getCodexStatus();
|
||||
if (result.success && result.auth) {
|
||||
// Cast to extended type that includes server-added fields
|
||||
const auth = result.auth as typeof result.auth & {
|
||||
hasSubscription?: boolean;
|
||||
cliLoggedIn?: boolean;
|
||||
hasEnvApiKey?: boolean;
|
||||
};
|
||||
// Map server method names to client method types
|
||||
// Server returns: subscription, cli_verified, cli_tokens, api_key, env, none
|
||||
const validMethods = ["subscription", "cli_verified", "cli_tokens", "api_key", "env", "none"] as const;
|
||||
type CodexMethod = typeof validMethods[number];
|
||||
const method: CodexMethod = validMethods.includes(auth.method as CodexMethod)
|
||||
? (auth.method as CodexMethod)
|
||||
: auth.authenticated ? "api_key" : "none"; // Default authenticated to api_key
|
||||
|
||||
const authStatus = {
|
||||
authenticated: auth.authenticated,
|
||||
method,
|
||||
// Only set apiKeyValid for actual API key methods, not CLI login or subscription
|
||||
apiKeyValid:
|
||||
method === "cli_verified" || method === "cli_tokens" || method === "subscription"
|
||||
? undefined
|
||||
: auth.hasAuthFile || auth.hasEnvKey || auth.hasEnvApiKey,
|
||||
hasSubscription: auth.hasSubscription,
|
||||
cliLoggedIn: auth.cliLoggedIn,
|
||||
};
|
||||
setCodexAuthStatus(authStatus);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check Codex auth status:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkCliStatus();
|
||||
}, [setClaudeAuthStatus, setCodexAuthStatus]);
|
||||
}, [setClaudeAuthStatus]);
|
||||
|
||||
// Refresh Claude CLI status
|
||||
const handleRefreshClaudeCli = useCallback(async () => {
|
||||
@@ -153,28 +98,9 @@ export function useCliStatus() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Refresh Codex CLI status
|
||||
const handleRefreshCodexCli = useCallback(async () => {
|
||||
setIsCheckingCodexCli(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api?.checkCodexCli) {
|
||||
const status = await api.checkCodexCli();
|
||||
setCodexCliStatus(status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh Codex CLI status:", error);
|
||||
} finally {
|
||||
setIsCheckingCodexCli(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
claudeCliStatus,
|
||||
codexCliStatus,
|
||||
isCheckingClaudeCli,
|
||||
isCheckingCodexCli,
|
||||
handleRefreshClaudeCli,
|
||||
handleRefreshCodexCli,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
WelcomeStep,
|
||||
CompleteStep,
|
||||
ClaudeSetupStep,
|
||||
CodexSetupStep,
|
||||
} from "./setup-view/steps";
|
||||
|
||||
// Main Setup View
|
||||
@@ -17,17 +16,14 @@ export function SetupView() {
|
||||
setCurrentStep,
|
||||
completeSetup,
|
||||
setSkipClaudeSetup,
|
||||
setSkipCodexSetup,
|
||||
} = useSetupStore();
|
||||
const { setCurrentView } = useAppStore();
|
||||
|
||||
const steps = ["welcome", "claude", "codex", "complete"] as const;
|
||||
const steps = ["welcome", "claude", "complete"] as const;
|
||||
type StepName = (typeof steps)[number];
|
||||
const getStepName = (): StepName => {
|
||||
if (currentStep === "claude_detect" || currentStep === "claude_auth")
|
||||
return "claude";
|
||||
if (currentStep === "codex_detect" || currentStep === "codex_auth")
|
||||
return "codex";
|
||||
if (currentStep === "welcome") return "welcome";
|
||||
return "complete";
|
||||
};
|
||||
@@ -46,10 +42,6 @@ export function SetupView() {
|
||||
setCurrentStep("claude_detect");
|
||||
break;
|
||||
case "claude":
|
||||
console.log("[Setup Flow] Moving to codex_detect step");
|
||||
setCurrentStep("codex_detect");
|
||||
break;
|
||||
case "codex":
|
||||
console.log("[Setup Flow] Moving to complete step");
|
||||
setCurrentStep("complete");
|
||||
break;
|
||||
@@ -62,21 +54,12 @@ export function SetupView() {
|
||||
case "claude":
|
||||
setCurrentStep("welcome");
|
||||
break;
|
||||
case "codex":
|
||||
setCurrentStep("claude_detect");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipClaude = () => {
|
||||
console.log("[Setup Flow] Skipping Claude setup");
|
||||
setSkipClaudeSetup(true);
|
||||
setCurrentStep("codex_detect");
|
||||
};
|
||||
|
||||
const handleSkipCodex = () => {
|
||||
console.log("[Setup Flow] Skipping Codex setup");
|
||||
setSkipCodexSetup(true);
|
||||
setCurrentStep("complete");
|
||||
};
|
||||
|
||||
@@ -127,15 +110,6 @@ export function SetupView() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{(currentStep === "codex_detect" ||
|
||||
currentStep === "codex_auth") && (
|
||||
<CodexSetupStep
|
||||
onNext={() => handleNext("codex")}
|
||||
onBack={() => handleBack("codex")}
|
||||
onSkip={handleSkipCodex}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === "complete" && (
|
||||
<CompleteStep onFinish={handleFinish} />
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface UseCliInstallationOptions {
|
||||
cliType: "claude" | "codex";
|
||||
cliType: "claude";
|
||||
installApi: () => Promise<any>;
|
||||
onProgressEvent?: (callback: (progress: any) => void) => (() => void) | undefined;
|
||||
onSuccess?: () => void;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
interface UseCliStatusOptions {
|
||||
cliType: "claude" | "codex";
|
||||
cliType: "claude";
|
||||
statusApi: () => Promise<any>;
|
||||
setCliStatus: (status: any) => void;
|
||||
setAuthStatus: (status: any) => void;
|
||||
@@ -33,65 +33,35 @@ export function useCliStatus({
|
||||
setCliStatus(cliStatus);
|
||||
|
||||
if (result.auth) {
|
||||
if (cliType === "claude") {
|
||||
// Validate method is one of the expected values, default to "none"
|
||||
const validMethods = [
|
||||
"oauth_token_env",
|
||||
"oauth_token",
|
||||
"api_key",
|
||||
"api_key_env",
|
||||
"credentials_file",
|
||||
"cli_authenticated",
|
||||
"none",
|
||||
] as const;
|
||||
type AuthMethod = (typeof validMethods)[number];
|
||||
const method: AuthMethod = validMethods.includes(
|
||||
result.auth.method as AuthMethod
|
||||
)
|
||||
? (result.auth.method as AuthMethod)
|
||||
: "none";
|
||||
const authStatus = {
|
||||
authenticated: result.auth.authenticated,
|
||||
method,
|
||||
hasCredentialsFile: false,
|
||||
oauthTokenValid:
|
||||
result.auth.hasStoredOAuthToken ||
|
||||
result.auth.hasEnvOAuthToken,
|
||||
apiKeyValid:
|
||||
result.auth.hasStoredApiKey || result.auth.hasEnvApiKey,
|
||||
hasEnvOAuthToken: result.auth.hasEnvOAuthToken,
|
||||
hasEnvApiKey: result.auth.hasEnvApiKey,
|
||||
};
|
||||
setAuthStatus(authStatus);
|
||||
} else {
|
||||
// Codex auth status mapping
|
||||
const mapAuthMethod = (method?: string): any => {
|
||||
switch (method) {
|
||||
case "cli_verified":
|
||||
return "cli_verified";
|
||||
case "cli_tokens":
|
||||
return "cli_tokens";
|
||||
case "auth_file":
|
||||
return "api_key";
|
||||
case "env_var":
|
||||
return "env";
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
};
|
||||
|
||||
const method = mapAuthMethod(result.auth.method);
|
||||
const authStatus = {
|
||||
authenticated: result.auth.authenticated,
|
||||
method,
|
||||
apiKeyValid:
|
||||
method === "cli_verified" || method === "cli_tokens"
|
||||
? undefined
|
||||
: result.auth.authenticated,
|
||||
};
|
||||
console.log(`[${cliType} Setup] Auth Status:`, authStatus);
|
||||
setAuthStatus(authStatus);
|
||||
}
|
||||
// Validate method is one of the expected values, default to "none"
|
||||
const validMethods = [
|
||||
"oauth_token_env",
|
||||
"oauth_token",
|
||||
"api_key",
|
||||
"api_key_env",
|
||||
"credentials_file",
|
||||
"cli_authenticated",
|
||||
"none",
|
||||
] as const;
|
||||
type AuthMethod = (typeof validMethods)[number];
|
||||
const method: AuthMethod = validMethods.includes(
|
||||
result.auth.method as AuthMethod
|
||||
)
|
||||
? (result.auth.method as AuthMethod)
|
||||
: "none";
|
||||
const authStatus = {
|
||||
authenticated: result.auth.authenticated,
|
||||
method,
|
||||
hasCredentialsFile: false,
|
||||
oauthTokenValid:
|
||||
result.auth.hasStoredOAuthToken ||
|
||||
result.auth.hasEnvOAuthToken,
|
||||
apiKeyValid:
|
||||
result.auth.hasStoredApiKey || result.auth.hasEnvApiKey,
|
||||
hasEnvOAuthToken: result.auth.hasEnvOAuthToken,
|
||||
hasEnvApiKey: result.auth.hasEnvApiKey,
|
||||
};
|
||||
setAuthStatus(authStatus);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getElectronAPI } from "@/lib/electron";
|
||||
type AuthState = "idle" | "running" | "success" | "error" | "manual";
|
||||
|
||||
interface UseOAuthAuthenticationOptions {
|
||||
cliType: "claude" | "codex";
|
||||
cliType: "claude";
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -70,11 +70,8 @@ export function useOAuthAuthentication({
|
||||
}
|
||||
|
||||
try {
|
||||
// Call the appropriate auth API based on cliType
|
||||
const result =
|
||||
cliType === "claude"
|
||||
? await api.setup.authClaude()
|
||||
: await api.setup.authCodex?.();
|
||||
// Call the auth API
|
||||
const result = await api.setup.authClaude();
|
||||
|
||||
// Cleanup subscription
|
||||
if (unsubscribeRef.current) {
|
||||
|
||||
@@ -1,445 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useSetupStore } from "@/store/setup-store";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Terminal,
|
||||
Key,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { StatusBadge, TerminalOutput } from "../components";
|
||||
import {
|
||||
useCliStatus,
|
||||
useCliInstallation,
|
||||
useTokenSave,
|
||||
} from "../hooks";
|
||||
|
||||
interface CodexSetupStepProps {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
export function CodexSetupStep({
|
||||
onNext,
|
||||
onBack,
|
||||
onSkip,
|
||||
}: CodexSetupStepProps) {
|
||||
const {
|
||||
codexCliStatus,
|
||||
codexAuthStatus,
|
||||
setCodexCliStatus,
|
||||
setCodexAuthStatus,
|
||||
setCodexInstallProgress,
|
||||
} = useSetupStore();
|
||||
const { setApiKeys, apiKeys } = useAppStore();
|
||||
|
||||
const [showApiKeyInput, setShowApiKeyInput] = useState(false);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
|
||||
// Memoize API functions to prevent infinite loops
|
||||
const statusApi = useCallback(
|
||||
() => getElectronAPI().setup?.getCodexStatus() || Promise.reject(),
|
||||
[]
|
||||
);
|
||||
|
||||
const installApi = useCallback(
|
||||
() => getElectronAPI().setup?.installCodex() || Promise.reject(),
|
||||
[]
|
||||
);
|
||||
|
||||
// Use custom hooks
|
||||
const { isChecking, checkStatus } = useCliStatus({
|
||||
cliType: "codex",
|
||||
statusApi,
|
||||
setCliStatus: setCodexCliStatus,
|
||||
setAuthStatus: setCodexAuthStatus,
|
||||
});
|
||||
|
||||
const onInstallSuccess = useCallback(() => {
|
||||
checkStatus();
|
||||
}, [checkStatus]);
|
||||
|
||||
const { isInstalling, installProgress, install } = useCliInstallation({
|
||||
cliType: "codex",
|
||||
installApi,
|
||||
onProgressEvent: getElectronAPI().setup?.onInstallProgress,
|
||||
onSuccess: onInstallSuccess,
|
||||
});
|
||||
|
||||
const { isSaving: isSavingKey, saveToken: saveApiKeyToken } = useTokenSave({
|
||||
provider: "openai",
|
||||
onSuccess: () => {
|
||||
setCodexAuthStatus({
|
||||
authenticated: true,
|
||||
method: "api_key",
|
||||
apiKeyValid: true,
|
||||
});
|
||||
setApiKeys({ ...apiKeys, openai: apiKey });
|
||||
setShowApiKeyInput(false);
|
||||
checkStatus();
|
||||
},
|
||||
});
|
||||
|
||||
// Sync install progress to store
|
||||
useEffect(() => {
|
||||
setCodexInstallProgress({
|
||||
isInstalling,
|
||||
output: installProgress.output,
|
||||
});
|
||||
}, [isInstalling, installProgress, setCodexInstallProgress]);
|
||||
|
||||
// Check status on mount
|
||||
useEffect(() => {
|
||||
checkStatus();
|
||||
}, [checkStatus]);
|
||||
|
||||
const copyCommand = (command: string) => {
|
||||
navigator.clipboard.writeText(command);
|
||||
toast.success("Command copied to clipboard");
|
||||
};
|
||||
|
||||
const isAuthenticated = codexAuthStatus?.authenticated || apiKeys.openai;
|
||||
|
||||
const getAuthMethodLabel = () => {
|
||||
if (!isAuthenticated) return null;
|
||||
if (apiKeys.openai) return "API Key (Manual)";
|
||||
if (codexAuthStatus?.method === "api_key") return "API Key (Auth File)";
|
||||
if (codexAuthStatus?.method === "env") return "API Key (Environment)";
|
||||
if (codexAuthStatus?.method === "cli_verified")
|
||||
return "CLI Login (ChatGPT)";
|
||||
return "Authenticated";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 rounded-xl bg-green-500/10 flex items-center justify-center mx-auto mb-4">
|
||||
<Terminal className="w-8 h-8 text-green-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">
|
||||
Codex CLI Setup
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
OpenAI's GPT-5.1 Codex for advanced code generation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Card */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Installation Status</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={checkStatus}
|
||||
disabled={isChecking}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-foreground">CLI Installation</span>
|
||||
{isChecking ? (
|
||||
<StatusBadge status="checking" label="Checking..." />
|
||||
) : codexCliStatus?.installed ? (
|
||||
<StatusBadge status="installed" label="Installed" />
|
||||
) : (
|
||||
<StatusBadge status="not_installed" label="Not Installed" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{codexCliStatus?.version && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Version</span>
|
||||
<span className="text-sm font-mono text-foreground">
|
||||
{codexCliStatus.version}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-foreground">Authentication</span>
|
||||
{isAuthenticated ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status="authenticated" label="Authenticated" />
|
||||
{getAuthMethodLabel() && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({getAuthMethodLabel()})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<StatusBadge
|
||||
status="not_authenticated"
|
||||
label="Not Authenticated"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Installation Section */}
|
||||
{!codexCliStatus?.installed && (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Download className="w-5 h-5" />
|
||||
Install Codex CLI
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Install via npm (Node.js required)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
npm (Global installation)
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
||||
npm install -g @openai/codex
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyCommand("npm install -g @openai/codex")}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isInstalling && (
|
||||
<TerminalOutput lines={installProgress.output} />
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={install}
|
||||
disabled={isInstalling}
|
||||
className="flex-1 bg-green-500 hover:bg-green-600 text-white"
|
||||
data-testid="install-codex-button"
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Auto Install
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-yellow-500 mt-0.5" />
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400">
|
||||
Requires Node.js to be installed. If the auto-install fails,
|
||||
try running the command manually in your terminal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Authentication Section */}
|
||||
{!isAuthenticated && (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Key className="w-5 h-5" />
|
||||
Authentication
|
||||
</CardTitle>
|
||||
<CardDescription>Codex requires an OpenAI API key</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{codexCliStatus?.installed && (
|
||||
<div className="p-4 rounded-lg bg-muted/50 border border-border">
|
||||
<div className="flex items-start gap-3">
|
||||
<Terminal className="w-5 h-5 text-green-500 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
Authenticate via CLI
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Run this command in your terminal:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-muted px-3 py-1 rounded text-sm font-mono text-foreground">
|
||||
codex auth login
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyCommand("codex auth login")}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-border" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-card px-2 text-muted-foreground">
|
||||
or enter API key
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showApiKeyInput ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="openai-key" className="text-foreground">
|
||||
OpenAI API Key
|
||||
</Label>
|
||||
<Input
|
||||
id="openai-key"
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className="bg-input border-border text-foreground"
|
||||
data-testid="openai-api-key-input"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Get your API key from{" "}
|
||||
<a
|
||||
href="https://platform.openai.com/api-keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-green-500 hover:underline"
|
||||
>
|
||||
platform.openai.com
|
||||
<ExternalLink className="w-3 h-3 inline ml-1" />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowApiKeyInput(false)}
|
||||
className="border-border"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => saveApiKeyToken(apiKey)}
|
||||
disabled={isSavingKey || !apiKey.trim()}
|
||||
className="flex-1 bg-green-500 hover:bg-green-600 text-white"
|
||||
data-testid="save-openai-key-button"
|
||||
>
|
||||
{isSavingKey ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
"Save API Key"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowApiKeyInput(true)}
|
||||
className="w-full border-border"
|
||||
data-testid="use-openai-key-button"
|
||||
>
|
||||
<Key className="w-4 h-4 mr-2" />
|
||||
Enter OpenAI API Key
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Success State */}
|
||||
{isAuthenticated && (
|
||||
<Card className="bg-green-500/5 border-green-500/20">
|
||||
<CardContent className="py-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
Codex is ready to use!
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{getAuthMethodLabel() &&
|
||||
`Authenticated via ${getAuthMethodLabel()}. `}
|
||||
You can proceed to complete setup
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onSkip}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNext}
|
||||
className="bg-green-500 hover:bg-green-600 text-white"
|
||||
data-testid="codex-next-button"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,16 +14,13 @@ interface CompleteStepProps {
|
||||
}
|
||||
|
||||
export function CompleteStep({ onFinish }: CompleteStepProps) {
|
||||
const { claudeCliStatus, claudeAuthStatus, codexCliStatus, codexAuthStatus } =
|
||||
const { claudeCliStatus, claudeAuthStatus } =
|
||||
useSetupStore();
|
||||
const { apiKeys } = useAppStore();
|
||||
|
||||
const claudeReady =
|
||||
(claudeCliStatus?.installed && claudeAuthStatus?.authenticated) ||
|
||||
apiKeys.anthropic;
|
||||
const codexReady =
|
||||
(codexCliStatus?.installed && codexAuthStatus?.authenticated) ||
|
||||
apiKeys.openai;
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-6">
|
||||
@@ -41,7 +38,7 @@ export function CompleteStep({ onFinish }: CompleteStepProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl mx-auto">
|
||||
<div className="max-w-md mx-auto">
|
||||
<Card
|
||||
className={`bg-card/50 border ${
|
||||
claudeReady ? "border-green-500/50" : "border-yellow-500/50"
|
||||
@@ -63,28 +60,6 @@ export function CompleteStep({ onFinish }: CompleteStepProps) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={`bg-card/50 border ${
|
||||
codexReady ? "border-green-500/50" : "border-yellow-500/50"
|
||||
}`}
|
||||
>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{codexReady ? (
|
||||
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="w-6 h-6 text-yellow-500" />
|
||||
)}
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-foreground">Codex</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{codexReady ? "Ready to use" : "Configure later in settings"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-muted/50 border border-border max-w-md mx-auto">
|
||||
|
||||
@@ -2,4 +2,3 @@
|
||||
export { WelcomeStep } from "./welcome-step";
|
||||
export { CompleteStep } from "./complete-step";
|
||||
export { ClaudeSetupStep } from "./claude-setup-step";
|
||||
export { CodexSetupStep } from "./codex-setup-step";
|
||||
|
||||
@@ -24,7 +24,7 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl mx-auto">
|
||||
<div className="grid grid-cols-1 gap-4 max-w-md mx-auto place-items-center">
|
||||
<Card className="bg-card/50 border-border hover:border-brand-500/50 transition-colors">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
@@ -40,19 +40,6 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/50 border-border hover:border-brand-500/50 transition-colors">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5 text-green-500" />
|
||||
Codex CLI
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
OpenAI's GPT-5.1 Codex for advanced code generation tasks
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -200,7 +200,7 @@ export function WikiView() {
|
||||
{
|
||||
icon: Cpu,
|
||||
title: "Multi-Model Support",
|
||||
description: "Claude Haiku/Sonnet/Opus + OpenAI Codex models. Choose the right model for each task.",
|
||||
description: "Claude Haiku/Sonnet/Opus models. Choose the right model for each task.",
|
||||
},
|
||||
{
|
||||
icon: Brain,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Dispatch, SetStateAction } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ApiKeys } from "@/store/app-store";
|
||||
|
||||
export type ProviderKey = "anthropic" | "google" | "openai";
|
||||
export type ProviderKey = "anthropic" | "google";
|
||||
|
||||
export interface ProviderConfig {
|
||||
key: ProviderKey;
|
||||
@@ -51,22 +51,12 @@ export interface ProviderConfigParams {
|
||||
onTest: () => Promise<void>;
|
||||
result: { success: boolean; message: string } | null;
|
||||
};
|
||||
openai: {
|
||||
value: string;
|
||||
setValue: Dispatch<SetStateAction<string>>;
|
||||
show: boolean;
|
||||
setShow: Dispatch<SetStateAction<boolean>>;
|
||||
testing: boolean;
|
||||
onTest: () => Promise<void>;
|
||||
result: { success: boolean; message: string } | null;
|
||||
};
|
||||
}
|
||||
|
||||
export const buildProviderConfigs = ({
|
||||
apiKeys,
|
||||
anthropic,
|
||||
google,
|
||||
openai,
|
||||
}: ProviderConfigParams): ProviderConfig[] => [
|
||||
{
|
||||
key: "anthropic",
|
||||
@@ -121,29 +111,4 @@ export const buildProviderConfigs = ({
|
||||
descriptionLinkHref: "https://makersuite.google.com/app/apikey",
|
||||
descriptionLinkText: "makersuite.google.com",
|
||||
},
|
||||
{
|
||||
key: "openai",
|
||||
label: "OpenAI API Key (Codex/GPT)",
|
||||
inputId: "openai-key",
|
||||
placeholder: "sk-...",
|
||||
value: openai.value,
|
||||
setValue: openai.setValue,
|
||||
showValue: openai.show,
|
||||
setShowValue: openai.setShow,
|
||||
hasStoredKey: apiKeys.openai,
|
||||
inputTestId: "openai-api-key-input",
|
||||
toggleTestId: "toggle-openai-visibility",
|
||||
testButton: {
|
||||
onClick: openai.onTest,
|
||||
disabled: !openai.value || openai.testing,
|
||||
loading: openai.testing,
|
||||
testId: "test-openai-connection",
|
||||
},
|
||||
result: openai.result,
|
||||
resultTestId: "openai-test-connection-result",
|
||||
resultMessageTestId: "openai-test-connection-message",
|
||||
descriptionPrefix: "Used for OpenAI Codex CLI and GPT models. Get your key at",
|
||||
descriptionLinkHref: "https://platform.openai.com/api-keys",
|
||||
descriptionLinkText: "platform.openai.com",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -287,22 +287,6 @@ export interface ElectronAPI {
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
checkCodexCli?: () => Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
method?: string;
|
||||
version?: string;
|
||||
path?: string;
|
||||
hasApiKey?: boolean;
|
||||
recommendation?: string;
|
||||
installCommands?: {
|
||||
macos?: string;
|
||||
windows?: string;
|
||||
linux?: string;
|
||||
npm?: string;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
model?: {
|
||||
getAvailable: () => Promise<{
|
||||
success: boolean;
|
||||
@@ -315,11 +299,6 @@ export interface ElectronAPI {
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
testOpenAIConnection?: (apiKey?: string) => Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
worktree?: WorktreeAPI;
|
||||
git?: GitAPI;
|
||||
suggestions?: SuggestionsAPI;
|
||||
@@ -347,32 +326,11 @@ export interface ElectronAPI {
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
getCodexStatus: () => Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
method?: string;
|
||||
version?: string;
|
||||
path?: string;
|
||||
auth?: {
|
||||
authenticated: boolean;
|
||||
method: string; // Can be: "cli_verified", "cli_tokens", "auth_file", "env_var", "none"
|
||||
hasAuthFile: boolean;
|
||||
hasEnvKey: boolean;
|
||||
hasStoredApiKey?: boolean;
|
||||
hasEnvApiKey?: boolean;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
installClaude: () => Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
installCodex: () => Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
authClaude: () => Promise<{
|
||||
success: boolean;
|
||||
token?: string;
|
||||
@@ -383,12 +341,6 @@ export interface ElectronAPI {
|
||||
message?: string;
|
||||
output?: string;
|
||||
}>;
|
||||
authCodex: (apiKey?: string) => Promise<{
|
||||
success: boolean;
|
||||
requiresManualAuth?: boolean;
|
||||
command?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
storeApiKey: (
|
||||
provider: string,
|
||||
apiKey: string
|
||||
@@ -396,12 +348,8 @@ export interface ElectronAPI {
|
||||
getApiKeys: () => Promise<{
|
||||
success: boolean;
|
||||
hasAnthropicKey: boolean;
|
||||
hasOpenAIKey: boolean;
|
||||
hasGoogleKey: boolean;
|
||||
}>;
|
||||
configureCodexMcp: (
|
||||
projectPath: string
|
||||
) => Promise<{ success: boolean; configPath?: string; error?: string }>;
|
||||
getPlatform: () => Promise<{
|
||||
success: boolean;
|
||||
platform: string;
|
||||
@@ -838,22 +786,11 @@ const getMockElectronAPI = (): ElectronAPI => {
|
||||
recommendation: "Claude CLI checks are unavailable in the web preview.",
|
||||
}),
|
||||
|
||||
checkCodexCli: async () => ({
|
||||
success: false,
|
||||
status: "not_installed",
|
||||
recommendation: "Codex CLI checks are unavailable in the web preview.",
|
||||
}),
|
||||
|
||||
model: {
|
||||
getAvailable: async () => ({ success: true, models: [] }),
|
||||
checkProviders: async () => ({ success: true, providers: {} }),
|
||||
},
|
||||
|
||||
testOpenAIConnection: async () => ({
|
||||
success: false,
|
||||
error: "OpenAI connection test is only available in the Electron app.",
|
||||
}),
|
||||
|
||||
// Mock Setup API
|
||||
setup: createMockSetupAPI(),
|
||||
|
||||
@@ -903,32 +840,11 @@ interface SetupAPI {
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
getCodexStatus: () => Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
method?: string;
|
||||
version?: string;
|
||||
path?: string;
|
||||
auth?: {
|
||||
authenticated: boolean;
|
||||
method: string; // Can be: "cli_verified", "cli_tokens", "auth_file", "env_var", "none"
|
||||
hasAuthFile: boolean;
|
||||
hasEnvKey: boolean;
|
||||
hasStoredApiKey?: boolean;
|
||||
hasEnvApiKey?: boolean;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
installClaude: () => Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
installCodex: () => Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
authClaude: () => Promise<{
|
||||
success: boolean;
|
||||
token?: string;
|
||||
@@ -939,12 +855,6 @@ interface SetupAPI {
|
||||
message?: string;
|
||||
output?: string;
|
||||
}>;
|
||||
authCodex: (apiKey?: string) => Promise<{
|
||||
success: boolean;
|
||||
requiresManualAuth?: boolean;
|
||||
command?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
storeApiKey: (
|
||||
provider: string,
|
||||
apiKey: string
|
||||
@@ -952,12 +862,8 @@ interface SetupAPI {
|
||||
getApiKeys: () => Promise<{
|
||||
success: boolean;
|
||||
hasAnthropicKey: boolean;
|
||||
hasOpenAIKey: boolean;
|
||||
hasGoogleKey: boolean;
|
||||
}>;
|
||||
configureCodexMcp: (
|
||||
projectPath: string
|
||||
) => Promise<{ success: boolean; configPath?: string; error?: string }>;
|
||||
getPlatform: () => Promise<{
|
||||
success: boolean;
|
||||
platform: string;
|
||||
@@ -991,20 +897,6 @@ function createMockSetupAPI(): SetupAPI {
|
||||
};
|
||||
},
|
||||
|
||||
getCodexStatus: async () => {
|
||||
console.log("[Mock] Getting Codex status");
|
||||
return {
|
||||
success: true,
|
||||
status: "not_installed",
|
||||
auth: {
|
||||
authenticated: false,
|
||||
method: "none",
|
||||
hasAuthFile: false,
|
||||
hasEnvKey: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
installClaude: async () => {
|
||||
console.log("[Mock] Installing Claude CLI");
|
||||
// Simulate installation delay
|
||||
@@ -1016,16 +908,6 @@ function createMockSetupAPI(): SetupAPI {
|
||||
};
|
||||
},
|
||||
|
||||
installCodex: async () => {
|
||||
console.log("[Mock] Installing Codex CLI");
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"CLI installation is only available in the Electron app. Please run the command manually.",
|
||||
};
|
||||
},
|
||||
|
||||
authClaude: async () => {
|
||||
console.log("[Mock] Auth Claude CLI");
|
||||
return {
|
||||
@@ -1035,18 +917,6 @@ function createMockSetupAPI(): SetupAPI {
|
||||
};
|
||||
},
|
||||
|
||||
authCodex: async (apiKey?: string) => {
|
||||
console.log("[Mock] Auth Codex CLI", { hasApiKey: !!apiKey });
|
||||
if (apiKey) {
|
||||
return { success: true };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
requiresManualAuth: true,
|
||||
command: "codex auth login",
|
||||
};
|
||||
},
|
||||
|
||||
storeApiKey: async (provider: string, apiKey: string) => {
|
||||
console.log("[Mock] Storing API key for:", provider);
|
||||
// In mock mode, we just pretend to store it (it's already in the app store)
|
||||
@@ -1058,19 +928,10 @@ function createMockSetupAPI(): SetupAPI {
|
||||
return {
|
||||
success: true,
|
||||
hasAnthropicKey: false,
|
||||
hasOpenAIKey: false,
|
||||
hasGoogleKey: false,
|
||||
};
|
||||
},
|
||||
|
||||
configureCodexMcp: async (projectPath: string) => {
|
||||
console.log("[Mock] Configuring Codex MCP for:", projectPath);
|
||||
return {
|
||||
success: true,
|
||||
configPath: `${projectPath}/.codex/config.toml`,
|
||||
};
|
||||
},
|
||||
|
||||
getPlatform: async () => {
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -371,25 +371,6 @@ export class HttpApiClient implements ElectronAPI {
|
||||
return this.get("/api/setup/claude-status");
|
||||
}
|
||||
|
||||
async checkCodexCli(): Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
method?: string;
|
||||
version?: string;
|
||||
path?: string;
|
||||
hasApiKey?: boolean;
|
||||
recommendation?: string;
|
||||
installCommands?: {
|
||||
macos?: string;
|
||||
windows?: string;
|
||||
linux?: string;
|
||||
npm?: string;
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
return this.get("/api/setup/codex-status");
|
||||
}
|
||||
|
||||
// Model API
|
||||
model = {
|
||||
getAvailable: async (): Promise<{
|
||||
@@ -408,14 +389,6 @@ export class HttpApiClient implements ElectronAPI {
|
||||
},
|
||||
};
|
||||
|
||||
async testOpenAIConnection(apiKey?: string): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
return this.post("/api/setup/test-openai", { apiKey });
|
||||
}
|
||||
|
||||
// Setup API
|
||||
setup = {
|
||||
getClaudeStatus: (): Promise<{
|
||||
@@ -440,35 +413,12 @@ export class HttpApiClient implements ElectronAPI {
|
||||
error?: string;
|
||||
}> => this.get("/api/setup/claude-status"),
|
||||
|
||||
getCodexStatus: (): Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
method?: string;
|
||||
version?: string;
|
||||
path?: string;
|
||||
auth?: {
|
||||
authenticated: boolean;
|
||||
method: string;
|
||||
hasAuthFile: boolean;
|
||||
hasEnvKey: boolean;
|
||||
hasStoredApiKey?: boolean;
|
||||
hasEnvApiKey?: boolean;
|
||||
};
|
||||
error?: string;
|
||||
}> => this.get("/api/setup/codex-status"),
|
||||
|
||||
installClaude: (): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}> => this.post("/api/setup/install-claude"),
|
||||
|
||||
installCodex: (): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}> => this.post("/api/setup/install-codex"),
|
||||
|
||||
authClaude: (): Promise<{
|
||||
success: boolean;
|
||||
token?: string;
|
||||
@@ -480,15 +430,6 @@ export class HttpApiClient implements ElectronAPI {
|
||||
output?: string;
|
||||
}> => this.post("/api/setup/auth-claude"),
|
||||
|
||||
authCodex: (
|
||||
apiKey?: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
requiresManualAuth?: boolean;
|
||||
command?: string;
|
||||
error?: string;
|
||||
}> => this.post("/api/setup/auth-codex", { apiKey }),
|
||||
|
||||
storeApiKey: (
|
||||
provider: string,
|
||||
apiKey: string
|
||||
@@ -500,18 +441,9 @@ export class HttpApiClient implements ElectronAPI {
|
||||
getApiKeys: (): Promise<{
|
||||
success: boolean;
|
||||
hasAnthropicKey: boolean;
|
||||
hasOpenAIKey: boolean;
|
||||
hasGoogleKey: boolean;
|
||||
}> => this.get("/api/setup/api-keys"),
|
||||
|
||||
configureCodexMcp: (
|
||||
projectPath: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
configPath?: string;
|
||||
error?: string;
|
||||
}> => this.post("/api/setup/configure-codex-mcp", { projectPath }),
|
||||
|
||||
getPlatform: (): Promise<{
|
||||
success: boolean;
|
||||
platform: string;
|
||||
|
||||
@@ -6,26 +6,12 @@ export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model is a Codex/OpenAI model (doesn't support thinking)
|
||||
*/
|
||||
export function isCodexModel(model?: AgentModel | string): boolean {
|
||||
if (!model) return false;
|
||||
const codexModels: string[] = [
|
||||
"gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex",
|
||||
"gpt-5.1-codex-mini",
|
||||
"gpt-5.1",
|
||||
];
|
||||
return codexModels.includes(model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the current model supports extended thinking controls
|
||||
*/
|
||||
export function modelSupportsThinking(model?: AgentModel | string): boolean {
|
||||
if (!model) return true;
|
||||
return !isCodexModel(model);
|
||||
// All Claude models support thinking
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,10 +22,6 @@ export function getModelDisplayName(model: AgentModel | string): string {
|
||||
haiku: "Claude Haiku",
|
||||
sonnet: "Claude Sonnet",
|
||||
opus: "Claude Opus",
|
||||
"gpt-5.1-codex-max": "GPT-5.1 Codex Max",
|
||||
"gpt-5.1-codex": "GPT-5.1 Codex",
|
||||
"gpt-5.1-codex-mini": "GPT-5.1 Codex Mini",
|
||||
"gpt-5.1": "GPT-5.1",
|
||||
};
|
||||
return displayNames[model] || model;
|
||||
}
|
||||
|
||||
@@ -246,19 +246,11 @@ export interface FeatureImagePath {
|
||||
}
|
||||
|
||||
// Available models for feature execution
|
||||
// Claude models
|
||||
export type ClaudeModel = "opus" | "sonnet" | "haiku";
|
||||
// OpenAI/Codex models
|
||||
export type OpenAIModel =
|
||||
| "gpt-5.1-codex-max"
|
||||
| "gpt-5.1-codex"
|
||||
| "gpt-5.1-codex-mini"
|
||||
| "gpt-5.1";
|
||||
// Combined model type
|
||||
export type AgentModel = ClaudeModel | OpenAIModel;
|
||||
export type AgentModel = ClaudeModel;
|
||||
|
||||
// Model provider type
|
||||
export type ModelProvider = "claude" | "codex";
|
||||
export type ModelProvider = "claude";
|
||||
|
||||
// Thinking level (budget_tokens) options
|
||||
export type ThinkingLevel = "none" | "low" | "medium" | "high" | "ultrathink";
|
||||
@@ -570,6 +562,7 @@ export interface AppActions {
|
||||
updateAIProfile: (id: string, updates: Partial<AIProfile>) => void;
|
||||
removeAIProfile: (id: string) => void;
|
||||
reorderAIProfiles: (oldIndex: number, newIndex: number) => void;
|
||||
resetAIProfiles: () => void;
|
||||
|
||||
// Project Analysis actions
|
||||
setProjectAnalysis: (analysis: ProjectAnalysis | null) => void;
|
||||
@@ -657,26 +650,6 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [
|
||||
isBuiltIn: true,
|
||||
icon: "Zap",
|
||||
},
|
||||
{
|
||||
id: "profile-codex-power",
|
||||
name: "Codex Power",
|
||||
description: "GPT-5.1 Codex Max for deep coding tasks via OpenAI CLI.",
|
||||
model: "gpt-5.1-codex-max",
|
||||
thinkingLevel: "none",
|
||||
provider: "codex",
|
||||
isBuiltIn: true,
|
||||
icon: "Cpu",
|
||||
},
|
||||
{
|
||||
id: "profile-codex-fast",
|
||||
name: "Codex Fast",
|
||||
description: "GPT-5.1 Codex Mini for lightweight and quick edits.",
|
||||
model: "gpt-5.1-codex-mini",
|
||||
thinkingLevel: "none",
|
||||
provider: "codex",
|
||||
isBuiltIn: true,
|
||||
icon: "Rocket",
|
||||
},
|
||||
];
|
||||
|
||||
const initialState: AppState = {
|
||||
@@ -1356,6 +1329,13 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
set({ aiProfiles: profiles });
|
||||
},
|
||||
|
||||
resetAIProfiles: () => {
|
||||
// Merge: keep user-created profiles, but refresh all built-in profiles to latest defaults
|
||||
const defaultProfileIds = new Set(DEFAULT_AI_PROFILES.map(p => p.id));
|
||||
const userProfiles = get().aiProfiles.filter(p => !p.isBuiltIn && !defaultProfileIds.has(p.id));
|
||||
set({ aiProfiles: [...DEFAULT_AI_PROFILES, ...userProfiles] });
|
||||
},
|
||||
|
||||
// Project Analysis actions
|
||||
setProjectAnalysis: (analysis) => set({ projectAnalysis: analysis }),
|
||||
setIsAnalyzing: (analyzing) => set({ isAnalyzing: analyzing }),
|
||||
|
||||
@@ -32,26 +32,6 @@ export interface ClaudeAuthStatus {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Codex Auth Method - all possible authentication sources
|
||||
export type CodexAuthMethod =
|
||||
| "subscription" // Codex/OpenAI Plus or Team subscription
|
||||
| "cli_verified" // CLI logged in with OpenAI account
|
||||
| "cli_tokens" // CLI with stored access tokens
|
||||
| "api_key" // Manually stored API key
|
||||
| "env" // OPENAI_API_KEY environment variable
|
||||
| "none";
|
||||
|
||||
// Codex Auth Status
|
||||
export interface CodexAuthStatus {
|
||||
authenticated: boolean;
|
||||
method: CodexAuthMethod;
|
||||
apiKeyValid?: boolean;
|
||||
mcpConfigured?: boolean;
|
||||
hasSubscription?: boolean;
|
||||
cliLoggedIn?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Installation Progress
|
||||
export interface InstallProgress {
|
||||
isInstalling: boolean;
|
||||
@@ -65,8 +45,6 @@ export type SetupStep =
|
||||
| "welcome"
|
||||
| "claude_detect"
|
||||
| "claude_auth"
|
||||
| "codex_detect"
|
||||
| "codex_auth"
|
||||
| "complete";
|
||||
|
||||
export interface SetupState {
|
||||
@@ -80,14 +58,8 @@ export interface SetupState {
|
||||
claudeAuthStatus: ClaudeAuthStatus | null;
|
||||
claudeInstallProgress: InstallProgress;
|
||||
|
||||
// Codex CLI state
|
||||
codexCliStatus: CliStatus | null;
|
||||
codexAuthStatus: CodexAuthStatus | null;
|
||||
codexInstallProgress: InstallProgress;
|
||||
|
||||
// Setup preferences
|
||||
skipClaudeSetup: boolean;
|
||||
skipCodexSetup: boolean;
|
||||
}
|
||||
|
||||
export interface SetupActions {
|
||||
@@ -103,15 +75,8 @@ export interface SetupActions {
|
||||
setClaudeInstallProgress: (progress: Partial<InstallProgress>) => void;
|
||||
resetClaudeInstallProgress: () => void;
|
||||
|
||||
// Codex CLI
|
||||
setCodexCliStatus: (status: CliStatus | null) => void;
|
||||
setCodexAuthStatus: (status: CodexAuthStatus | null) => void;
|
||||
setCodexInstallProgress: (progress: Partial<InstallProgress>) => void;
|
||||
resetCodexInstallProgress: () => void;
|
||||
|
||||
// Preferences
|
||||
setSkipClaudeSetup: (skip: boolean) => void;
|
||||
setSkipCodexSetup: (skip: boolean) => void;
|
||||
}
|
||||
|
||||
const initialInstallProgress: InstallProgress = {
|
||||
@@ -130,12 +95,7 @@ const initialState: SetupState = {
|
||||
claudeAuthStatus: null,
|
||||
claudeInstallProgress: { ...initialInstallProgress },
|
||||
|
||||
codexCliStatus: null,
|
||||
codexAuthStatus: null,
|
||||
codexInstallProgress: { ...initialInstallProgress },
|
||||
|
||||
skipClaudeSetup: false,
|
||||
skipCodexSetup: false,
|
||||
};
|
||||
|
||||
export const useSetupStore = create<SetupState & SetupActions>()(
|
||||
@@ -171,26 +131,8 @@ export const useSetupStore = create<SetupState & SetupActions>()(
|
||||
claudeInstallProgress: { ...initialInstallProgress },
|
||||
}),
|
||||
|
||||
// Codex CLI
|
||||
setCodexCliStatus: (status) => set({ codexCliStatus: status }),
|
||||
|
||||
setCodexAuthStatus: (status) => set({ codexAuthStatus: status }),
|
||||
|
||||
setCodexInstallProgress: (progress) => set({
|
||||
codexInstallProgress: {
|
||||
...get().codexInstallProgress,
|
||||
...progress,
|
||||
},
|
||||
}),
|
||||
|
||||
resetCodexInstallProgress: () => set({
|
||||
codexInstallProgress: { ...initialInstallProgress },
|
||||
}),
|
||||
|
||||
// Preferences
|
||||
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),
|
||||
|
||||
setSkipCodexSetup: (skip) => set({ skipCodexSetup: skip }),
|
||||
}),
|
||||
{
|
||||
name: "automaker-setup",
|
||||
@@ -198,7 +140,6 @@ export const useSetupStore = create<SetupState & SetupActions>()(
|
||||
isFirstRun: state.isFirstRun,
|
||||
setupComplete: state.setupComplete,
|
||||
skipClaudeSetup: state.skipClaudeSetup,
|
||||
skipCodexSetup: state.skipCodexSetup,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
20
apps/app/src/types/electron.d.ts
vendored
20
apps/app/src/types/electron.d.ts
vendored
@@ -471,24 +471,6 @@ export interface ElectronAPI {
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Codex CLI Detection API
|
||||
checkCodexCli: () => Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
method?: string;
|
||||
version?: string;
|
||||
path?: string;
|
||||
hasApiKey?: boolean;
|
||||
recommendation?: string;
|
||||
installCommands?: {
|
||||
macos?: string;
|
||||
windows?: string;
|
||||
linux?: string;
|
||||
npm?: string;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Model Management APIs
|
||||
model: {
|
||||
// Get all available models from all providers
|
||||
@@ -641,7 +623,7 @@ export interface ModelDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
modelString: string;
|
||||
provider: "claude" | "codex";
|
||||
provider: "claude";
|
||||
description?: string;
|
||||
tier?: "basic" | "standard" | "premium";
|
||||
default?: boolean;
|
||||
|
||||
@@ -2437,16 +2437,7 @@ export async function setupFirstRun(page: Page): Promise<void> {
|
||||
progress: 0,
|
||||
output: [],
|
||||
},
|
||||
codexCliStatus: null,
|
||||
codexAuthStatus: null,
|
||||
codexInstallProgress: {
|
||||
isInstalling: false,
|
||||
currentStep: "",
|
||||
progress: 0,
|
||||
output: [],
|
||||
},
|
||||
skipClaudeSetup: false,
|
||||
skipCodexSetup: false,
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
@@ -2460,7 +2451,7 @@ export async function setupFirstRun(page: Page): Promise<void> {
|
||||
currentProject: null,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "", openai: "" },
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
@@ -2488,7 +2479,6 @@ export async function setupComplete(page: Page): Promise<void> {
|
||||
setupComplete: true,
|
||||
currentStep: "complete",
|
||||
skipClaudeSetup: false,
|
||||
skipCodexSetup: false,
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
@@ -2530,14 +2520,6 @@ export async function clickClaudeContinue(page: Page): Promise<void> {
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Click continue on Codex setup step
|
||||
*/
|
||||
export async function clickCodexContinue(page: Page): Promise<void> {
|
||||
const button = await getByTestId(page, "codex-next-button");
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Click finish on setup complete step
|
||||
*/
|
||||
|
||||
@@ -38,9 +38,6 @@ DATA_DIR=./data
|
||||
# OPTIONAL - Additional AI Providers
|
||||
# ============================================
|
||||
|
||||
# OpenAI API key (for Codex CLI support)
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Google API key (for future Gemini support)
|
||||
GOOGLE_API_KEY=
|
||||
|
||||
|
||||
4
apps/server/.gitignore
vendored
4
apps/server/.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
.env
|
||||
data
|
||||
data
|
||||
node_modules
|
||||
coverage
|
||||
@@ -9,7 +9,13 @@
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint src/"
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:run": "vitest run",
|
||||
"test:cov": "vitest run --coverage",
|
||||
"test:watch": "vitest watch",
|
||||
"test:unit": "vitest run tests/unit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.61",
|
||||
@@ -24,7 +30,10 @@
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/node": "^20",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^4.0.15",
|
||||
"@vitest/ui": "^4.0.15",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.0.15"
|
||||
}
|
||||
}
|
||||
|
||||
2267
apps/server/pnpm-lock.yaml
generated
Normal file
2267
apps/server/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
97
apps/server/src/lib/conversation-utils.ts
Normal file
97
apps/server/src/lib/conversation-utils.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Conversation history utilities for processing message history
|
||||
*
|
||||
* Provides standardized conversation history handling:
|
||||
* - Extract text from content (string or array format)
|
||||
* - Normalize content blocks to array format
|
||||
* - Format history as plain text for CLI-based providers
|
||||
* - Convert history to Claude SDK message format
|
||||
*/
|
||||
|
||||
import type { ConversationMessage } from "../providers/types.js";
|
||||
|
||||
/**
|
||||
* Extract plain text from message content (handles both string and array formats)
|
||||
*
|
||||
* @param content - Message content (string or array of content blocks)
|
||||
* @returns Extracted text content
|
||||
*/
|
||||
export function extractTextFromContent(
|
||||
content: string | Array<{ type: string; text?: string; source?: object }>
|
||||
): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
|
||||
// Extract text blocks only
|
||||
return content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.text || "")
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize message content to array format
|
||||
*
|
||||
* @param content - Message content (string or array)
|
||||
* @returns Content as array of blocks
|
||||
*/
|
||||
export function normalizeContentBlocks(
|
||||
content: string | Array<{ type: string; text?: string; source?: object }>
|
||||
): Array<{ type: string; text?: string; source?: object }> {
|
||||
if (Array.isArray(content)) {
|
||||
return content;
|
||||
}
|
||||
return [{ type: "text", text: content }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format conversation history as plain text for CLI-based providers
|
||||
*
|
||||
* @param history - Array of conversation messages
|
||||
* @returns Formatted text with role labels
|
||||
*/
|
||||
export function formatHistoryAsText(history: ConversationMessage[]): string {
|
||||
if (history.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let historyText = "Previous conversation:\n\n";
|
||||
|
||||
for (const msg of history) {
|
||||
const contentText = extractTextFromContent(msg.content);
|
||||
const role = msg.role === "user" ? "User" : "Assistant";
|
||||
historyText += `${role}: ${contentText}\n\n`;
|
||||
}
|
||||
|
||||
historyText += "---\n\n";
|
||||
return historyText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert conversation history to Claude SDK message format
|
||||
*
|
||||
* @param history - Array of conversation messages
|
||||
* @returns Array of Claude SDK formatted messages
|
||||
*/
|
||||
export function convertHistoryToMessages(
|
||||
history: ConversationMessage[]
|
||||
): Array<{
|
||||
type: "user" | "assistant";
|
||||
session_id: string;
|
||||
message: {
|
||||
role: "user" | "assistant";
|
||||
content: Array<{ type: string; text?: string; source?: object }>;
|
||||
};
|
||||
parent_tool_use_id: null;
|
||||
}> {
|
||||
return history.map((historyMsg) => ({
|
||||
type: historyMsg.role,
|
||||
session_id: "",
|
||||
message: {
|
||||
role: historyMsg.role,
|
||||
content: normalizeContentBlocks(historyMsg.content),
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
}));
|
||||
}
|
||||
104
apps/server/src/lib/error-handler.ts
Normal file
104
apps/server/src/lib/error-handler.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Error handling utilities for standardized error classification
|
||||
*
|
||||
* Provides utilities for:
|
||||
* - Detecting abort/cancellation errors
|
||||
* - Detecting authentication errors
|
||||
* - Classifying errors by type
|
||||
* - Generating user-friendly error messages
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if an error is an abort/cancellation error
|
||||
*
|
||||
* @param error - The error to check
|
||||
* @returns True if the error is an abort error
|
||||
*/
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.name === "AbortError" || error.message.includes("abort"))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is an authentication/API key error
|
||||
*
|
||||
* @param errorMessage - The error message to check
|
||||
* @returns True if the error is authentication-related
|
||||
*/
|
||||
export function isAuthenticationError(errorMessage: string): boolean {
|
||||
return (
|
||||
errorMessage.includes("Authentication failed") ||
|
||||
errorMessage.includes("Invalid API key") ||
|
||||
errorMessage.includes("authentication_failed") ||
|
||||
errorMessage.includes("Fix external API key")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error type classification
|
||||
*/
|
||||
export type ErrorType = "authentication" | "abort" | "execution" | "unknown";
|
||||
|
||||
/**
|
||||
* Classified error information
|
||||
*/
|
||||
export interface ErrorInfo {
|
||||
type: ErrorType;
|
||||
message: string;
|
||||
isAbort: boolean;
|
||||
isAuth: boolean;
|
||||
originalError: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify an error into a specific type
|
||||
*
|
||||
* @param error - The error to classify
|
||||
* @returns Classified error information
|
||||
*/
|
||||
export function classifyError(error: unknown): ErrorInfo {
|
||||
const message = error instanceof Error ? error.message : String(error || "Unknown error");
|
||||
const isAbort = isAbortError(error);
|
||||
const isAuth = isAuthenticationError(message);
|
||||
|
||||
let type: ErrorType;
|
||||
if (isAuth) {
|
||||
type = "authentication";
|
||||
} else if (isAbort) {
|
||||
type = "abort";
|
||||
} else if (error instanceof Error) {
|
||||
type = "execution";
|
||||
} else {
|
||||
type = "unknown";
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
message,
|
||||
isAbort,
|
||||
isAuth,
|
||||
originalError: error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly error message
|
||||
*
|
||||
* @param error - The error to convert
|
||||
* @returns User-friendly error message
|
||||
*/
|
||||
export function getUserFriendlyErrorMessage(error: unknown): string {
|
||||
const info = classifyError(error);
|
||||
|
||||
if (info.isAbort) {
|
||||
return "Operation was cancelled";
|
||||
}
|
||||
|
||||
if (info.isAuth) {
|
||||
return "Authentication failed. Please check your API key.";
|
||||
}
|
||||
|
||||
return info.message;
|
||||
}
|
||||
135
apps/server/src/lib/image-handler.ts
Normal file
135
apps/server/src/lib/image-handler.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Image handling utilities for processing image files
|
||||
*
|
||||
* Provides utilities for:
|
||||
* - MIME type detection based on file extensions
|
||||
* - Base64 encoding of image files
|
||||
* - Content block generation for Claude SDK format
|
||||
* - Path resolution (relative/absolute)
|
||||
*/
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* MIME type mapping for image file extensions
|
||||
*/
|
||||
const IMAGE_MIME_TYPES: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Image data with base64 encoding and metadata
|
||||
*/
|
||||
export interface ImageData {
|
||||
base64: string;
|
||||
mimeType: string;
|
||||
filename: string;
|
||||
originalPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content block for image (Claude SDK format)
|
||||
*/
|
||||
export interface ImageContentBlock {
|
||||
type: "image";
|
||||
source: {
|
||||
type: "base64";
|
||||
media_type: string;
|
||||
data: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type for an image file based on extension
|
||||
*
|
||||
* @param imagePath - Path to the image file
|
||||
* @returns MIME type string (defaults to "image/png" for unknown extensions)
|
||||
*/
|
||||
export function getMimeTypeForImage(imagePath: string): string {
|
||||
const ext = path.extname(imagePath).toLowerCase();
|
||||
return IMAGE_MIME_TYPES[ext] || "image/png";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an image file and convert to base64 with metadata
|
||||
*
|
||||
* @param imagePath - Path to the image file
|
||||
* @returns Promise resolving to image data with base64 encoding
|
||||
* @throws Error if file cannot be read
|
||||
*/
|
||||
export async function readImageAsBase64(imagePath: string): Promise<ImageData> {
|
||||
const imageBuffer = await fs.readFile(imagePath);
|
||||
const base64Data = imageBuffer.toString("base64");
|
||||
const mimeType = getMimeTypeForImage(imagePath);
|
||||
|
||||
return {
|
||||
base64: base64Data,
|
||||
mimeType,
|
||||
filename: path.basename(imagePath),
|
||||
originalPath: imagePath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert image paths to content blocks (Claude SDK format)
|
||||
* Handles both relative and absolute paths
|
||||
*
|
||||
* @param imagePaths - Array of image file paths
|
||||
* @param workDir - Optional working directory for resolving relative paths
|
||||
* @returns Promise resolving to array of image content blocks
|
||||
*/
|
||||
export async function convertImagesToContentBlocks(
|
||||
imagePaths: string[],
|
||||
workDir?: string
|
||||
): Promise<ImageContentBlock[]> {
|
||||
const blocks: ImageContentBlock[] = [];
|
||||
|
||||
for (const imagePath of imagePaths) {
|
||||
try {
|
||||
// Resolve to absolute path if needed
|
||||
const absolutePath = workDir && !path.isAbsolute(imagePath)
|
||||
? path.join(workDir, imagePath)
|
||||
: imagePath;
|
||||
|
||||
const imageData = await readImageAsBase64(absolutePath);
|
||||
|
||||
blocks.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: imageData.mimeType,
|
||||
data: imageData.base64,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[ImageHandler] Failed to load image ${imagePath}:`, error);
|
||||
// Continue processing other images
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a list of image paths for text prompts
|
||||
* Formats image paths as a bulleted list for inclusion in text prompts
|
||||
*
|
||||
* @param imagePaths - Array of image file paths
|
||||
* @returns Formatted string with image paths, or empty string if no images
|
||||
*/
|
||||
export function formatImagePathsForPrompt(imagePaths: string[]): string {
|
||||
if (imagePaths.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let text = "\n\nAttached images:\n";
|
||||
for (const imagePath of imagePaths) {
|
||||
text += `- ${imagePath}\n`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
80
apps/server/src/lib/model-resolver.ts
Normal file
80
apps/server/src/lib/model-resolver.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Model resolution utilities for handling model string mapping
|
||||
*
|
||||
* Provides centralized model resolution logic:
|
||||
* - Maps Claude model aliases to full model strings
|
||||
* - Provides default models per provider
|
||||
* - Handles multiple model sources with priority
|
||||
*/
|
||||
|
||||
/**
|
||||
* Model alias mapping for Claude models
|
||||
*/
|
||||
export const CLAUDE_MODEL_MAP: Record<string, string> = {
|
||||
haiku: "claude-haiku-4-5",
|
||||
sonnet: "claude-sonnet-4-20250514",
|
||||
opus: "claude-opus-4-5-20251101",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Default models per provider
|
||||
*/
|
||||
export const DEFAULT_MODELS = {
|
||||
claude: "claude-opus-4-5-20251101",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Resolve a model key/alias to a full model string
|
||||
*
|
||||
* @param modelKey - Model key (e.g., "opus", "gpt-5.2", "claude-sonnet-4-20250514")
|
||||
* @param defaultModel - Fallback model if modelKey is undefined
|
||||
* @returns Full model string
|
||||
*/
|
||||
export function resolveModelString(
|
||||
modelKey?: string,
|
||||
defaultModel: string = DEFAULT_MODELS.claude
|
||||
): string {
|
||||
// No model specified - use default
|
||||
if (!modelKey) {
|
||||
return defaultModel;
|
||||
}
|
||||
|
||||
// Full Claude model string - pass through unchanged
|
||||
if (modelKey.includes("claude-")) {
|
||||
console.log(`[ModelResolver] Using full Claude model string: ${modelKey}`);
|
||||
return modelKey;
|
||||
}
|
||||
|
||||
// Look up Claude model alias
|
||||
const resolved = CLAUDE_MODEL_MAP[modelKey];
|
||||
if (resolved) {
|
||||
console.log(`[ModelResolver] Resolved model alias: "${modelKey}" -> "${resolved}"`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Unknown model key - use default
|
||||
console.warn(
|
||||
`[ModelResolver] Unknown model key "${modelKey}", using default: "${defaultModel}"`
|
||||
);
|
||||
return defaultModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective model from multiple sources
|
||||
* Priority: explicit model > session model > default
|
||||
*
|
||||
* @param explicitModel - Explicitly provided model (highest priority)
|
||||
* @param sessionModel - Model from session (medium priority)
|
||||
* @param defaultModel - Fallback default model (lowest priority)
|
||||
* @returns Resolved model string
|
||||
*/
|
||||
export function getEffectiveModel(
|
||||
explicitModel?: string,
|
||||
sessionModel?: string,
|
||||
defaultModel?: string
|
||||
): string {
|
||||
return resolveModelString(
|
||||
explicitModel || sessionModel,
|
||||
defaultModel
|
||||
);
|
||||
}
|
||||
79
apps/server/src/lib/prompt-builder.ts
Normal file
79
apps/server/src/lib/prompt-builder.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Prompt building utilities for constructing prompts with images
|
||||
*
|
||||
* Provides standardized prompt building that:
|
||||
* - Combines text prompts with image attachments
|
||||
* - Handles content block array generation
|
||||
* - Optionally includes image paths in text
|
||||
* - Supports both vision and non-vision models
|
||||
*/
|
||||
|
||||
import { convertImagesToContentBlocks, formatImagePathsForPrompt } from "./image-handler.js";
|
||||
|
||||
/**
|
||||
* Content that can be either simple text or structured blocks
|
||||
*/
|
||||
export type PromptContent = string | Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
source?: object;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Result of building a prompt with optional images
|
||||
*/
|
||||
export interface PromptWithImages {
|
||||
content: PromptContent;
|
||||
hasImages: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a prompt with optional image attachments
|
||||
*
|
||||
* @param basePrompt - The text prompt
|
||||
* @param imagePaths - Optional array of image file paths
|
||||
* @param workDir - Optional working directory for resolving relative paths
|
||||
* @param includeImagePaths - Whether to append image paths to the text (default: false)
|
||||
* @returns Promise resolving to prompt content and metadata
|
||||
*/
|
||||
export async function buildPromptWithImages(
|
||||
basePrompt: string,
|
||||
imagePaths?: string[],
|
||||
workDir?: string,
|
||||
includeImagePaths: boolean = false
|
||||
): Promise<PromptWithImages> {
|
||||
// No images - return plain text
|
||||
if (!imagePaths || imagePaths.length === 0) {
|
||||
return { content: basePrompt, hasImages: false };
|
||||
}
|
||||
|
||||
// Build text content with optional image path listing
|
||||
let textContent = basePrompt;
|
||||
if (includeImagePaths) {
|
||||
textContent += formatImagePathsForPrompt(imagePaths);
|
||||
}
|
||||
|
||||
// Build content blocks array
|
||||
const contentBlocks: Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
source?: object;
|
||||
}> = [];
|
||||
|
||||
// Add text block if we have text
|
||||
if (textContent.trim()) {
|
||||
contentBlocks.push({ type: "text", text: textContent });
|
||||
}
|
||||
|
||||
// Add image blocks
|
||||
const imageBlocks = await convertImagesToContentBlocks(imagePaths, workDir);
|
||||
contentBlocks.push(...imageBlocks);
|
||||
|
||||
// Return appropriate format
|
||||
const content: PromptContent =
|
||||
contentBlocks.length > 1 || contentBlocks[0]?.type === "image"
|
||||
? contentBlocks
|
||||
: textContent;
|
||||
|
||||
return { content, hasImages: true };
|
||||
}
|
||||
206
apps/server/src/lib/subprocess-manager.ts
Normal file
206
apps/server/src/lib/subprocess-manager.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Subprocess management utilities for CLI providers
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "child_process";
|
||||
import readline from "readline";
|
||||
|
||||
export interface SubprocessOptions {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
env?: Record<string, string>;
|
||||
abortController?: AbortController;
|
||||
timeout?: number; // Milliseconds of no output before timeout
|
||||
}
|
||||
|
||||
export interface SubprocessResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a subprocess and streams JSONL output line-by-line
|
||||
*/
|
||||
export async function* spawnJSONLProcess(
|
||||
options: SubprocessOptions
|
||||
): AsyncGenerator<unknown> {
|
||||
const { command, args, cwd, env, abortController, timeout = 30000 } = options;
|
||||
|
||||
const processEnv = {
|
||||
...process.env,
|
||||
...env,
|
||||
};
|
||||
|
||||
console.log(`[SubprocessManager] Spawning: ${command} ${args.slice(0, -1).join(" ")}`);
|
||||
console.log(`[SubprocessManager] Working directory: ${cwd}`);
|
||||
|
||||
const childProcess: ChildProcess = spawn(command, args, {
|
||||
cwd,
|
||||
env: processEnv,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stderrOutput = "";
|
||||
let lastOutputTime = Date.now();
|
||||
let timeoutHandle: NodeJS.Timeout | null = null;
|
||||
|
||||
// Collect stderr for error reporting
|
||||
if (childProcess.stderr) {
|
||||
childProcess.stderr.on("data", (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
stderrOutput += text;
|
||||
console.error(`[SubprocessManager] stderr: ${text}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Setup timeout detection
|
||||
const resetTimeout = () => {
|
||||
lastOutputTime = Date.now();
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
timeoutHandle = setTimeout(() => {
|
||||
const elapsed = Date.now() - lastOutputTime;
|
||||
if (elapsed >= timeout) {
|
||||
console.error(
|
||||
`[SubprocessManager] Process timeout: no output for ${timeout}ms`
|
||||
);
|
||||
childProcess.kill("SIGTERM");
|
||||
}
|
||||
}, timeout);
|
||||
};
|
||||
|
||||
resetTimeout();
|
||||
|
||||
// Setup abort handling
|
||||
if (abortController) {
|
||||
abortController.signal.addEventListener("abort", () => {
|
||||
console.log("[SubprocessManager] Abort signal received, killing process");
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
childProcess.kill("SIGTERM");
|
||||
});
|
||||
}
|
||||
|
||||
// Parse stdout as JSONL (one JSON object per line)
|
||||
if (childProcess.stdout) {
|
||||
const rl = readline.createInterface({
|
||||
input: childProcess.stdout,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const line of rl) {
|
||||
resetTimeout();
|
||||
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
yield parsed;
|
||||
} catch (parseError) {
|
||||
console.error(
|
||||
`[SubprocessManager] Failed to parse JSONL line: ${line}`,
|
||||
parseError
|
||||
);
|
||||
// Yield error but continue processing
|
||||
yield {
|
||||
type: "error",
|
||||
error: `Failed to parse output: ${line}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[SubprocessManager] Error reading stdout:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for process to exit
|
||||
const exitCode = await new Promise<number | null>((resolve) => {
|
||||
childProcess.on("exit", (code) => {
|
||||
console.log(`[SubprocessManager] Process exited with code: ${code}`);
|
||||
resolve(code);
|
||||
});
|
||||
|
||||
childProcess.on("error", (error) => {
|
||||
console.error("[SubprocessManager] Process error:", error);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle non-zero exit codes
|
||||
if (exitCode !== 0 && exitCode !== null) {
|
||||
const errorMessage = stderrOutput || `Process exited with code ${exitCode}`;
|
||||
console.error(`[SubprocessManager] Process failed: ${errorMessage}`);
|
||||
yield {
|
||||
type: "error",
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
// Process completed successfully
|
||||
if (exitCode === 0 && !stderrOutput) {
|
||||
console.log("[SubprocessManager] Process completed successfully");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a subprocess and collects all output
|
||||
*/
|
||||
export async function spawnProcess(
|
||||
options: SubprocessOptions
|
||||
): Promise<SubprocessResult> {
|
||||
const { command, args, cwd, env, abortController } = options;
|
||||
|
||||
const processEnv = {
|
||||
...process.env,
|
||||
...env,
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const childProcess = spawn(command, args, {
|
||||
cwd,
|
||||
env: processEnv,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
if (childProcess.stdout) {
|
||||
childProcess.stdout.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
}
|
||||
|
||||
if (childProcess.stderr) {
|
||||
childProcess.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
}
|
||||
|
||||
// Setup abort handling
|
||||
if (abortController) {
|
||||
abortController.signal.addEventListener("abort", () => {
|
||||
childProcess.kill("SIGTERM");
|
||||
reject(new Error("Process aborted"));
|
||||
});
|
||||
}
|
||||
|
||||
childProcess.on("exit", (code) => {
|
||||
resolve({ stdout, stderr, exitCode: code });
|
||||
});
|
||||
|
||||
childProcess.on("error", (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
96
apps/server/src/providers/base-provider.ts
Normal file
96
apps/server/src/providers/base-provider.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Abstract base class for AI model providers
|
||||
*/
|
||||
|
||||
import type {
|
||||
ProviderConfig,
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ValidationResult,
|
||||
ModelDefinition,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Base provider class that all provider implementations must extend
|
||||
*/
|
||||
export abstract class BaseProvider {
|
||||
protected config: ProviderConfig;
|
||||
protected name: string;
|
||||
|
||||
constructor(config: ProviderConfig = {}) {
|
||||
this.config = config;
|
||||
this.name = this.getName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the provider name (e.g., "claude", "cursor")
|
||||
*/
|
||||
abstract getName(): string;
|
||||
|
||||
/**
|
||||
* Execute a query and stream responses
|
||||
* @param options Execution options
|
||||
* @returns AsyncGenerator yielding provider messages
|
||||
*/
|
||||
abstract executeQuery(
|
||||
options: ExecuteOptions
|
||||
): AsyncGenerator<ProviderMessage>;
|
||||
|
||||
/**
|
||||
* Detect if the provider is installed and configured
|
||||
* @returns Installation status
|
||||
*/
|
||||
abstract detectInstallation(): Promise<InstallationStatus>;
|
||||
|
||||
/**
|
||||
* Get available models for this provider
|
||||
* @returns Array of model definitions
|
||||
*/
|
||||
abstract getAvailableModels(): ModelDefinition[];
|
||||
|
||||
/**
|
||||
* Validate the provider configuration
|
||||
* @returns Validation result
|
||||
*/
|
||||
validateConfig(): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Base validation (can be overridden)
|
||||
if (!this.config) {
|
||||
errors.push("Provider config is missing");
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the provider supports a specific feature
|
||||
* @param feature Feature name (e.g., "vision", "tools", "mcp")
|
||||
* @returns Whether the feature is supported
|
||||
*/
|
||||
supportsFeature(feature: string): boolean {
|
||||
// Default implementation - override in subclasses
|
||||
const commonFeatures = ["tools", "text"];
|
||||
return commonFeatures.includes(feature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider configuration
|
||||
*/
|
||||
getConfig(): ProviderConfig {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update provider configuration
|
||||
*/
|
||||
setConfig(config: Partial<ProviderConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
}
|
||||
192
apps/server/src/providers/claude-provider.ts
Normal file
192
apps/server/src/providers/claude-provider.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Claude Provider - Executes queries using Claude Agent SDK
|
||||
*
|
||||
* Wraps the @anthropic-ai/claude-agent-sdk for seamless integration
|
||||
* with the provider architecture.
|
||||
*/
|
||||
|
||||
import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { BaseProvider } from "./base-provider.js";
|
||||
import { convertHistoryToMessages, normalizeContentBlocks } from "../lib/conversation-utils.js";
|
||||
import type {
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ModelDefinition,
|
||||
} from "./types.js";
|
||||
|
||||
export class ClaudeProvider extends BaseProvider {
|
||||
getName(): string {
|
||||
return "claude";
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query using Claude Agent SDK
|
||||
*/
|
||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
const {
|
||||
prompt,
|
||||
model,
|
||||
cwd,
|
||||
systemPrompt,
|
||||
maxTurns = 20,
|
||||
allowedTools,
|
||||
abortController,
|
||||
conversationHistory,
|
||||
} = options;
|
||||
|
||||
// Build Claude SDK options
|
||||
const sdkOptions: Options = {
|
||||
model,
|
||||
systemPrompt,
|
||||
maxTurns,
|
||||
cwd,
|
||||
allowedTools: allowedTools || [
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Bash",
|
||||
"WebSearch",
|
||||
"WebFetch",
|
||||
],
|
||||
permissionMode: "acceptEdits",
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
},
|
||||
abortController,
|
||||
};
|
||||
|
||||
// Build prompt payload with conversation history
|
||||
let promptPayload: string | AsyncGenerator<any, void, unknown>;
|
||||
|
||||
if (conversationHistory && conversationHistory.length > 0) {
|
||||
// Multi-turn conversation with history
|
||||
promptPayload = (async function* () {
|
||||
// Yield history messages using utility
|
||||
const historyMessages = convertHistoryToMessages(conversationHistory);
|
||||
for (const msg of historyMessages) {
|
||||
yield msg;
|
||||
}
|
||||
|
||||
// Yield current prompt
|
||||
yield {
|
||||
type: "user" as const,
|
||||
session_id: "",
|
||||
message: {
|
||||
role: "user" as const,
|
||||
content: normalizeContentBlocks(prompt),
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
})();
|
||||
} else if (Array.isArray(prompt)) {
|
||||
// Multi-part prompt (with images) - no history
|
||||
promptPayload = (async function* () {
|
||||
yield {
|
||||
type: "user" as const,
|
||||
session_id: "",
|
||||
message: {
|
||||
role: "user" as const,
|
||||
content: prompt,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
})();
|
||||
} else {
|
||||
// Simple text prompt - no history
|
||||
promptPayload = prompt;
|
||||
}
|
||||
|
||||
// Execute via Claude Agent SDK
|
||||
const stream = query({ prompt: promptPayload, options: sdkOptions });
|
||||
|
||||
// Stream messages directly - they're already in the correct format
|
||||
for await (const msg of stream) {
|
||||
yield msg as ProviderMessage;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Claude SDK installation (always available via npm)
|
||||
*/
|
||||
async detectInstallation(): Promise<InstallationStatus> {
|
||||
// Claude SDK is always available since it's a dependency
|
||||
const hasApiKey =
|
||||
!!process.env.ANTHROPIC_API_KEY || !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
method: "sdk",
|
||||
hasApiKey,
|
||||
authenticated: hasApiKey,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available Claude models
|
||||
*/
|
||||
getAvailableModels(): ModelDefinition[] {
|
||||
return [
|
||||
{
|
||||
id: "claude-opus-4-5-20251101",
|
||||
name: "Claude Opus 4.5",
|
||||
modelString: "claude-opus-4-5-20251101",
|
||||
provider: "anthropic",
|
||||
description: "Most capable Claude model",
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 16000,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: "premium",
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-20250514",
|
||||
name: "Claude Sonnet 4",
|
||||
modelString: "claude-sonnet-4-20250514",
|
||||
provider: "anthropic",
|
||||
description: "Balanced performance and cost",
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 16000,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: "standard",
|
||||
},
|
||||
{
|
||||
id: "claude-3-5-sonnet-20241022",
|
||||
name: "Claude 3.5 Sonnet",
|
||||
modelString: "claude-3-5-sonnet-20241022",
|
||||
provider: "anthropic",
|
||||
description: "Fast and capable",
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 8000,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: "standard",
|
||||
},
|
||||
{
|
||||
id: "claude-3-5-haiku-20241022",
|
||||
name: "Claude 3.5 Haiku",
|
||||
modelString: "claude-3-5-haiku-20241022",
|
||||
provider: "anthropic",
|
||||
description: "Fastest Claude model",
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 8000,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: "basic",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the provider supports a specific feature
|
||||
*/
|
||||
supportsFeature(feature: string): boolean {
|
||||
const supportedFeatures = ["tools", "text", "vision", "thinking"];
|
||||
return supportedFeatures.includes(feature);
|
||||
}
|
||||
}
|
||||
115
apps/server/src/providers/provider-factory.ts
Normal file
115
apps/server/src/providers/provider-factory.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Provider Factory - Routes model IDs to the appropriate provider
|
||||
*
|
||||
* This factory implements model-based routing to automatically select
|
||||
* the correct provider based on the model string. This makes adding
|
||||
* new providers (Cursor, OpenCode, etc.) trivial - just add one line.
|
||||
*/
|
||||
|
||||
import { BaseProvider } from "./base-provider.js";
|
||||
import { ClaudeProvider } from "./claude-provider.js";
|
||||
import type { InstallationStatus } from "./types.js";
|
||||
|
||||
export class ProviderFactory {
|
||||
/**
|
||||
* Get the appropriate provider for a given model ID
|
||||
*
|
||||
* @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "gpt-5.2", "cursor-fast")
|
||||
* @returns Provider instance for the model
|
||||
*/
|
||||
static getProviderForModel(modelId: string): BaseProvider {
|
||||
const lowerModel = modelId.toLowerCase();
|
||||
|
||||
// Claude models (claude-*, opus, sonnet, haiku)
|
||||
if (
|
||||
lowerModel.startsWith("claude-") ||
|
||||
["haiku", "sonnet", "opus"].includes(lowerModel)
|
||||
) {
|
||||
return new ClaudeProvider();
|
||||
}
|
||||
|
||||
// Future providers:
|
||||
// if (lowerModel.startsWith("cursor-")) {
|
||||
// return new CursorProvider();
|
||||
// }
|
||||
// if (lowerModel.startsWith("opencode-")) {
|
||||
// return new OpenCodeProvider();
|
||||
// }
|
||||
|
||||
// Default to Claude for unknown models
|
||||
console.warn(
|
||||
`[ProviderFactory] Unknown model prefix for "${modelId}", defaulting to Claude`
|
||||
);
|
||||
return new ClaudeProvider();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available providers
|
||||
*/
|
||||
static getAllProviders(): BaseProvider[] {
|
||||
return [
|
||||
new ClaudeProvider(),
|
||||
// Future providers...
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check installation status for all providers
|
||||
*
|
||||
* @returns Map of provider name to installation status
|
||||
*/
|
||||
static async checkAllProviders(): Promise<
|
||||
Record<string, InstallationStatus>
|
||||
> {
|
||||
const providers = this.getAllProviders();
|
||||
const statuses: Record<string, InstallationStatus> = {};
|
||||
|
||||
for (const provider of providers) {
|
||||
const name = provider.getName();
|
||||
const status = await provider.detectInstallation();
|
||||
statuses[name] = status;
|
||||
}
|
||||
|
||||
return statuses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider by name (for direct access if needed)
|
||||
*
|
||||
* @param name Provider name (e.g., "claude", "cursor")
|
||||
* @returns Provider instance or null if not found
|
||||
*/
|
||||
static getProviderByName(name: string): BaseProvider | null {
|
||||
const lowerName = name.toLowerCase();
|
||||
|
||||
switch (lowerName) {
|
||||
case "claude":
|
||||
case "anthropic":
|
||||
return new ClaudeProvider();
|
||||
|
||||
// Future providers:
|
||||
// case "cursor":
|
||||
// return new CursorProvider();
|
||||
// case "opencode":
|
||||
// return new OpenCodeProvider();
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available models from all providers
|
||||
*/
|
||||
static getAllAvailableModels() {
|
||||
const providers = this.getAllProviders();
|
||||
const allModels = [];
|
||||
|
||||
for (const provider of providers) {
|
||||
const models = provider.getAvailableModels();
|
||||
allModels.push(...models);
|
||||
}
|
||||
|
||||
return allModels;
|
||||
}
|
||||
}
|
||||
103
apps/server/src/providers/types.ts
Normal file
103
apps/server/src/providers/types.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Shared types for AI model providers
|
||||
*/
|
||||
|
||||
/**
|
||||
* Configuration for a provider instance
|
||||
*/
|
||||
export interface ProviderConfig {
|
||||
apiKey?: string;
|
||||
cliPath?: string;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message in conversation history
|
||||
*/
|
||||
export interface ConversationMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string | Array<{ type: string; text?: string; source?: object }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for executing a query via a provider
|
||||
*/
|
||||
export interface ExecuteOptions {
|
||||
prompt: string | Array<{ type: string; text?: string; source?: object }>;
|
||||
model: string;
|
||||
cwd: string;
|
||||
systemPrompt?: string;
|
||||
maxTurns?: number;
|
||||
allowedTools?: string[];
|
||||
mcpServers?: Record<string, unknown>;
|
||||
abortController?: AbortController;
|
||||
conversationHistory?: ConversationMessage[]; // Previous messages for context
|
||||
}
|
||||
|
||||
/**
|
||||
* Content block in a provider message (matches Claude SDK format)
|
||||
*/
|
||||
export interface ContentBlock {
|
||||
type: "text" | "tool_use" | "thinking" | "tool_result";
|
||||
text?: string;
|
||||
thinking?: string;
|
||||
name?: string;
|
||||
input?: unknown;
|
||||
tool_use_id?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message returned by a provider (matches Claude SDK streaming format)
|
||||
*/
|
||||
export interface ProviderMessage {
|
||||
type: "assistant" | "user" | "error" | "result";
|
||||
subtype?: "success" | "error";
|
||||
session_id?: string;
|
||||
message?: {
|
||||
role: "user" | "assistant";
|
||||
content: ContentBlock[];
|
||||
};
|
||||
result?: string;
|
||||
error?: string;
|
||||
parent_tool_use_id?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installation status for a provider
|
||||
*/
|
||||
export interface InstallationStatus {
|
||||
installed: boolean;
|
||||
path?: string;
|
||||
version?: string;
|
||||
method?: "cli" | "npm" | "brew" | "sdk";
|
||||
hasApiKey?: boolean;
|
||||
authenticated?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation result
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Model definition
|
||||
*/
|
||||
export interface ModelDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
modelString: string;
|
||||
provider: string;
|
||||
description: string;
|
||||
contextWindow?: number;
|
||||
maxOutputTokens?: number;
|
||||
supportsVision?: boolean;
|
||||
supportsTools?: boolean;
|
||||
tier?: "basic" | "standard" | "premium";
|
||||
default?: boolean;
|
||||
}
|
||||
@@ -40,11 +40,12 @@ export function createAgentRoutes(
|
||||
// Send a message
|
||||
router.post("/send", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { sessionId, message, workingDirectory, imagePaths } = req.body as {
|
||||
const { sessionId, message, workingDirectory, imagePaths, model } = req.body as {
|
||||
sessionId: string;
|
||||
message: string;
|
||||
workingDirectory?: string;
|
||||
imagePaths?: string[];
|
||||
model?: string;
|
||||
};
|
||||
|
||||
if (!sessionId || !message) {
|
||||
@@ -61,6 +62,7 @@ export function createAgentRoutes(
|
||||
message,
|
||||
workingDirectory,
|
||||
imagePaths,
|
||||
model,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Agent Route] Error sending message:", error);
|
||||
@@ -128,5 +130,26 @@ export function createAgentRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// Set session model
|
||||
router.post("/model", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { sessionId, model } = req.body as {
|
||||
sessionId: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
if (!sessionId || !model) {
|
||||
res.status(400).json({ success: false, error: "sessionId and model are required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await agentService.setSessionModel(sessionId, model);
|
||||
res.json({ success: result });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { ProviderFactory } from "../providers/provider-factory.js";
|
||||
|
||||
interface ModelDefinition {
|
||||
id: string;
|
||||
@@ -63,33 +64,6 @@ export function createModelsRoutes(): Router {
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
},
|
||||
{
|
||||
id: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
provider: "openai",
|
||||
contextWindow: 128000,
|
||||
maxOutputTokens: 16384,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
},
|
||||
{
|
||||
id: "gpt-4o-mini",
|
||||
name: "GPT-4o Mini",
|
||||
provider: "openai",
|
||||
contextWindow: 128000,
|
||||
maxOutputTokens: 16384,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
},
|
||||
{
|
||||
id: "o1",
|
||||
name: "o1",
|
||||
provider: "openai",
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 100000,
|
||||
supportsVision: true,
|
||||
supportsTools: false,
|
||||
},
|
||||
];
|
||||
|
||||
res.json({ success: true, models });
|
||||
@@ -102,14 +76,13 @@ export function createModelsRoutes(): Router {
|
||||
// Check provider status
|
||||
router.get("/providers", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const providers: Record<string, ProviderStatus> = {
|
||||
// Get installation status from all providers
|
||||
const statuses = await ProviderFactory.checkAllProviders();
|
||||
|
||||
const providers: Record<string, any> = {
|
||||
anthropic: {
|
||||
available: !!process.env.ANTHROPIC_API_KEY,
|
||||
hasApiKey: !!process.env.ANTHROPIC_API_KEY,
|
||||
},
|
||||
openai: {
|
||||
available: !!process.env.OPENAI_API_KEY,
|
||||
hasApiKey: !!process.env.OPENAI_API_KEY,
|
||||
available: statuses.claude?.installed || false,
|
||||
hasApiKey: !!process.env.ANTHROPIC_API_KEY || !!process.env.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
},
|
||||
google: {
|
||||
available: !!process.env.GOOGLE_API_KEY,
|
||||
|
||||
@@ -46,10 +46,11 @@ export function createSessionsRoutes(agentService: AgentService): Router {
|
||||
// Create a new session
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name, projectPath, workingDirectory } = req.body as {
|
||||
const { name, projectPath, workingDirectory, model } = req.body as {
|
||||
name: string;
|
||||
projectPath?: string;
|
||||
workingDirectory?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
if (!name) {
|
||||
@@ -60,7 +61,8 @@ export function createSessionsRoutes(agentService: AgentService): Router {
|
||||
const session = await agentService.createSession(
|
||||
name,
|
||||
projectPath,
|
||||
workingDirectory
|
||||
workingDirectory,
|
||||
model
|
||||
);
|
||||
res.json({ success: true, session });
|
||||
} catch (error) {
|
||||
@@ -73,12 +75,13 @@ export function createSessionsRoutes(agentService: AgentService): Router {
|
||||
router.put("/:sessionId", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
const { name, tags } = req.body as {
|
||||
const { name, tags, model } = req.body as {
|
||||
name?: string;
|
||||
tags?: string[];
|
||||
model?: string;
|
||||
};
|
||||
|
||||
const session = await agentService.updateSession(sessionId, { name, tags });
|
||||
const session = await agentService.updateSession(sessionId, { name, tags, model });
|
||||
if (!session) {
|
||||
res.status(404).json({ success: false, error: "Session not found" });
|
||||
return;
|
||||
|
||||
@@ -230,99 +230,6 @@ export function createSetupRoutes(): Router {
|
||||
}
|
||||
});
|
||||
|
||||
// Get Codex CLI status
|
||||
router.get("/codex-status", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
let installed = false;
|
||||
let version = "";
|
||||
let cliPath = "";
|
||||
let method = "none";
|
||||
|
||||
// Try to find Codex CLI
|
||||
try {
|
||||
const { stdout } = await execAsync("which codex || where codex 2>/dev/null");
|
||||
cliPath = stdout.trim();
|
||||
installed = true;
|
||||
method = "path";
|
||||
|
||||
try {
|
||||
const { stdout: versionOut } = await execAsync("codex --version");
|
||||
version = versionOut.trim();
|
||||
} catch {
|
||||
// Version command might not be available
|
||||
}
|
||||
} catch {
|
||||
// Not found
|
||||
}
|
||||
|
||||
// Check for OpenAI/Codex authentication
|
||||
let auth = {
|
||||
authenticated: false,
|
||||
method: "none" as string,
|
||||
hasAuthFile: false,
|
||||
hasEnvKey: !!process.env.OPENAI_API_KEY,
|
||||
hasStoredApiKey: !!apiKeys.openai,
|
||||
hasEnvApiKey: !!process.env.OPENAI_API_KEY,
|
||||
// Additional fields for subscription/account detection
|
||||
hasSubscription: false,
|
||||
cliLoggedIn: false,
|
||||
};
|
||||
|
||||
// Check for OpenAI CLI auth file (~/.codex/auth.json or similar)
|
||||
const codexAuthPaths = [
|
||||
path.join(os.homedir(), ".codex", "auth.json"),
|
||||
path.join(os.homedir(), ".openai", "credentials"),
|
||||
path.join(os.homedir(), ".config", "openai", "credentials.json"),
|
||||
];
|
||||
|
||||
for (const authPath of codexAuthPaths) {
|
||||
try {
|
||||
const authContent = await fs.readFile(authPath, "utf-8");
|
||||
const authData = JSON.parse(authContent);
|
||||
auth.hasAuthFile = true;
|
||||
|
||||
// Check for subscription/tokens
|
||||
if (authData.subscription || authData.plan || authData.account_type) {
|
||||
auth.hasSubscription = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = "subscription"; // Codex subscription (Plus/Team)
|
||||
} else if (authData.access_token || authData.api_key) {
|
||||
auth.cliLoggedIn = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = "cli_verified"; // CLI logged in with account
|
||||
}
|
||||
break;
|
||||
} catch {
|
||||
// Auth file not found at this path
|
||||
}
|
||||
}
|
||||
|
||||
// Environment variable has highest priority
|
||||
if (auth.hasEnvApiKey) {
|
||||
auth.authenticated = true;
|
||||
auth.method = "env"; // OPENAI_API_KEY environment variable
|
||||
}
|
||||
|
||||
// In-memory stored API key (from settings UI)
|
||||
if (!auth.authenticated && apiKeys.openai) {
|
||||
auth.authenticated = true;
|
||||
auth.method = "api_key"; // Manually stored API key
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
status: installed ? "installed" : "not_installed",
|
||||
method,
|
||||
version,
|
||||
path: cliPath,
|
||||
auth,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Install Claude CLI
|
||||
router.post("/install-claude", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -339,20 +246,6 @@ export function createSetupRoutes(): Router {
|
||||
}
|
||||
});
|
||||
|
||||
// Install Codex CLI
|
||||
router.post("/install-codex", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({
|
||||
success: false,
|
||||
error:
|
||||
"CLI installation requires terminal access. Please install manually using: npm install -g @openai/codex",
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Auth Claude
|
||||
router.post("/auth-claude", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -368,28 +261,6 @@ export function createSetupRoutes(): Router {
|
||||
}
|
||||
});
|
||||
|
||||
// Auth Codex
|
||||
router.post("/auth-codex", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { apiKey } = req.body as { apiKey?: string };
|
||||
|
||||
if (apiKey) {
|
||||
apiKeys.openai = apiKey;
|
||||
process.env.OPENAI_API_KEY = apiKey;
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.json({
|
||||
success: true,
|
||||
requiresManualAuth: true,
|
||||
command: "codex auth login",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Store API key
|
||||
router.post("/store-api-key", async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -416,9 +287,6 @@ export function createSetupRoutes(): Router {
|
||||
process.env.ANTHROPIC_API_KEY = apiKey;
|
||||
await persistApiKeyToEnv("ANTHROPIC_API_KEY", apiKey);
|
||||
console.log("[Setup] Stored API key as ANTHROPIC_API_KEY");
|
||||
} else if (provider === "openai") {
|
||||
process.env.OPENAI_API_KEY = apiKey;
|
||||
await persistApiKeyToEnv("OPENAI_API_KEY", apiKey);
|
||||
} else if (provider === "google") {
|
||||
process.env.GOOGLE_API_KEY = apiKey;
|
||||
await persistApiKeyToEnv("GOOGLE_API_KEY", apiKey);
|
||||
@@ -437,7 +305,6 @@ export function createSetupRoutes(): Router {
|
||||
res.json({
|
||||
success: true,
|
||||
hasAnthropicKey: !!apiKeys.anthropic || !!process.env.ANTHROPIC_API_KEY,
|
||||
hasOpenAIKey: !!apiKeys.openai || !!process.env.OPENAI_API_KEY,
|
||||
hasGoogleKey: !!apiKeys.google || !!process.env.GOOGLE_API_KEY,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -446,34 +313,6 @@ export function createSetupRoutes(): Router {
|
||||
}
|
||||
});
|
||||
|
||||
// Configure Codex MCP
|
||||
router.post("/configure-codex-mcp", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: "projectPath required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create .codex directory and config
|
||||
const codexDir = path.join(projectPath, ".codex");
|
||||
await fs.mkdir(codexDir, { recursive: true });
|
||||
|
||||
const configPath = path.join(codexDir, "config.toml");
|
||||
const config = `# Codex configuration
|
||||
[mcp]
|
||||
enabled = true
|
||||
`;
|
||||
await fs.writeFile(configPath, config);
|
||||
|
||||
res.json({ success: true, configPath });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get platform info
|
||||
router.get("/platform", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -493,29 +332,5 @@ enabled = true
|
||||
}
|
||||
});
|
||||
|
||||
// Test OpenAI connection
|
||||
router.post("/test-openai", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { apiKey } = req.body as { apiKey?: string };
|
||||
const key = apiKey || apiKeys.openai || process.env.OPENAI_API_KEY;
|
||||
|
||||
if (!key) {
|
||||
res.json({ success: false, error: "No OpenAI API key provided" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Simple test - just verify the key format
|
||||
if (!key.startsWith("sk-")) {
|
||||
res.json({ success: false, error: "Invalid OpenAI API key format" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, message: "API key format is valid" });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ export function createTemplatesRoutes(): Router {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Templates] Clone request - Repo: ${repoUrl}, Project: ${projectName}, Parent: ${parentDir}`);
|
||||
|
||||
// Validate repo URL is a valid GitHub URL
|
||||
const githubUrlPattern = /^https:\/\/github\.com\/[\w-]+\/[\w.-]+$/;
|
||||
if (!githubUrlPattern.test(repoUrl)) {
|
||||
@@ -79,12 +81,32 @@ export function createTemplatesRoutes(): Router {
|
||||
|
||||
// Ensure parent directory exists
|
||||
try {
|
||||
await fs.mkdir(parentDir, { recursive: true });
|
||||
// Check if parentDir is a root path (Windows: C:\, D:\, etc. or Unix: /)
|
||||
const isWindowsRoot = /^[A-Za-z]:\\?$/.test(parentDir);
|
||||
const isUnixRoot = parentDir === '/' || parentDir === '';
|
||||
const isRoot = isWindowsRoot || isUnixRoot;
|
||||
|
||||
if (isRoot) {
|
||||
// Root paths always exist, just verify access
|
||||
console.log(`[Templates] Using root path: ${parentDir}`);
|
||||
await fs.access(parentDir);
|
||||
} else {
|
||||
// Check if parent directory exists
|
||||
const parentExists = await fs.access(parentDir).then(() => true).catch(() => false);
|
||||
|
||||
if (!parentExists) {
|
||||
console.log(`[Templates] Creating parent directory: ${parentDir}`);
|
||||
await fs.mkdir(parentDir, { recursive: true });
|
||||
} else {
|
||||
console.log(`[Templates] Parent directory exists: ${parentDir}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Templates] Failed to create parent directory:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error("[Templates] Failed to access parent directory:", parentDir, error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to create parent directory",
|
||||
error: `Failed to access parent directory: ${errorMessage}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
/**
|
||||
* Agent Service - Runs Claude agents via the Claude Agent SDK
|
||||
* Agent Service - Runs AI agents via provider architecture
|
||||
* Manages conversation sessions and streams responses via WebSocket
|
||||
*/
|
||||
|
||||
import { query, AbortError, type Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { AbortError } from "@anthropic-ai/claude-agent-sdk";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import type { EventEmitter } from "../lib/events.js";
|
||||
import { ProviderFactory } from "../providers/provider-factory.js";
|
||||
import type { ExecuteOptions } from "../providers/types.js";
|
||||
import {
|
||||
readImageAsBase64,
|
||||
} from "../lib/image-handler.js";
|
||||
import { buildPromptWithImages } from "../lib/prompt-builder.js";
|
||||
import { getEffectiveModel } from "../lib/model-resolver.js";
|
||||
import { isAbortError } from "../lib/error-handler.js";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
@@ -26,6 +34,7 @@ interface Session {
|
||||
isRunning: boolean;
|
||||
abortController: AbortController | null;
|
||||
workingDirectory: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
interface SessionMetadata {
|
||||
@@ -37,6 +46,7 @@ interface SessionMetadata {
|
||||
updatedAt: string;
|
||||
archived?: boolean;
|
||||
tags?: string[];
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export class AgentService {
|
||||
@@ -91,11 +101,13 @@ export class AgentService {
|
||||
message,
|
||||
workingDirectory,
|
||||
imagePaths,
|
||||
model,
|
||||
}: {
|
||||
sessionId: string;
|
||||
message: string;
|
||||
workingDirectory?: string;
|
||||
imagePaths?: string[];
|
||||
model?: string;
|
||||
}) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
@@ -106,27 +118,22 @@ export class AgentService {
|
||||
throw new Error("Agent is already processing a message");
|
||||
}
|
||||
|
||||
// Update session model if provided
|
||||
if (model) {
|
||||
session.model = model;
|
||||
await this.updateSession(sessionId, { model });
|
||||
}
|
||||
|
||||
// Read images and convert to base64
|
||||
const images: Message["images"] = [];
|
||||
if (imagePaths && imagePaths.length > 0) {
|
||||
for (const imagePath of imagePaths) {
|
||||
try {
|
||||
const imageBuffer = await fs.readFile(imagePath);
|
||||
const base64Data = imageBuffer.toString("base64");
|
||||
const ext = path.extname(imagePath).toLowerCase();
|
||||
const mimeTypeMap: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
};
|
||||
const mediaType = mimeTypeMap[ext] || "image/png";
|
||||
|
||||
const imageData = await readImageAsBase64(imagePath);
|
||||
images.push({
|
||||
data: base64Data,
|
||||
mimeType: mediaType,
|
||||
filename: path.basename(imagePath),
|
||||
data: imageData.base64,
|
||||
mimeType: imageData.mimeType,
|
||||
filename: imageData.filename,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
|
||||
@@ -143,6 +150,12 @@ export class AgentService {
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Build conversation history from existing messages BEFORE adding current message
|
||||
const conversationHistory = session.messages.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
}));
|
||||
|
||||
session.messages.push(userMessage);
|
||||
session.isRunning = true;
|
||||
session.abortController = new AbortController();
|
||||
@@ -156,11 +169,23 @@ export class AgentService {
|
||||
await this.saveSession(sessionId, session.messages);
|
||||
|
||||
try {
|
||||
const options: Options = {
|
||||
model: "claude-opus-4-5-20251101",
|
||||
// Use session model, parameter model, or default
|
||||
const effectiveModel = getEffectiveModel(model, session.model);
|
||||
|
||||
// Get provider for this model
|
||||
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
||||
|
||||
console.log(
|
||||
`[AgentService] Using provider "${provider.getName()}" for model "${effectiveModel}"`
|
||||
);
|
||||
|
||||
// Build options for provider
|
||||
const options: ExecuteOptions = {
|
||||
prompt: "", // Will be set below based on images
|
||||
model: effectiveModel,
|
||||
cwd: workingDirectory || session.workingDirectory,
|
||||
systemPrompt: this.getSystemPrompt(),
|
||||
maxTurns: 20,
|
||||
cwd: workingDirectory || session.workingDirectory,
|
||||
allowedTools: [
|
||||
"Read",
|
||||
"Write",
|
||||
@@ -171,73 +196,23 @@ export class AgentService {
|
||||
"WebSearch",
|
||||
"WebFetch",
|
||||
],
|
||||
permissionMode: "acceptEdits",
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
},
|
||||
abortController: session.abortController!,
|
||||
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||
};
|
||||
|
||||
// Build prompt content
|
||||
let promptContent: string | Array<{ type: string; text?: string; source?: object }> =
|
||||
message;
|
||||
// Build prompt content with images
|
||||
const { content: promptContent } = await buildPromptWithImages(
|
||||
message,
|
||||
imagePaths,
|
||||
undefined, // no workDir for agent service
|
||||
true // include image paths in text
|
||||
);
|
||||
|
||||
if (imagePaths && imagePaths.length > 0) {
|
||||
const contentBlocks: Array<{ type: string; text?: string; source?: object }> = [];
|
||||
// Set the prompt in options
|
||||
options.prompt = promptContent;
|
||||
|
||||
if (message && message.trim()) {
|
||||
contentBlocks.push({ type: "text", text: message });
|
||||
}
|
||||
|
||||
for (const imagePath of imagePaths) {
|
||||
try {
|
||||
const imageBuffer = await fs.readFile(imagePath);
|
||||
const base64Data = imageBuffer.toString("base64");
|
||||
const ext = path.extname(imagePath).toLowerCase();
|
||||
const mimeTypeMap: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
};
|
||||
const mediaType = mimeTypeMap[ext] || "image/png";
|
||||
|
||||
contentBlocks.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: mediaType,
|
||||
data: base64Data,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (contentBlocks.length > 1 || contentBlocks[0]?.type === "image") {
|
||||
promptContent = contentBlocks;
|
||||
}
|
||||
}
|
||||
|
||||
// Build payload
|
||||
const promptPayload = Array.isArray(promptContent)
|
||||
? (async function* () {
|
||||
yield {
|
||||
type: "user" as const,
|
||||
session_id: "",
|
||||
message: {
|
||||
role: "user" as const,
|
||||
content: promptContent,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
})()
|
||||
: promptContent;
|
||||
|
||||
const stream = query({ prompt: promptPayload, options });
|
||||
// Execute via provider
|
||||
const stream = provider.executeQuery(options);
|
||||
|
||||
let currentAssistantMessage: Message | null = null;
|
||||
let responseText = "";
|
||||
@@ -245,7 +220,7 @@ export class AgentService {
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === "assistant") {
|
||||
if (msg.message.content) {
|
||||
if (msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === "text") {
|
||||
responseText += block.text;
|
||||
@@ -270,7 +245,7 @@ export class AgentService {
|
||||
});
|
||||
} else if (block.type === "tool_use") {
|
||||
const toolUse = {
|
||||
name: block.name,
|
||||
name: block.name || "unknown",
|
||||
input: block.input,
|
||||
};
|
||||
toolUses.push(toolUse);
|
||||
@@ -309,7 +284,7 @@ export class AgentService {
|
||||
message: currentAssistantMessage,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AbortError || (error as Error)?.name === "AbortError") {
|
||||
if (isAbortError(error)) {
|
||||
session.isRunning = false;
|
||||
session.abortController = null;
|
||||
return { success: false, aborted: true };
|
||||
@@ -450,7 +425,8 @@ export class AgentService {
|
||||
async createSession(
|
||||
name: string,
|
||||
projectPath?: string,
|
||||
workingDirectory?: string
|
||||
workingDirectory?: string,
|
||||
model?: string
|
||||
): Promise<SessionMetadata> {
|
||||
const sessionId = this.generateId();
|
||||
const metadata = await this.loadMetadata();
|
||||
@@ -462,6 +438,7 @@ export class AgentService {
|
||||
workingDirectory: workingDirectory || projectPath || process.cwd(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
model,
|
||||
};
|
||||
|
||||
metadata[sessionId] = session;
|
||||
@@ -470,6 +447,16 @@ export class AgentService {
|
||||
return session;
|
||||
}
|
||||
|
||||
async setSessionModel(sessionId: string, model: string): Promise<boolean> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.model = model;
|
||||
await this.updateSession(sessionId, { model });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async updateSession(
|
||||
sessionId: string,
|
||||
updates: Partial<SessionMetadata>
|
||||
|
||||
@@ -9,16 +9,17 @@
|
||||
* - Verification and merge workflows
|
||||
*/
|
||||
|
||||
import {
|
||||
query,
|
||||
AbortError,
|
||||
type Options,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { AbortError } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { ProviderFactory } from "../providers/provider-factory.js";
|
||||
import type { ExecuteOptions } from "../providers/types.js";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import type { EventEmitter, EventType } from "../lib/events.js";
|
||||
import { buildPromptWithImages } from "../lib/prompt-builder.js";
|
||||
import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js";
|
||||
import { isAbortError, classifyError } from "../lib/error-handler.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -29,8 +30,9 @@ interface Feature {
|
||||
steps?: string[];
|
||||
status: string;
|
||||
priority?: number;
|
||||
imagePaths?: Array<string | { path: string; [key: string]: unknown }>;
|
||||
[key: string]: unknown; // Allow additional fields
|
||||
spec?: string;
|
||||
model?: string; // Model to use for this feature
|
||||
imagePaths?: Array<string | { path: string; filename?: string; mimeType?: string; [key: string]: unknown }>;
|
||||
}
|
||||
|
||||
interface RunningFeature {
|
||||
@@ -222,17 +224,17 @@ export class AutoModeService {
|
||||
const prompt = this.buildFeaturePrompt(feature);
|
||||
|
||||
// Extract image paths from feature
|
||||
const imagePaths = this.extractImagePaths(feature.imagePaths, workDir);
|
||||
|
||||
// Run the agent with image paths
|
||||
await this.runAgent(
|
||||
workDir,
|
||||
featureId,
|
||||
prompt,
|
||||
abortController,
|
||||
imagePaths
|
||||
const imagePaths = feature.imagePaths?.map((img) =>
|
||||
typeof img === "string" ? img : img.path
|
||||
);
|
||||
|
||||
// Get model from feature
|
||||
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
|
||||
console.log(`[AutoMode] Executing feature ${featureId} with model: ${model}`);
|
||||
|
||||
// Run the agent with the feature's model and images
|
||||
await this.runAgent(workDir, featureId, prompt, abortController, imagePaths, model);
|
||||
|
||||
// Mark as waiting_approval for user review
|
||||
await this.updateFeatureStatus(
|
||||
projectPath,
|
||||
@@ -249,10 +251,9 @@ export class AutoModeService {
|
||||
projectPath,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof AbortError ||
|
||||
(error as Error)?.name === "AbortError"
|
||||
) {
|
||||
const errorInfo = classifyError(error);
|
||||
|
||||
if (errorInfo.isAbort) {
|
||||
this.emitAutoModeEvent("auto_mode_feature_complete", {
|
||||
featureId,
|
||||
passes: false,
|
||||
@@ -260,18 +261,12 @@ export class AutoModeService {
|
||||
projectPath,
|
||||
});
|
||||
} else {
|
||||
const errorMessage = (error as Error).message || "Unknown error";
|
||||
const isAuthError =
|
||||
errorMessage.includes("Authentication failed") ||
|
||||
errorMessage.includes("Invalid API key") ||
|
||||
errorMessage.includes("authentication_failed");
|
||||
|
||||
console.error(`[AutoMode] Feature ${featureId} failed:`, error);
|
||||
await this.updateFeatureStatus(projectPath, featureId, "backlog");
|
||||
this.emitAutoModeEvent("auto_mode_error", {
|
||||
featureId,
|
||||
error: errorMessage,
|
||||
errorType: isAuthError ? "authentication" : "execution",
|
||||
error: errorInfo.message,
|
||||
errorType: errorInfo.isAuth ? "authentication" : "execution",
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
@@ -425,13 +420,93 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
});
|
||||
|
||||
try {
|
||||
await this.runAgent(
|
||||
workDir,
|
||||
featureId,
|
||||
fullPrompt,
|
||||
abortController,
|
||||
imagePaths
|
||||
);
|
||||
// Get model from feature (already loaded above)
|
||||
const model = resolveModelString(feature?.model, DEFAULT_MODELS.claude);
|
||||
console.log(`[AutoMode] Follow-up for feature ${featureId} using model: ${model}`);
|
||||
|
||||
// Update feature status to in_progress
|
||||
await this.updateFeatureStatus(projectPath, featureId, "in_progress");
|
||||
|
||||
// Copy follow-up images to feature folder
|
||||
const copiedImagePaths: string[] = [];
|
||||
if (imagePaths && imagePaths.length > 0) {
|
||||
const featureImagesDir = path.join(
|
||||
projectPath,
|
||||
".automaker",
|
||||
"features",
|
||||
featureId,
|
||||
"images"
|
||||
);
|
||||
|
||||
await fs.mkdir(featureImagesDir, { recursive: true });
|
||||
|
||||
for (const imagePath of imagePaths) {
|
||||
try {
|
||||
// Get the filename from the path
|
||||
const filename = path.basename(imagePath);
|
||||
const destPath = path.join(featureImagesDir, filename);
|
||||
|
||||
// Copy the image
|
||||
await fs.copyFile(imagePath, destPath);
|
||||
|
||||
// Store the relative path (like FeatureLoader does)
|
||||
const relativePath = path.join(
|
||||
".automaker",
|
||||
"features",
|
||||
featureId,
|
||||
"images",
|
||||
filename
|
||||
);
|
||||
copiedImagePaths.push(relativePath);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[AutoMode] Failed to copy follow-up image ${imagePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update feature object with new follow-up images BEFORE building prompt
|
||||
if (copiedImagePaths.length > 0 && feature) {
|
||||
const currentImagePaths = feature.imagePaths || [];
|
||||
const newImagePaths = copiedImagePaths.map((p) => ({
|
||||
path: p,
|
||||
filename: path.basename(p),
|
||||
mimeType: "image/png", // Default, could be improved
|
||||
}));
|
||||
|
||||
feature.imagePaths = [...currentImagePaths, ...newImagePaths];
|
||||
}
|
||||
|
||||
// Combine original feature images with new follow-up images
|
||||
const allImagePaths: string[] = [];
|
||||
|
||||
// Add all images from feature (now includes both original and new)
|
||||
if (feature?.imagePaths) {
|
||||
const allPaths = feature.imagePaths.map((img) =>
|
||||
typeof img === "string" ? img : img.path
|
||||
);
|
||||
allImagePaths.push(...allPaths);
|
||||
}
|
||||
|
||||
// Save updated feature.json with new images
|
||||
if (copiedImagePaths.length > 0 && feature) {
|
||||
const featurePath = path.join(
|
||||
projectPath,
|
||||
".automaker",
|
||||
"features",
|
||||
featureId,
|
||||
"feature.json"
|
||||
);
|
||||
|
||||
try {
|
||||
await fs.writeFile(featurePath, JSON.stringify(feature, null, 2));
|
||||
} catch (error) {
|
||||
console.error(`[AutoMode] Failed to save feature.json:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Use fullPrompt (already built above) with model and all images
|
||||
await this.runAgent(workDir, featureId, fullPrompt, abortController, allImagePaths.length > 0 ? allImagePaths : imagePaths, model);
|
||||
|
||||
// Mark as waiting_approval for user review
|
||||
await this.updateFeatureStatus(
|
||||
@@ -447,7 +522,7 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
projectPath,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof AbortError)) {
|
||||
if (!isAbortError(error)) {
|
||||
this.emitAutoModeEvent("auto_mode_error", {
|
||||
featureId,
|
||||
error: (error as Error).message,
|
||||
@@ -641,23 +716,27 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
Format your response as a structured markdown document.`;
|
||||
|
||||
try {
|
||||
const options: Options = {
|
||||
model: "claude-sonnet-4-20250514",
|
||||
// Use default Claude model for analysis (can be overridden in the future)
|
||||
const analysisModel = resolveModelString(undefined, DEFAULT_MODELS.claude);
|
||||
const provider = ProviderFactory.getProviderForModel(analysisModel);
|
||||
|
||||
const options: ExecuteOptions = {
|
||||
prompt,
|
||||
model: analysisModel,
|
||||
maxTurns: 5,
|
||||
cwd: projectPath,
|
||||
allowedTools: ["Read", "Glob", "Grep"],
|
||||
permissionMode: "acceptEdits",
|
||||
abortController,
|
||||
};
|
||||
|
||||
const stream = query({ prompt, options });
|
||||
const stream = provider.executeQuery(options);
|
||||
let analysisResult = "";
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === "assistant" && msg.message.content) {
|
||||
if (msg.type === "assistant" && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === "text") {
|
||||
analysisResult = block.text;
|
||||
analysisResult = block.text || "";
|
||||
this.emitAutoModeEvent("auto_mode_progress", {
|
||||
featureId: analysisFeatureId,
|
||||
content: block.text,
|
||||
@@ -907,6 +986,34 @@ Format your response as a structured markdown document.`;
|
||||
**Description:** ${feature.description}
|
||||
`;
|
||||
|
||||
if (feature.spec) {
|
||||
prompt += `
|
||||
**Specification:**
|
||||
${feature.spec}
|
||||
`;
|
||||
}
|
||||
|
||||
// Add images note (like old implementation)
|
||||
if (feature.imagePaths && feature.imagePaths.length > 0) {
|
||||
const imagesList = feature.imagePaths
|
||||
.map((img, idx) => {
|
||||
const path = typeof img === "string" ? img : img.path;
|
||||
const filename = typeof img === "string" ? path.split("/").pop() : img.filename || path.split("/").pop();
|
||||
const mimeType = typeof img === "string" ? "image/*" : img.mimeType || "image/*";
|
||||
return ` ${idx + 1}. ${filename} (${mimeType})\n Path: ${path}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
prompt += `
|
||||
**📎 Context Images Attached:**
|
||||
The user has attached ${feature.imagePaths.length} image(s) for context. These images are provided both visually (in the initial message) and as files you can read:
|
||||
|
||||
${imagesList}
|
||||
|
||||
You can use the Read tool to view these images at any time during implementation. Review them carefully before implementing.
|
||||
`;
|
||||
}
|
||||
|
||||
prompt += `
|
||||
## Instructions
|
||||
|
||||
@@ -927,31 +1034,45 @@ When done, summarize what you implemented and any notes for the developer.`;
|
||||
featureId: string,
|
||||
prompt: string,
|
||||
abortController: AbortController,
|
||||
imagePaths?: string[]
|
||||
imagePaths?: string[],
|
||||
model?: string
|
||||
): Promise<void> {
|
||||
const options: Options = {
|
||||
model: "claude-opus-4-5-20251101",
|
||||
const finalModel = resolveModelString(model, DEFAULT_MODELS.claude);
|
||||
console.log(`[AutoMode] runAgent called for feature ${featureId} with model: ${finalModel}`);
|
||||
|
||||
// Get provider for this model
|
||||
const provider = ProviderFactory.getProviderForModel(finalModel);
|
||||
|
||||
console.log(
|
||||
`[AutoMode] Using provider "${provider.getName()}" for model "${finalModel}"`
|
||||
);
|
||||
|
||||
// Build prompt content with images using utility
|
||||
const { content: promptContent } = await buildPromptWithImages(
|
||||
prompt,
|
||||
imagePaths,
|
||||
workDir,
|
||||
false // don't duplicate paths in text
|
||||
);
|
||||
|
||||
const options: ExecuteOptions = {
|
||||
prompt: promptContent,
|
||||
model: finalModel,
|
||||
maxTurns: 50,
|
||||
cwd: workDir,
|
||||
allowedTools: ["Read", "Write", "Edit", "Glob", "Grep", "Bash"],
|
||||
permissionMode: "acceptEdits",
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
},
|
||||
allowedTools: [
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Bash",
|
||||
],
|
||||
abortController,
|
||||
};
|
||||
|
||||
// Build prompt - include image paths for the agent to read
|
||||
let finalPrompt = prompt;
|
||||
|
||||
if (imagePaths && imagePaths.length > 0) {
|
||||
finalPrompt = `${prompt}\n\n## Reference Images\nThe following images are available for reference. Use the Read tool to view them:\n${imagePaths
|
||||
.map((p) => `- ${p}`)
|
||||
.join("\n")}`;
|
||||
}
|
||||
|
||||
const stream = query({ prompt: finalPrompt, options });
|
||||
// Execute via provider
|
||||
const stream = provider.executeQuery(options);
|
||||
let responseText = "";
|
||||
const outputPath = path.join(
|
||||
workDir,
|
||||
@@ -962,20 +1083,18 @@ When done, summarize what you implemented and any notes for the developer.`;
|
||||
);
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === "assistant" && msg.message.content) {
|
||||
if (msg.type === "assistant" && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === "text") {
|
||||
responseText = block.text;
|
||||
responseText = block.text || "";
|
||||
|
||||
// Check for authentication errors in the response
|
||||
if (
|
||||
block.text.includes("Invalid API key") ||
|
||||
block.text.includes("authentication_failed") ||
|
||||
block.text.includes("Fix external API key")
|
||||
) {
|
||||
if (block.text && (block.text.includes("Invalid API key") ||
|
||||
block.text.includes("authentication_failed") ||
|
||||
block.text.includes("Fix external API key"))) {
|
||||
throw new Error(
|
||||
"Authentication failed: Invalid or expired API key. " +
|
||||
"Please check your ANTHROPIC_API_KEY or run 'claude login' to re-authenticate."
|
||||
"Please check your ANTHROPIC_API_KEY or GOOGLE_API_KEY, or run 'claude login' to re-authenticate."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -991,23 +1110,10 @@ When done, summarize what you implemented and any notes for the developer.`;
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
msg.type === "assistant" &&
|
||||
(msg as { error?: string }).error === "authentication_failed"
|
||||
) {
|
||||
// Handle authentication error from the SDK
|
||||
throw new Error(
|
||||
"Authentication failed: Invalid or expired API key. " +
|
||||
"Please set a valid ANTHROPIC_API_KEY environment variable or run 'claude login' to authenticate."
|
||||
);
|
||||
} else if (msg.type === "error") {
|
||||
// Handle error messages
|
||||
throw new Error(msg.error || "Unknown error");
|
||||
} else if (msg.type === "result" && msg.subtype === "success") {
|
||||
// Check if result indicates an error
|
||||
if (msg.is_error && msg.result?.includes("Invalid API key")) {
|
||||
throw new Error(
|
||||
"Authentication failed: Invalid or expired API key. " +
|
||||
"Please set a valid ANTHROPIC_API_KEY environment variable or run 'claude login' to authenticate."
|
||||
);
|
||||
}
|
||||
responseText = msg.result || responseText;
|
||||
}
|
||||
}
|
||||
|
||||
17
apps/server/tests/fixtures/configs.ts
vendored
Normal file
17
apps/server/tests/fixtures/configs.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Configuration fixtures for testing
|
||||
*/
|
||||
|
||||
export const tomlConfigFixture = `
|
||||
experimental_use_rmcp_client = true
|
||||
|
||||
[mcp_servers.automaker-tools]
|
||||
command = "node"
|
||||
args = ["/path/to/server.js"]
|
||||
startup_timeout_sec = 10
|
||||
tool_timeout_sec = 60
|
||||
enabled_tools = ["UpdateFeatureStatus"]
|
||||
|
||||
[mcp_servers.automaker-tools.env]
|
||||
AUTOMAKER_PROJECT_PATH = "/path/to/project"
|
||||
`;
|
||||
14
apps/server/tests/fixtures/images.ts
vendored
Normal file
14
apps/server/tests/fixtures/images.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Image fixtures for testing image handling
|
||||
*/
|
||||
|
||||
// 1x1 transparent PNG base64 data
|
||||
export const pngBase64Fixture =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
|
||||
export const imageDataFixture = {
|
||||
base64: pngBase64Fixture,
|
||||
mimeType: "image/png",
|
||||
filename: "test.png",
|
||||
originalPath: "/path/to/test.png",
|
||||
};
|
||||
39
apps/server/tests/fixtures/messages.ts
vendored
Normal file
39
apps/server/tests/fixtures/messages.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Message fixtures for testing providers and lib utilities
|
||||
*/
|
||||
|
||||
import type {
|
||||
ConversationMessage,
|
||||
ProviderMessage,
|
||||
ContentBlock,
|
||||
} from "../../src/providers/types.js";
|
||||
|
||||
export const conversationHistoryFixture: ConversationMessage[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: "Hello, can you help me?",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Of course! How can I assist you today?",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "What is in this image?" },
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "base64data" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const claudeProviderMessageFixture: ProviderMessage = {
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "This is a test response" }],
|
||||
},
|
||||
};
|
||||
|
||||
144
apps/server/tests/integration/helpers/git-test-repo.ts
Normal file
144
apps/server/tests/integration/helpers/git-test-repo.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Helper for creating test git repositories for integration tests
|
||||
*/
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import * as fs from "fs/promises";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface TestRepo {
|
||||
path: string;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a temporary git repository for testing
|
||||
*/
|
||||
export async function createTestGitRepo(): Promise<TestRepo> {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "automaker-test-"));
|
||||
|
||||
// Initialize git repo
|
||||
await execAsync("git init", { cwd: tmpDir });
|
||||
await execAsync('git config user.email "test@example.com"', { cwd: tmpDir });
|
||||
await execAsync('git config user.name "Test User"', { cwd: tmpDir });
|
||||
|
||||
// Create initial commit
|
||||
await fs.writeFile(path.join(tmpDir, "README.md"), "# Test Project\n");
|
||||
await execAsync("git add .", { cwd: tmpDir });
|
||||
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir });
|
||||
|
||||
// Create main branch explicitly
|
||||
await execAsync("git branch -M main", { cwd: tmpDir });
|
||||
|
||||
return {
|
||||
path: tmpDir,
|
||||
cleanup: async () => {
|
||||
try {
|
||||
// Remove all worktrees first
|
||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||
cwd: tmpDir,
|
||||
}).catch(() => ({ stdout: "" }));
|
||||
|
||||
const worktrees = stdout
|
||||
.split("\n\n")
|
||||
.slice(1) // Skip main worktree
|
||||
.map((block) => {
|
||||
const pathLine = block.split("\n").find((line) => line.startsWith("worktree "));
|
||||
return pathLine ? pathLine.replace("worktree ", "") : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
for (const worktreePath of worktrees) {
|
||||
try {
|
||||
await execAsync(`git worktree remove "${worktreePath}" --force`, {
|
||||
cwd: tmpDir,
|
||||
});
|
||||
} catch (err) {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the repository
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
console.error("Failed to cleanup test repo:", error);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a feature file in the test repo
|
||||
*/
|
||||
export async function createTestFeature(
|
||||
repoPath: string,
|
||||
featureId: string,
|
||||
featureData: any
|
||||
): Promise<void> {
|
||||
const featuresDir = path.join(repoPath, ".automaker", "features");
|
||||
const featureDir = path.join(featuresDir, featureId);
|
||||
|
||||
await fs.mkdir(featureDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(featureDir, "feature.json"),
|
||||
JSON.stringify(featureData, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of git branches
|
||||
*/
|
||||
export async function listBranches(repoPath: string): Promise<string[]> {
|
||||
const { stdout } = await execAsync("git branch --list", { cwd: repoPath });
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim().replace(/^[*+]\s*/, ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of git worktrees
|
||||
*/
|
||||
export async function listWorktrees(repoPath: string): Promise<string[]> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||
cwd: repoPath,
|
||||
});
|
||||
|
||||
return stdout
|
||||
.split("\n\n")
|
||||
.slice(1) // Skip main worktree
|
||||
.map((block) => {
|
||||
const pathLine = block.split("\n").find((line) => line.startsWith("worktree "));
|
||||
return pathLine ? pathLine.replace("worktree ", "") : null;
|
||||
})
|
||||
.filter(Boolean) as string[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a branch exists
|
||||
*/
|
||||
export async function branchExists(
|
||||
repoPath: string,
|
||||
branchName: string
|
||||
): Promise<boolean> {
|
||||
const branches = await listBranches(repoPath);
|
||||
return branches.includes(branchName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a worktree exists
|
||||
*/
|
||||
export async function worktreeExists(
|
||||
repoPath: string,
|
||||
worktreePath: string
|
||||
): Promise<boolean> {
|
||||
const worktrees = await listWorktrees(repoPath);
|
||||
return worktrees.some((wt) => wt === worktreePath);
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { AutoModeService } from "@/services/auto-mode-service.js";
|
||||
import { ProviderFactory } from "@/providers/provider-factory.js";
|
||||
import { FeatureLoader } from "@/services/feature-loader.js";
|
||||
import {
|
||||
createTestGitRepo,
|
||||
createTestFeature,
|
||||
listBranches,
|
||||
listWorktrees,
|
||||
branchExists,
|
||||
worktreeExists,
|
||||
type TestRepo,
|
||||
} from "../helpers/git-test-repo.js";
|
||||
import * as fs from "fs/promises";
|
||||
import * as path from "path";
|
||||
|
||||
vi.mock("@/providers/provider-factory.js");
|
||||
|
||||
describe("auto-mode-service.ts (integration)", () => {
|
||||
let service: AutoModeService;
|
||||
let testRepo: TestRepo;
|
||||
let featureLoader: FeatureLoader;
|
||||
const mockEvents = {
|
||||
subscribe: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
service = new AutoModeService(mockEvents as any);
|
||||
featureLoader = new FeatureLoader();
|
||||
testRepo = await createTestGitRepo();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Stop any running auto loops
|
||||
await service.stopAutoLoop();
|
||||
|
||||
// Cleanup test repo
|
||||
if (testRepo) {
|
||||
await testRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
describe("worktree operations", () => {
|
||||
it("should create git worktree for feature", async () => {
|
||||
// Create a test feature
|
||||
await createTestFeature(testRepo.path, "test-feature-1", {
|
||||
id: "test-feature-1",
|
||||
category: "test",
|
||||
description: "Test feature",
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
// Mock provider to complete quickly
|
||||
const mockProvider = {
|
||||
getName: () => "claude",
|
||||
executeQuery: async function* () {
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Feature implemented" }],
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||
mockProvider as any
|
||||
);
|
||||
|
||||
// Execute feature with worktrees enabled
|
||||
await service.executeFeature(
|
||||
testRepo.path,
|
||||
"test-feature-1",
|
||||
true, // useWorktrees
|
||||
false // isAutoMode
|
||||
);
|
||||
|
||||
// Verify branch was created
|
||||
const branches = await listBranches(testRepo.path);
|
||||
expect(branches).toContain("feature/test-feature-1");
|
||||
|
||||
// Note: Worktrees are not automatically cleaned up by the service
|
||||
// This is expected behavior - manual cleanup is required
|
||||
}, 30000);
|
||||
|
||||
it("should handle error gracefully", async () => {
|
||||
await createTestFeature(testRepo.path, "test-feature-error", {
|
||||
id: "test-feature-error",
|
||||
category: "test",
|
||||
description: "Test feature that errors",
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
// Mock provider that throws error
|
||||
const mockProvider = {
|
||||
getName: () => "claude",
|
||||
executeQuery: async function* () {
|
||||
throw new Error("Provider error");
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||
mockProvider as any
|
||||
);
|
||||
|
||||
// Execute feature (should handle error)
|
||||
await service.executeFeature(
|
||||
testRepo.path,
|
||||
"test-feature-error",
|
||||
true,
|
||||
false
|
||||
);
|
||||
|
||||
// Verify feature status was updated to backlog (error status)
|
||||
const feature = await featureLoader.get(testRepo.path, "test-feature-error");
|
||||
expect(feature?.status).toBe("backlog");
|
||||
}, 30000);
|
||||
|
||||
it("should work without worktrees", async () => {
|
||||
await createTestFeature(testRepo.path, "test-no-worktree", {
|
||||
id: "test-no-worktree",
|
||||
category: "test",
|
||||
description: "Test without worktree",
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => "claude",
|
||||
executeQuery: async function* () {
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||
mockProvider as any
|
||||
);
|
||||
|
||||
// Execute without worktrees
|
||||
await service.executeFeature(
|
||||
testRepo.path,
|
||||
"test-no-worktree",
|
||||
false, // useWorktrees = false
|
||||
false
|
||||
);
|
||||
|
||||
// Feature should be updated successfully
|
||||
const feature = await featureLoader.get(testRepo.path, "test-no-worktree");
|
||||
expect(feature?.status).toBe("waiting_approval");
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("feature execution", () => {
|
||||
it("should execute feature and update status", async () => {
|
||||
await createTestFeature(testRepo.path, "feature-exec-1", {
|
||||
id: "feature-exec-1",
|
||||
category: "ui",
|
||||
description: "Execute this feature",
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => "claude",
|
||||
executeQuery: async function* () {
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Implemented the feature" }],
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||
mockProvider as any
|
||||
);
|
||||
|
||||
await service.executeFeature(
|
||||
testRepo.path,
|
||||
"feature-exec-1",
|
||||
false, // Don't use worktrees so agent output is saved to main project
|
||||
false
|
||||
);
|
||||
|
||||
// Check feature status was updated
|
||||
const feature = await featureLoader.get(testRepo.path, "feature-exec-1");
|
||||
expect(feature?.status).toBe("waiting_approval");
|
||||
|
||||
// Check agent output was saved
|
||||
const agentOutput = await featureLoader.getAgentOutput(
|
||||
testRepo.path,
|
||||
"feature-exec-1"
|
||||
);
|
||||
expect(agentOutput).toBeTruthy();
|
||||
expect(agentOutput).toContain("Implemented the feature");
|
||||
}, 30000);
|
||||
|
||||
it("should handle feature not found", async () => {
|
||||
const mockProvider = {
|
||||
getName: () => "claude",
|
||||
executeQuery: async function* () {
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||
mockProvider as any
|
||||
);
|
||||
|
||||
// Try to execute non-existent feature
|
||||
await service.executeFeature(
|
||||
testRepo.path,
|
||||
"nonexistent-feature",
|
||||
true,
|
||||
false
|
||||
);
|
||||
|
||||
// Should emit error event
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
featureId: "nonexistent-feature",
|
||||
error: expect.stringContaining("not found"),
|
||||
})
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
it("should prevent duplicate feature execution", async () => {
|
||||
await createTestFeature(testRepo.path, "feature-dup", {
|
||||
id: "feature-dup",
|
||||
category: "test",
|
||||
description: "Duplicate test",
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => "claude",
|
||||
executeQuery: async function* () {
|
||||
// Simulate slow execution
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||
mockProvider as any
|
||||
);
|
||||
|
||||
// Start first execution
|
||||
const promise1 = service.executeFeature(
|
||||
testRepo.path,
|
||||
"feature-dup",
|
||||
false,
|
||||
false
|
||||
);
|
||||
|
||||
// Try to start second execution (should throw)
|
||||
await expect(
|
||||
service.executeFeature(testRepo.path, "feature-dup", false, false)
|
||||
).rejects.toThrow("already running");
|
||||
|
||||
await promise1;
|
||||
}, 30000);
|
||||
|
||||
it("should use feature-specific model", async () => {
|
||||
await createTestFeature(testRepo.path, "feature-model", {
|
||||
id: "feature-model",
|
||||
category: "test",
|
||||
description: "Model test",
|
||||
status: "pending",
|
||||
model: "claude-sonnet-4-20250514",
|
||||
});
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => "claude",
|
||||
executeQuery: async function* () {
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||
mockProvider as any
|
||||
);
|
||||
|
||||
await service.executeFeature(
|
||||
testRepo.path,
|
||||
"feature-model",
|
||||
false,
|
||||
false
|
||||
);
|
||||
|
||||
// Should have used claude-sonnet-4-20250514
|
||||
expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith("claude-sonnet-4-20250514");
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("auto loop", () => {
|
||||
it("should start and stop auto loop", async () => {
|
||||
const startPromise = service.startAutoLoop(testRepo.path, 2);
|
||||
|
||||
// Give it time to start
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Stop the loop
|
||||
const runningCount = await service.stopAutoLoop();
|
||||
|
||||
expect(runningCount).toBe(0);
|
||||
await startPromise.catch(() => {}); // Cleanup
|
||||
}, 10000);
|
||||
|
||||
it("should process pending features in auto loop", async () => {
|
||||
// Create multiple pending features
|
||||
await createTestFeature(testRepo.path, "auto-1", {
|
||||
id: "auto-1",
|
||||
category: "test",
|
||||
description: "Auto feature 1",
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
await createTestFeature(testRepo.path, "auto-2", {
|
||||
id: "auto-2",
|
||||
category: "test",
|
||||
description: "Auto feature 2",
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => "claude",
|
||||
executeQuery: async function* () {
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||
mockProvider as any
|
||||
);
|
||||
|
||||
// Start auto loop
|
||||
const startPromise = service.startAutoLoop(testRepo.path, 2);
|
||||
|
||||
// Wait for features to be processed
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
|
||||
// Stop the loop
|
||||
await service.stopAutoLoop();
|
||||
await startPromise.catch(() => {});
|
||||
|
||||
// Check that features were updated
|
||||
const feature1 = await featureLoader.get(testRepo.path, "auto-1");
|
||||
const feature2 = await featureLoader.get(testRepo.path, "auto-2");
|
||||
|
||||
// At least one should have been processed
|
||||
const processedCount = [feature1, feature2].filter(
|
||||
(f) => f?.status === "waiting_approval" || f?.status === "in_progress"
|
||||
).length;
|
||||
|
||||
expect(processedCount).toBeGreaterThan(0);
|
||||
}, 15000);
|
||||
|
||||
it("should respect max concurrency", async () => {
|
||||
// Create 5 features
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await createTestFeature(testRepo.path, `concurrent-${i}`, {
|
||||
id: `concurrent-${i}`,
|
||||
category: "test",
|
||||
description: `Concurrent feature ${i}`,
|
||||
status: "pending",
|
||||
});
|
||||
}
|
||||
|
||||
let concurrentCount = 0;
|
||||
let maxConcurrent = 0;
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => "claude",
|
||||
executeQuery: async function* () {
|
||||
concurrentCount++;
|
||||
maxConcurrent = Math.max(maxConcurrent, concurrentCount);
|
||||
|
||||
// Simulate work
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
concurrentCount--;
|
||||
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||
mockProvider as any
|
||||
);
|
||||
|
||||
// Start with max concurrency of 2
|
||||
const startPromise = service.startAutoLoop(testRepo.path, 2);
|
||||
|
||||
// Wait for some features to be processed
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
|
||||
await service.stopAutoLoop();
|
||||
await startPromise.catch(() => {});
|
||||
|
||||
// Max concurrent should not exceed 2
|
||||
expect(maxConcurrent).toBeLessThanOrEqual(2);
|
||||
}, 15000);
|
||||
|
||||
it("should emit auto mode events", async () => {
|
||||
const startPromise = service.startAutoLoop(testRepo.path, 1);
|
||||
|
||||
// Wait for start event
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Check start event was emitted
|
||||
const startEvent = mockEvents.emit.mock.calls.find((call) =>
|
||||
call[1]?.message?.includes("Auto mode started")
|
||||
);
|
||||
expect(startEvent).toBeTruthy();
|
||||
|
||||
await service.stopAutoLoop();
|
||||
await startPromise.catch(() => {});
|
||||
|
||||
// Check stop event was emitted (auto_mode_complete event)
|
||||
const stopEvent = mockEvents.emit.mock.calls.find((call) =>
|
||||
call[1]?.type === "auto_mode_complete" || call[1]?.message?.includes("stopped")
|
||||
);
|
||||
expect(stopEvent).toBeTruthy();
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle provider errors gracefully", async () => {
|
||||
await createTestFeature(testRepo.path, "error-feature", {
|
||||
id: "error-feature",
|
||||
category: "test",
|
||||
description: "Error test",
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => "claude",
|
||||
executeQuery: async function* () {
|
||||
throw new Error("Provider execution failed");
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||
mockProvider as any
|
||||
);
|
||||
|
||||
// Should not throw
|
||||
await service.executeFeature(
|
||||
testRepo.path,
|
||||
"error-feature",
|
||||
true,
|
||||
false
|
||||
);
|
||||
|
||||
// Feature should be marked as backlog (error status)
|
||||
const feature = await featureLoader.get(testRepo.path, "error-feature");
|
||||
expect(feature?.status).toBe("backlog");
|
||||
}, 30000);
|
||||
|
||||
it("should continue auto loop after feature error", async () => {
|
||||
await createTestFeature(testRepo.path, "fail-1", {
|
||||
id: "fail-1",
|
||||
category: "test",
|
||||
description: "Will fail",
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
await createTestFeature(testRepo.path, "success-1", {
|
||||
id: "success-1",
|
||||
category: "test",
|
||||
description: "Will succeed",
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
let callCount = 0;
|
||||
const mockProvider = {
|
||||
getName: () => "claude",
|
||||
executeQuery: async function* () {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
throw new Error("First feature fails");
|
||||
}
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||
mockProvider as any
|
||||
);
|
||||
|
||||
const startPromise = service.startAutoLoop(testRepo.path, 1);
|
||||
|
||||
// Wait for both features to be attempted
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
|
||||
await service.stopAutoLoop();
|
||||
await startPromise.catch(() => {});
|
||||
|
||||
// Both features should have been attempted
|
||||
expect(callCount).toBeGreaterThanOrEqual(1);
|
||||
}, 15000);
|
||||
});
|
||||
});
|
||||
16
apps/server/tests/setup.ts
Normal file
16
apps/server/tests/setup.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Vitest global setup file
|
||||
* Runs before each test file
|
||||
*/
|
||||
|
||||
import { vi, beforeEach } from "vitest";
|
||||
|
||||
// Set test environment variables
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.DATA_DIR = "/tmp/test-data";
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/tmp/test-projects";
|
||||
|
||||
// Reset all mocks before each test
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
116
apps/server/tests/unit/lib/auth.test.ts
Normal file
116
apps/server/tests/unit/lib/auth.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { createMockExpressContext } from "../../utils/mocks.js";
|
||||
|
||||
/**
|
||||
* Note: auth.ts reads AUTOMAKER_API_KEY at module load time.
|
||||
* We need to reset modules and reimport for each test to get fresh state.
|
||||
*/
|
||||
describe("auth.ts", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("authMiddleware - no API key", () => {
|
||||
it("should call next() when no API key is set", async () => {
|
||||
delete process.env.AUTOMAKER_API_KEY;
|
||||
|
||||
const { authMiddleware } = await import("@/lib/auth.js");
|
||||
const { req, res, next } = createMockExpressContext();
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("authMiddleware - with API key", () => {
|
||||
it("should reject request without API key header", async () => {
|
||||
process.env.AUTOMAKER_API_KEY = "test-secret-key";
|
||||
|
||||
const { authMiddleware } = await import("@/lib/auth.js");
|
||||
const { req, res, next } = createMockExpressContext();
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: "Authentication required. Provide X-API-Key header.",
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should reject request with invalid API key", async () => {
|
||||
process.env.AUTOMAKER_API_KEY = "test-secret-key";
|
||||
|
||||
const { authMiddleware } = await import("@/lib/auth.js");
|
||||
const { req, res, next } = createMockExpressContext();
|
||||
req.headers["x-api-key"] = "wrong-key";
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: "Invalid API key.",
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call next() with valid API key", async () => {
|
||||
process.env.AUTOMAKER_API_KEY = "test-secret-key";
|
||||
|
||||
const { authMiddleware } = await import("@/lib/auth.js");
|
||||
const { req, res, next} = createMockExpressContext();
|
||||
req.headers["x-api-key"] = "test-secret-key";
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthEnabled", () => {
|
||||
it("should return false when no API key is set", async () => {
|
||||
delete process.env.AUTOMAKER_API_KEY;
|
||||
|
||||
const { isAuthEnabled } = await import("@/lib/auth.js");
|
||||
expect(isAuthEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when API key is set", async () => {
|
||||
process.env.AUTOMAKER_API_KEY = "test-key";
|
||||
|
||||
const { isAuthEnabled } = await import("@/lib/auth.js");
|
||||
expect(isAuthEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAuthStatus", () => {
|
||||
it("should return disabled status when no API key", async () => {
|
||||
delete process.env.AUTOMAKER_API_KEY;
|
||||
|
||||
const { getAuthStatus } = await import("@/lib/auth.js");
|
||||
const status = getAuthStatus();
|
||||
|
||||
expect(status).toEqual({
|
||||
enabled: false,
|
||||
method: "none",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return enabled status when API key is set", async () => {
|
||||
process.env.AUTOMAKER_API_KEY = "test-key";
|
||||
|
||||
const { getAuthStatus } = await import("@/lib/auth.js");
|
||||
const status = getAuthStatus();
|
||||
|
||||
expect(status).toEqual({
|
||||
enabled: true,
|
||||
method: "api_key",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
226
apps/server/tests/unit/lib/conversation-utils.test.ts
Normal file
226
apps/server/tests/unit/lib/conversation-utils.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
extractTextFromContent,
|
||||
normalizeContentBlocks,
|
||||
formatHistoryAsText,
|
||||
convertHistoryToMessages,
|
||||
} from "@/lib/conversation-utils.js";
|
||||
import { conversationHistoryFixture } from "../../fixtures/messages.js";
|
||||
|
||||
describe("conversation-utils.ts", () => {
|
||||
describe("extractTextFromContent", () => {
|
||||
it("should return string content as-is", () => {
|
||||
const result = extractTextFromContent("Hello world");
|
||||
expect(result).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("should extract text from single text block", () => {
|
||||
const content = [{ type: "text", text: "Hello" }];
|
||||
const result = extractTextFromContent(content);
|
||||
expect(result).toBe("Hello");
|
||||
});
|
||||
|
||||
it("should extract and join multiple text blocks with newlines", () => {
|
||||
const content = [
|
||||
{ type: "text", text: "First block" },
|
||||
{ type: "text", text: "Second block" },
|
||||
{ type: "text", text: "Third block" },
|
||||
];
|
||||
const result = extractTextFromContent(content);
|
||||
expect(result).toBe("First block\nSecond block\nThird block");
|
||||
});
|
||||
|
||||
it("should ignore non-text blocks", () => {
|
||||
const content = [
|
||||
{ type: "text", text: "Text content" },
|
||||
{ type: "image", source: { type: "base64", data: "abc" } },
|
||||
{ type: "text", text: "More text" },
|
||||
{ type: "tool_use", name: "bash", input: {} },
|
||||
];
|
||||
const result = extractTextFromContent(content);
|
||||
expect(result).toBe("Text content\nMore text");
|
||||
});
|
||||
|
||||
it("should handle blocks without text property", () => {
|
||||
const content = [
|
||||
{ type: "text", text: "Valid" },
|
||||
{ type: "text" } as any,
|
||||
{ type: "text", text: "Also valid" },
|
||||
];
|
||||
const result = extractTextFromContent(content);
|
||||
expect(result).toBe("Valid\n\nAlso valid");
|
||||
});
|
||||
|
||||
it("should handle empty array", () => {
|
||||
const result = extractTextFromContent([]);
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("should handle array with only non-text blocks", () => {
|
||||
const content = [
|
||||
{ type: "image", source: {} },
|
||||
{ type: "tool_use", name: "test" },
|
||||
];
|
||||
const result = extractTextFromContent(content);
|
||||
expect(result).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeContentBlocks", () => {
|
||||
it("should convert string to content block array", () => {
|
||||
const result = normalizeContentBlocks("Hello");
|
||||
expect(result).toEqual([{ type: "text", text: "Hello" }]);
|
||||
});
|
||||
|
||||
it("should return array content as-is", () => {
|
||||
const content = [
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "image", source: {} },
|
||||
];
|
||||
const result = normalizeContentBlocks(content);
|
||||
expect(result).toBe(content);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
const result = normalizeContentBlocks("");
|
||||
expect(result).toEqual([{ type: "text", text: "" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatHistoryAsText", () => {
|
||||
it("should return empty string for empty history", () => {
|
||||
const result = formatHistoryAsText([]);
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("should format single user message", () => {
|
||||
const history = [{ role: "user" as const, content: "Hello" }];
|
||||
const result = formatHistoryAsText(history);
|
||||
|
||||
expect(result).toContain("Previous conversation:");
|
||||
expect(result).toContain("User: Hello");
|
||||
expect(result).toContain("---");
|
||||
});
|
||||
|
||||
it("should format single assistant message", () => {
|
||||
const history = [{ role: "assistant" as const, content: "Hi there" }];
|
||||
const result = formatHistoryAsText(history);
|
||||
|
||||
expect(result).toContain("Assistant: Hi there");
|
||||
});
|
||||
|
||||
it("should format multiple messages with correct roles", () => {
|
||||
const history = conversationHistoryFixture.slice(0, 2);
|
||||
const result = formatHistoryAsText(history);
|
||||
|
||||
expect(result).toContain("User: Hello, can you help me?");
|
||||
expect(result).toContain("Assistant: Of course! How can I assist you today?");
|
||||
expect(result).toContain("---");
|
||||
});
|
||||
|
||||
it("should handle messages with array content (multipart)", () => {
|
||||
const history = [conversationHistoryFixture[2]]; // Has text + image
|
||||
const result = formatHistoryAsText(history);
|
||||
|
||||
expect(result).toContain("What is in this image?");
|
||||
expect(result).not.toContain("base64"); // Should not include image data
|
||||
});
|
||||
|
||||
it("should format all messages from fixture", () => {
|
||||
const result = formatHistoryAsText(conversationHistoryFixture);
|
||||
|
||||
expect(result).toContain("Previous conversation:");
|
||||
expect(result).toContain("User: Hello, can you help me?");
|
||||
expect(result).toContain("Assistant: Of course!");
|
||||
expect(result).toContain("User: What is in this image?");
|
||||
expect(result).toContain("---");
|
||||
});
|
||||
|
||||
it("should separate messages with double newlines", () => {
|
||||
const history = [
|
||||
{ role: "user" as const, content: "First" },
|
||||
{ role: "assistant" as const, content: "Second" },
|
||||
];
|
||||
const result = formatHistoryAsText(history);
|
||||
|
||||
expect(result).toMatch(/User: First\n\nAssistant: Second/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertHistoryToMessages", () => {
|
||||
it("should convert empty history", () => {
|
||||
const result = convertHistoryToMessages([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should convert single message to SDK format", () => {
|
||||
const history = [{ role: "user" as const, content: "Hello" }];
|
||||
const result = convertHistoryToMessages(history);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
type: "user",
|
||||
session_id: "",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should normalize string content to array", () => {
|
||||
const history = [{ role: "assistant" as const, content: "Response" }];
|
||||
const result = convertHistoryToMessages(history);
|
||||
|
||||
expect(result[0].message.content).toEqual([
|
||||
{ type: "text", text: "Response" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should preserve array content", () => {
|
||||
const history = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "image", source: {} },
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = convertHistoryToMessages(history);
|
||||
|
||||
expect(result[0].message.content).toHaveLength(2);
|
||||
expect(result[0].message.content[0]).toEqual({ type: "text", text: "Hello" });
|
||||
});
|
||||
|
||||
it("should convert multiple messages", () => {
|
||||
const history = conversationHistoryFixture.slice(0, 2);
|
||||
const result = convertHistoryToMessages(history);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].type).toBe("user");
|
||||
expect(result[1].type).toBe("assistant");
|
||||
});
|
||||
|
||||
it("should set correct fields for SDK format", () => {
|
||||
const history = [{ role: "user" as const, content: "Test" }];
|
||||
const result = convertHistoryToMessages(history);
|
||||
|
||||
expect(result[0].session_id).toBe("");
|
||||
expect(result[0].parent_tool_use_id).toBeNull();
|
||||
expect(result[0].type).toBe("user");
|
||||
expect(result[0].message.role).toBe("user");
|
||||
});
|
||||
|
||||
it("should handle all messages from fixture", () => {
|
||||
const result = convertHistoryToMessages(conversationHistoryFixture);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].message.content).toBeInstanceOf(Array);
|
||||
expect(result[1].message.content).toBeInstanceOf(Array);
|
||||
expect(result[2].message.content).toBeInstanceOf(Array);
|
||||
});
|
||||
});
|
||||
});
|
||||
146
apps/server/tests/unit/lib/error-handler.test.ts
Normal file
146
apps/server/tests/unit/lib/error-handler.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isAbortError,
|
||||
isAuthenticationError,
|
||||
classifyError,
|
||||
getUserFriendlyErrorMessage,
|
||||
type ErrorType,
|
||||
} from "@/lib/error-handler.js";
|
||||
|
||||
describe("error-handler.ts", () => {
|
||||
describe("isAbortError", () => {
|
||||
it("should detect AbortError by error name", () => {
|
||||
const error = new Error("Operation cancelled");
|
||||
error.name = "AbortError";
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect abort error by message content", () => {
|
||||
const error = new Error("Request was aborted");
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for non-abort errors", () => {
|
||||
const error = new Error("Something else went wrong");
|
||||
expect(isAbortError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for non-Error objects", () => {
|
||||
expect(isAbortError("not an error")).toBe(false);
|
||||
expect(isAbortError(null)).toBe(false);
|
||||
expect(isAbortError(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthenticationError", () => {
|
||||
it("should detect 'Authentication failed' message", () => {
|
||||
expect(isAuthenticationError("Authentication failed")).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect 'Invalid API key' message", () => {
|
||||
expect(isAuthenticationError("Invalid API key provided")).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect 'authentication_failed' message", () => {
|
||||
expect(isAuthenticationError("authentication_failed")).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect 'Fix external API key' message", () => {
|
||||
expect(isAuthenticationError("Fix external API key configuration")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for non-authentication errors", () => {
|
||||
expect(isAuthenticationError("Network connection error")).toBe(false);
|
||||
expect(isAuthenticationError("File not found")).toBe(false);
|
||||
});
|
||||
|
||||
it("should be case sensitive", () => {
|
||||
expect(isAuthenticationError("authentication Failed")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyError", () => {
|
||||
it("should classify authentication errors", () => {
|
||||
const error = new Error("Authentication failed");
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe("authentication");
|
||||
expect(result.isAuth).toBe(true);
|
||||
expect(result.isAbort).toBe(false);
|
||||
expect(result.message).toBe("Authentication failed");
|
||||
expect(result.originalError).toBe(error);
|
||||
});
|
||||
|
||||
it("should classify abort errors", () => {
|
||||
const error = new Error("Operation aborted");
|
||||
error.name = "AbortError";
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe("abort");
|
||||
expect(result.isAbort).toBe(true);
|
||||
expect(result.isAuth).toBe(false);
|
||||
expect(result.message).toBe("Operation aborted");
|
||||
});
|
||||
|
||||
it("should prioritize auth over abort if both match", () => {
|
||||
const error = new Error("Authentication failed and aborted");
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe("authentication");
|
||||
expect(result.isAuth).toBe(true);
|
||||
expect(result.isAbort).toBe(true); // Still detected as abort too
|
||||
});
|
||||
|
||||
it("should classify generic Error as execution error", () => {
|
||||
const error = new Error("Something went wrong");
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe("execution");
|
||||
expect(result.isAuth).toBe(false);
|
||||
expect(result.isAbort).toBe(false);
|
||||
});
|
||||
|
||||
it("should classify non-Error objects as unknown", () => {
|
||||
const error = "string error";
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe("unknown");
|
||||
expect(result.message).toBe("string error");
|
||||
});
|
||||
|
||||
it("should handle null and undefined", () => {
|
||||
const nullResult = classifyError(null);
|
||||
expect(nullResult.type).toBe("unknown");
|
||||
expect(nullResult.message).toBe("Unknown error");
|
||||
|
||||
const undefinedResult = classifyError(undefined);
|
||||
expect(undefinedResult.type).toBe("unknown");
|
||||
expect(undefinedResult.message).toBe("Unknown error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserFriendlyErrorMessage", () => {
|
||||
it("should return friendly message for abort errors", () => {
|
||||
const error = new Error("abort");
|
||||
const result = getUserFriendlyErrorMessage(error);
|
||||
expect(result).toBe("Operation was cancelled");
|
||||
});
|
||||
|
||||
it("should return friendly message for authentication errors", () => {
|
||||
const error = new Error("Authentication failed");
|
||||
const result = getUserFriendlyErrorMessage(error);
|
||||
expect(result).toBe("Authentication failed. Please check your API key.");
|
||||
});
|
||||
|
||||
it("should return original message for other errors", () => {
|
||||
const error = new Error("File not found");
|
||||
const result = getUserFriendlyErrorMessage(error);
|
||||
expect(result).toBe("File not found");
|
||||
});
|
||||
|
||||
it("should handle non-Error objects", () => {
|
||||
const result = getUserFriendlyErrorMessage("Custom error");
|
||||
expect(result).toBe("Custom error");
|
||||
});
|
||||
});
|
||||
});
|
||||
130
apps/server/tests/unit/lib/events.test.ts
Normal file
130
apps/server/tests/unit/lib/events.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createEventEmitter, type EventType } from "@/lib/events.js";
|
||||
|
||||
describe("events.ts", () => {
|
||||
describe("createEventEmitter", () => {
|
||||
it("should emit events to single subscriber", () => {
|
||||
const emitter = createEventEmitter();
|
||||
const callback = vi.fn();
|
||||
|
||||
emitter.subscribe(callback);
|
||||
emitter.emit("agent:stream", { message: "test" });
|
||||
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
expect(callback).toHaveBeenCalledWith("agent:stream", { message: "test" });
|
||||
});
|
||||
|
||||
it("should emit events to multiple subscribers", () => {
|
||||
const emitter = createEventEmitter();
|
||||
const callback1 = vi.fn();
|
||||
const callback2 = vi.fn();
|
||||
const callback3 = vi.fn();
|
||||
|
||||
emitter.subscribe(callback1);
|
||||
emitter.subscribe(callback2);
|
||||
emitter.subscribe(callback3);
|
||||
emitter.emit("feature:started", { id: "123" });
|
||||
|
||||
expect(callback1).toHaveBeenCalledOnce();
|
||||
expect(callback2).toHaveBeenCalledOnce();
|
||||
expect(callback3).toHaveBeenCalledOnce();
|
||||
expect(callback1).toHaveBeenCalledWith("feature:started", { id: "123" });
|
||||
});
|
||||
|
||||
it("should support unsubscribe functionality", () => {
|
||||
const emitter = createEventEmitter();
|
||||
const callback = vi.fn();
|
||||
|
||||
const unsubscribe = emitter.subscribe(callback);
|
||||
emitter.emit("agent:stream", { test: 1 });
|
||||
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
|
||||
unsubscribe();
|
||||
emitter.emit("agent:stream", { test: 2 });
|
||||
|
||||
expect(callback).toHaveBeenCalledOnce(); // Still called only once
|
||||
});
|
||||
|
||||
it("should handle errors in subscribers without crashing", () => {
|
||||
const emitter = createEventEmitter();
|
||||
const errorCallback = vi.fn(() => {
|
||||
throw new Error("Subscriber error");
|
||||
});
|
||||
const normalCallback = vi.fn();
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
emitter.subscribe(errorCallback);
|
||||
emitter.subscribe(normalCallback);
|
||||
|
||||
expect(() => {
|
||||
emitter.emit("feature:error", { error: "test" });
|
||||
}).not.toThrow();
|
||||
|
||||
expect(errorCallback).toHaveBeenCalledOnce();
|
||||
expect(normalCallback).toHaveBeenCalledOnce();
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should emit different event types", () => {
|
||||
const emitter = createEventEmitter();
|
||||
const callback = vi.fn();
|
||||
|
||||
emitter.subscribe(callback);
|
||||
|
||||
const eventTypes: EventType[] = [
|
||||
"agent:stream",
|
||||
"auto-mode:started",
|
||||
"feature:completed",
|
||||
"project:analysis-progress",
|
||||
];
|
||||
|
||||
eventTypes.forEach((type) => {
|
||||
emitter.emit(type, { type });
|
||||
});
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("should handle emitting without subscribers", () => {
|
||||
const emitter = createEventEmitter();
|
||||
|
||||
expect(() => {
|
||||
emitter.emit("agent:stream", { test: true });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("should allow multiple subscriptions and unsubscriptions", () => {
|
||||
const emitter = createEventEmitter();
|
||||
const callback1 = vi.fn();
|
||||
const callback2 = vi.fn();
|
||||
const callback3 = vi.fn();
|
||||
|
||||
const unsub1 = emitter.subscribe(callback1);
|
||||
const unsub2 = emitter.subscribe(callback2);
|
||||
const unsub3 = emitter.subscribe(callback3);
|
||||
|
||||
emitter.emit("feature:started", { test: 1 });
|
||||
expect(callback1).toHaveBeenCalledOnce();
|
||||
expect(callback2).toHaveBeenCalledOnce();
|
||||
expect(callback3).toHaveBeenCalledOnce();
|
||||
|
||||
unsub2();
|
||||
|
||||
emitter.emit("feature:started", { test: 2 });
|
||||
expect(callback1).toHaveBeenCalledTimes(2);
|
||||
expect(callback2).toHaveBeenCalledOnce(); // Still just once
|
||||
expect(callback3).toHaveBeenCalledTimes(2);
|
||||
|
||||
unsub1();
|
||||
unsub3();
|
||||
|
||||
emitter.emit("feature:started", { test: 3 });
|
||||
expect(callback1).toHaveBeenCalledTimes(2);
|
||||
expect(callback2).toHaveBeenCalledOnce();
|
||||
expect(callback3).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
231
apps/server/tests/unit/lib/image-handler.test.ts
Normal file
231
apps/server/tests/unit/lib/image-handler.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
getMimeTypeForImage,
|
||||
readImageAsBase64,
|
||||
convertImagesToContentBlocks,
|
||||
formatImagePathsForPrompt,
|
||||
} from "@/lib/image-handler.js";
|
||||
import { pngBase64Fixture } from "../../fixtures/images.js";
|
||||
import * as fs from "fs/promises";
|
||||
|
||||
vi.mock("fs/promises");
|
||||
|
||||
describe("image-handler.ts", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getMimeTypeForImage", () => {
|
||||
it("should return correct MIME type for .jpg", () => {
|
||||
expect(getMimeTypeForImage("test.jpg")).toBe("image/jpeg");
|
||||
expect(getMimeTypeForImage("/path/to/test.jpg")).toBe("image/jpeg");
|
||||
});
|
||||
|
||||
it("should return correct MIME type for .jpeg", () => {
|
||||
expect(getMimeTypeForImage("test.jpeg")).toBe("image/jpeg");
|
||||
});
|
||||
|
||||
it("should return correct MIME type for .png", () => {
|
||||
expect(getMimeTypeForImage("test.png")).toBe("image/png");
|
||||
});
|
||||
|
||||
it("should return correct MIME type for .gif", () => {
|
||||
expect(getMimeTypeForImage("test.gif")).toBe("image/gif");
|
||||
});
|
||||
|
||||
it("should return correct MIME type for .webp", () => {
|
||||
expect(getMimeTypeForImage("test.webp")).toBe("image/webp");
|
||||
});
|
||||
|
||||
it("should be case-insensitive", () => {
|
||||
expect(getMimeTypeForImage("test.PNG")).toBe("image/png");
|
||||
expect(getMimeTypeForImage("test.JPG")).toBe("image/jpeg");
|
||||
expect(getMimeTypeForImage("test.GIF")).toBe("image/gif");
|
||||
expect(getMimeTypeForImage("test.WEBP")).toBe("image/webp");
|
||||
});
|
||||
|
||||
it("should default to image/png for unknown extensions", () => {
|
||||
expect(getMimeTypeForImage("test.unknown")).toBe("image/png");
|
||||
expect(getMimeTypeForImage("test.txt")).toBe("image/png");
|
||||
expect(getMimeTypeForImage("test")).toBe("image/png");
|
||||
});
|
||||
|
||||
it("should handle paths with multiple dots", () => {
|
||||
expect(getMimeTypeForImage("my.image.file.jpg")).toBe("image/jpeg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readImageAsBase64", () => {
|
||||
it("should read image and return base64 data", async () => {
|
||||
const mockBuffer = Buffer.from(pngBase64Fixture, "base64");
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
|
||||
const result = await readImageAsBase64("/path/to/test.png");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
base64: pngBase64Fixture,
|
||||
mimeType: "image/png",
|
||||
filename: "test.png",
|
||||
originalPath: "/path/to/test.png",
|
||||
});
|
||||
expect(fs.readFile).toHaveBeenCalledWith("/path/to/test.png");
|
||||
});
|
||||
|
||||
it("should handle different image formats", async () => {
|
||||
const mockBuffer = Buffer.from("jpeg-data");
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
|
||||
const result = await readImageAsBase64("/path/to/photo.jpg");
|
||||
|
||||
expect(result.mimeType).toBe("image/jpeg");
|
||||
expect(result.filename).toBe("photo.jpg");
|
||||
expect(result.base64).toBe(mockBuffer.toString("base64"));
|
||||
});
|
||||
|
||||
it("should extract filename from path", async () => {
|
||||
const mockBuffer = Buffer.from("data");
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
|
||||
const result = await readImageAsBase64("/deep/nested/path/image.webp");
|
||||
|
||||
expect(result.filename).toBe("image.webp");
|
||||
});
|
||||
|
||||
it("should throw error if file cannot be read", async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error("File not found"));
|
||||
|
||||
await expect(readImageAsBase64("/nonexistent.png")).rejects.toThrow(
|
||||
"File not found"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertImagesToContentBlocks", () => {
|
||||
it("should convert single image to content block", async () => {
|
||||
const mockBuffer = Buffer.from(pngBase64Fixture, "base64");
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
|
||||
const result = await convertImagesToContentBlocks(["/path/test.png"]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: "image/png",
|
||||
data: pngBase64Fixture,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should convert multiple images to content blocks", async () => {
|
||||
const mockBuffer = Buffer.from("test-data");
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
|
||||
const result = await convertImagesToContentBlocks([
|
||||
"/a.png",
|
||||
"/b.jpg",
|
||||
"/c.webp",
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].source.media_type).toBe("image/png");
|
||||
expect(result[1].source.media_type).toBe("image/jpeg");
|
||||
expect(result[2].source.media_type).toBe("image/webp");
|
||||
});
|
||||
|
||||
it("should resolve relative paths with workDir", async () => {
|
||||
const mockBuffer = Buffer.from("data");
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
|
||||
await convertImagesToContentBlocks(["relative.png"], "/work/dir");
|
||||
|
||||
// Use path-agnostic check since Windows uses backslashes
|
||||
const calls = vi.mocked(fs.readFile).mock.calls;
|
||||
expect(calls[0][0]).toMatch(/relative\.png$/);
|
||||
expect(calls[0][0]).toContain("work");
|
||||
expect(calls[0][0]).toContain("dir");
|
||||
});
|
||||
|
||||
it("should handle absolute paths without workDir", async () => {
|
||||
const mockBuffer = Buffer.from("data");
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
|
||||
await convertImagesToContentBlocks(["/absolute/path.png"]);
|
||||
|
||||
expect(fs.readFile).toHaveBeenCalledWith("/absolute/path.png");
|
||||
});
|
||||
|
||||
it("should continue processing on individual image errors", async () => {
|
||||
vi.mocked(fs.readFile)
|
||||
.mockResolvedValueOnce(Buffer.from("ok1"))
|
||||
.mockRejectedValueOnce(new Error("Failed"))
|
||||
.mockResolvedValueOnce(Buffer.from("ok2"));
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const result = await convertImagesToContentBlocks([
|
||||
"/a.png",
|
||||
"/b.png",
|
||||
"/c.png",
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(2); // Only successful images
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should return empty array for empty input", async () => {
|
||||
const result = await convertImagesToContentBlocks([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle undefined workDir", async () => {
|
||||
const mockBuffer = Buffer.from("data");
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
|
||||
const result = await convertImagesToContentBlocks(["/test.png"], undefined);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(fs.readFile).toHaveBeenCalledWith("/test.png");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatImagePathsForPrompt", () => {
|
||||
it("should format single image path as bulleted list", () => {
|
||||
const result = formatImagePathsForPrompt(["/path/image.png"]);
|
||||
|
||||
expect(result).toContain("\n\nAttached images:");
|
||||
expect(result).toContain("- /path/image.png");
|
||||
});
|
||||
|
||||
it("should format multiple image paths as bulleted list", () => {
|
||||
const result = formatImagePathsForPrompt([
|
||||
"/path/a.png",
|
||||
"/path/b.jpg",
|
||||
"/path/c.webp",
|
||||
]);
|
||||
|
||||
expect(result).toContain("Attached images:");
|
||||
expect(result).toContain("- /path/a.png");
|
||||
expect(result).toContain("- /path/b.jpg");
|
||||
expect(result).toContain("- /path/c.webp");
|
||||
});
|
||||
|
||||
it("should return empty string for empty array", () => {
|
||||
const result = formatImagePathsForPrompt([]);
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("should start with double newline", () => {
|
||||
const result = formatImagePathsForPrompt(["/test.png"]);
|
||||
expect(result.startsWith("\n\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle paths with special characters", () => {
|
||||
const result = formatImagePathsForPrompt(["/path/with spaces/image.png"]);
|
||||
expect(result).toContain("- /path/with spaces/image.png");
|
||||
});
|
||||
});
|
||||
});
|
||||
143
apps/server/tests/unit/lib/model-resolver.test.ts
Normal file
143
apps/server/tests/unit/lib/model-resolver.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
resolveModelString,
|
||||
getEffectiveModel,
|
||||
CLAUDE_MODEL_MAP,
|
||||
DEFAULT_MODELS,
|
||||
} from "@/lib/model-resolver.js";
|
||||
|
||||
describe("model-resolver.ts", () => {
|
||||
let consoleSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleSpy = {
|
||||
log: vi.spyOn(console, "log").mockImplementation(() => {}),
|
||||
warn: vi.spyOn(console, "warn").mockImplementation(() => {}),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.log.mockRestore();
|
||||
consoleSpy.warn.mockRestore();
|
||||
});
|
||||
|
||||
describe("resolveModelString", () => {
|
||||
it("should resolve 'haiku' alias to full model string", () => {
|
||||
const result = resolveModelString("haiku");
|
||||
expect(result).toBe("claude-haiku-4-5");
|
||||
});
|
||||
|
||||
it("should resolve 'sonnet' alias to full model string", () => {
|
||||
const result = resolveModelString("sonnet");
|
||||
expect(result).toBe("claude-sonnet-4-20250514");
|
||||
});
|
||||
|
||||
it("should resolve 'opus' alias to full model string", () => {
|
||||
const result = resolveModelString("opus");
|
||||
expect(result).toBe("claude-opus-4-5-20251101");
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Resolved model alias: "opus"')
|
||||
);
|
||||
});
|
||||
|
||||
it("should treat unknown models as falling back to default", () => {
|
||||
const models = ["o1", "o1-mini", "o3", "gpt-5.2", "unknown-model"];
|
||||
models.forEach((model) => {
|
||||
const result = resolveModelString(model);
|
||||
// Should fall back to default since these aren't supported
|
||||
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||
});
|
||||
});
|
||||
|
||||
it("should pass through full Claude model strings", () => {
|
||||
const models = [
|
||||
"claude-opus-4-5-20251101",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-haiku-4-5",
|
||||
];
|
||||
models.forEach((model) => {
|
||||
const result = resolveModelString(model);
|
||||
expect(result).toBe(model);
|
||||
});
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Using full Claude model string")
|
||||
);
|
||||
});
|
||||
|
||||
it("should return default model when modelKey is undefined", () => {
|
||||
const result = resolveModelString(undefined);
|
||||
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||
});
|
||||
|
||||
it("should return custom default model when provided", () => {
|
||||
const customDefault = "custom-model";
|
||||
const result = resolveModelString(undefined, customDefault);
|
||||
expect(result).toBe(customDefault);
|
||||
});
|
||||
|
||||
it("should return default for unknown model key", () => {
|
||||
const result = resolveModelString("unknown-model");
|
||||
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Unknown model key "unknown-model"')
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
const result = resolveModelString("");
|
||||
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEffectiveModel", () => {
|
||||
it("should prioritize explicit model over session and default", () => {
|
||||
const result = getEffectiveModel("opus", "haiku", "gpt-5.2");
|
||||
expect(result).toBe("claude-opus-4-5-20251101");
|
||||
});
|
||||
|
||||
it("should use session model when explicit is not provided", () => {
|
||||
const result = getEffectiveModel(undefined, "sonnet", "gpt-5.2");
|
||||
expect(result).toBe("claude-sonnet-4-20250514");
|
||||
});
|
||||
|
||||
it("should use default when neither explicit nor session is provided", () => {
|
||||
const customDefault = "claude-haiku-4-5";
|
||||
const result = getEffectiveModel(undefined, undefined, customDefault);
|
||||
expect(result).toBe(customDefault);
|
||||
});
|
||||
|
||||
it("should use Claude default when no arguments provided", () => {
|
||||
const result = getEffectiveModel();
|
||||
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||
});
|
||||
|
||||
it("should handle explicit empty strings as undefined", () => {
|
||||
const result = getEffectiveModel("", "haiku");
|
||||
expect(result).toBe("claude-haiku-4-5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLAUDE_MODEL_MAP", () => {
|
||||
it("should have haiku, sonnet, opus mappings", () => {
|
||||
expect(CLAUDE_MODEL_MAP).toHaveProperty("haiku");
|
||||
expect(CLAUDE_MODEL_MAP).toHaveProperty("sonnet");
|
||||
expect(CLAUDE_MODEL_MAP).toHaveProperty("opus");
|
||||
});
|
||||
|
||||
it("should have valid Claude model strings", () => {
|
||||
expect(CLAUDE_MODEL_MAP.haiku).toContain("haiku");
|
||||
expect(CLAUDE_MODEL_MAP.sonnet).toContain("sonnet");
|
||||
expect(CLAUDE_MODEL_MAP.opus).toContain("opus");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_MODELS", () => {
|
||||
it("should have claude default", () => {
|
||||
expect(DEFAULT_MODELS).toHaveProperty("claude");
|
||||
});
|
||||
|
||||
it("should have valid default model", () => {
|
||||
expect(DEFAULT_MODELS.claude).toContain("claude");
|
||||
});
|
||||
});
|
||||
});
|
||||
197
apps/server/tests/unit/lib/prompt-builder.test.ts
Normal file
197
apps/server/tests/unit/lib/prompt-builder.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { buildPromptWithImages } from "@/lib/prompt-builder.js";
|
||||
import * as imageHandler from "@/lib/image-handler.js";
|
||||
|
||||
vi.mock("@/lib/image-handler.js");
|
||||
|
||||
describe("prompt-builder.ts", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("buildPromptWithImages", () => {
|
||||
it("should return plain text when no images provided", async () => {
|
||||
const result = await buildPromptWithImages("Hello world");
|
||||
|
||||
expect(result).toEqual({
|
||||
content: "Hello world",
|
||||
hasImages: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return plain text when imagePaths is empty array", async () => {
|
||||
const result = await buildPromptWithImages("Hello world", []);
|
||||
|
||||
expect(result).toEqual({
|
||||
content: "Hello world",
|
||||
hasImages: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should build content blocks with single image", async () => {
|
||||
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "base64data" },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await buildPromptWithImages("Describe this image", [
|
||||
"/test.png",
|
||||
]);
|
||||
|
||||
expect(result.hasImages).toBe(true);
|
||||
expect(Array.isArray(result.content)).toBe(true);
|
||||
const content = result.content as Array<any>;
|
||||
expect(content).toHaveLength(2);
|
||||
expect(content[0]).toEqual({ type: "text", text: "Describe this image" });
|
||||
expect(content[1].type).toBe("image");
|
||||
});
|
||||
|
||||
it("should build content blocks with multiple images", async () => {
|
||||
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "data1" },
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/jpeg", data: "data2" },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await buildPromptWithImages("Analyze these", [
|
||||
"/a.png",
|
||||
"/b.jpg",
|
||||
]);
|
||||
|
||||
expect(result.hasImages).toBe(true);
|
||||
const content = result.content as Array<any>;
|
||||
expect(content).toHaveLength(3); // 1 text + 2 images
|
||||
expect(content[0].type).toBe("text");
|
||||
expect(content[1].type).toBe("image");
|
||||
expect(content[2].type).toBe("image");
|
||||
});
|
||||
|
||||
it("should include image paths in text when requested", async () => {
|
||||
vi.mocked(imageHandler.formatImagePathsForPrompt).mockReturnValue(
|
||||
"\n\nAttached images:\n- /test.png"
|
||||
);
|
||||
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "data" },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await buildPromptWithImages(
|
||||
"Base prompt",
|
||||
["/test.png"],
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
|
||||
expect(imageHandler.formatImagePathsForPrompt).toHaveBeenCalledWith([
|
||||
"/test.png",
|
||||
]);
|
||||
const content = result.content as Array<any>;
|
||||
expect(content[0].text).toContain("Base prompt");
|
||||
expect(content[0].text).toContain("Attached images:");
|
||||
});
|
||||
|
||||
it("should not include image paths by default", async () => {
|
||||
vi.mocked(imageHandler.formatImagePathsForPrompt).mockReturnValue(
|
||||
"\n\nAttached images:\n- /test.png"
|
||||
);
|
||||
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "data" },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await buildPromptWithImages("Base prompt", ["/test.png"]);
|
||||
|
||||
expect(imageHandler.formatImagePathsForPrompt).not.toHaveBeenCalled();
|
||||
const content = result.content as Array<any>;
|
||||
expect(content[0].text).toBe("Base prompt");
|
||||
});
|
||||
|
||||
it("should pass workDir to convertImagesToContentBlocks", async () => {
|
||||
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "data" },
|
||||
},
|
||||
]);
|
||||
|
||||
await buildPromptWithImages("Test", ["/test.png"], "/work/dir");
|
||||
|
||||
expect(imageHandler.convertImagesToContentBlocks).toHaveBeenCalledWith(
|
||||
["/test.png"],
|
||||
"/work/dir"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty text content", async () => {
|
||||
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "data" },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await buildPromptWithImages("", ["/test.png"]);
|
||||
|
||||
expect(result.hasImages).toBe(true);
|
||||
// When text is empty/whitespace, should only have image blocks
|
||||
const content = result.content as Array<any>;
|
||||
expect(content.every((block) => block.type === "image")).toBe(true);
|
||||
});
|
||||
|
||||
it("should trim text content before checking if empty", async () => {
|
||||
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "data" },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await buildPromptWithImages(" ", ["/test.png"]);
|
||||
|
||||
const content = result.content as Array<any>;
|
||||
// Whitespace-only text should be excluded
|
||||
expect(content.every((block) => block.type === "image")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return text when only one block and it's text", async () => {
|
||||
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([]);
|
||||
|
||||
const result = await buildPromptWithImages("Just text", ["/missing.png"]);
|
||||
|
||||
// If no images are successfully loaded, should return just the text
|
||||
expect(result.content).toBe("Just text");
|
||||
expect(result.hasImages).toBe(true); // Still true because images were requested
|
||||
});
|
||||
|
||||
it("should handle workDir with relative paths", async () => {
|
||||
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "data" },
|
||||
},
|
||||
]);
|
||||
|
||||
await buildPromptWithImages(
|
||||
"Test",
|
||||
["relative.png"],
|
||||
"/absolute/work/dir"
|
||||
);
|
||||
|
||||
expect(imageHandler.convertImagesToContentBlocks).toHaveBeenCalledWith(
|
||||
["relative.png"],
|
||||
"/absolute/work/dir"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
297
apps/server/tests/unit/lib/security.test.ts
Normal file
297
apps/server/tests/unit/lib/security.test.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* Note: security.ts maintains module-level state (allowed paths Set).
|
||||
* We need to reset modules and reimport for each test to get fresh state.
|
||||
*/
|
||||
describe("security.ts", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("initAllowedPaths", () => {
|
||||
it("should parse comma-separated directories from environment", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/path1,/path2,/path3";
|
||||
process.env.DATA_DIR = "";
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve("/path1"));
|
||||
expect(allowed).toContain(path.resolve("/path2"));
|
||||
expect(allowed).toContain(path.resolve("/path3"));
|
||||
});
|
||||
|
||||
it("should trim whitespace from paths", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = " /path1 , /path2 , /path3 ";
|
||||
process.env.DATA_DIR = "";
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve("/path1"));
|
||||
expect(allowed).toContain(path.resolve("/path2"));
|
||||
});
|
||||
|
||||
it("should always include DATA_DIR if set", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "";
|
||||
process.env.DATA_DIR = "/data/dir";
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve("/data/dir"));
|
||||
});
|
||||
|
||||
it("should handle empty ALLOWED_PROJECT_DIRS", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "";
|
||||
process.env.DATA_DIR = "/data";
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toHaveLength(1);
|
||||
expect(allowed[0]).toBe(path.resolve("/data"));
|
||||
});
|
||||
|
||||
it("should skip empty entries in comma list", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/path1,,/path2, ,/path3";
|
||||
process.env.DATA_DIR = "";
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addAllowedPath", () => {
|
||||
it("should add path to allowed list", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "";
|
||||
process.env.DATA_DIR = "";
|
||||
|
||||
const { initAllowedPaths, addAllowedPath, getAllowedPaths } =
|
||||
await import("@/lib/security.js");
|
||||
initAllowedPaths();
|
||||
|
||||
addAllowedPath("/new/path");
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve("/new/path"));
|
||||
});
|
||||
|
||||
it("should resolve relative paths before adding", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "";
|
||||
process.env.DATA_DIR = "";
|
||||
|
||||
const { initAllowedPaths, addAllowedPath, getAllowedPaths } =
|
||||
await import("@/lib/security.js");
|
||||
initAllowedPaths();
|
||||
|
||||
addAllowedPath("./relative/path");
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
const cwd = process.cwd();
|
||||
expect(allowed).toContain(path.resolve(cwd, "./relative/path"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPathAllowed", () => {
|
||||
it("should allow paths under allowed directories", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/allowed/project";
|
||||
process.env.DATA_DIR = "";
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
initAllowedPaths();
|
||||
|
||||
expect(isPathAllowed("/allowed/project/file.txt")).toBe(true);
|
||||
expect(isPathAllowed("/allowed/project/subdir/file.txt")).toBe(true);
|
||||
expect(isPathAllowed("/allowed/project/deep/nested/file.txt")).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow the exact allowed directory", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/allowed/project";
|
||||
process.env.DATA_DIR = "";
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
initAllowedPaths();
|
||||
|
||||
expect(isPathAllowed("/allowed/project")).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject paths outside allowed directories", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/allowed/project";
|
||||
process.env.DATA_DIR = "";
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
initAllowedPaths();
|
||||
|
||||
expect(isPathAllowed("/not/allowed/file.txt")).toBe(false);
|
||||
expect(isPathAllowed("/tmp/file.txt")).toBe(false);
|
||||
expect(isPathAllowed("/etc/passwd")).toBe(false);
|
||||
});
|
||||
|
||||
it("should block path traversal attempts", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/allowed/project";
|
||||
process.env.DATA_DIR = "";
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
initAllowedPaths();
|
||||
|
||||
// These should resolve outside the allowed directory
|
||||
expect(isPathAllowed("/allowed/project/../../../etc/passwd")).toBe(false);
|
||||
expect(isPathAllowed("/allowed/project/../../other/file.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("should resolve relative paths correctly", async () => {
|
||||
const cwd = process.cwd();
|
||||
process.env.ALLOWED_PROJECT_DIRS = cwd;
|
||||
process.env.DATA_DIR = "";
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
initAllowedPaths();
|
||||
|
||||
expect(isPathAllowed("./file.txt")).toBe(true);
|
||||
expect(isPathAllowed("./subdir/file.txt")).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject paths that are parents of allowed directories", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/allowed/project/subdir";
|
||||
process.env.DATA_DIR = "";
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
initAllowedPaths();
|
||||
|
||||
expect(isPathAllowed("/allowed/project")).toBe(false);
|
||||
expect(isPathAllowed("/allowed")).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle multiple allowed directories", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/path1,/path2,/path3";
|
||||
process.env.DATA_DIR = "";
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
initAllowedPaths();
|
||||
|
||||
expect(isPathAllowed("/path1/file.txt")).toBe(true);
|
||||
expect(isPathAllowed("/path2/file.txt")).toBe(true);
|
||||
expect(isPathAllowed("/path3/file.txt")).toBe(true);
|
||||
expect(isPathAllowed("/path4/file.txt")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePath", () => {
|
||||
it("should return resolved path for allowed paths", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/allowed";
|
||||
process.env.DATA_DIR = "";
|
||||
|
||||
const { initAllowedPaths, validatePath } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
initAllowedPaths();
|
||||
|
||||
const result = validatePath("/allowed/file.txt");
|
||||
expect(result).toBe(path.resolve("/allowed/file.txt"));
|
||||
});
|
||||
|
||||
it("should throw error for disallowed paths", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/allowed";
|
||||
process.env.DATA_DIR = "";
|
||||
|
||||
const { initAllowedPaths, validatePath } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
initAllowedPaths();
|
||||
|
||||
expect(() => validatePath("/disallowed/file.txt")).toThrow("Access denied");
|
||||
expect(() => validatePath("/disallowed/file.txt")).toThrow(
|
||||
"not in an allowed directory"
|
||||
);
|
||||
});
|
||||
|
||||
it("should include the file path in error message", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/allowed";
|
||||
process.env.DATA_DIR = "";
|
||||
|
||||
const { initAllowedPaths, validatePath } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
initAllowedPaths();
|
||||
|
||||
expect(() => validatePath("/bad/path.txt")).toThrow("/bad/path.txt");
|
||||
});
|
||||
|
||||
it("should resolve paths before validation", async () => {
|
||||
const cwd = process.cwd();
|
||||
process.env.ALLOWED_PROJECT_DIRS = cwd;
|
||||
process.env.DATA_DIR = "";
|
||||
|
||||
const { initAllowedPaths, validatePath } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
initAllowedPaths();
|
||||
|
||||
const result = validatePath("./file.txt");
|
||||
expect(result).toBe(path.resolve(cwd, "./file.txt"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllowedPaths", () => {
|
||||
it("should return array of allowed paths", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/path1,/path2";
|
||||
process.env.DATA_DIR = "/data";
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
initAllowedPaths();
|
||||
|
||||
const result = getAllowedPaths();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should return resolved paths", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/test";
|
||||
process.env.DATA_DIR = "";
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
initAllowedPaths();
|
||||
|
||||
const result = getAllowedPaths();
|
||||
expect(result[0]).toBe(path.resolve("/test"));
|
||||
});
|
||||
});
|
||||
});
|
||||
482
apps/server/tests/unit/lib/subprocess-manager.test.ts
Normal file
482
apps/server/tests/unit/lib/subprocess-manager.test.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
spawnJSONLProcess,
|
||||
spawnProcess,
|
||||
type SubprocessOptions,
|
||||
} from "@/lib/subprocess-manager.js";
|
||||
import * as cp from "child_process";
|
||||
import { EventEmitter } from "events";
|
||||
import { Readable } from "stream";
|
||||
import { collectAsyncGenerator } from "../../utils/helpers.js";
|
||||
|
||||
vi.mock("child_process");
|
||||
|
||||
describe("subprocess-manager.ts", () => {
|
||||
let consoleSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
consoleSpy = {
|
||||
log: vi.spyOn(console, "log").mockImplementation(() => {}),
|
||||
error: vi.spyOn(console, "error").mockImplementation(() => {}),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.log.mockRestore();
|
||||
consoleSpy.error.mockRestore();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to create a mock ChildProcess with stdout/stderr streams
|
||||
*/
|
||||
function createMockProcess(config: {
|
||||
stdoutLines?: string[];
|
||||
stderrLines?: string[];
|
||||
exitCode?: number;
|
||||
error?: Error;
|
||||
delayMs?: number;
|
||||
}) {
|
||||
const mockProcess = new EventEmitter() as any;
|
||||
|
||||
// Create readable streams for stdout and stderr
|
||||
const stdout = new Readable({ read() {} });
|
||||
const stderr = new Readable({ read() {} });
|
||||
|
||||
mockProcess.stdout = stdout;
|
||||
mockProcess.stderr = stderr;
|
||||
mockProcess.kill = vi.fn();
|
||||
|
||||
// Use process.nextTick to ensure readline interface is set up first
|
||||
process.nextTick(() => {
|
||||
// Emit stderr lines immediately
|
||||
if (config.stderrLines) {
|
||||
for (const line of config.stderrLines) {
|
||||
stderr.emit("data", Buffer.from(line));
|
||||
}
|
||||
}
|
||||
|
||||
// Emit stdout lines with small delays to ensure readline processes them
|
||||
const emitLines = async () => {
|
||||
if (config.stdoutLines) {
|
||||
for (const line of config.stdoutLines) {
|
||||
stdout.push(line + "\n");
|
||||
// Small delay to allow readline to process
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay before ending stream
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
stdout.push(null); // End stdout
|
||||
|
||||
// Small delay before exit
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, config.delayMs ?? 10)
|
||||
);
|
||||
|
||||
// Emit exit or error
|
||||
if (config.error) {
|
||||
mockProcess.emit("error", config.error);
|
||||
} else {
|
||||
mockProcess.emit("exit", config.exitCode ?? 0);
|
||||
}
|
||||
};
|
||||
|
||||
emitLines();
|
||||
});
|
||||
|
||||
return mockProcess;
|
||||
}
|
||||
|
||||
describe("spawnJSONLProcess", () => {
|
||||
const baseOptions: SubprocessOptions = {
|
||||
command: "test-command",
|
||||
args: ["arg1", "arg2"],
|
||||
cwd: "/test/dir",
|
||||
};
|
||||
|
||||
it("should yield parsed JSONL objects line by line", async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: [
|
||||
'{"type":"start","id":1}',
|
||||
'{"type":"progress","value":50}',
|
||||
'{"type":"complete","result":"success"}',
|
||||
],
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const generator = spawnJSONLProcess(baseOptions);
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results[0]).toEqual({ type: "start", id: 1 });
|
||||
expect(results[1]).toEqual({ type: "progress", value: 50 });
|
||||
expect(results[2]).toEqual({ type: "complete", result: "success" });
|
||||
});
|
||||
|
||||
it("should skip empty lines", async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: [
|
||||
'{"type":"first"}',
|
||||
"",
|
||||
" ",
|
||||
'{"type":"second"}',
|
||||
],
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const generator = spawnJSONLProcess(baseOptions);
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toEqual({ type: "first" });
|
||||
expect(results[1]).toEqual({ type: "second" });
|
||||
});
|
||||
|
||||
it("should yield error for malformed JSON and continue processing", async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: [
|
||||
'{"type":"valid"}',
|
||||
'{invalid json}',
|
||||
'{"type":"also_valid"}',
|
||||
],
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const generator = spawnJSONLProcess(baseOptions);
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results[0]).toEqual({ type: "valid" });
|
||||
expect(results[1]).toMatchObject({
|
||||
type: "error",
|
||||
error: expect.stringContaining("Failed to parse output"),
|
||||
});
|
||||
expect(results[2]).toEqual({ type: "also_valid" });
|
||||
});
|
||||
|
||||
it("should collect stderr output", async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: ['{"type":"test"}'],
|
||||
stderrLines: ["Warning: something happened", "Error: critical issue"],
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const generator = spawnJSONLProcess(baseOptions);
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(consoleSpy.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Warning: something happened")
|
||||
);
|
||||
expect(consoleSpy.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Error: critical issue")
|
||||
);
|
||||
});
|
||||
|
||||
it("should yield error on non-zero exit code", async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: ['{"type":"started"}'],
|
||||
stderrLines: ["Process failed with error"],
|
||||
exitCode: 1,
|
||||
});
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const generator = spawnJSONLProcess(baseOptions);
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toEqual({ type: "started" });
|
||||
expect(results[1]).toMatchObject({
|
||||
type: "error",
|
||||
error: expect.stringContaining("Process failed with error"),
|
||||
});
|
||||
});
|
||||
|
||||
it("should yield error with exit code when stderr is empty", async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: ['{"type":"test"}'],
|
||||
exitCode: 127,
|
||||
});
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const generator = spawnJSONLProcess(baseOptions);
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[1]).toMatchObject({
|
||||
type: "error",
|
||||
error: "Process exited with code 127",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle process spawn errors", async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
error: new Error("Command not found"),
|
||||
});
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const generator = spawnJSONLProcess(baseOptions);
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
|
||||
// When process.on('error') fires, exitCode is null
|
||||
// The generator should handle this gracefully
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("should kill process on AbortController signal", async () => {
|
||||
const abortController = new AbortController();
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: ['{"type":"start"}'],
|
||||
exitCode: 0,
|
||||
delayMs: 100, // Delay to allow abort
|
||||
});
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const generator = spawnJSONLProcess({
|
||||
...baseOptions,
|
||||
abortController,
|
||||
});
|
||||
|
||||
// Start consuming the generator
|
||||
const promise = collectAsyncGenerator(generator);
|
||||
|
||||
// Abort after a short delay
|
||||
setTimeout(() => abortController.abort(), 20);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Abort signal received")
|
||||
);
|
||||
});
|
||||
|
||||
// Note: Timeout behavior tests are omitted from unit tests as they involve
|
||||
// complex timing interactions that are difficult to mock reliably.
|
||||
// These scenarios are better covered by integration tests with real subprocesses.
|
||||
|
||||
it("should spawn process with correct arguments", async () => {
|
||||
const mockProcess = createMockProcess({ exitCode: 0 });
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const options: SubprocessOptions = {
|
||||
command: "my-command",
|
||||
args: ["--flag", "value"],
|
||||
cwd: "/work/dir",
|
||||
env: { CUSTOM_VAR: "test" },
|
||||
};
|
||||
|
||||
const generator = spawnJSONLProcess(options);
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(cp.spawn).toHaveBeenCalledWith("my-command", ["--flag", "value"], {
|
||||
cwd: "/work/dir",
|
||||
env: expect.objectContaining({ CUSTOM_VAR: "test" }),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should merge env with process.env", async () => {
|
||||
const mockProcess = createMockProcess({ exitCode: 0 });
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const options: SubprocessOptions = {
|
||||
command: "test",
|
||||
args: [],
|
||||
cwd: "/test",
|
||||
env: { CUSTOM: "value" },
|
||||
};
|
||||
|
||||
const generator = spawnJSONLProcess(options);
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(cp.spawn).toHaveBeenCalledWith(
|
||||
"test",
|
||||
[],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
CUSTOM: "value",
|
||||
// Should also include existing process.env
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle complex JSON objects", async () => {
|
||||
const complexObject = {
|
||||
type: "complex",
|
||||
nested: { deep: { value: [1, 2, 3] } },
|
||||
array: [{ id: 1 }, { id: 2 }],
|
||||
string: "with \"quotes\" and \\backslashes",
|
||||
};
|
||||
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: [JSON.stringify(complexObject)],
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const generator = spawnJSONLProcess(baseOptions);
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]).toEqual(complexObject);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spawnProcess", () => {
|
||||
const baseOptions: SubprocessOptions = {
|
||||
command: "test-command",
|
||||
args: ["arg1"],
|
||||
cwd: "/test",
|
||||
};
|
||||
|
||||
it("should collect stdout and stderr", async () => {
|
||||
const mockProcess = new EventEmitter() as any;
|
||||
const stdout = new Readable({ read() {} });
|
||||
const stderr = new Readable({ read() {} });
|
||||
|
||||
mockProcess.stdout = stdout;
|
||||
mockProcess.stderr = stderr;
|
||||
mockProcess.kill = vi.fn();
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
setTimeout(() => {
|
||||
stdout.push("line 1\n");
|
||||
stdout.push("line 2\n");
|
||||
stdout.push(null);
|
||||
|
||||
stderr.push("error 1\n");
|
||||
stderr.push("error 2\n");
|
||||
stderr.push(null);
|
||||
|
||||
mockProcess.emit("exit", 0);
|
||||
}, 10);
|
||||
|
||||
const result = await spawnProcess(baseOptions);
|
||||
|
||||
expect(result.stdout).toBe("line 1\nline 2\n");
|
||||
expect(result.stderr).toBe("error 1\nerror 2\n");
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("should return correct exit code", async () => {
|
||||
const mockProcess = new EventEmitter() as any;
|
||||
mockProcess.stdout = new Readable({ read() {} });
|
||||
mockProcess.stderr = new Readable({ read() {} });
|
||||
mockProcess.kill = vi.fn();
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
setTimeout(() => {
|
||||
mockProcess.stdout.push(null);
|
||||
mockProcess.stderr.push(null);
|
||||
mockProcess.emit("exit", 42);
|
||||
}, 10);
|
||||
|
||||
const result = await spawnProcess(baseOptions);
|
||||
|
||||
expect(result.exitCode).toBe(42);
|
||||
});
|
||||
|
||||
it("should handle process errors", async () => {
|
||||
const mockProcess = new EventEmitter() as any;
|
||||
mockProcess.stdout = new Readable({ read() {} });
|
||||
mockProcess.stderr = new Readable({ read() {} });
|
||||
mockProcess.kill = vi.fn();
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
setTimeout(() => {
|
||||
mockProcess.emit("error", new Error("Spawn failed"));
|
||||
}, 10);
|
||||
|
||||
await expect(spawnProcess(baseOptions)).rejects.toThrow("Spawn failed");
|
||||
});
|
||||
|
||||
it("should handle AbortController signal", async () => {
|
||||
const abortController = new AbortController();
|
||||
const mockProcess = new EventEmitter() as any;
|
||||
mockProcess.stdout = new Readable({ read() {} });
|
||||
mockProcess.stderr = new Readable({ read() {} });
|
||||
mockProcess.kill = vi.fn();
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
setTimeout(() => abortController.abort(), 20);
|
||||
|
||||
await expect(
|
||||
spawnProcess({ ...baseOptions, abortController })
|
||||
).rejects.toThrow("Process aborted");
|
||||
|
||||
expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
});
|
||||
|
||||
it("should spawn with correct options", async () => {
|
||||
const mockProcess = new EventEmitter() as any;
|
||||
mockProcess.stdout = new Readable({ read() {} });
|
||||
mockProcess.stderr = new Readable({ read() {} });
|
||||
mockProcess.kill = vi.fn();
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
setTimeout(() => {
|
||||
mockProcess.stdout.push(null);
|
||||
mockProcess.stderr.push(null);
|
||||
mockProcess.emit("exit", 0);
|
||||
}, 10);
|
||||
|
||||
const options: SubprocessOptions = {
|
||||
command: "my-cmd",
|
||||
args: ["--verbose"],
|
||||
cwd: "/my/dir",
|
||||
env: { MY_VAR: "value" },
|
||||
};
|
||||
|
||||
await spawnProcess(options);
|
||||
|
||||
expect(cp.spawn).toHaveBeenCalledWith("my-cmd", ["--verbose"], {
|
||||
cwd: "/my/dir",
|
||||
env: expect.objectContaining({ MY_VAR: "value" }),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle empty stdout and stderr", async () => {
|
||||
const mockProcess = new EventEmitter() as any;
|
||||
mockProcess.stdout = new Readable({ read() {} });
|
||||
mockProcess.stderr = new Readable({ read() {} });
|
||||
mockProcess.kill = vi.fn();
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
setTimeout(() => {
|
||||
mockProcess.stdout.push(null);
|
||||
mockProcess.stderr.push(null);
|
||||
mockProcess.emit("exit", 0);
|
||||
}, 10);
|
||||
|
||||
const result = await spawnProcess(baseOptions);
|
||||
|
||||
expect(result.stdout).toBe("");
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
242
apps/server/tests/unit/providers/base-provider.test.ts
Normal file
242
apps/server/tests/unit/providers/base-provider.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { BaseProvider } from "@/providers/base-provider.js";
|
||||
import type {
|
||||
ProviderConfig,
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ModelDefinition,
|
||||
} from "@/providers/types.js";
|
||||
|
||||
// Concrete implementation for testing the abstract class
|
||||
class TestProvider extends BaseProvider {
|
||||
getName(): string {
|
||||
return "test-provider";
|
||||
}
|
||||
|
||||
async *executeQuery(
|
||||
_options: ExecuteOptions
|
||||
): AsyncGenerator<ProviderMessage> {
|
||||
yield { type: "text", text: "test response" };
|
||||
}
|
||||
|
||||
async detectInstallation(): Promise<InstallationStatus> {
|
||||
return { installed: true };
|
||||
}
|
||||
|
||||
getAvailableModels(): ModelDefinition[] {
|
||||
return [
|
||||
{ id: "test-model-1", name: "Test Model 1", description: "A test model" },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
describe("base-provider.ts", () => {
|
||||
describe("constructor", () => {
|
||||
it("should initialize with empty config when none provided", () => {
|
||||
const provider = new TestProvider();
|
||||
expect(provider.getConfig()).toEqual({});
|
||||
});
|
||||
|
||||
it("should initialize with provided config", () => {
|
||||
const config: ProviderConfig = {
|
||||
apiKey: "test-key",
|
||||
baseUrl: "https://test.com",
|
||||
};
|
||||
const provider = new TestProvider(config);
|
||||
expect(provider.getConfig()).toEqual(config);
|
||||
});
|
||||
|
||||
it("should call getName() during initialization", () => {
|
||||
const provider = new TestProvider();
|
||||
expect(provider.getName()).toBe("test-provider");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateConfig", () => {
|
||||
it("should return valid when config exists", () => {
|
||||
const provider = new TestProvider({ apiKey: "test" });
|
||||
const result = provider.validateConfig();
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should return invalid when config is undefined", () => {
|
||||
// Create provider without config
|
||||
const provider = new TestProvider();
|
||||
// Manually set config to undefined to test edge case
|
||||
(provider as any).config = undefined;
|
||||
|
||||
const result = provider.validateConfig();
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("Provider config is missing");
|
||||
});
|
||||
|
||||
it("should return valid for empty config object", () => {
|
||||
const provider = new TestProvider({});
|
||||
const result = provider.validateConfig();
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should include warnings array in result", () => {
|
||||
const provider = new TestProvider();
|
||||
const result = provider.validateConfig();
|
||||
|
||||
expect(result).toHaveProperty("warnings");
|
||||
expect(Array.isArray(result.warnings)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("supportsFeature", () => {
|
||||
it("should support 'tools' feature", () => {
|
||||
const provider = new TestProvider();
|
||||
expect(provider.supportsFeature("tools")).toBe(true);
|
||||
});
|
||||
|
||||
it("should support 'text' feature", () => {
|
||||
const provider = new TestProvider();
|
||||
expect(provider.supportsFeature("text")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not support unknown features", () => {
|
||||
const provider = new TestProvider();
|
||||
expect(provider.supportsFeature("vision")).toBe(false);
|
||||
expect(provider.supportsFeature("mcp")).toBe(false);
|
||||
expect(provider.supportsFeature("unknown")).toBe(false);
|
||||
});
|
||||
|
||||
it("should be case-sensitive", () => {
|
||||
const provider = new TestProvider();
|
||||
expect(provider.supportsFeature("TOOLS")).toBe(false);
|
||||
expect(provider.supportsFeature("Text")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConfig", () => {
|
||||
it("should return current config", () => {
|
||||
const config: ProviderConfig = {
|
||||
apiKey: "test-key",
|
||||
model: "test-model",
|
||||
};
|
||||
const provider = new TestProvider(config);
|
||||
|
||||
expect(provider.getConfig()).toEqual(config);
|
||||
});
|
||||
|
||||
it("should return same reference", () => {
|
||||
const config: ProviderConfig = { apiKey: "test" };
|
||||
const provider = new TestProvider(config);
|
||||
|
||||
const retrieved1 = provider.getConfig();
|
||||
const retrieved2 = provider.getConfig();
|
||||
|
||||
expect(retrieved1).toBe(retrieved2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setConfig", () => {
|
||||
it("should merge partial config with existing config", () => {
|
||||
const provider = new TestProvider({ apiKey: "original-key" });
|
||||
|
||||
provider.setConfig({ model: "new-model" });
|
||||
|
||||
expect(provider.getConfig()).toEqual({
|
||||
apiKey: "original-key",
|
||||
model: "new-model",
|
||||
});
|
||||
});
|
||||
|
||||
it("should override existing fields", () => {
|
||||
const provider = new TestProvider({ apiKey: "old-key", model: "old-model" });
|
||||
|
||||
provider.setConfig({ apiKey: "new-key" });
|
||||
|
||||
expect(provider.getConfig()).toEqual({
|
||||
apiKey: "new-key",
|
||||
model: "old-model",
|
||||
});
|
||||
});
|
||||
|
||||
it("should accept empty object", () => {
|
||||
const provider = new TestProvider({ apiKey: "test" });
|
||||
const originalConfig = provider.getConfig();
|
||||
|
||||
provider.setConfig({});
|
||||
|
||||
expect(provider.getConfig()).toEqual(originalConfig);
|
||||
});
|
||||
|
||||
it("should handle multiple updates", () => {
|
||||
const provider = new TestProvider();
|
||||
|
||||
provider.setConfig({ apiKey: "key1" });
|
||||
provider.setConfig({ model: "model1" });
|
||||
provider.setConfig({ baseUrl: "https://test.com" });
|
||||
|
||||
expect(provider.getConfig()).toEqual({
|
||||
apiKey: "key1",
|
||||
model: "model1",
|
||||
baseUrl: "https://test.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("should preserve other fields when updating one field", () => {
|
||||
const provider = new TestProvider({
|
||||
apiKey: "key",
|
||||
model: "model",
|
||||
baseUrl: "https://test.com",
|
||||
});
|
||||
|
||||
provider.setConfig({ model: "new-model" });
|
||||
|
||||
expect(provider.getConfig()).toEqual({
|
||||
apiKey: "key",
|
||||
model: "new-model",
|
||||
baseUrl: "https://test.com",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("abstract methods", () => {
|
||||
it("should require getName implementation", () => {
|
||||
const provider = new TestProvider();
|
||||
expect(typeof provider.getName).toBe("function");
|
||||
expect(provider.getName()).toBe("test-provider");
|
||||
});
|
||||
|
||||
it("should require executeQuery implementation", async () => {
|
||||
const provider = new TestProvider();
|
||||
expect(typeof provider.executeQuery).toBe("function");
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: "test",
|
||||
projectDirectory: "/test",
|
||||
});
|
||||
const result = await generator.next();
|
||||
|
||||
expect(result.value).toEqual({ type: "text", text: "test response" });
|
||||
});
|
||||
|
||||
it("should require detectInstallation implementation", async () => {
|
||||
const provider = new TestProvider();
|
||||
expect(typeof provider.detectInstallation).toBe("function");
|
||||
|
||||
const status = await provider.detectInstallation();
|
||||
expect(status).toHaveProperty("installed");
|
||||
});
|
||||
|
||||
it("should require getAvailableModels implementation", () => {
|
||||
const provider = new TestProvider();
|
||||
expect(typeof provider.getAvailableModels).toBe("function");
|
||||
|
||||
const models = provider.getAvailableModels();
|
||||
expect(Array.isArray(models)).toBe(true);
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
398
apps/server/tests/unit/providers/claude-provider.test.ts
Normal file
398
apps/server/tests/unit/providers/claude-provider.test.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { ClaudeProvider } from "@/providers/claude-provider.js";
|
||||
import * as sdk from "@anthropic-ai/claude-agent-sdk";
|
||||
import { collectAsyncGenerator } from "../../utils/helpers.js";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk");
|
||||
|
||||
describe("claude-provider.ts", () => {
|
||||
let provider: ClaudeProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
provider = new ClaudeProvider();
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
});
|
||||
|
||||
describe("getName", () => {
|
||||
it("should return 'claude' as provider name", () => {
|
||||
expect(provider.getName()).toBe("claude");
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeQuery", () => {
|
||||
it("should execute simple text query", async () => {
|
||||
const mockMessages = [
|
||||
{ type: "text", text: "Response 1" },
|
||||
{ type: "text", text: "Response 2" },
|
||||
];
|
||||
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
for (const msg of mockMessages) {
|
||||
yield msg;
|
||||
}
|
||||
})()
|
||||
);
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: "Hello",
|
||||
cwd: "/test",
|
||||
});
|
||||
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toEqual({ type: "text", text: "Response 1" });
|
||||
expect(results[1]).toEqual({ type: "text", text: "Response 2" });
|
||||
});
|
||||
|
||||
it("should pass correct options to SDK", async () => {
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: "text", text: "test" };
|
||||
})()
|
||||
);
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: "Test prompt",
|
||||
model: "claude-opus-4-5-20251101",
|
||||
cwd: "/test/dir",
|
||||
systemPrompt: "You are helpful",
|
||||
maxTurns: 10,
|
||||
allowedTools: ["Read", "Write"],
|
||||
});
|
||||
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(sdk.query).toHaveBeenCalledWith({
|
||||
prompt: "Test prompt",
|
||||
options: expect.objectContaining({
|
||||
model: "claude-opus-4-5-20251101",
|
||||
systemPrompt: "You are helpful",
|
||||
maxTurns: 10,
|
||||
cwd: "/test/dir",
|
||||
allowedTools: ["Read", "Write"],
|
||||
permissionMode: "acceptEdits",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should use default allowed tools when not specified", async () => {
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: "text", text: "test" };
|
||||
})()
|
||||
);
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: "Test",
|
||||
cwd: "/test",
|
||||
});
|
||||
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(sdk.query).toHaveBeenCalledWith({
|
||||
prompt: "Test",
|
||||
options: expect.objectContaining({
|
||||
allowedTools: [
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Bash",
|
||||
"WebSearch",
|
||||
"WebFetch",
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should enable sandbox by default", async () => {
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: "text", text: "test" };
|
||||
})()
|
||||
);
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: "Test",
|
||||
cwd: "/test",
|
||||
});
|
||||
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(sdk.query).toHaveBeenCalledWith({
|
||||
prompt: "Test",
|
||||
options: expect.objectContaining({
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should pass abortController if provided", async () => {
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: "text", text: "test" };
|
||||
})()
|
||||
);
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: "Test",
|
||||
cwd: "/test",
|
||||
abortController,
|
||||
});
|
||||
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(sdk.query).toHaveBeenCalledWith({
|
||||
prompt: "Test",
|
||||
options: expect.objectContaining({
|
||||
abortController,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle conversation history", async () => {
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: "text", text: "test" };
|
||||
})()
|
||||
);
|
||||
|
||||
const conversationHistory = [
|
||||
{ role: "user" as const, content: "Previous message" },
|
||||
{ role: "assistant" as const, content: "Previous response" },
|
||||
];
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: "Current message",
|
||||
cwd: "/test",
|
||||
conversationHistory,
|
||||
});
|
||||
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
// Should pass an async generator as prompt
|
||||
const callArgs = vi.mocked(sdk.query).mock.calls[0][0];
|
||||
expect(typeof callArgs.prompt).not.toBe("string");
|
||||
});
|
||||
|
||||
it("should handle array prompt (with images)", async () => {
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: "text", text: "test" };
|
||||
})()
|
||||
);
|
||||
|
||||
const arrayPrompt = [
|
||||
{ type: "text", text: "Describe this" },
|
||||
{ type: "image", source: { type: "base64", data: "..." } },
|
||||
];
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: arrayPrompt as any,
|
||||
cwd: "/test",
|
||||
});
|
||||
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
// Should pass an async generator as prompt for array inputs
|
||||
const callArgs = vi.mocked(sdk.query).mock.calls[0][0];
|
||||
expect(typeof callArgs.prompt).not.toBe("string");
|
||||
});
|
||||
|
||||
it("should use maxTurns default of 20", async () => {
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: "text", text: "test" };
|
||||
})()
|
||||
);
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: "Test",
|
||||
cwd: "/test",
|
||||
});
|
||||
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(sdk.query).toHaveBeenCalledWith({
|
||||
prompt: "Test",
|
||||
options: expect.objectContaining({
|
||||
maxTurns: 20,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectInstallation", () => {
|
||||
it("should return installed with SDK method", async () => {
|
||||
const result = await provider.detectInstallation();
|
||||
|
||||
expect(result.installed).toBe(true);
|
||||
expect(result.method).toBe("sdk");
|
||||
});
|
||||
|
||||
it("should detect ANTHROPIC_API_KEY", async () => {
|
||||
process.env.ANTHROPIC_API_KEY = "test-key";
|
||||
|
||||
const result = await provider.detectInstallation();
|
||||
|
||||
expect(result.hasApiKey).toBe(true);
|
||||
expect(result.authenticated).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect CLAUDE_CODE_OAUTH_TOKEN", async () => {
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = "oauth-token";
|
||||
|
||||
const result = await provider.detectInstallation();
|
||||
|
||||
expect(result.hasApiKey).toBe(true);
|
||||
expect(result.authenticated).toBe(true);
|
||||
});
|
||||
|
||||
it("should return hasApiKey false when no keys present", async () => {
|
||||
const result = await provider.detectInstallation();
|
||||
|
||||
expect(result.hasApiKey).toBe(false);
|
||||
expect(result.authenticated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAvailableModels", () => {
|
||||
it("should return 4 Claude models", () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
expect(models).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("should include Claude Opus 4.5", () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
const opus = models.find((m) => m.id === "claude-opus-4-5-20251101");
|
||||
expect(opus).toBeDefined();
|
||||
expect(opus?.name).toBe("Claude Opus 4.5");
|
||||
expect(opus?.provider).toBe("anthropic");
|
||||
});
|
||||
|
||||
it("should include Claude Sonnet 4", () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
const sonnet = models.find((m) => m.id === "claude-sonnet-4-20250514");
|
||||
expect(sonnet).toBeDefined();
|
||||
expect(sonnet?.name).toBe("Claude Sonnet 4");
|
||||
});
|
||||
|
||||
it("should include Claude 3.5 Sonnet", () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
const sonnet35 = models.find(
|
||||
(m) => m.id === "claude-3-5-sonnet-20241022"
|
||||
);
|
||||
expect(sonnet35).toBeDefined();
|
||||
});
|
||||
|
||||
it("should include Claude 3.5 Haiku", () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
const haiku = models.find((m) => m.id === "claude-3-5-haiku-20241022");
|
||||
expect(haiku).toBeDefined();
|
||||
});
|
||||
|
||||
it("should mark Opus as default", () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
const opus = models.find((m) => m.id === "claude-opus-4-5-20251101");
|
||||
expect(opus?.default).toBe(true);
|
||||
});
|
||||
|
||||
it("should all support vision and tools", () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
models.forEach((model) => {
|
||||
expect(model.supportsVision).toBe(true);
|
||||
expect(model.supportsTools).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should have correct context windows", () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
models.forEach((model) => {
|
||||
expect(model.contextWindow).toBe(200000);
|
||||
});
|
||||
});
|
||||
|
||||
it("should have modelString field matching id", () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
models.forEach((model) => {
|
||||
expect(model.modelString).toBe(model.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("supportsFeature", () => {
|
||||
it("should support 'tools' feature", () => {
|
||||
expect(provider.supportsFeature("tools")).toBe(true);
|
||||
});
|
||||
|
||||
it("should support 'text' feature", () => {
|
||||
expect(provider.supportsFeature("text")).toBe(true);
|
||||
});
|
||||
|
||||
it("should support 'vision' feature", () => {
|
||||
expect(provider.supportsFeature("vision")).toBe(true);
|
||||
});
|
||||
|
||||
it("should support 'thinking' feature", () => {
|
||||
expect(provider.supportsFeature("thinking")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not support 'mcp' feature", () => {
|
||||
expect(provider.supportsFeature("mcp")).toBe(false);
|
||||
});
|
||||
|
||||
it("should not support 'cli' feature", () => {
|
||||
expect(provider.supportsFeature("cli")).toBe(false);
|
||||
});
|
||||
|
||||
it("should not support unknown features", () => {
|
||||
expect(provider.supportsFeature("unknown")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateConfig", () => {
|
||||
it("should validate config from base class", () => {
|
||||
const result = provider.validateConfig();
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("config management", () => {
|
||||
it("should get and set config", () => {
|
||||
provider.setConfig({ apiKey: "test-key" });
|
||||
|
||||
const config = provider.getConfig();
|
||||
expect(config.apiKey).toBe("test-key");
|
||||
});
|
||||
|
||||
it("should merge config updates", () => {
|
||||
provider.setConfig({ apiKey: "key1" });
|
||||
provider.setConfig({ model: "model1" });
|
||||
|
||||
const config = provider.getConfig();
|
||||
expect(config.apiKey).toBe("key1");
|
||||
expect(config.model).toBe("model1");
|
||||
});
|
||||
});
|
||||
});
|
||||
234
apps/server/tests/unit/providers/provider-factory.test.ts
Normal file
234
apps/server/tests/unit/providers/provider-factory.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { ProviderFactory } from "@/providers/provider-factory.js";
|
||||
import { ClaudeProvider } from "@/providers/claude-provider.js";
|
||||
|
||||
describe("provider-factory.ts", () => {
|
||||
let consoleSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleSpy = {
|
||||
warn: vi.spyOn(console, "warn").mockImplementation(() => {}),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.warn.mockRestore();
|
||||
});
|
||||
|
||||
describe("getProviderForModel", () => {
|
||||
describe("Claude models (claude-* prefix)", () => {
|
||||
it("should return ClaudeProvider for claude-opus-4-5-20251101", () => {
|
||||
const provider = ProviderFactory.getProviderForModel(
|
||||
"claude-opus-4-5-20251101"
|
||||
);
|
||||
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||
});
|
||||
|
||||
it("should return ClaudeProvider for claude-sonnet-4-20250514", () => {
|
||||
const provider = ProviderFactory.getProviderForModel(
|
||||
"claude-sonnet-4-20250514"
|
||||
);
|
||||
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||
});
|
||||
|
||||
it("should return ClaudeProvider for claude-haiku-4-5", () => {
|
||||
const provider = ProviderFactory.getProviderForModel("claude-haiku-4-5");
|
||||
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||
});
|
||||
|
||||
it("should be case-insensitive for claude models", () => {
|
||||
const provider = ProviderFactory.getProviderForModel(
|
||||
"CLAUDE-OPUS-4-5-20251101"
|
||||
);
|
||||
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude aliases", () => {
|
||||
it("should return ClaudeProvider for 'haiku'", () => {
|
||||
const provider = ProviderFactory.getProviderForModel("haiku");
|
||||
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||
});
|
||||
|
||||
it("should return ClaudeProvider for 'sonnet'", () => {
|
||||
const provider = ProviderFactory.getProviderForModel("sonnet");
|
||||
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||
});
|
||||
|
||||
it("should return ClaudeProvider for 'opus'", () => {
|
||||
const provider = ProviderFactory.getProviderForModel("opus");
|
||||
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||
});
|
||||
|
||||
it("should be case-insensitive for aliases", () => {
|
||||
const provider1 = ProviderFactory.getProviderForModel("HAIKU");
|
||||
const provider2 = ProviderFactory.getProviderForModel("Sonnet");
|
||||
const provider3 = ProviderFactory.getProviderForModel("Opus");
|
||||
|
||||
expect(provider1).toBeInstanceOf(ClaudeProvider);
|
||||
expect(provider2).toBeInstanceOf(ClaudeProvider);
|
||||
expect(provider3).toBeInstanceOf(ClaudeProvider);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Unknown models", () => {
|
||||
it("should default to ClaudeProvider for unknown model", () => {
|
||||
const provider = ProviderFactory.getProviderForModel("unknown-model-123");
|
||||
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||
});
|
||||
|
||||
it("should warn when defaulting to Claude", () => {
|
||||
ProviderFactory.getProviderForModel("random-model");
|
||||
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Unknown model prefix")
|
||||
);
|
||||
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("random-model")
|
||||
);
|
||||
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("defaulting to Claude")
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
const provider = ProviderFactory.getProviderForModel("");
|
||||
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||
expect(consoleSpy.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should default to ClaudeProvider for gpt models (not supported)", () => {
|
||||
const provider = ProviderFactory.getProviderForModel("gpt-5.2");
|
||||
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||
expect(consoleSpy.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should default to ClaudeProvider for o-series models (not supported)", () => {
|
||||
const provider = ProviderFactory.getProviderForModel("o1");
|
||||
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||
expect(consoleSpy.warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllProviders", () => {
|
||||
it("should return array of all providers", () => {
|
||||
const providers = ProviderFactory.getAllProviders();
|
||||
expect(Array.isArray(providers)).toBe(true);
|
||||
});
|
||||
|
||||
it("should include ClaudeProvider", () => {
|
||||
const providers = ProviderFactory.getAllProviders();
|
||||
const hasClaudeProvider = providers.some(
|
||||
(p) => p instanceof ClaudeProvider
|
||||
);
|
||||
expect(hasClaudeProvider).toBe(true);
|
||||
});
|
||||
|
||||
it("should return exactly 1 provider", () => {
|
||||
const providers = ProviderFactory.getAllProviders();
|
||||
expect(providers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should create new instances each time", () => {
|
||||
const providers1 = ProviderFactory.getAllProviders();
|
||||
const providers2 = ProviderFactory.getAllProviders();
|
||||
|
||||
expect(providers1[0]).not.toBe(providers2[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkAllProviders", () => {
|
||||
it("should return installation status for all providers", async () => {
|
||||
const statuses = await ProviderFactory.checkAllProviders();
|
||||
|
||||
expect(statuses).toHaveProperty("claude");
|
||||
});
|
||||
|
||||
it("should call detectInstallation on each provider", async () => {
|
||||
const statuses = await ProviderFactory.checkAllProviders();
|
||||
|
||||
expect(statuses.claude).toHaveProperty("installed");
|
||||
});
|
||||
|
||||
it("should return correct provider names as keys", async () => {
|
||||
const statuses = await ProviderFactory.checkAllProviders();
|
||||
const keys = Object.keys(statuses);
|
||||
|
||||
expect(keys).toContain("claude");
|
||||
expect(keys).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProviderByName", () => {
|
||||
it("should return ClaudeProvider for 'claude'", () => {
|
||||
const provider = ProviderFactory.getProviderByName("claude");
|
||||
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||
});
|
||||
|
||||
it("should return ClaudeProvider for 'anthropic'", () => {
|
||||
const provider = ProviderFactory.getProviderByName("anthropic");
|
||||
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||
});
|
||||
|
||||
it("should be case-insensitive", () => {
|
||||
const provider1 = ProviderFactory.getProviderByName("CLAUDE");
|
||||
const provider2 = ProviderFactory.getProviderByName("ANTHROPIC");
|
||||
|
||||
expect(provider1).toBeInstanceOf(ClaudeProvider);
|
||||
expect(provider2).toBeInstanceOf(ClaudeProvider);
|
||||
});
|
||||
|
||||
it("should return null for unknown provider", () => {
|
||||
const provider = ProviderFactory.getProviderByName("unknown");
|
||||
expect(provider).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for empty string", () => {
|
||||
const provider = ProviderFactory.getProviderByName("");
|
||||
expect(provider).toBeNull();
|
||||
});
|
||||
|
||||
it("should create new instance each time", () => {
|
||||
const provider1 = ProviderFactory.getProviderByName("claude");
|
||||
const provider2 = ProviderFactory.getProviderByName("claude");
|
||||
|
||||
expect(provider1).not.toBe(provider2);
|
||||
expect(provider1).toBeInstanceOf(ClaudeProvider);
|
||||
expect(provider2).toBeInstanceOf(ClaudeProvider);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllAvailableModels", () => {
|
||||
it("should return array of models", () => {
|
||||
const models = ProviderFactory.getAllAvailableModels();
|
||||
expect(Array.isArray(models)).toBe(true);
|
||||
});
|
||||
|
||||
it("should include models from all providers", () => {
|
||||
const models = ProviderFactory.getAllAvailableModels();
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should return models with required fields", () => {
|
||||
const models = ProviderFactory.getAllAvailableModels();
|
||||
|
||||
models.forEach((model) => {
|
||||
expect(model).toHaveProperty("id");
|
||||
expect(model).toHaveProperty("name");
|
||||
expect(typeof model.id).toBe("string");
|
||||
expect(typeof model.name).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
it("should include Claude models", () => {
|
||||
const models = ProviderFactory.getAllAvailableModels();
|
||||
|
||||
// Claude models should include claude-* in their IDs
|
||||
const hasClaudeModels = models.some((m) =>
|
||||
m.id.toLowerCase().includes("claude")
|
||||
);
|
||||
|
||||
expect(hasClaudeModels).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
361
apps/server/tests/unit/services/agent-service.test.ts
Normal file
361
apps/server/tests/unit/services/agent-service.test.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { AgentService } from "@/services/agent-service.js";
|
||||
import { ProviderFactory } from "@/providers/provider-factory.js";
|
||||
import * as fs from "fs/promises";
|
||||
import * as imageHandler from "@/lib/image-handler.js";
|
||||
import * as promptBuilder from "@/lib/prompt-builder.js";
|
||||
import { collectAsyncGenerator } from "../../utils/helpers.js";
|
||||
|
||||
vi.mock("fs/promises");
|
||||
vi.mock("@/providers/provider-factory.js");
|
||||
vi.mock("@/lib/image-handler.js");
|
||||
vi.mock("@/lib/prompt-builder.js");
|
||||
|
||||
describe("agent-service.ts", () => {
|
||||
let service: AgentService;
|
||||
const mockEvents = {
|
||||
subscribe: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new AgentService("/test/data", mockEvents as any);
|
||||
});
|
||||
|
||||
describe("initialize", () => {
|
||||
it("should create state directory", async () => {
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
|
||||
await service.initialize();
|
||||
|
||||
expect(fs.mkdir).toHaveBeenCalledWith(
|
||||
expect.stringContaining("agent-sessions"),
|
||||
{ recursive: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("startConversation", () => {
|
||||
it("should create new session with empty messages", async () => {
|
||||
const error: any = new Error("ENOENT");
|
||||
error.code = "ENOENT";
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
|
||||
const result = await service.startConversation({
|
||||
sessionId: "session-1",
|
||||
workingDirectory: "/test/dir",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toEqual([]);
|
||||
expect(result.sessionId).toBe("session-1");
|
||||
});
|
||||
|
||||
it("should load existing session", async () => {
|
||||
const existingMessages = [
|
||||
{
|
||||
id: "msg-1",
|
||||
role: "user",
|
||||
content: "Hello",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
JSON.stringify(existingMessages)
|
||||
);
|
||||
|
||||
const result = await service.startConversation({
|
||||
sessionId: "session-1",
|
||||
workingDirectory: "/test/dir",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toEqual(existingMessages);
|
||||
});
|
||||
|
||||
it("should use process.cwd() if no working directory provided", async () => {
|
||||
const error: any = new Error("ENOENT");
|
||||
error.code = "ENOENT";
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
|
||||
const result = await service.startConversation({
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reuse existing session if already started", async () => {
|
||||
const error: any = new Error("ENOENT");
|
||||
error.code = "ENOENT";
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
|
||||
// Start session first time
|
||||
await service.startConversation({
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
// Start again with same ID
|
||||
const result = await service.startConversation({
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Should only read file once
|
||||
expect(fs.readFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
beforeEach(async () => {
|
||||
const error: any = new Error("ENOENT");
|
||||
error.code = "ENOENT";
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
|
||||
await service.startConversation({
|
||||
sessionId: "session-1",
|
||||
workingDirectory: "/test/dir",
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw if session not found", async () => {
|
||||
await expect(
|
||||
service.sendMessage({
|
||||
sessionId: "nonexistent",
|
||||
message: "Hello",
|
||||
})
|
||||
).rejects.toThrow("Session nonexistent not found");
|
||||
});
|
||||
|
||||
|
||||
it("should process message and stream responses", async () => {
|
||||
const mockProvider = {
|
||||
getName: () => "claude",
|
||||
executeQuery: async function* () {
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Response" }],
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||
mockProvider as any
|
||||
);
|
||||
|
||||
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
|
||||
content: "Hello",
|
||||
hasImages: false,
|
||||
});
|
||||
|
||||
const result = await service.sendMessage({
|
||||
sessionId: "session-1",
|
||||
message: "Hello",
|
||||
workingDirectory: "/custom/dir",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockEvents.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle images in message", async () => {
|
||||
const mockProvider = {
|
||||
getName: () => "claude",
|
||||
executeQuery: async function* () {
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||
mockProvider as any
|
||||
);
|
||||
|
||||
vi.mocked(imageHandler.readImageAsBase64).mockResolvedValue({
|
||||
base64: "base64data",
|
||||
mimeType: "image/png",
|
||||
filename: "test.png",
|
||||
originalPath: "/path/test.png",
|
||||
});
|
||||
|
||||
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
|
||||
content: "Check image",
|
||||
hasImages: true,
|
||||
});
|
||||
|
||||
await service.sendMessage({
|
||||
sessionId: "session-1",
|
||||
message: "Check this",
|
||||
imagePaths: ["/path/test.png"],
|
||||
});
|
||||
|
||||
expect(imageHandler.readImageAsBase64).toHaveBeenCalledWith(
|
||||
"/path/test.png"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle failed image loading gracefully", async () => {
|
||||
const mockProvider = {
|
||||
getName: () => "claude",
|
||||
executeQuery: async function* () {
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||
mockProvider as any
|
||||
);
|
||||
|
||||
vi.mocked(imageHandler.readImageAsBase64).mockRejectedValue(
|
||||
new Error("Image not found")
|
||||
);
|
||||
|
||||
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
|
||||
content: "Check image",
|
||||
hasImages: false,
|
||||
});
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await service.sendMessage({
|
||||
sessionId: "session-1",
|
||||
message: "Check this",
|
||||
imagePaths: ["/path/test.png"],
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should use custom model if provided", async () => {
|
||||
const mockProvider = {
|
||||
getName: () => "claude",
|
||||
executeQuery: async function* () {
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||
mockProvider as any
|
||||
);
|
||||
|
||||
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
|
||||
content: "Hello",
|
||||
hasImages: false,
|
||||
});
|
||||
|
||||
await service.sendMessage({
|
||||
sessionId: "session-1",
|
||||
message: "Hello",
|
||||
model: "claude-sonnet-4-20250514",
|
||||
});
|
||||
|
||||
expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith("claude-sonnet-4-20250514");
|
||||
});
|
||||
|
||||
it("should save session messages", async () => {
|
||||
const mockProvider = {
|
||||
getName: () => "claude",
|
||||
executeQuery: async function* () {
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||
mockProvider as any
|
||||
);
|
||||
|
||||
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
|
||||
content: "Hello",
|
||||
hasImages: false,
|
||||
});
|
||||
|
||||
await service.sendMessage({
|
||||
sessionId: "session-1",
|
||||
message: "Hello",
|
||||
});
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopExecution", () => {
|
||||
it("should stop execution for a session", async () => {
|
||||
const error: any = new Error("ENOENT");
|
||||
error.code = "ENOENT";
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
|
||||
await service.startConversation({
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
// Should return success
|
||||
const result = await service.stopExecution("session-1");
|
||||
expect(result.success).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getHistory", () => {
|
||||
it("should return message history", async () => {
|
||||
const error: any = new Error("ENOENT");
|
||||
error.code = "ENOENT";
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
|
||||
await service.startConversation({
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
const history = service.getHistory("session-1");
|
||||
|
||||
expect(history).toBeDefined();
|
||||
expect(history?.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle non-existent session", () => {
|
||||
const history = service.getHistory("nonexistent");
|
||||
expect(history).toBeDefined(); // Returns error object
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearSession", () => {
|
||||
it("should clear session messages", async () => {
|
||||
const error: any = new Error("ENOENT");
|
||||
error.code = "ENOENT";
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
|
||||
await service.startConversation({
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
await service.clearSession("session-1");
|
||||
|
||||
const history = service.getHistory("session-1");
|
||||
expect(history?.messages).toEqual([]);
|
||||
expect(fs.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
71
apps/server/tests/unit/services/auto-mode-service.test.ts
Normal file
71
apps/server/tests/unit/services/auto-mode-service.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { AutoModeService } from "@/services/auto-mode-service.js";
|
||||
|
||||
describe("auto-mode-service.ts", () => {
|
||||
let service: AutoModeService;
|
||||
const mockEvents = {
|
||||
subscribe: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new AutoModeService(mockEvents as any);
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should initialize with event emitter", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("startAutoLoop", () => {
|
||||
it("should throw if auto mode is already running", async () => {
|
||||
// Start first loop
|
||||
const promise1 = service.startAutoLoop("/test/project", 3);
|
||||
|
||||
// Try to start second loop
|
||||
await expect(
|
||||
service.startAutoLoop("/test/project", 3)
|
||||
).rejects.toThrow("already running");
|
||||
|
||||
// Cleanup
|
||||
await service.stopAutoLoop();
|
||||
await promise1.catch(() => {});
|
||||
});
|
||||
|
||||
it("should emit auto mode start event", async () => {
|
||||
const promise = service.startAutoLoop("/test/project", 3);
|
||||
|
||||
// Give it time to emit the event
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("Auto mode started"),
|
||||
})
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
await service.stopAutoLoop();
|
||||
await promise.catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopAutoLoop", () => {
|
||||
it("should stop the auto loop", async () => {
|
||||
const promise = service.startAutoLoop("/test/project", 3);
|
||||
|
||||
const runningCount = await service.stopAutoLoop();
|
||||
|
||||
expect(runningCount).toBe(0);
|
||||
await promise.catch(() => {});
|
||||
});
|
||||
|
||||
it("should return 0 when not running", async () => {
|
||||
const runningCount = await service.stopAutoLoop();
|
||||
expect(runningCount).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
446
apps/server/tests/unit/services/feature-loader.test.ts
Normal file
446
apps/server/tests/unit/services/feature-loader.test.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { FeatureLoader } from "@/services/feature-loader.js";
|
||||
import * as fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
vi.mock("fs/promises");
|
||||
|
||||
describe("feature-loader.ts", () => {
|
||||
let loader: FeatureLoader;
|
||||
const testProjectPath = "/test/project";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
loader = new FeatureLoader();
|
||||
});
|
||||
|
||||
describe("getFeaturesDir", () => {
|
||||
it("should return features directory path", () => {
|
||||
const result = loader.getFeaturesDir(testProjectPath);
|
||||
expect(result).toContain("test");
|
||||
expect(result).toContain("project");
|
||||
expect(result).toContain(".automaker");
|
||||
expect(result).toContain("features");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFeatureImagesDir", () => {
|
||||
it("should return feature images directory path", () => {
|
||||
const result = loader.getFeatureImagesDir(testProjectPath, "feature-123");
|
||||
expect(result).toContain("features");
|
||||
expect(result).toContain("feature-123");
|
||||
expect(result).toContain("images");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFeatureDir", () => {
|
||||
it("should return feature directory path", () => {
|
||||
const result = loader.getFeatureDir(testProjectPath, "feature-123");
|
||||
expect(result).toContain("features");
|
||||
expect(result).toContain("feature-123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFeatureJsonPath", () => {
|
||||
it("should return feature.json path", () => {
|
||||
const result = loader.getFeatureJsonPath(testProjectPath, "feature-123");
|
||||
expect(result).toContain("features");
|
||||
expect(result).toContain("feature-123");
|
||||
expect(result).toContain("feature.json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAgentOutputPath", () => {
|
||||
it("should return agent-output.md path", () => {
|
||||
const result = loader.getAgentOutputPath(testProjectPath, "feature-123");
|
||||
expect(result).toContain("features");
|
||||
expect(result).toContain("feature-123");
|
||||
expect(result).toContain("agent-output.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateFeatureId", () => {
|
||||
it("should generate unique feature ID with timestamp", () => {
|
||||
const id1 = loader.generateFeatureId();
|
||||
const id2 = loader.generateFeatureId();
|
||||
|
||||
expect(id1).toMatch(/^feature-\d+-[a-z0-9]+$/);
|
||||
expect(id2).toMatch(/^feature-\d+-[a-z0-9]+$/);
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
|
||||
it("should start with 'feature-'", () => {
|
||||
const id = loader.generateFeatureId();
|
||||
expect(id).toMatch(/^feature-/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAll", () => {
|
||||
it("should return empty array when features directory doesn't exist", async () => {
|
||||
vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"));
|
||||
|
||||
const result = await loader.getAll(testProjectPath);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should load all features from feature directories", async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: "feature-1", isDirectory: () => true } as any,
|
||||
{ name: "feature-2", isDirectory: () => true } as any,
|
||||
{ name: "file.txt", isDirectory: () => false } as any,
|
||||
]);
|
||||
|
||||
vi.mocked(fs.readFile)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: "feature-1",
|
||||
category: "ui",
|
||||
description: "Feature 1",
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: "feature-2",
|
||||
category: "backend",
|
||||
description: "Feature 2",
|
||||
})
|
||||
);
|
||||
|
||||
const result = await loader.getAll(testProjectPath);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toBe("feature-1");
|
||||
expect(result[1].id).toBe("feature-2");
|
||||
});
|
||||
|
||||
it("should skip features without id field", async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: "feature-1", isDirectory: () => true } as any,
|
||||
{ name: "feature-2", isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
vi.mocked(fs.readFile)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
category: "ui",
|
||||
description: "Missing ID",
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: "feature-2",
|
||||
category: "backend",
|
||||
description: "Feature 2",
|
||||
})
|
||||
);
|
||||
|
||||
const result = await loader.getAll(testProjectPath);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("feature-2");
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("missing required 'id' field")
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should skip features with missing feature.json", async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: "feature-1", isDirectory: () => true } as any,
|
||||
{ name: "feature-2", isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
const error: any = new Error("File not found");
|
||||
error.code = "ENOENT";
|
||||
|
||||
vi.mocked(fs.readFile)
|
||||
.mockRejectedValueOnce(error)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: "feature-2",
|
||||
category: "backend",
|
||||
description: "Feature 2",
|
||||
})
|
||||
);
|
||||
|
||||
const result = await loader.getAll(testProjectPath);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("feature-2");
|
||||
});
|
||||
|
||||
it("should handle malformed JSON gracefully", async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: "feature-1", isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
vi.mocked(fs.readFile).mockResolvedValue("invalid json{");
|
||||
|
||||
const result = await loader.getAll(testProjectPath);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should sort features by creation order (timestamp)", async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: "feature-3", isDirectory: () => true } as any,
|
||||
{ name: "feature-1", isDirectory: () => true } as any,
|
||||
{ name: "feature-2", isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
vi.mocked(fs.readFile)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: "feature-3000-xyz",
|
||||
category: "ui",
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: "feature-1000-abc",
|
||||
category: "ui",
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: "feature-2000-def",
|
||||
category: "ui",
|
||||
})
|
||||
);
|
||||
|
||||
const result = await loader.getAll(testProjectPath);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].id).toBe("feature-1000-abc");
|
||||
expect(result[1].id).toBe("feature-2000-def");
|
||||
expect(result[2].id).toBe("feature-3000-xyz");
|
||||
});
|
||||
});
|
||||
|
||||
describe("get", () => {
|
||||
it("should return feature by ID", async () => {
|
||||
const featureData = {
|
||||
id: "feature-123",
|
||||
category: "ui",
|
||||
description: "Test feature",
|
||||
};
|
||||
|
||||
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(featureData));
|
||||
|
||||
const result = await loader.get(testProjectPath, "feature-123");
|
||||
|
||||
expect(result).toEqual(featureData);
|
||||
});
|
||||
|
||||
it("should return null when feature doesn't exist", async () => {
|
||||
const error: any = new Error("File not found");
|
||||
error.code = "ENOENT";
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
|
||||
const result = await loader.get(testProjectPath, "feature-123");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should throw on other errors", async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error("Permission denied"));
|
||||
|
||||
await expect(
|
||||
loader.get(testProjectPath, "feature-123")
|
||||
).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should create new feature", async () => {
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const featureData = {
|
||||
category: "ui",
|
||||
description: "New feature",
|
||||
};
|
||||
|
||||
const result = await loader.create(testProjectPath, featureData);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
category: "ui",
|
||||
description: "New feature",
|
||||
id: expect.stringMatching(/^feature-/),
|
||||
});
|
||||
expect(fs.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use provided ID if given", async () => {
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const result = await loader.create(testProjectPath, {
|
||||
id: "custom-id",
|
||||
category: "ui",
|
||||
description: "Test",
|
||||
});
|
||||
|
||||
expect(result.id).toBe("custom-id");
|
||||
});
|
||||
|
||||
it("should set default category if not provided", async () => {
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const result = await loader.create(testProjectPath, {
|
||||
description: "Test",
|
||||
});
|
||||
|
||||
expect(result.category).toBe("Uncategorized");
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("should update existing feature", async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
JSON.stringify({
|
||||
id: "feature-123",
|
||||
category: "ui",
|
||||
description: "Old description",
|
||||
})
|
||||
);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const result = await loader.update(testProjectPath, "feature-123", {
|
||||
description: "New description",
|
||||
});
|
||||
|
||||
expect(result.description).toBe("New description");
|
||||
expect(result.category).toBe("ui");
|
||||
expect(fs.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw if feature doesn't exist", async () => {
|
||||
const error: any = new Error("File not found");
|
||||
error.code = "ENOENT";
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
loader.update(testProjectPath, "feature-123", {})
|
||||
).rejects.toThrow("not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("should delete feature directory", async () => {
|
||||
vi.mocked(fs.rm).mockResolvedValue(undefined);
|
||||
|
||||
const result = await loader.delete(testProjectPath, "feature-123");
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fs.rm).toHaveBeenCalledWith(
|
||||
expect.stringContaining("feature-123"),
|
||||
{ recursive: true, force: true }
|
||||
);
|
||||
});
|
||||
|
||||
it("should return false on error", async () => {
|
||||
vi.mocked(fs.rm).mockRejectedValue(new Error("Permission denied"));
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const result = await loader.delete(testProjectPath, "feature-123");
|
||||
|
||||
expect(result).toBe(false);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAgentOutput", () => {
|
||||
it("should return agent output content", async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue("Agent output content");
|
||||
|
||||
const result = await loader.getAgentOutput(testProjectPath, "feature-123");
|
||||
|
||||
expect(result).toBe("Agent output content");
|
||||
});
|
||||
|
||||
it("should return null when file doesn't exist", async () => {
|
||||
const error: any = new Error("File not found");
|
||||
error.code = "ENOENT";
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
|
||||
const result = await loader.getAgentOutput(testProjectPath, "feature-123");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should throw on other errors", async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error("Permission denied"));
|
||||
|
||||
await expect(
|
||||
loader.getAgentOutput(testProjectPath, "feature-123")
|
||||
).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveAgentOutput", () => {
|
||||
it("should save agent output to file", async () => {
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
await loader.saveAgentOutput(
|
||||
testProjectPath,
|
||||
"feature-123",
|
||||
"Output content"
|
||||
);
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining("agent-output.md"),
|
||||
"Output content",
|
||||
"utf-8"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteAgentOutput", () => {
|
||||
it("should delete agent output file", async () => {
|
||||
vi.mocked(fs.unlink).mockResolvedValue(undefined);
|
||||
|
||||
await loader.deleteAgentOutput(testProjectPath, "feature-123");
|
||||
|
||||
expect(fs.unlink).toHaveBeenCalledWith(
|
||||
expect.stringContaining("agent-output.md")
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle missing file gracefully", async () => {
|
||||
const error: any = new Error("File not found");
|
||||
error.code = "ENOENT";
|
||||
vi.mocked(fs.unlink).mockRejectedValue(error);
|
||||
|
||||
// Should not throw
|
||||
await expect(
|
||||
loader.deleteAgentOutput(testProjectPath, "feature-123")
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("should throw on other errors", async () => {
|
||||
vi.mocked(fs.unlink).mockRejectedValue(new Error("Permission denied"));
|
||||
|
||||
await expect(
|
||||
loader.deleteAgentOutput(testProjectPath, "feature-123")
|
||||
).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
});
|
||||
567
apps/server/tests/unit/services/terminal-service.test.ts
Normal file
567
apps/server/tests/unit/services/terminal-service.test.ts
Normal file
@@ -0,0 +1,567 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { TerminalService, getTerminalService } from "@/services/terminal-service.js";
|
||||
import * as pty from "node-pty";
|
||||
import * as os from "os";
|
||||
import * as fs from "fs";
|
||||
|
||||
vi.mock("node-pty");
|
||||
vi.mock("fs");
|
||||
vi.mock("os");
|
||||
|
||||
describe("terminal-service.ts", () => {
|
||||
let service: TerminalService;
|
||||
let mockPtyProcess: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new TerminalService();
|
||||
|
||||
// Mock PTY process
|
||||
mockPtyProcess = {
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked(pty.spawn).mockReturnValue(mockPtyProcess);
|
||||
vi.mocked(os.homedir).mockReturnValue("/home/user");
|
||||
vi.mocked(os.platform).mockReturnValue("linux");
|
||||
vi.mocked(os.arch).mockReturnValue("x64");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.cleanup();
|
||||
});
|
||||
|
||||
describe("detectShell", () => {
|
||||
it("should detect PowerShell Core on Windows when available", () => {
|
||||
vi.mocked(os.platform).mockReturnValue("win32");
|
||||
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
|
||||
return path === "C:\\Program Files\\PowerShell\\7\\pwsh.exe";
|
||||
});
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
expect(result.shell).toBe("C:\\Program Files\\PowerShell\\7\\pwsh.exe");
|
||||
expect(result.args).toEqual([]);
|
||||
});
|
||||
|
||||
it("should fall back to PowerShell on Windows if Core not available", () => {
|
||||
vi.mocked(os.platform).mockReturnValue("win32");
|
||||
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
|
||||
return path === "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
|
||||
});
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
expect(result.shell).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe");
|
||||
expect(result.args).toEqual([]);
|
||||
});
|
||||
|
||||
it("should fall back to cmd.exe on Windows if no PowerShell", () => {
|
||||
vi.mocked(os.platform).mockReturnValue("win32");
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
expect(result.shell).toBe("cmd.exe");
|
||||
expect(result.args).toEqual([]);
|
||||
});
|
||||
|
||||
it("should detect user shell on macOS", () => {
|
||||
vi.mocked(os.platform).mockReturnValue("darwin");
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/zsh" });
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
expect(result.shell).toBe("/bin/zsh");
|
||||
expect(result.args).toEqual(["--login"]);
|
||||
});
|
||||
|
||||
it("should fall back to zsh on macOS if user shell not available", () => {
|
||||
vi.mocked(os.platform).mockReturnValue("darwin");
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({});
|
||||
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
|
||||
return path === "/bin/zsh";
|
||||
});
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
expect(result.shell).toBe("/bin/zsh");
|
||||
expect(result.args).toEqual(["--login"]);
|
||||
});
|
||||
|
||||
it("should fall back to bash on macOS if zsh not available", () => {
|
||||
vi.mocked(os.platform).mockReturnValue("darwin");
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
expect(result.shell).toBe("/bin/bash");
|
||||
expect(result.args).toEqual(["--login"]);
|
||||
});
|
||||
|
||||
it("should detect user shell on Linux", () => {
|
||||
vi.mocked(os.platform).mockReturnValue("linux");
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
expect(result.shell).toBe("/bin/bash");
|
||||
expect(result.args).toEqual(["--login"]);
|
||||
});
|
||||
|
||||
it("should fall back to bash on Linux if user shell not available", () => {
|
||||
vi.mocked(os.platform).mockReturnValue("linux");
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({});
|
||||
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
|
||||
return path === "/bin/bash";
|
||||
});
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
expect(result.shell).toBe("/bin/bash");
|
||||
expect(result.args).toEqual(["--login"]);
|
||||
});
|
||||
|
||||
it("should fall back to sh on Linux if bash not available", () => {
|
||||
vi.mocked(os.platform).mockReturnValue("linux");
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
expect(result.shell).toBe("/bin/sh");
|
||||
expect(result.args).toEqual([]);
|
||||
});
|
||||
|
||||
it("should detect WSL and use appropriate shell", () => {
|
||||
vi.mocked(os.platform).mockReturnValue("linux");
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.0-microsoft-standard-WSL2");
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
expect(result.shell).toBe("/bin/bash");
|
||||
expect(result.args).toEqual(["--login"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWSL", () => {
|
||||
it("should return true if /proc/version contains microsoft", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.0-microsoft-standard-WSL2");
|
||||
|
||||
expect(service.isWSL()).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true if /proc/version contains wsl", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.0-wsl2");
|
||||
|
||||
expect(service.isWSL()).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true if WSL_DISTRO_NAME is set", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({ WSL_DISTRO_NAME: "Ubuntu" });
|
||||
|
||||
expect(service.isWSL()).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true if WSLENV is set", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({ WSLENV: "PATH/l" });
|
||||
|
||||
expect(service.isWSL()).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if not in WSL", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({});
|
||||
|
||||
expect(service.isWSL()).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if error reading /proc/version", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockImplementation(() => {
|
||||
throw new Error("Permission denied");
|
||||
});
|
||||
|
||||
expect(service.isWSL()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPlatformInfo", () => {
|
||||
it("should return platform information", () => {
|
||||
vi.mocked(os.platform).mockReturnValue("linux");
|
||||
vi.mocked(os.arch).mockReturnValue("x64");
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||
|
||||
const info = service.getPlatformInfo();
|
||||
|
||||
expect(info.platform).toBe("linux");
|
||||
expect(info.arch).toBe("x64");
|
||||
expect(info.defaultShell).toBe("/bin/bash");
|
||||
expect(typeof info.isWSL).toBe("boolean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSession", () => {
|
||||
it("should create a new terminal session", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||
|
||||
const session = service.createSession({
|
||||
cwd: "/test/dir",
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
});
|
||||
|
||||
expect(session.id).toMatch(/^term-/);
|
||||
expect(session.cwd).toBe("/test/dir");
|
||||
expect(session.shell).toBe("/bin/bash");
|
||||
expect(pty.spawn).toHaveBeenCalledWith(
|
||||
"/bin/bash",
|
||||
["--login"],
|
||||
expect.objectContaining({
|
||||
cwd: "/test/dir",
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should use default cols and rows if not provided", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||
|
||||
service.createSession();
|
||||
|
||||
expect(pty.spawn).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should fall back to home directory if cwd does not exist", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||
|
||||
const session = service.createSession({
|
||||
cwd: "/nonexistent",
|
||||
});
|
||||
|
||||
expect(session.cwd).toBe("/home/user");
|
||||
});
|
||||
|
||||
it("should fall back to home directory if cwd is not a directory", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => false } as any);
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||
|
||||
const session = service.createSession({
|
||||
cwd: "/file.txt",
|
||||
});
|
||||
|
||||
expect(session.cwd).toBe("/home/user");
|
||||
});
|
||||
|
||||
it("should fix double slashes in path", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||
|
||||
const session = service.createSession({
|
||||
cwd: "//test/dir",
|
||||
});
|
||||
|
||||
expect(session.cwd).toBe("/test/dir");
|
||||
});
|
||||
|
||||
it("should preserve WSL UNC paths", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||
|
||||
const session = service.createSession({
|
||||
cwd: "//wsl$/Ubuntu/home",
|
||||
});
|
||||
|
||||
expect(session.cwd).toBe("//wsl$/Ubuntu/home");
|
||||
});
|
||||
|
||||
it("should handle data events from PTY", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||
|
||||
const dataCallback = vi.fn();
|
||||
service.onData(dataCallback);
|
||||
|
||||
service.createSession();
|
||||
|
||||
// Simulate data event
|
||||
const onDataHandler = mockPtyProcess.onData.mock.calls[0][0];
|
||||
onDataHandler("test data");
|
||||
|
||||
// Wait for throttled output
|
||||
vi.advanceTimersByTime(20);
|
||||
|
||||
expect(dataCallback).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should handle exit events from PTY", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||
|
||||
const exitCallback = vi.fn();
|
||||
service.onExit(exitCallback);
|
||||
|
||||
const session = service.createSession();
|
||||
|
||||
// Simulate exit event
|
||||
const onExitHandler = mockPtyProcess.onExit.mock.calls[0][0];
|
||||
onExitHandler({ exitCode: 0 });
|
||||
|
||||
expect(exitCallback).toHaveBeenCalledWith(session.id, 0);
|
||||
expect(service.getSession(session.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("write", () => {
|
||||
it("should write data to existing session", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||
|
||||
const session = service.createSession();
|
||||
const result = service.write(session.id, "ls\n");
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockPtyProcess.write).toHaveBeenCalledWith("ls\n");
|
||||
});
|
||||
|
||||
it("should return false for non-existent session", () => {
|
||||
const result = service.write("nonexistent", "data");
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockPtyProcess.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resize", () => {
|
||||
it("should resize existing session", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||
|
||||
const session = service.createSession();
|
||||
const result = service.resize(session.id, 120, 40);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockPtyProcess.resize).toHaveBeenCalledWith(120, 40);
|
||||
});
|
||||
|
||||
it("should return false for non-existent session", () => {
|
||||
const result = service.resize("nonexistent", 120, 40);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockPtyProcess.resize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle resize errors", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||
mockPtyProcess.resize.mockImplementation(() => {
|
||||
throw new Error("Resize failed");
|
||||
});
|
||||
|
||||
const session = service.createSession();
|
||||
const result = service.resize(session.id, 120, 40);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("killSession", () => {
|
||||
it("should kill existing session", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||
|
||||
const session = service.createSession();
|
||||
const result = service.killSession(session.id);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockPtyProcess.kill).toHaveBeenCalled();
|
||||
expect(service.getSession(session.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return false for non-existent session", () => {
|
||||
const result = service.killSession("nonexistent");
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle kill errors", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||
mockPtyProcess.kill.mockImplementation(() => {
|
||||
throw new Error("Kill failed");
|
||||
});
|
||||
|
||||
const session = service.createSession();
|
||||
const result = service.killSession(session.id);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSession", () => {
|
||||
it("should return existing session", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||
|
||||
const session = service.createSession();
|
||||
const retrieved = service.getSession(session.id);
|
||||
|
||||
expect(retrieved).toBe(session);
|
||||
});
|
||||
|
||||
it("should return undefined for non-existent session", () => {
|
||||
const retrieved = service.getSession("nonexistent");
|
||||
|
||||
expect(retrieved).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getScrollback", () => {
|
||||
it("should return scrollback buffer for existing session", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||
|
||||
const session = service.createSession();
|
||||
session.scrollbackBuffer = "test scrollback";
|
||||
|
||||
const scrollback = service.getScrollback(session.id);
|
||||
|
||||
expect(scrollback).toBe("test scrollback");
|
||||
});
|
||||
|
||||
it("should return null for non-existent session", () => {
|
||||
const scrollback = service.getScrollback("nonexistent");
|
||||
|
||||
expect(scrollback).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllSessions", () => {
|
||||
it("should return all active sessions", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||
|
||||
const session1 = service.createSession({ cwd: "/dir1" });
|
||||
const session2 = service.createSession({ cwd: "/dir2" });
|
||||
|
||||
const sessions = service.getAllSessions();
|
||||
|
||||
expect(sessions).toHaveLength(2);
|
||||
expect(sessions[0].id).toBe(session1.id);
|
||||
expect(sessions[1].id).toBe(session2.id);
|
||||
expect(sessions[0].cwd).toBe("/dir1");
|
||||
expect(sessions[1].cwd).toBe("/dir2");
|
||||
});
|
||||
|
||||
it("should return empty array if no sessions", () => {
|
||||
const sessions = service.getAllSessions();
|
||||
|
||||
expect(sessions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onData and onExit", () => {
|
||||
it("should allow subscribing and unsubscribing from data events", () => {
|
||||
const callback = vi.fn();
|
||||
const unsubscribe = service.onData(callback);
|
||||
|
||||
expect(typeof unsubscribe).toBe("function");
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("should allow subscribing and unsubscribing from exit events", () => {
|
||||
const callback = vi.fn();
|
||||
const unsubscribe = service.onExit(callback);
|
||||
|
||||
expect(typeof unsubscribe).toBe("function");
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanup", () => {
|
||||
it("should clean up all sessions", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||
|
||||
const session1 = service.createSession();
|
||||
const session2 = service.createSession();
|
||||
|
||||
service.cleanup();
|
||||
|
||||
expect(service.getSession(session1.id)).toBeUndefined();
|
||||
expect(service.getSession(session2.id)).toBeUndefined();
|
||||
expect(service.getAllSessions()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should handle cleanup errors gracefully", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||
mockPtyProcess.kill.mockImplementation(() => {
|
||||
throw new Error("Kill failed");
|
||||
});
|
||||
|
||||
service.createSession();
|
||||
|
||||
expect(() => service.cleanup()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTerminalService", () => {
|
||||
it("should return singleton instance", () => {
|
||||
const instance1 = getTerminalService();
|
||||
const instance2 = getTerminalService();
|
||||
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
});
|
||||
});
|
||||
38
apps/server/tests/utils/helpers.ts
Normal file
38
apps/server/tests/utils/helpers.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Test helper functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Collect all values from an async generator
|
||||
*/
|
||||
export async function collectAsyncGenerator<T>(gen: AsyncGenerator<T>): Promise<T[]> {
|
||||
const results: T[] = [];
|
||||
for await (const item of gen) {
|
||||
results.push(item);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a condition to be true
|
||||
*/
|
||||
export async function waitFor(
|
||||
condition: () => boolean,
|
||||
timeout = 1000,
|
||||
interval = 10
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!condition()) {
|
||||
if (Date.now() - start > timeout) {
|
||||
throw new Error("Timeout waiting for condition");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a temporary directory for tests
|
||||
*/
|
||||
export function createTempDir(): string {
|
||||
return `/tmp/test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
107
apps/server/tests/utils/mocks.ts
Normal file
107
apps/server/tests/utils/mocks.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Mock utilities for testing
|
||||
* Provides reusable mocks for common dependencies
|
||||
*/
|
||||
|
||||
import { vi } from "vitest";
|
||||
import type { ChildProcess } from "child_process";
|
||||
import { EventEmitter } from "events";
|
||||
import type { Readable } from "stream";
|
||||
|
||||
/**
|
||||
* Mock child_process.spawn for subprocess tests
|
||||
*/
|
||||
export function createMockChildProcess(options: {
|
||||
stdout?: string[];
|
||||
stderr?: string[];
|
||||
exitCode?: number | null;
|
||||
shouldError?: boolean;
|
||||
}): ChildProcess {
|
||||
const { stdout = [], stderr = [], exitCode = 0, shouldError = false } = options;
|
||||
|
||||
const mockProcess = new EventEmitter() as any;
|
||||
|
||||
// Create mock stdout stream
|
||||
mockProcess.stdout = new EventEmitter() as Readable;
|
||||
mockProcess.stderr = new EventEmitter() as Readable;
|
||||
|
||||
mockProcess.kill = vi.fn();
|
||||
|
||||
// Simulate async output
|
||||
process.nextTick(() => {
|
||||
// Emit stdout lines
|
||||
for (const line of stdout) {
|
||||
mockProcess.stdout.emit("data", Buffer.from(line + "\n"));
|
||||
}
|
||||
|
||||
// Emit stderr lines
|
||||
for (const line of stderr) {
|
||||
mockProcess.stderr.emit("data", Buffer.from(line + "\n"));
|
||||
}
|
||||
|
||||
// Emit exit or error
|
||||
if (shouldError) {
|
||||
mockProcess.emit("error", new Error("Process error"));
|
||||
} else {
|
||||
mockProcess.emit("exit", exitCode);
|
||||
}
|
||||
});
|
||||
|
||||
return mockProcess as ChildProcess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock fs/promises for file system tests
|
||||
*/
|
||||
export function createMockFs() {
|
||||
return {
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
access: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Express request/response/next for middleware tests
|
||||
*/
|
||||
export function createMockExpressContext() {
|
||||
const req = {
|
||||
headers: {},
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
} as any;
|
||||
|
||||
const res = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis(),
|
||||
send: vi.fn().mockReturnThis(),
|
||||
} as any;
|
||||
|
||||
const next = vi.fn();
|
||||
|
||||
return { req, res, next };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock AbortController for async operation tests
|
||||
*/
|
||||
export function createMockAbortController() {
|
||||
const controller = new AbortController();
|
||||
const originalAbort = controller.abort.bind(controller);
|
||||
controller.abort = vi.fn(originalAbort);
|
||||
return controller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Claude SDK query function
|
||||
*/
|
||||
export function createMockClaudeQuery(messages: any[] = []) {
|
||||
return vi.fn(async function* ({ prompt, options }: any) {
|
||||
for (const msg of messages) {
|
||||
yield msg;
|
||||
}
|
||||
});
|
||||
}
|
||||
10
apps/server/tsconfig.test.json
Normal file
10
apps/server/tsconfig.test.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["vitest/globals", "node"],
|
||||
"moduleResolution": "Bundler",
|
||||
"module": "ESNext"
|
||||
},
|
||||
"include": ["src/**/*", "tests/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
37
apps/server/vitest.config.ts
Normal file
37
apps/server/vitest.config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
reporters: ['verbose'],
|
||||
globals: true,
|
||||
environment: "node",
|
||||
setupFiles: ["./tests/setup.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.d.ts",
|
||||
"src/index.ts",
|
||||
"src/routes/**", // Routes are better tested with integration tests
|
||||
],
|
||||
thresholds: {
|
||||
lines: 65,
|
||||
functions: 75,
|
||||
branches: 58,
|
||||
statements: 65,
|
||||
},
|
||||
},
|
||||
include: ["tests/**/*.test.ts", "tests/**/*.spec.ts"],
|
||||
exclude: ["**/node_modules/**", "**/dist/**"],
|
||||
mockReset: true,
|
||||
restoreMocks: true,
|
||||
clearMocks: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
786
docs/server/providers.md
Normal file
786
docs/server/providers.md
Normal file
@@ -0,0 +1,786 @@
|
||||
# Provider Architecture Reference
|
||||
|
||||
This document describes the modular provider architecture in `apps/server/src/providers/` that enables support for multiple AI model providers (Claude SDK, OpenAI Codex CLI, and future providers like Cursor, OpenCode, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Architecture Overview](#architecture-overview)
|
||||
2. [Provider Interface](#provider-interface)
|
||||
3. [Available Providers](#available-providers)
|
||||
4. [Provider Factory](#provider-factory)
|
||||
5. [Adding New Providers](#adding-new-providers)
|
||||
6. [Provider Types](#provider-types)
|
||||
7. [Best Practices](#best-practices)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The provider architecture separates AI model execution logic from business logic, enabling clean abstraction and easy extensibility.
|
||||
|
||||
### Architecture Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ AgentService / AutoModeService │
|
||||
│ (No provider logic) │
|
||||
└──────────────────┬──────────────────────┘
|
||||
│
|
||||
┌─────────▼──────────┐
|
||||
│ ProviderFactory │ Model-based routing
|
||||
│ (Routes by model) │ "gpt-*" → Codex
|
||||
└─────────┬──────────┘ "claude-*" → Claude
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
│ │
|
||||
┌─────▼──────┐ ┌──────▼──────┐
|
||||
│ Claude │ │ Codex │
|
||||
│ Provider │ │ Provider │
|
||||
│ (Agent SDK)│ │ (CLI Spawn) │
|
||||
└────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
### Key Benefits
|
||||
|
||||
- ✅ **Adding new providers**: Only 1 new file + 1 line in factory
|
||||
- ✅ **Services remain clean**: No provider-specific logic
|
||||
- ✅ **All providers implement same interface**: Consistent behavior
|
||||
- ✅ **Model prefix determines provider**: Automatic routing
|
||||
- ✅ **Easy to test**: Each provider can be tested independently
|
||||
|
||||
---
|
||||
|
||||
## Provider Interface
|
||||
|
||||
**Location**: `apps/server/src/providers/base-provider.ts`
|
||||
|
||||
All providers must extend `BaseProvider` and implement the required methods.
|
||||
|
||||
### BaseProvider Abstract Class
|
||||
|
||||
```typescript
|
||||
export abstract class BaseProvider {
|
||||
protected config: ProviderConfig;
|
||||
|
||||
constructor(config: ProviderConfig = {}) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider name (e.g., "claude", "codex")
|
||||
*/
|
||||
abstract getName(): string;
|
||||
|
||||
/**
|
||||
* Execute a query and stream responses
|
||||
*/
|
||||
abstract executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage>;
|
||||
|
||||
/**
|
||||
* Detect provider installation status
|
||||
*/
|
||||
abstract detectInstallation(): Promise<InstallationStatus>;
|
||||
|
||||
/**
|
||||
* Get available models for this provider
|
||||
*/
|
||||
abstract getAvailableModels(): ModelDefinition[];
|
||||
|
||||
/**
|
||||
* Check if provider supports a specific feature (optional)
|
||||
*/
|
||||
supportsFeature(feature: string): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Shared Types
|
||||
|
||||
**Location**: `apps/server/src/providers/types.ts`
|
||||
|
||||
#### ExecuteOptions
|
||||
|
||||
Input configuration for executing queries:
|
||||
|
||||
```typescript
|
||||
export interface ExecuteOptions {
|
||||
prompt: string | Array<{ type: string; text?: string; source?: object }>;
|
||||
model: string;
|
||||
cwd: string;
|
||||
systemPrompt?: string;
|
||||
maxTurns?: number;
|
||||
allowedTools?: string[];
|
||||
mcpServers?: Record<string, unknown>;
|
||||
abortController?: AbortController;
|
||||
conversationHistory?: ConversationMessage[];
|
||||
}
|
||||
```
|
||||
|
||||
#### ProviderMessage
|
||||
|
||||
Output messages streamed from providers:
|
||||
|
||||
```typescript
|
||||
export interface ProviderMessage {
|
||||
type: "assistant" | "user" | "error" | "result";
|
||||
subtype?: "success" | "error";
|
||||
message?: {
|
||||
role: "user" | "assistant";
|
||||
content: ContentBlock[];
|
||||
};
|
||||
result?: string;
|
||||
error?: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### ContentBlock
|
||||
|
||||
Individual content blocks in messages:
|
||||
|
||||
```typescript
|
||||
export interface ContentBlock {
|
||||
type: "text" | "tool_use" | "thinking" | "tool_result";
|
||||
text?: string;
|
||||
thinking?: string;
|
||||
name?: string;
|
||||
input?: unknown;
|
||||
tool_use_id?: string;
|
||||
content?: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Available Providers
|
||||
|
||||
### 1. Claude Provider (SDK-based)
|
||||
|
||||
**Location**: `apps/server/src/providers/claude-provider.ts`
|
||||
|
||||
Uses `@anthropic-ai/claude-agent-sdk` for direct SDK integration.
|
||||
|
||||
#### Features
|
||||
|
||||
- ✅ Native multi-turn conversation support
|
||||
- ✅ Vision support (images)
|
||||
- ✅ Tool use (Read, Write, Edit, Glob, Grep, Bash, WebSearch, WebFetch)
|
||||
- ✅ Thinking blocks (extended thinking)
|
||||
- ✅ Streaming responses
|
||||
- ✅ No CLI installation required (npm dependency)
|
||||
|
||||
#### Model Detection
|
||||
|
||||
Routes models that:
|
||||
- Start with `"claude-"` (e.g., `"claude-opus-4-5-20251101"`)
|
||||
- Are Claude aliases: `"opus"`, `"sonnet"`, `"haiku"`
|
||||
|
||||
#### Authentication
|
||||
|
||||
Requires one of:
|
||||
- `ANTHROPIC_API_KEY` environment variable
|
||||
- `CLAUDE_CODE_OAUTH_TOKEN` environment variable
|
||||
|
||||
#### Example Usage
|
||||
|
||||
```typescript
|
||||
const provider = new ClaudeProvider();
|
||||
|
||||
const stream = provider.executeQuery({
|
||||
prompt: "What is 2+2?",
|
||||
model: "claude-opus-4-5-20251101",
|
||||
cwd: "/project/path",
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
maxTurns: 20,
|
||||
allowedTools: ["Read", "Write", "Bash"],
|
||||
abortController: new AbortController(),
|
||||
conversationHistory: [
|
||||
{ role: "user", content: "Hello" },
|
||||
{ role: "assistant", content: "Hi! How can I help?" }
|
||||
]
|
||||
});
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === "assistant") {
|
||||
console.log(msg.message?.content);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Conversation History Handling
|
||||
|
||||
Uses `convertHistoryToMessages()` utility to convert history to SDK format:
|
||||
|
||||
```typescript
|
||||
const historyMessages = convertHistoryToMessages(conversationHistory);
|
||||
for (const msg of historyMessages) {
|
||||
yield msg; // Yield to SDK
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Codex Provider (CLI-based)
|
||||
|
||||
**Location**: `apps/server/src/providers/codex-provider.ts`
|
||||
|
||||
Spawns OpenAI Codex CLI as a subprocess and converts JSONL output to provider format.
|
||||
|
||||
#### Features
|
||||
|
||||
- ✅ Subprocess execution (`codex exec --model <model> --json --full-auto`)
|
||||
- ✅ JSONL stream parsing
|
||||
- ✅ Supports GPT-5.1/5.2 Codex models
|
||||
- ✅ Vision support (GPT-5.1, GPT-5.2)
|
||||
- ✅ Tool use via MCP servers
|
||||
- ✅ Timeout detection (30s no output)
|
||||
- ✅ Abort signal handling
|
||||
|
||||
#### Model Detection
|
||||
|
||||
Routes models that:
|
||||
- Start with `"gpt-"` (e.g., `"gpt-5.2"`, `"gpt-5.1-codex-max"`)
|
||||
- Start with `"o"` (e.g., `"o1"`, `"o1-mini"`)
|
||||
|
||||
#### Available Models
|
||||
|
||||
| Model | Description | Context | Max Output | Vision |
|
||||
|-------|-------------|---------|------------|--------|
|
||||
| `gpt-5.2` | Latest Codex model | 256K | 32K | Yes |
|
||||
| `gpt-5.1-codex-max` | Maximum capability | 256K | 32K | Yes |
|
||||
| `gpt-5.1-codex` | Standard Codex | 256K | 32K | Yes |
|
||||
| `gpt-5.1-codex-mini` | Lightweight | 256K | 16K | No |
|
||||
| `gpt-5.1` | General-purpose | 256K | 32K | Yes |
|
||||
|
||||
#### Authentication
|
||||
|
||||
Supports two methods:
|
||||
1. **CLI login**: `codex login` (OAuth tokens stored in `~/.codex/auth.json`)
|
||||
2. **API key**: `OPENAI_API_KEY` environment variable
|
||||
|
||||
#### Installation Detection
|
||||
|
||||
Uses `CodexCliDetector` to check:
|
||||
- PATH for `codex` command
|
||||
- npm global: `npm list -g @openai/codex`
|
||||
- Homebrew (macOS): `/opt/homebrew/bin/codex`
|
||||
- Common paths: `~/.local/bin/codex`, `/usr/local/bin/codex`
|
||||
|
||||
#### Example Usage
|
||||
|
||||
```typescript
|
||||
const provider = new CodexProvider();
|
||||
|
||||
const stream = provider.executeQuery({
|
||||
prompt: "Fix the bug in main.ts",
|
||||
model: "gpt-5.2",
|
||||
cwd: "/project/path",
|
||||
systemPrompt: "You are an expert TypeScript developer.",
|
||||
abortController: new AbortController()
|
||||
});
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === "assistant") {
|
||||
console.log(msg.message?.content);
|
||||
} else if (msg.type === "error") {
|
||||
console.error(msg.error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### JSONL Event Conversion
|
||||
|
||||
Codex CLI outputs JSONL events that get converted to `ProviderMessage` format:
|
||||
|
||||
| Codex Event | Provider Message |
|
||||
|-------------|------------------|
|
||||
| `item.completed` (reasoning) | `{ type: "assistant", content: [{ type: "thinking" }] }` |
|
||||
| `item.completed` (agent_message) | `{ type: "assistant", content: [{ type: "text" }] }` |
|
||||
| `item.completed` (command_execution) | `{ type: "assistant", content: [{ type: "text", text: "```bash\n...\n```" }] }` |
|
||||
| `item.started` (command_execution) | `{ type: "assistant", content: [{ type: "tool_use" }] }` |
|
||||
| `item.updated` (todo_list) | `{ type: "assistant", content: [{ type: "text", text: "**Updated Todo List:**..." }] }` |
|
||||
| `thread.completed` | `{ type: "result", subtype: "success" }` |
|
||||
| `error` | `{ type: "error", error: "..." }` |
|
||||
|
||||
#### Conversation History Handling
|
||||
|
||||
Uses `formatHistoryAsText()` utility to prepend history as text context (CLI doesn't support native multi-turn):
|
||||
|
||||
```typescript
|
||||
const historyText = formatHistoryAsText(conversationHistory);
|
||||
combinedPrompt = `${historyText}Current request:\n${combinedPrompt}`;
|
||||
```
|
||||
|
||||
#### MCP Server Configuration
|
||||
|
||||
**Location**: `apps/server/src/providers/codex-config-manager.ts`
|
||||
|
||||
Manages TOML configuration for MCP servers:
|
||||
|
||||
```typescript
|
||||
await codexConfigManager.configureMcpServer(cwd, mcpServerScriptPath);
|
||||
```
|
||||
|
||||
Generates `.codex/config.toml`:
|
||||
```toml
|
||||
[mcp_servers.automaker-tools]
|
||||
command = "node"
|
||||
args = ["/path/to/mcp-server.js"]
|
||||
enabled_tools = ["UpdateFeatureStatus"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Provider Factory
|
||||
|
||||
**Location**: `apps/server/src/providers/provider-factory.ts`
|
||||
|
||||
Routes requests to the appropriate provider based on model string.
|
||||
|
||||
### Model-Based Routing
|
||||
|
||||
```typescript
|
||||
export class ProviderFactory {
|
||||
/**
|
||||
* Get provider for a specific model
|
||||
*/
|
||||
static getProviderForModel(modelId: string): BaseProvider {
|
||||
const lowerModel = modelId.toLowerCase();
|
||||
|
||||
// OpenAI/Codex models
|
||||
if (lowerModel.startsWith("gpt-") || lowerModel.startsWith("o")) {
|
||||
return new CodexProvider();
|
||||
}
|
||||
|
||||
// Claude models
|
||||
if (lowerModel.startsWith("claude-") ||
|
||||
["haiku", "sonnet", "opus"].includes(lowerModel)) {
|
||||
return new ClaudeProvider();
|
||||
}
|
||||
|
||||
// Default to Claude
|
||||
return new ClaudeProvider();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check installation status of all providers
|
||||
*/
|
||||
static async checkAllProviders(): Promise<Record<string, InstallationStatus>> {
|
||||
const claude = new ClaudeProvider();
|
||||
const codex = new CodexProvider();
|
||||
|
||||
return {
|
||||
claude: await claude.detectInstallation(),
|
||||
codex: await codex.detectInstallation(),
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Usage in Services
|
||||
|
||||
```typescript
|
||||
import { ProviderFactory } from "../providers/provider-factory.js";
|
||||
|
||||
// In AgentService or AutoModeService
|
||||
const provider = ProviderFactory.getProviderForModel(model);
|
||||
const stream = provider.executeQuery(options);
|
||||
|
||||
for await (const msg of stream) {
|
||||
// Handle messages (format is consistent across all providers)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding New Providers
|
||||
|
||||
### Step 1: Create Provider File
|
||||
|
||||
Create `apps/server/src/providers/[name]-provider.ts`:
|
||||
|
||||
```typescript
|
||||
import { BaseProvider } from "./base-provider.js";
|
||||
import type {
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ModelDefinition,
|
||||
} from "./types.js";
|
||||
|
||||
export class CursorProvider extends BaseProvider {
|
||||
getName(): string {
|
||||
return "cursor";
|
||||
}
|
||||
|
||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
// Implementation here
|
||||
// 1. Spawn cursor CLI or use SDK
|
||||
// 2. Convert output to ProviderMessage format
|
||||
// 3. Yield messages
|
||||
}
|
||||
|
||||
async detectInstallation(): Promise<InstallationStatus> {
|
||||
// Check if cursor is installed
|
||||
// Return { installed: boolean, path?: string, version?: string }
|
||||
}
|
||||
|
||||
getAvailableModels(): ModelDefinition[] {
|
||||
return [
|
||||
{
|
||||
id: "cursor-premium",
|
||||
name: "Cursor Premium",
|
||||
modelString: "cursor-premium",
|
||||
provider: "cursor",
|
||||
description: "Cursor's premium model",
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 8192,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: "premium",
|
||||
default: true,
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
supportsFeature(feature: string): boolean {
|
||||
const supportedFeatures = ["tools", "text", "vision"];
|
||||
return supportedFeatures.includes(feature);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Add Routing in Factory
|
||||
|
||||
Update `apps/server/src/providers/provider-factory.ts`:
|
||||
|
||||
```typescript
|
||||
import { CursorProvider } from "./cursor-provider.js";
|
||||
|
||||
static getProviderForModel(modelId: string): BaseProvider {
|
||||
const lowerModel = modelId.toLowerCase();
|
||||
|
||||
// Cursor models
|
||||
if (lowerModel.startsWith("cursor-")) {
|
||||
return new CursorProvider();
|
||||
}
|
||||
|
||||
// ... existing routing
|
||||
}
|
||||
|
||||
static async checkAllProviders() {
|
||||
const cursor = new CursorProvider();
|
||||
|
||||
return {
|
||||
claude: await claude.detectInstallation(),
|
||||
codex: await codex.detectInstallation(),
|
||||
cursor: await cursor.detectInstallation(), // NEW
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Update Models List
|
||||
|
||||
Update `apps/server/src/routes/models.ts`:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: "cursor-premium",
|
||||
name: "Cursor Premium",
|
||||
provider: "cursor",
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 8192,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Done!
|
||||
|
||||
No changes needed in:
|
||||
- ✅ AgentService
|
||||
- ✅ AutoModeService
|
||||
- ✅ Any business logic
|
||||
|
||||
The provider architecture handles everything automatically.
|
||||
|
||||
---
|
||||
|
||||
## Provider Types
|
||||
|
||||
### SDK-Based Providers (like Claude)
|
||||
|
||||
**Characteristics**:
|
||||
- Direct SDK/library integration
|
||||
- No subprocess spawning
|
||||
- Native multi-turn support
|
||||
- Streaming via async generators
|
||||
|
||||
**Example**: ClaudeProvider using `@anthropic-ai/claude-agent-sdk`
|
||||
|
||||
**Advantages**:
|
||||
- Lower latency
|
||||
- More control over options
|
||||
- Easier error handling
|
||||
- No CLI installation required
|
||||
|
||||
---
|
||||
|
||||
### CLI-Based Providers (like Codex)
|
||||
|
||||
**Characteristics**:
|
||||
- Subprocess spawning
|
||||
- JSONL stream parsing
|
||||
- Text-based conversation history
|
||||
- Requires CLI installation
|
||||
|
||||
**Example**: CodexProvider using `codex exec --json`
|
||||
|
||||
**Advantages**:
|
||||
- Access to CLI-only features
|
||||
- No SDK dependency
|
||||
- Can use any CLI tool
|
||||
|
||||
**Implementation Pattern**:
|
||||
1. Use `spawnJSONLProcess()` from `subprocess-manager.ts`
|
||||
2. Convert JSONL events to `ProviderMessage` format
|
||||
3. Handle authentication (CLI login or API key)
|
||||
4. Implement timeout detection
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Message Format Consistency
|
||||
|
||||
All providers MUST output the same `ProviderMessage` format so services can handle them uniformly:
|
||||
|
||||
```typescript
|
||||
// ✅ Correct - Consistent format
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Response" }]
|
||||
}
|
||||
};
|
||||
|
||||
// ❌ Incorrect - Provider-specific format
|
||||
yield {
|
||||
customType: "response",
|
||||
data: "Response"
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Error Handling
|
||||
|
||||
Always yield error messages, never throw:
|
||||
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
try {
|
||||
// ...
|
||||
} catch (error) {
|
||||
yield {
|
||||
type: "error",
|
||||
error: (error as Error).message
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// ❌ Incorrect
|
||||
throw new Error("Provider failed");
|
||||
```
|
||||
|
||||
### 3. Abort Signal Support
|
||||
|
||||
Respect the abort controller:
|
||||
|
||||
```typescript
|
||||
if (abortController?.signal.aborted) {
|
||||
yield { type: "error", error: "Operation cancelled" };
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Conversation History
|
||||
|
||||
- **SDK providers**: Use `convertHistoryToMessages()` and yield messages
|
||||
- **CLI providers**: Use `formatHistoryAsText()` and prepend to prompt
|
||||
|
||||
### 5. Image Handling
|
||||
|
||||
- **Vision models**: Pass images as content blocks
|
||||
- **Non-vision models**: Extract text only using utilities
|
||||
|
||||
### 6. Logging
|
||||
|
||||
Use consistent logging prefixes:
|
||||
|
||||
```typescript
|
||||
console.log(`[${this.getName()}Provider] Operation started`);
|
||||
console.error(`[${this.getName()}Provider] Error:`, error);
|
||||
```
|
||||
|
||||
### 7. Installation Detection
|
||||
|
||||
Implement thorough detection:
|
||||
- Check multiple installation methods
|
||||
- Verify authentication
|
||||
- Return detailed status
|
||||
|
||||
### 8. Model Definitions
|
||||
|
||||
Provide accurate model metadata:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: "model-id",
|
||||
name: "Human-readable name",
|
||||
modelString: "exact-model-string",
|
||||
provider: "provider-name",
|
||||
description: "What this model is good for",
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 8192,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: "premium" | "standard" | "basic",
|
||||
default: false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Providers
|
||||
|
||||
### Unit Tests
|
||||
|
||||
Test each provider method independently:
|
||||
|
||||
```typescript
|
||||
describe("ClaudeProvider", () => {
|
||||
it("should detect installation", async () => {
|
||||
const provider = new ClaudeProvider();
|
||||
const status = await provider.detectInstallation();
|
||||
|
||||
expect(status.installed).toBe(true);
|
||||
expect(status.method).toBe("sdk");
|
||||
});
|
||||
|
||||
it("should stream messages correctly", async () => {
|
||||
const provider = new ClaudeProvider();
|
||||
const messages = [];
|
||||
|
||||
for await (const msg of provider.executeQuery(options)) {
|
||||
messages.push(msg);
|
||||
}
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
expect(messages[0].type).toBe("assistant");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
Test provider interaction with services:
|
||||
|
||||
```typescript
|
||||
describe("Provider Integration", () => {
|
||||
it("should work with AgentService", async () => {
|
||||
const provider = ProviderFactory.getProviderForModel("claude-opus-4-5-20251101");
|
||||
|
||||
// Test full workflow
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Claude Provider
|
||||
|
||||
```bash
|
||||
# Required (one of):
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
CLAUDE_CODE_OAUTH_TOKEN=...
|
||||
```
|
||||
|
||||
### Codex Provider
|
||||
|
||||
```bash
|
||||
# Required (one of):
|
||||
OPENAI_API_KEY=sk-...
|
||||
# OR run: codex login
|
||||
|
||||
# Optional:
|
||||
CODEX_CLI_PATH=/custom/path/to/codex
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Provider Not Found
|
||||
|
||||
**Problem**: `ProviderFactory.getProviderForModel()` returns wrong provider
|
||||
|
||||
**Solution**: Check model string prefix in factory routing
|
||||
|
||||
### Authentication Errors
|
||||
|
||||
**Problem**: Provider fails with auth error
|
||||
|
||||
**Solution**:
|
||||
1. Check environment variables
|
||||
2. For CLI providers, verify CLI login status
|
||||
3. Check `detectInstallation()` output
|
||||
|
||||
### JSONL Parsing Errors (CLI providers)
|
||||
|
||||
**Problem**: Failed to parse JSONL line
|
||||
|
||||
**Solution**:
|
||||
1. Check CLI output format
|
||||
2. Verify JSON is valid
|
||||
3. Add error handling for malformed lines
|
||||
|
||||
### Timeout Issues (CLI providers)
|
||||
|
||||
**Problem**: Subprocess hangs
|
||||
|
||||
**Solution**:
|
||||
1. Increase timeout in `spawnJSONLProcess` options
|
||||
2. Check CLI process for hangs
|
||||
3. Verify abort signal handling
|
||||
|
||||
---
|
||||
|
||||
## Future Provider Ideas
|
||||
|
||||
Potential providers to add:
|
||||
|
||||
1. **Cursor Provider** (`cursor-*`)
|
||||
- CLI-based
|
||||
- Code completion specialist
|
||||
|
||||
2. **OpenCode Provider** (`opencode-*`)
|
||||
- SDK or CLI-based
|
||||
- Open-source alternative
|
||||
|
||||
3. **Gemini Provider** (`gemini-*`)
|
||||
- Google's AI models
|
||||
- SDK-based via `@google/generative-ai`
|
||||
|
||||
4. **Ollama Provider** (`ollama-*`)
|
||||
- Local model hosting
|
||||
- CLI or HTTP API
|
||||
|
||||
Each would follow the same pattern:
|
||||
1. Create `[name]-provider.ts` implementing `BaseProvider`
|
||||
2. Add routing in `provider-factory.ts`
|
||||
3. Update models list
|
||||
4. Done! ✅
|
||||
672
docs/server/utilities.md
Normal file
672
docs/server/utilities.md
Normal file
@@ -0,0 +1,672 @@
|
||||
# Server Utilities Reference
|
||||
|
||||
This document describes all utility modules available in `apps/server/src/lib/`. These utilities provide reusable functionality for image handling, prompt building, model resolution, conversation management, and error handling.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Image Handler](#image-handler)
|
||||
2. [Prompt Builder](#prompt-builder)
|
||||
3. [Model Resolver](#model-resolver)
|
||||
4. [Conversation Utils](#conversation-utils)
|
||||
5. [Error Handler](#error-handler)
|
||||
6. [Subprocess Manager](#subprocess-manager)
|
||||
7. [Events](#events)
|
||||
8. [Auth](#auth)
|
||||
9. [Security](#security)
|
||||
|
||||
---
|
||||
|
||||
## Image Handler
|
||||
|
||||
**Location**: `apps/server/src/lib/image-handler.ts`
|
||||
|
||||
Centralized utilities for processing image files, including MIME type detection, base64 encoding, and content block generation for Claude SDK format.
|
||||
|
||||
### Functions
|
||||
|
||||
#### `getMimeTypeForImage(imagePath: string): string`
|
||||
|
||||
Get MIME type for an image file based on its extension.
|
||||
|
||||
**Supported formats**:
|
||||
- `.jpg`, `.jpeg` → `image/jpeg`
|
||||
- `.png` → `image/png`
|
||||
- `.gif` → `image/gif`
|
||||
- `.webp` → `image/webp`
|
||||
- Default: `image/png`
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
import { getMimeTypeForImage } from "../lib/image-handler.js";
|
||||
|
||||
const mimeType = getMimeTypeForImage("/path/to/image.jpg");
|
||||
// Returns: "image/jpeg"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `readImageAsBase64(imagePath: string): Promise<ImageData>`
|
||||
|
||||
Read an image file and convert to base64 with metadata.
|
||||
|
||||
**Returns**: `ImageData`
|
||||
```typescript
|
||||
interface ImageData {
|
||||
base64: string; // Base64-encoded image data
|
||||
mimeType: string; // MIME type
|
||||
filename: string; // File basename
|
||||
originalPath: string; // Original file path
|
||||
}
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
const imageData = await readImageAsBase64("/path/to/photo.png");
|
||||
console.log(imageData.base64); // "iVBORw0KG..."
|
||||
console.log(imageData.mimeType); // "image/png"
|
||||
console.log(imageData.filename); // "photo.png"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `convertImagesToContentBlocks(imagePaths: string[], workDir?: string): Promise<ImageContentBlock[]>`
|
||||
|
||||
Convert image paths to content blocks in Claude SDK format. Handles both relative and absolute paths.
|
||||
|
||||
**Parameters**:
|
||||
- `imagePaths` - Array of image file paths
|
||||
- `workDir` - Optional working directory for resolving relative paths
|
||||
|
||||
**Returns**: Array of `ImageContentBlock`
|
||||
```typescript
|
||||
interface ImageContentBlock {
|
||||
type: "image";
|
||||
source: {
|
||||
type: "base64";
|
||||
media_type: string;
|
||||
data: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
const imageBlocks = await convertImagesToContentBlocks(
|
||||
["./screenshot.png", "/absolute/path/diagram.jpg"],
|
||||
"/project/root"
|
||||
);
|
||||
|
||||
// Use in prompt content
|
||||
const contentBlocks = [
|
||||
{ type: "text", text: "Analyze these images:" },
|
||||
...imageBlocks
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `formatImagePathsForPrompt(imagePaths: string[]): string`
|
||||
|
||||
Format image paths as a bulleted list for inclusion in text prompts.
|
||||
|
||||
**Returns**: Formatted string with image paths, or empty string if no images.
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
const pathsList = formatImagePathsForPrompt([
|
||||
"/screenshots/login.png",
|
||||
"/diagrams/architecture.png"
|
||||
]);
|
||||
|
||||
// Returns:
|
||||
// "\n\nAttached images:\n- /screenshots/login.png\n- /diagrams/architecture.png\n"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prompt Builder
|
||||
|
||||
**Location**: `apps/server/src/lib/prompt-builder.ts`
|
||||
|
||||
Standardized prompt building that combines text prompts with image attachments.
|
||||
|
||||
### Functions
|
||||
|
||||
#### `buildPromptWithImages(basePrompt: string, imagePaths?: string[], workDir?: string, includeImagePaths: boolean = false): Promise<PromptWithImages>`
|
||||
|
||||
Build a prompt with optional image attachments.
|
||||
|
||||
**Parameters**:
|
||||
- `basePrompt` - The text prompt
|
||||
- `imagePaths` - Optional array of image file paths
|
||||
- `workDir` - Optional working directory for resolving relative paths
|
||||
- `includeImagePaths` - Whether to append image paths to the text (default: false)
|
||||
|
||||
**Returns**: `PromptWithImages`
|
||||
```typescript
|
||||
interface PromptWithImages {
|
||||
content: PromptContent; // string | Array<ContentBlock>
|
||||
hasImages: boolean;
|
||||
}
|
||||
|
||||
type PromptContent = string | Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
source?: object;
|
||||
}>;
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
import { buildPromptWithImages } from "../lib/prompt-builder.js";
|
||||
|
||||
// Without images
|
||||
const { content } = await buildPromptWithImages("What is 2+2?");
|
||||
// content: "What is 2+2?" (simple string)
|
||||
|
||||
// With images
|
||||
const { content, hasImages } = await buildPromptWithImages(
|
||||
"Analyze this screenshot",
|
||||
["/path/to/screenshot.png"],
|
||||
"/project/root",
|
||||
true // include image paths in text
|
||||
);
|
||||
// content: [
|
||||
// { type: "text", text: "Analyze this screenshot\n\nAttached images:\n- /path/to/screenshot.png\n" },
|
||||
// { type: "image", source: { type: "base64", media_type: "image/png", data: "..." } }
|
||||
// ]
|
||||
// hasImages: true
|
||||
```
|
||||
|
||||
**Use Cases**:
|
||||
- **AgentService**: Set `includeImagePaths: true` to list paths for Read tool access
|
||||
- **AutoModeService**: Set `includeImagePaths: false` to avoid duplication in feature descriptions
|
||||
|
||||
---
|
||||
|
||||
## Model Resolver
|
||||
|
||||
**Location**: `apps/server/src/lib/model-resolver.ts`
|
||||
|
||||
Centralized model string mapping and resolution for handling model aliases and provider detection.
|
||||
|
||||
### Constants
|
||||
|
||||
#### `CLAUDE_MODEL_MAP`
|
||||
|
||||
Model alias mapping for Claude models.
|
||||
|
||||
```typescript
|
||||
export const CLAUDE_MODEL_MAP: Record<string, string> = {
|
||||
haiku: "claude-haiku-4-5",
|
||||
sonnet: "claude-sonnet-4-20250514",
|
||||
opus: "claude-opus-4-5-20251101",
|
||||
} as const;
|
||||
```
|
||||
|
||||
#### `DEFAULT_MODELS`
|
||||
|
||||
Default models per provider.
|
||||
|
||||
```typescript
|
||||
export const DEFAULT_MODELS = {
|
||||
claude: "claude-opus-4-5-20251101",
|
||||
openai: "gpt-5.2",
|
||||
} as const;
|
||||
```
|
||||
|
||||
### Functions
|
||||
|
||||
#### `resolveModelString(modelKey?: string, defaultModel: string = DEFAULT_MODELS.claude): string`
|
||||
|
||||
Resolve a model key/alias to a full model string.
|
||||
|
||||
**Logic**:
|
||||
1. If `modelKey` is undefined → return `defaultModel`
|
||||
2. If starts with `"gpt-"` or `"o"` → pass through (OpenAI/Codex model)
|
||||
3. If includes `"claude-"` → pass through (full Claude model string)
|
||||
4. If in `CLAUDE_MODEL_MAP` → return mapped value
|
||||
5. Otherwise → return `defaultModel` with warning
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js";
|
||||
|
||||
resolveModelString("opus");
|
||||
// Returns: "claude-opus-4-5-20251101"
|
||||
// Logs: "[ModelResolver] Resolved model alias: "opus" -> "claude-opus-4-5-20251101""
|
||||
|
||||
resolveModelString("gpt-5.2");
|
||||
// Returns: "gpt-5.2"
|
||||
// Logs: "[ModelResolver] Using OpenAI/Codex model: gpt-5.2"
|
||||
|
||||
resolveModelString("claude-sonnet-4-20250514");
|
||||
// Returns: "claude-sonnet-4-20250514"
|
||||
// Logs: "[ModelResolver] Using full Claude model string: claude-sonnet-4-20250514"
|
||||
|
||||
resolveModelString("invalid-model");
|
||||
// Returns: "claude-opus-4-5-20251101"
|
||||
// Logs: "[ModelResolver] Unknown model key "invalid-model", using default: "claude-opus-4-5-20251101""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `getEffectiveModel(explicitModel?: string, sessionModel?: string, defaultModel?: string): string`
|
||||
|
||||
Get the effective model from multiple sources with priority.
|
||||
|
||||
**Priority**: explicit model > session model > default model
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
import { getEffectiveModel } from "../lib/model-resolver.js";
|
||||
|
||||
// Explicit model takes precedence
|
||||
getEffectiveModel("sonnet", "opus");
|
||||
// Returns: "claude-sonnet-4-20250514"
|
||||
|
||||
// Falls back to session model
|
||||
getEffectiveModel(undefined, "haiku");
|
||||
// Returns: "claude-haiku-4-5"
|
||||
|
||||
// Falls back to default
|
||||
getEffectiveModel(undefined, undefined, "gpt-5.2");
|
||||
// Returns: "gpt-5.2"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conversation Utils
|
||||
|
||||
**Location**: `apps/server/src/lib/conversation-utils.ts`
|
||||
|
||||
Standardized conversation history processing for both SDK-based and CLI-based providers.
|
||||
|
||||
### Types
|
||||
|
||||
```typescript
|
||||
import type { ConversationMessage } from "../providers/types.js";
|
||||
|
||||
interface ConversationMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string | Array<{ type: string; text?: string; source?: object }>;
|
||||
}
|
||||
```
|
||||
|
||||
### Functions
|
||||
|
||||
#### `extractTextFromContent(content: string | Array<ContentBlock>): string`
|
||||
|
||||
Extract plain text from message content (handles both string and array formats).
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
import { extractTextFromContent } from "../lib/conversation-utils.js";
|
||||
|
||||
// String content
|
||||
extractTextFromContent("Hello world");
|
||||
// Returns: "Hello world"
|
||||
|
||||
// Array content
|
||||
extractTextFromContent([
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "image", source: {...} },
|
||||
{ type: "text", text: "world" }
|
||||
]);
|
||||
// Returns: "Hello\nworld"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `normalizeContentBlocks(content: string | Array<ContentBlock>): Array<ContentBlock>`
|
||||
|
||||
Normalize message content to array format.
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
// String → array
|
||||
normalizeContentBlocks("Hello");
|
||||
// Returns: [{ type: "text", text: "Hello" }]
|
||||
|
||||
// Array → pass through
|
||||
normalizeContentBlocks([{ type: "text", text: "Hello" }]);
|
||||
// Returns: [{ type: "text", text: "Hello" }]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `formatHistoryAsText(history: ConversationMessage[]): string`
|
||||
|
||||
Format conversation history as plain text for CLI-based providers (e.g., Codex).
|
||||
|
||||
**Returns**: Formatted text with role labels, or empty string if no history.
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
const history = [
|
||||
{ role: "user", content: "What is 2+2?" },
|
||||
{ role: "assistant", content: "2+2 equals 4." }
|
||||
];
|
||||
|
||||
const formatted = formatHistoryAsText(history);
|
||||
// Returns:
|
||||
// "Previous conversation:
|
||||
//
|
||||
// User: What is 2+2?
|
||||
//
|
||||
// Assistant: 2+2 equals 4.
|
||||
//
|
||||
// ---
|
||||
//
|
||||
// "
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `convertHistoryToMessages(history: ConversationMessage[]): Array<SDKMessage>`
|
||||
|
||||
Convert conversation history to Claude SDK message format.
|
||||
|
||||
**Returns**: Array of SDK-formatted messages ready to yield in async generator.
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
const history = [
|
||||
{ role: "user", content: "Hello" },
|
||||
{ role: "assistant", content: "Hi there!" }
|
||||
];
|
||||
|
||||
const messages = convertHistoryToMessages(history);
|
||||
// Returns:
|
||||
// [
|
||||
// {
|
||||
// type: "user",
|
||||
// session_id: "",
|
||||
// message: {
|
||||
// role: "user",
|
||||
// content: [{ type: "text", text: "Hello" }]
|
||||
// },
|
||||
// parent_tool_use_id: null
|
||||
// },
|
||||
// {
|
||||
// type: "assistant",
|
||||
// session_id: "",
|
||||
// message: {
|
||||
// role: "assistant",
|
||||
// content: [{ type: "text", text: "Hi there!" }]
|
||||
// },
|
||||
// parent_tool_use_id: null
|
||||
// }
|
||||
// ]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handler
|
||||
|
||||
**Location**: `apps/server/src/lib/error-handler.ts`
|
||||
|
||||
Standardized error classification and handling utilities.
|
||||
|
||||
### Types
|
||||
|
||||
```typescript
|
||||
export type ErrorType = "authentication" | "abort" | "execution" | "unknown";
|
||||
|
||||
export interface ErrorInfo {
|
||||
type: ErrorType;
|
||||
message: string;
|
||||
isAbort: boolean;
|
||||
isAuth: boolean;
|
||||
originalError: unknown;
|
||||
}
|
||||
```
|
||||
|
||||
### Functions
|
||||
|
||||
#### `isAbortError(error: unknown): boolean`
|
||||
|
||||
Check if an error is an abort/cancellation error.
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
import { isAbortError } from "../lib/error-handler.js";
|
||||
|
||||
try {
|
||||
// ... operation
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
console.log("Operation was cancelled");
|
||||
return { success: false, aborted: true };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `isAuthenticationError(errorMessage: string): boolean`
|
||||
|
||||
Check if an error is an authentication/API key error.
|
||||
|
||||
**Detects**:
|
||||
- "Authentication failed"
|
||||
- "Invalid API key"
|
||||
- "authentication_failed"
|
||||
- "Fix external API key"
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
if (isAuthenticationError(error.message)) {
|
||||
console.error("Please check your API key configuration");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `classifyError(error: unknown): ErrorInfo`
|
||||
|
||||
Classify an error into a specific type.
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
import { classifyError } from "../lib/error-handler.js";
|
||||
|
||||
try {
|
||||
// ... operation
|
||||
} catch (error) {
|
||||
const errorInfo = classifyError(error);
|
||||
|
||||
switch (errorInfo.type) {
|
||||
case "authentication":
|
||||
// Handle auth errors
|
||||
break;
|
||||
case "abort":
|
||||
// Handle cancellation
|
||||
break;
|
||||
case "execution":
|
||||
// Handle other errors
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `getUserFriendlyErrorMessage(error: unknown): string`
|
||||
|
||||
Get a user-friendly error message.
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
try {
|
||||
// ... operation
|
||||
} catch (error) {
|
||||
const friendlyMessage = getUserFriendlyErrorMessage(error);
|
||||
// "Operation was cancelled" for abort errors
|
||||
// "Authentication failed. Please check your API key." for auth errors
|
||||
// Original error message for other errors
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Subprocess Manager
|
||||
|
||||
**Location**: `apps/server/src/lib/subprocess-manager.ts`
|
||||
|
||||
Utilities for spawning CLI processes and parsing JSONL streams (used by Codex provider).
|
||||
|
||||
### Types
|
||||
|
||||
```typescript
|
||||
export interface SubprocessOptions {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
env?: Record<string, string>;
|
||||
abortController?: AbortController;
|
||||
timeout?: number; // Milliseconds of no output before timeout
|
||||
}
|
||||
|
||||
export interface SubprocessResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Functions
|
||||
|
||||
#### `async function* spawnJSONLProcess(options: SubprocessOptions): AsyncGenerator<unknown>`
|
||||
|
||||
Spawns a subprocess and streams JSONL output line-by-line.
|
||||
|
||||
**Features**:
|
||||
- Parses each line as JSON
|
||||
- Handles abort signals
|
||||
- 30-second timeout detection for hanging processes
|
||||
- Collects stderr for error reporting
|
||||
- Continues processing other lines if one fails to parse
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
import { spawnJSONLProcess } from "../lib/subprocess-manager.js";
|
||||
|
||||
const stream = spawnJSONLProcess({
|
||||
command: "codex",
|
||||
args: ["exec", "--model", "gpt-5.2", "--json", "--full-auto", "Fix the bug"],
|
||||
cwd: "/project/path",
|
||||
env: { OPENAI_API_KEY: "sk-..." },
|
||||
abortController: new AbortController(),
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
for await (const event of stream) {
|
||||
console.log("Received event:", event);
|
||||
// Process JSONL events
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `async function spawnProcess(options: SubprocessOptions): Promise<SubprocessResult>`
|
||||
|
||||
Spawns a subprocess and collects all output.
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
const result = await spawnProcess({
|
||||
command: "git",
|
||||
args: ["status"],
|
||||
cwd: "/project/path"
|
||||
});
|
||||
|
||||
console.log(result.stdout); // Git status output
|
||||
console.log(result.exitCode); // 0 for success
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Events
|
||||
|
||||
**Location**: `apps/server/src/lib/events.ts`
|
||||
|
||||
Event emitter system for WebSocket communication.
|
||||
|
||||
**Documented separately** - see existing codebase for event types and usage.
|
||||
|
||||
---
|
||||
|
||||
## Auth
|
||||
|
||||
**Location**: `apps/server/src/lib/auth.ts`
|
||||
|
||||
Authentication utilities for API endpoints.
|
||||
|
||||
**Documented separately** - see existing codebase for authentication flow.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
**Location**: `apps/server/src/lib/security.ts`
|
||||
|
||||
Security utilities for input validation and sanitization.
|
||||
|
||||
**Documented separately** - see existing codebase for security patterns.
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### When to Use Which Utility
|
||||
|
||||
1. **Image handling** → Always use `image-handler.ts` utilities
|
||||
- ✅ Do: `convertImagesToContentBlocks(imagePaths, workDir)`
|
||||
- ❌ Don't: Manually read files and encode base64
|
||||
|
||||
2. **Prompt building** → Use `prompt-builder.ts` for consistency
|
||||
- ✅ Do: `buildPromptWithImages(text, images, workDir, includePathsInText)`
|
||||
- ❌ Don't: Manually construct content block arrays
|
||||
|
||||
3. **Model resolution** → Use `model-resolver.ts` for all model handling
|
||||
- ✅ Do: `resolveModelString(feature.model, DEFAULT_MODELS.claude)`
|
||||
- ❌ Don't: Inline model mapping logic
|
||||
|
||||
4. **Error handling** → Use `error-handler.ts` for classification
|
||||
- ✅ Do: `if (isAbortError(error)) { ... }`
|
||||
- ❌ Don't: `if (error instanceof AbortError || error.name === "AbortError") { ... }`
|
||||
|
||||
### Importing Utilities
|
||||
|
||||
Always use `.js` extension in imports for ESM compatibility:
|
||||
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
import { buildPromptWithImages } from "../lib/prompt-builder.js";
|
||||
|
||||
// ❌ Incorrect
|
||||
import { buildPromptWithImages } from "../lib/prompt-builder";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Utilities
|
||||
|
||||
When writing tests for utilities:
|
||||
|
||||
1. **Unit tests** - Test each function in isolation
|
||||
2. **Integration tests** - Test utilities working together
|
||||
3. **Mock external dependencies** - File system, child processes
|
||||
|
||||
Example:
|
||||
```typescript
|
||||
describe("image-handler", () => {
|
||||
it("should detect MIME type correctly", () => {
|
||||
expect(getMimeTypeForImage("photo.jpg")).toBe("image/jpeg");
|
||||
expect(getMimeTypeForImage("diagram.png")).toBe("image/png");
|
||||
});
|
||||
});
|
||||
```
|
||||
3372
package-lock.json
generated
3372
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -18,10 +18,15 @@
|
||||
"build": "npm run build --workspace=apps/app",
|
||||
"build:server": "npm run build --workspace=apps/server",
|
||||
"build:electron": "npm run build:electron --workspace=apps/app",
|
||||
"build:electron:win": "npm run build:electron:win --workspace=apps/app",
|
||||
"build:electron:mac": "npm run build:electron:mac --workspace=apps/app",
|
||||
"build:electron:linux": "npm run build:electron:linux --workspace=apps/app",
|
||||
"start": "npm run start --workspace=apps/app",
|
||||
"start:server": "npm run start --workspace=apps/server",
|
||||
"lint": "npm run lint --workspace=apps/app",
|
||||
"test": "npm run test --workspace=apps/app",
|
||||
"test:headed": "npm run test:headed --workspace=apps/app"
|
||||
"test:headed": "npm run test:headed --workspace=apps/app",
|
||||
"test:server": "npm run test --workspace=apps/server",
|
||||
"test:server:coverage": "npm run test:cov --workspace=apps/server"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user