Merge branch 'main' into move-marketing

This commit is contained in:
Cody Seibert
2025-12-13 20:29:11 -05:00
94 changed files with 13669 additions and 2857 deletions

View File

@@ -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

View File

@@ -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
View 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
View File

@@ -11,3 +11,5 @@ dist/
.automaker/
/.automaker/*
/.automaker/
/old

6
.npmrc
View File

@@ -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
View File

@@ -48,3 +48,4 @@ next-env.d.ts
# Electron
/dist/
/server-bundle/

View File

@@ -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;
}
});
// ============================================

View File

@@ -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 || "",
},

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

View 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);

View 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
}
};

View File

@@ -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>

View File

@@ -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()) {

View File

@@ -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}>

View File

@@ -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 */}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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}

View File

@@ -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}
/>

View File

@@ -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">

View File

@@ -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 {

View File

@@ -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>
);
}

View File

@@ -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 },

View File

@@ -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>

View File

@@ -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,
};
}

View File

@@ -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} />
)}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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&apos;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>
);
}

View File

@@ -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">

View File

@@ -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";

View File

@@ -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&apos;s GPT-5.1 Codex for advanced code generation tasks
</p>
</CardContent>
</Card>
</div>
<Button

View File

@@ -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,

View File

@@ -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",
},
];

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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 }),

View File

@@ -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,
}),
}
)

View File

@@ -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;

View File

@@ -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
*/

View File

@@ -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=

View File

@@ -1,2 +1,4 @@
.env
data
data
node_modules
coverage

View File

@@ -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

File diff suppressed because it is too large Load Diff

View 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,
}));
}

View 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;
}

View 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;
}

View 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
);
}

View 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 };
}

View 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);
});
});
}

View 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 };
}
}

View 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);
}
}

View 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;
}
}

View 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;
}

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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
View 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
View 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
View 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" }],
},
};

View 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);
}

View File

@@ -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);
});
});

View 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();
});

View 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",
});
});
});
});

View 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);
});
});
});

View 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");
});
});
});

View 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);
});
});
});

View 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");
});
});
});

View 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");
});
});
});

View 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"
);
});
});
});

View 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"));
});
});
});

View 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);
});
});
});

View 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);
});
});
});

View 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");
});
});
});

View 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);
});
});
});

View 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();
});
});
});

View 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);
});
});
});

View 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");
});
});
});

View 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);
});
});
});

View 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)}`;
}

View 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;
}
});
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["vitest/globals", "node"],
"moduleResolution": "Bundler",
"module": "ESNext"
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}