mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 21:23:07 +00:00
Merge pull request #60 from AutoMaker-Org/feat/add-unit-testing
feat: add unit testing to app/server and remove codex support
This commit is contained in:
8
.github/workflows/pr-check.yml
vendored
8
.github/workflows/pr-check.yml
vendored
@@ -29,5 +29,13 @@ jobs:
|
|||||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||||
run: npm install
|
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
|
- name: Run build:electron
|
||||||
run: npm run build:electron
|
run: npm run build:electron
|
||||||
|
|||||||
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
@@ -48,6 +48,15 @@ jobs:
|
|||||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||||
run: npm install
|
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
|
- name: Extract and set version
|
||||||
id: version
|
id: version
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
53
.github/workflows/test.yml
vendored
Normal file
53
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
name: Test Suite
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- "*"
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||||
|
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Install Linux native bindings
|
||||||
|
# Workaround for npm optional dependencies bug (npm/cli#4828)
|
||||||
|
# Explicitly install Linux bindings needed for build tools
|
||||||
|
run: |
|
||||||
|
npm install --no-save --force \
|
||||||
|
@rollup/rollup-linux-x64-gnu@4.53.3 \
|
||||||
|
@tailwindcss/oxide-linux-x64-gnu@4.1.17
|
||||||
|
|
||||||
|
- name: Run server tests with coverage
|
||||||
|
run: npm run test:server:coverage
|
||||||
|
env:
|
||||||
|
NODE_ENV: test
|
||||||
|
|
||||||
|
# - name: Upload coverage reports
|
||||||
|
# uses: codecov/codecov-action@v4
|
||||||
|
# if: always()
|
||||||
|
# with:
|
||||||
|
# files: ./apps/server/coverage/coverage-final.json
|
||||||
|
# flags: server
|
||||||
|
# name: server-coverage
|
||||||
|
# env:
|
||||||
|
# CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,3 +11,5 @@ dist/
|
|||||||
.automaker/
|
.automaker/
|
||||||
/.automaker/*
|
/.automaker/*
|
||||||
/.automaker/
|
/.automaker/
|
||||||
|
|
||||||
|
/old
|
||||||
6
.npmrc
6
.npmrc
@@ -8,3 +8,9 @@
|
|||||||
#
|
#
|
||||||
# In CI/CD: Use "npm install" instead of "npm ci" to allow npm to resolve
|
# In CI/CD: Use "npm install" instead of "npm ci" to allow npm to resolve
|
||||||
# the correct platform-specific binaries at install time.
|
# 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
|
||||||
|
|||||||
@@ -128,14 +128,18 @@ async function startServer() {
|
|||||||
let command, args, serverPath;
|
let command, args, serverPath;
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
// In development, use tsx to run TypeScript directly
|
// In development, use tsx to run TypeScript directly
|
||||||
// Use the node executable that's running Electron
|
// Use node from PATH (process.execPath in Electron points to Electron, not Node.js)
|
||||||
command = process.execPath; // This is the path to node.exe
|
// spawn() resolves "node" from PATH on all platforms (Windows, Linux, macOS)
|
||||||
|
command = "node";
|
||||||
serverPath = path.join(__dirname, "../../server/src/index.ts");
|
serverPath = path.join(__dirname, "../../server/src/index.ts");
|
||||||
|
|
||||||
// Find tsx CLI - check server node_modules first, then root
|
// 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");
|
const rootNodeModules = path.join(__dirname, "../../../node_modules/tsx");
|
||||||
|
|
||||||
let tsxCliPath;
|
let tsxCliPath;
|
||||||
if (fs.existsSync(path.join(serverNodeModules, "dist/cli.mjs"))) {
|
if (fs.existsSync(path.join(serverNodeModules, "dist/cli.mjs"))) {
|
||||||
tsxCliPath = path.join(serverNodeModules, "dist/cli.mjs");
|
tsxCliPath = path.join(serverNodeModules, "dist/cli.mjs");
|
||||||
@@ -144,12 +148,16 @@ async function startServer() {
|
|||||||
} else {
|
} else {
|
||||||
// Last resort: try require.resolve
|
// Last resort: try require.resolve
|
||||||
try {
|
try {
|
||||||
tsxCliPath = require.resolve("tsx/cli.mjs", { paths: [path.join(__dirname, "../../server")] });
|
tsxCliPath = require.resolve("tsx/cli.mjs", {
|
||||||
|
paths: [path.join(__dirname, "../../server")],
|
||||||
|
});
|
||||||
} catch {
|
} 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];
|
args = [tsxCliPath, "watch", serverPath];
|
||||||
} else {
|
} else {
|
||||||
// In production, use compiled JavaScript
|
// In production, use compiled JavaScript
|
||||||
@@ -230,13 +238,16 @@ async function waitForServer(maxAttempts = 30) {
|
|||||||
for (let i = 0; i < maxAttempts; i++) {
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const req = http.get(`http://localhost:${SERVER_PORT}/api/health`, (res) => {
|
const req = http.get(
|
||||||
if (res.statusCode === 200) {
|
`http://localhost:${SERVER_PORT}/api/health`,
|
||||||
resolve();
|
(res) => {
|
||||||
} else {
|
if (res.statusCode === 200) {
|
||||||
reject(new Error(`Status: ${res.statusCode}`));
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Status: ${res.statusCode}`));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
req.on("error", reject);
|
req.on("error", reject);
|
||||||
req.setTimeout(1000, () => {
|
req.setTimeout(1000, () => {
|
||||||
req.destroy();
|
req.destroy();
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"build:electron:win": "node scripts/prepare-server.js && next build && electron-builder --win",
|
"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:mac": "node scripts/prepare-server.js && next build && electron-builder --mac",
|
||||||
"build:electron:linux": "node scripts/prepare-server.js && next build && electron-builder --linux",
|
"build:electron:linux": "node scripts/prepare-server.js && next build && electron-builder --linux",
|
||||||
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
@@ -54,7 +55,7 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"geist": "^1.5.1",
|
"geist": "^1.5.1",
|
||||||
"lucide-react": "^0.556.0",
|
"lucide-react": "^0.556.0",
|
||||||
"next": "16.0.7",
|
"next": "^16.0.10",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ type ModelOption = {
|
|||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
badge?: string;
|
badge?: string;
|
||||||
provider: "claude" | "codex";
|
provider: "claude";
|
||||||
};
|
};
|
||||||
|
|
||||||
const CLAUDE_MODELS: ModelOption[] = [
|
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
|
// Profile icon mapping
|
||||||
const PROFILE_ICONS: Record<
|
const PROFILE_ICONS: Record<
|
||||||
string,
|
string,
|
||||||
@@ -1693,12 +1662,8 @@ export function BoardView() {
|
|||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
const isSelected = selectedModel === option.id;
|
const isSelected = selectedModel === option.id;
|
||||||
const isCodex = option.provider === "codex";
|
|
||||||
// Shorter display names for compact view
|
// Shorter display names for compact view
|
||||||
const shortName = option.label
|
const shortName = option.label.replace("Claude ", "");
|
||||||
.replace("Claude ", "")
|
|
||||||
.replace("GPT-5.1 Codex ", "")
|
|
||||||
.replace("GPT-5.1 ", "");
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={option.id}
|
key={option.id}
|
||||||
@@ -1708,9 +1673,7 @@ export function BoardView() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 min-w-[80px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
|
"flex-1 min-w-[80px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
|
||||||
isSelected
|
isSelected
|
||||||
? isCodex
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
? "bg-emerald-600 text-white border-emerald-500"
|
|
||||||
: "bg-primary text-primary-foreground border-primary"
|
|
||||||
: "bg-background hover:bg-accent border-input"
|
: "bg-background hover:bg-accent border-input"
|
||||||
)}
|
)}
|
||||||
data-testid={`${testIdPrefix}-${option.id}`}
|
data-testid={`${testIdPrefix}-${option.id}`}
|
||||||
@@ -2270,7 +2233,6 @@ export function BoardView() {
|
|||||||
const IconComponent = profile.icon
|
const IconComponent = profile.icon
|
||||||
? PROFILE_ICONS[profile.icon]
|
? PROFILE_ICONS[profile.icon]
|
||||||
: Brain;
|
: Brain;
|
||||||
const isCodex = profile.provider === "codex";
|
|
||||||
const isSelected =
|
const isSelected =
|
||||||
newFeature.model === profile.model &&
|
newFeature.model === profile.model &&
|
||||||
newFeature.thinkingLevel === profile.thinkingLevel;
|
newFeature.thinkingLevel === profile.thinkingLevel;
|
||||||
@@ -2284,13 +2246,6 @@ export function BoardView() {
|
|||||||
model: profile.model,
|
model: profile.model,
|
||||||
thinkingLevel: profile.thinkingLevel,
|
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(
|
className={cn(
|
||||||
"flex items-center gap-2 p-2 rounded-lg border text-left transition-all",
|
"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}`}
|
data-testid={`profile-quick-select-${profile.id}`}
|
||||||
>
|
>
|
||||||
<div
|
<div className="w-7 h-7 rounded flex items-center justify-center flex-shrink-0 bg-primary/10">
|
||||||
className={cn(
|
|
||||||
"w-7 h-7 rounded flex items-center justify-center flex-shrink-0",
|
|
||||||
isCodex ? "bg-emerald-500/10" : "bg-primary/10"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{IconComponent && (
|
{IconComponent && (
|
||||||
<IconComponent
|
<IconComponent className="w-4 h-4 text-primary" />
|
||||||
className={cn(
|
|
||||||
"w-4 h-4",
|
|
||||||
isCodex ? "text-emerald-500" : "text-primary"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -2401,13 +2346,6 @@ export function BoardView() {
|
|||||||
...newFeature,
|
...newFeature,
|
||||||
thinkingLevel: level,
|
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(
|
className={cn(
|
||||||
"flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors min-w-[60px]",
|
"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>
|
</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>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Testing Tab */}
|
{/* Testing Tab */}
|
||||||
@@ -2688,7 +2596,6 @@ export function BoardView() {
|
|||||||
const IconComponent = profile.icon
|
const IconComponent = profile.icon
|
||||||
? PROFILE_ICONS[profile.icon]
|
? PROFILE_ICONS[profile.icon]
|
||||||
: Brain;
|
: Brain;
|
||||||
const isCodex = profile.provider === "codex";
|
|
||||||
const isSelected =
|
const isSelected =
|
||||||
editingFeature.model === profile.model &&
|
editingFeature.model === profile.model &&
|
||||||
editingFeature.thinkingLevel ===
|
editingFeature.thinkingLevel ===
|
||||||
@@ -2703,13 +2610,6 @@ export function BoardView() {
|
|||||||
model: profile.model,
|
model: profile.model,
|
||||||
thinkingLevel: profile.thinkingLevel,
|
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(
|
className={cn(
|
||||||
"flex items-center gap-2 p-2 rounded-lg border text-left transition-all",
|
"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}`}
|
data-testid={`edit-profile-quick-select-${profile.id}`}
|
||||||
>
|
>
|
||||||
<div
|
<div className="w-7 h-7 rounded flex items-center justify-center flex-shrink-0 bg-primary/10">
|
||||||
className={cn(
|
|
||||||
"w-7 h-7 rounded flex items-center justify-center flex-shrink-0",
|
|
||||||
isCodex ? "bg-emerald-500/10" : "bg-primary/10"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{IconComponent && (
|
{IconComponent && (
|
||||||
<IconComponent
|
<IconComponent className="w-4 h-4 text-primary" />
|
||||||
className={cn(
|
|
||||||
"w-4 h-4",
|
|
||||||
isCodex
|
|
||||||
? "text-emerald-500"
|
|
||||||
: "text-primary"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -2813,13 +2701,6 @@ export function BoardView() {
|
|||||||
...editingFeature,
|
...editingFeature,
|
||||||
thinkingLevel: level,
|
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(
|
className={cn(
|
||||||
"flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors min-w-[60px]",
|
"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>
|
</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>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Testing Tab */}
|
{/* Testing Tab */}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
GripVertical,
|
GripVertical,
|
||||||
Lock,
|
Lock,
|
||||||
Check,
|
Check,
|
||||||
|
RefreshCw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -88,13 +89,6 @@ const CLAUDE_MODELS: { id: AgentModel; label: string }[] = [
|
|||||||
{ id: "opus", label: "Claude Opus" },
|
{ 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 }[] = [
|
const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [
|
||||||
{ id: "none", label: "None" },
|
{ id: "none", label: "None" },
|
||||||
{ id: "low", label: "Low" },
|
{ id: "low", label: "Low" },
|
||||||
@@ -105,9 +99,6 @@ const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [
|
|||||||
|
|
||||||
// Helper to determine provider from model
|
// Helper to determine provider from model
|
||||||
function getProviderFromModel(model: AgentModel): ModelProvider {
|
function getProviderFromModel(model: AgentModel): ModelProvider {
|
||||||
if (model.startsWith("gpt")) {
|
|
||||||
return "codex";
|
|
||||||
}
|
|
||||||
return "claude";
|
return "claude";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +128,6 @@ function SortableProfileCard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
|
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
|
||||||
const isCodex = profile.provider === "codex";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -165,18 +155,10 @@ function SortableProfileCard({
|
|||||||
|
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center bg-primary/10"
|
||||||
"flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center",
|
|
||||||
isCodex ? "bg-emerald-500/10" : "bg-primary/10"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{IconComponent && (
|
{IconComponent && (
|
||||||
<IconComponent
|
<IconComponent className="w-5 h-5 text-primary" />
|
||||||
className={cn(
|
|
||||||
"w-5 h-5",
|
|
||||||
isCodex ? "text-emerald-500" : "text-primary"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -196,12 +178,7 @@ function SortableProfileCard({
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className="text-xs px-2 py-0.5 rounded-full border border-primary/30 text-primary bg-primary/10"
|
||||||
"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"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{profile.model}
|
{profile.model}
|
||||||
</span>
|
</span>
|
||||||
@@ -266,12 +243,9 @@ function ProfileForm({
|
|||||||
const supportsThinking = modelSupportsThinking(formData.model);
|
const supportsThinking = modelSupportsThinking(formData.model);
|
||||||
|
|
||||||
const handleModelChange = (model: AgentModel) => {
|
const handleModelChange = (model: AgentModel) => {
|
||||||
const newProvider = getProviderFromModel(model);
|
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
model,
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model Selection - Claude */}
|
{/* Model Selection */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="flex items-center gap-2">
|
<Label className="flex items-center gap-2">
|
||||||
<Brain className="w-4 h-4 text-primary" />
|
<Brain className="w-4 h-4 text-primary" />
|
||||||
Claude Models
|
Model
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{CLAUDE_MODELS.map(({ id, label }) => (
|
{CLAUDE_MODELS.map(({ id, label }) => (
|
||||||
@@ -370,33 +344,7 @@ function ProfileForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model Selection - Codex */}
|
{/* Thinking Level */}
|
||||||
<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 */}
|
|
||||||
{supportsThinking && (
|
{supportsThinking && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="flex items-center gap-2">
|
<Label className="flex items-center gap-2">
|
||||||
@@ -461,6 +409,7 @@ export function ProfilesView() {
|
|||||||
updateAIProfile,
|
updateAIProfile,
|
||||||
removeAIProfile,
|
removeAIProfile,
|
||||||
reorderAIProfiles,
|
reorderAIProfiles,
|
||||||
|
resetAIProfiles,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
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
|
// Build keyboard shortcuts for profiles view
|
||||||
const profilesShortcuts: KeyboardShortcut[] = useMemo(() => {
|
const profilesShortcuts: KeyboardShortcut[] = useMemo(() => {
|
||||||
const shortcutsList: KeyboardShortcut[] = [];
|
const shortcutsList: KeyboardShortcut[] = [];
|
||||||
@@ -568,15 +524,26 @@ export function ProfilesView() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<HotkeyButton
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => setShowAddDialog(true)}
|
<Button
|
||||||
hotkey={shortcuts.addProfile}
|
variant="outline"
|
||||||
hotkeyActive={false}
|
onClick={handleResetProfiles}
|
||||||
data-testid="add-profile-button"
|
data-testid="refresh-profiles-button"
|
||||||
>
|
className="gap-2"
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
>
|
||||||
New Profile
|
<RefreshCw className="w-4 h-4" />
|
||||||
</HotkeyButton>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
Key,
|
Key,
|
||||||
Palette,
|
Palette,
|
||||||
Terminal,
|
Terminal,
|
||||||
Atom,
|
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
Trash2,
|
Trash2,
|
||||||
Settings2,
|
Settings2,
|
||||||
@@ -24,7 +23,6 @@ import { DeleteProjectDialog } from "./settings-view/components/delete-project-d
|
|||||||
import { SettingsNavigation } from "./settings-view/components/settings-navigation";
|
import { SettingsNavigation } from "./settings-view/components/settings-navigation";
|
||||||
import { ApiKeysSection } from "./settings-view/api-keys/api-keys-section";
|
import { ApiKeysSection } from "./settings-view/api-keys/api-keys-section";
|
||||||
import { ClaudeCliStatus } from "./settings-view/cli-status/claude-cli-status";
|
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 { AppearanceSection } from "./settings-view/appearance/appearance-section";
|
||||||
import { KeyboardShortcutsSection } from "./settings-view/keyboard-shortcuts/keyboard-shortcuts-section";
|
import { KeyboardShortcutsSection } from "./settings-view/keyboard-shortcuts/keyboard-shortcuts-section";
|
||||||
import { FeatureDefaultsSection } from "./settings-view/feature-defaults/feature-defaults-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 = [
|
const NAV_ITEMS = [
|
||||||
{ id: "api-keys", label: "API Keys", icon: Key },
|
{ id: "api-keys", label: "API Keys", icon: Key },
|
||||||
{ id: "claude", label: "Claude", icon: Terminal },
|
{ id: "claude", label: "Claude", icon: Terminal },
|
||||||
{ id: "codex", label: "Codex", icon: Atom },
|
|
||||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
{ id: "appearance", label: "Appearance", icon: Palette },
|
||||||
{ id: "audio", label: "Audio", icon: Volume2 },
|
{ id: "audio", label: "Audio", icon: Volume2 },
|
||||||
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
|
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
|
||||||
@@ -96,11 +93,8 @@ export function SettingsView() {
|
|||||||
// Use CLI status hook
|
// Use CLI status hook
|
||||||
const {
|
const {
|
||||||
claudeCliStatus,
|
claudeCliStatus,
|
||||||
codexCliStatus,
|
|
||||||
isCheckingClaudeCli,
|
isCheckingClaudeCli,
|
||||||
isCheckingCodexCli,
|
|
||||||
handleRefreshClaudeCli,
|
handleRefreshClaudeCli,
|
||||||
handleRefreshCodexCli,
|
|
||||||
} = useCliStatus();
|
} = useCliStatus();
|
||||||
|
|
||||||
// Use scroll tracking hook
|
// 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 */}
|
{/* Appearance Section */}
|
||||||
<AppearanceSection
|
<AppearanceSection
|
||||||
effectiveTheme={effectiveTheme}
|
effectiveTheme={effectiveTheme}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useApiKeyManagement } from "./hooks/use-api-key-management";
|
|||||||
|
|
||||||
export function ApiKeysSection() {
|
export function ApiKeysSection() {
|
||||||
const { apiKeys } = useAppStore();
|
const { apiKeys } = useAppStore();
|
||||||
const { claudeAuthStatus, codexAuthStatus } = useSetupStore();
|
const { claudeAuthStatus } = useSetupStore();
|
||||||
|
|
||||||
const { providerConfigParams, apiKeyStatus, handleSave, saved } =
|
const { providerConfigParams, apiKeyStatus, handleSave, saved } =
|
||||||
useApiKeyManagement();
|
useApiKeyManagement();
|
||||||
@@ -41,7 +41,6 @@ export function ApiKeysSection() {
|
|||||||
{/* Authentication Status Display */}
|
{/* Authentication Status Display */}
|
||||||
<AuthenticationStatusDisplay
|
<AuthenticationStatusDisplay
|
||||||
claudeAuthStatus={claudeAuthStatus}
|
claudeAuthStatus={claudeAuthStatus}
|
||||||
codexAuthStatus={codexAuthStatus}
|
|
||||||
apiKeyStatus={apiKeyStatus}
|
apiKeyStatus={apiKeyStatus}
|
||||||
apiKeys={apiKeys}
|
apiKeys={apiKeys}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,29 +4,24 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
Info,
|
Info,
|
||||||
Terminal,
|
Terminal,
|
||||||
Atom,
|
|
||||||
Sparkles,
|
Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ClaudeAuthStatus, CodexAuthStatus } from "@/store/setup-store";
|
import type { ClaudeAuthStatus } from "@/store/setup-store";
|
||||||
|
|
||||||
interface AuthenticationStatusDisplayProps {
|
interface AuthenticationStatusDisplayProps {
|
||||||
claudeAuthStatus: ClaudeAuthStatus | null;
|
claudeAuthStatus: ClaudeAuthStatus | null;
|
||||||
codexAuthStatus: CodexAuthStatus | null;
|
|
||||||
apiKeyStatus: {
|
apiKeyStatus: {
|
||||||
hasAnthropicKey: boolean;
|
hasAnthropicKey: boolean;
|
||||||
hasOpenAIKey: boolean;
|
|
||||||
hasGoogleKey: boolean;
|
hasGoogleKey: boolean;
|
||||||
} | null;
|
} | null;
|
||||||
apiKeys: {
|
apiKeys: {
|
||||||
anthropic: string;
|
anthropic: string;
|
||||||
google: string;
|
google: string;
|
||||||
openai: string;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthenticationStatusDisplay({
|
export function AuthenticationStatusDisplay({
|
||||||
claudeAuthStatus,
|
claudeAuthStatus,
|
||||||
codexAuthStatus,
|
|
||||||
apiKeyStatus,
|
apiKeyStatus,
|
||||||
apiKeys,
|
apiKeys,
|
||||||
}: AuthenticationStatusDisplayProps) {
|
}: AuthenticationStatusDisplayProps) {
|
||||||
@@ -93,56 +88,6 @@ export function AuthenticationStatusDisplay({
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Google/Gemini Authentication Status */}
|
||||||
<div className="p-3 rounded-lg bg-card border border-border">
|
<div className="p-3 rounded-lg bg-card border border-border">
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ interface TestResult {
|
|||||||
|
|
||||||
interface ApiKeyStatus {
|
interface ApiKeyStatus {
|
||||||
hasAnthropicKey: boolean;
|
hasAnthropicKey: boolean;
|
||||||
hasOpenAIKey: boolean;
|
|
||||||
hasGoogleKey: boolean;
|
hasGoogleKey: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,12 +23,10 @@ export function useApiKeyManagement() {
|
|||||||
// API key values
|
// API key values
|
||||||
const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic);
|
const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic);
|
||||||
const [googleKey, setGoogleKey] = useState(apiKeys.google);
|
const [googleKey, setGoogleKey] = useState(apiKeys.google);
|
||||||
const [openaiKey, setOpenaiKey] = useState(apiKeys.openai);
|
|
||||||
|
|
||||||
// Visibility toggles
|
// Visibility toggles
|
||||||
const [showAnthropicKey, setShowAnthropicKey] = useState(false);
|
const [showAnthropicKey, setShowAnthropicKey] = useState(false);
|
||||||
const [showGoogleKey, setShowGoogleKey] = useState(false);
|
const [showGoogleKey, setShowGoogleKey] = useState(false);
|
||||||
const [showOpenaiKey, setShowOpenaiKey] = useState(false);
|
|
||||||
|
|
||||||
// Test connection states
|
// Test connection states
|
||||||
const [testingConnection, setTestingConnection] = useState(false);
|
const [testingConnection, setTestingConnection] = useState(false);
|
||||||
@@ -38,10 +35,6 @@ export function useApiKeyManagement() {
|
|||||||
const [geminiTestResult, setGeminiTestResult] = useState<TestResult | null>(
|
const [geminiTestResult, setGeminiTestResult] = useState<TestResult | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
const [testingOpenaiConnection, setTestingOpenaiConnection] = useState(false);
|
|
||||||
const [openaiTestResult, setOpenaiTestResult] = useState<TestResult | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
// API key status from environment
|
// API key status from environment
|
||||||
const [apiKeyStatus, setApiKeyStatus] = useState<ApiKeyStatus | null>(null);
|
const [apiKeyStatus, setApiKeyStatus] = useState<ApiKeyStatus | null>(null);
|
||||||
@@ -53,7 +46,6 @@ export function useApiKeyManagement() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAnthropicKey(apiKeys.anthropic);
|
setAnthropicKey(apiKeys.anthropic);
|
||||||
setGoogleKey(apiKeys.google);
|
setGoogleKey(apiKeys.google);
|
||||||
setOpenaiKey(apiKeys.openai);
|
|
||||||
}, [apiKeys]);
|
}, [apiKeys]);
|
||||||
|
|
||||||
// Check API key status from environment on mount
|
// Check API key status from environment on mount
|
||||||
@@ -66,7 +58,6 @@ export function useApiKeyManagement() {
|
|||||||
if (status.success) {
|
if (status.success) {
|
||||||
setApiKeyStatus({
|
setApiKeyStatus({
|
||||||
hasAnthropicKey: status.hasAnthropicKey,
|
hasAnthropicKey: status.hasAnthropicKey,
|
||||||
hasOpenAIKey: status.hasOpenAIKey,
|
|
||||||
hasGoogleKey: status.hasGoogleKey,
|
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
|
// Save API keys
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
setApiKeys({
|
setApiKeys({
|
||||||
anthropic: anthropicKey,
|
anthropic: anthropicKey,
|
||||||
google: googleKey,
|
google: googleKey,
|
||||||
openai: openaiKey,
|
|
||||||
});
|
});
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
setTimeout(() => setSaved(false), 2000);
|
setTimeout(() => setSaved(false), 2000);
|
||||||
@@ -240,15 +174,6 @@ export function useApiKeyManagement() {
|
|||||||
onTest: handleTestGeminiConnection,
|
onTest: handleTestGeminiConnection,
|
||||||
result: geminiTestResult,
|
result: geminiTestResult,
|
||||||
},
|
},
|
||||||
openai: {
|
|
||||||
value: openaiKey,
|
|
||||||
setValue: setOpenaiKey,
|
|
||||||
show: showOpenaiKey,
|
|
||||||
setShow: setShowOpenaiKey,
|
|
||||||
testing: testingOpenaiConnection,
|
|
||||||
onTest: handleTestOpenaiConnection,
|
|
||||||
result: openaiTestResult,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Terminal,
|
|
||||||
CheckCircle2,
|
|
||||||
AlertCircle,
|
|
||||||
RefreshCw,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { CliStatus } from "../shared/types";
|
|
||||||
|
|
||||||
interface CliStatusProps {
|
|
||||||
status: CliStatus | null;
|
|
||||||
isChecking: boolean;
|
|
||||||
onRefresh: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CodexCliStatus({
|
|
||||||
status,
|
|
||||||
isChecking,
|
|
||||||
onRefresh,
|
|
||||||
}: CliStatusProps) {
|
|
||||||
if (!status) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id="codex"
|
|
||||||
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
|
||||||
>
|
|
||||||
<div className="p-6 border-b border-border">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Terminal className="w-5 h-5 text-green-500" />
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
|
||||||
OpenAI Codex CLI
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={onRefresh}
|
|
||||||
disabled={isChecking}
|
|
||||||
data-testid="refresh-codex-cli"
|
|
||||||
title="Refresh Codex CLI detection"
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Codex CLI enables GPT-5.1 Codex models for autonomous coding tasks.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 space-y-4">
|
|
||||||
{status.success && status.status === "installed" ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
|
|
||||||
<CheckCircle2 className="w-5 h-5 text-green-500 shrink-0" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-medium text-green-400">
|
|
||||||
Codex CLI Installed
|
|
||||||
</p>
|
|
||||||
<div className="text-xs text-green-400/80 mt-1 space-y-1">
|
|
||||||
{status.method && (
|
|
||||||
<p>
|
|
||||||
Method: <span className="font-mono">{status.method}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{status.version && (
|
|
||||||
<p>
|
|
||||||
Version:{" "}
|
|
||||||
<span className="font-mono">{status.version}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{status.path && (
|
|
||||||
<p className="truncate" title={status.path}>
|
|
||||||
Path:{" "}
|
|
||||||
<span className="font-mono text-[10px]">
|
|
||||||
{status.path}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{status.recommendation && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{status.recommendation}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : status.status === "api_key_only" ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
|
||||||
<AlertCircle className="w-5 h-5 text-blue-500 mt-0.5 shrink-0" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-medium text-blue-400">
|
|
||||||
API Key Detected - CLI Not Installed
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-blue-400/80 mt-1">
|
|
||||||
{status.recommendation ||
|
|
||||||
"OPENAI_API_KEY found but Codex CLI not installed. Install the CLI for full agentic capabilities."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{status.installCommands && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-xs font-medium text-foreground-secondary">
|
|
||||||
Installation Commands:
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{status.installCommands.npm && (
|
|
||||||
<div className="p-2 rounded bg-background border border-border-glass">
|
|
||||||
<p className="text-xs text-muted-foreground mb-1">npm:</p>
|
|
||||||
<code className="text-xs text-foreground-secondary font-mono break-all">
|
|
||||||
{status.installCommands.npm}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
|
||||||
<AlertCircle className="w-5 h-5 text-yellow-500 mt-0.5 shrink-0" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-medium text-yellow-400">
|
|
||||||
Codex CLI Not Detected
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-yellow-400/80 mt-1">
|
|
||||||
{status.recommendation ||
|
|
||||||
"Install OpenAI Codex CLI to use GPT-5.1 Codex models for autonomous coding."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{status.installCommands && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-xs font-medium text-foreground-secondary">
|
|
||||||
Installation Commands:
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{status.installCommands.npm && (
|
|
||||||
<div className="p-2 rounded bg-background border border-border-glass">
|
|
||||||
<p className="text-xs text-muted-foreground mb-1">npm:</p>
|
|
||||||
<code className="text-xs text-foreground-secondary font-mono break-all">
|
|
||||||
{status.installCommands.npm}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{status.installCommands.macos && (
|
|
||||||
<div className="p-2 rounded bg-background border border-border-glass">
|
|
||||||
<p className="text-xs text-muted-foreground mb-1">
|
|
||||||
macOS (Homebrew):
|
|
||||||
</p>
|
|
||||||
<code className="text-xs text-foreground-secondary font-mono break-all">
|
|
||||||
{status.installCommands.macos}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import type { LucideIcon } from "lucide-react";
|
|||||||
import {
|
import {
|
||||||
Key,
|
Key,
|
||||||
Terminal,
|
Terminal,
|
||||||
Atom,
|
|
||||||
Palette,
|
Palette,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Settings2,
|
Settings2,
|
||||||
@@ -20,7 +19,6 @@ export interface NavigationItem {
|
|||||||
export const NAV_ITEMS: NavigationItem[] = [
|
export const NAV_ITEMS: NavigationItem[] = [
|
||||||
{ id: "api-keys", label: "API Keys", icon: Key },
|
{ id: "api-keys", label: "API Keys", icon: Key },
|
||||||
{ id: "claude", label: "Claude", icon: Terminal },
|
{ id: "claude", label: "Claude", icon: Terminal },
|
||||||
{ id: "codex", label: "Codex", icon: Atom },
|
|
||||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
{ id: "appearance", label: "Appearance", icon: Palette },
|
||||||
{ id: "kanban", label: "Kanban Display", icon: LayoutGrid },
|
{ id: "kanban", label: "Kanban Display", icon: LayoutGrid },
|
||||||
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
|
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function FeatureDefaultsSection({
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
When enabled, the Add Feature dialog will show only AI profiles
|
When enabled, the Add Feature dialog will show only AI profiles
|
||||||
and hide advanced model tweaking options (Claude SDK, thinking
|
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
|
overwhelming UI. You can always disable this to access advanced
|
||||||
settings.
|
settings.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -18,25 +18,17 @@ interface CliStatusResult {
|
|||||||
error?: string;
|
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
|
* Handles checking CLI installation, authentication, and refresh functionality
|
||||||
*/
|
*/
|
||||||
export function useCliStatus() {
|
export function useCliStatus() {
|
||||||
const { setClaudeAuthStatus, setCodexAuthStatus } = useSetupStore();
|
const { setClaudeAuthStatus } = useSetupStore();
|
||||||
|
|
||||||
const [claudeCliStatus, setClaudeCliStatus] =
|
const [claudeCliStatus, setClaudeCliStatus] =
|
||||||
useState<CliStatusResult | null>(null);
|
useState<CliStatusResult | null>(null);
|
||||||
|
|
||||||
const [codexCliStatus, setCodexCliStatus] =
|
|
||||||
useState<CodexCliStatusResult | null>(null);
|
|
||||||
|
|
||||||
const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false);
|
const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false);
|
||||||
const [isCheckingCodexCli, setIsCheckingCodexCli] = useState(false);
|
|
||||||
|
|
||||||
// Check CLI status on mount
|
// Check CLI status on mount
|
||||||
useEffect(() => {
|
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)
|
// Check Claude auth status (re-fetch on mount to ensure persistence)
|
||||||
if (api?.setup?.getClaudeStatus) {
|
if (api?.setup?.getClaudeStatus) {
|
||||||
try {
|
try {
|
||||||
@@ -95,47 +77,10 @@ export function useCliStatus() {
|
|||||||
console.error("Failed to check Claude auth status:", error);
|
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();
|
checkCliStatus();
|
||||||
}, [setClaudeAuthStatus, setCodexAuthStatus]);
|
}, [setClaudeAuthStatus]);
|
||||||
|
|
||||||
// Refresh Claude CLI status
|
// Refresh Claude CLI status
|
||||||
const handleRefreshClaudeCli = useCallback(async () => {
|
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 {
|
return {
|
||||||
claudeCliStatus,
|
claudeCliStatus,
|
||||||
codexCliStatus,
|
|
||||||
isCheckingClaudeCli,
|
isCheckingClaudeCli,
|
||||||
isCheckingCodexCli,
|
|
||||||
handleRefreshClaudeCli,
|
handleRefreshClaudeCli,
|
||||||
handleRefreshCodexCli,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
WelcomeStep,
|
WelcomeStep,
|
||||||
CompleteStep,
|
CompleteStep,
|
||||||
ClaudeSetupStep,
|
ClaudeSetupStep,
|
||||||
CodexSetupStep,
|
|
||||||
} from "./setup-view/steps";
|
} from "./setup-view/steps";
|
||||||
|
|
||||||
// Main Setup View
|
// Main Setup View
|
||||||
@@ -17,17 +16,14 @@ export function SetupView() {
|
|||||||
setCurrentStep,
|
setCurrentStep,
|
||||||
completeSetup,
|
completeSetup,
|
||||||
setSkipClaudeSetup,
|
setSkipClaudeSetup,
|
||||||
setSkipCodexSetup,
|
|
||||||
} = useSetupStore();
|
} = useSetupStore();
|
||||||
const { setCurrentView } = useAppStore();
|
const { setCurrentView } = useAppStore();
|
||||||
|
|
||||||
const steps = ["welcome", "claude", "codex", "complete"] as const;
|
const steps = ["welcome", "claude", "complete"] as const;
|
||||||
type StepName = (typeof steps)[number];
|
type StepName = (typeof steps)[number];
|
||||||
const getStepName = (): StepName => {
|
const getStepName = (): StepName => {
|
||||||
if (currentStep === "claude_detect" || currentStep === "claude_auth")
|
if (currentStep === "claude_detect" || currentStep === "claude_auth")
|
||||||
return "claude";
|
return "claude";
|
||||||
if (currentStep === "codex_detect" || currentStep === "codex_auth")
|
|
||||||
return "codex";
|
|
||||||
if (currentStep === "welcome") return "welcome";
|
if (currentStep === "welcome") return "welcome";
|
||||||
return "complete";
|
return "complete";
|
||||||
};
|
};
|
||||||
@@ -46,10 +42,6 @@ export function SetupView() {
|
|||||||
setCurrentStep("claude_detect");
|
setCurrentStep("claude_detect");
|
||||||
break;
|
break;
|
||||||
case "claude":
|
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");
|
console.log("[Setup Flow] Moving to complete step");
|
||||||
setCurrentStep("complete");
|
setCurrentStep("complete");
|
||||||
break;
|
break;
|
||||||
@@ -62,21 +54,12 @@ export function SetupView() {
|
|||||||
case "claude":
|
case "claude":
|
||||||
setCurrentStep("welcome");
|
setCurrentStep("welcome");
|
||||||
break;
|
break;
|
||||||
case "codex":
|
|
||||||
setCurrentStep("claude_detect");
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSkipClaude = () => {
|
const handleSkipClaude = () => {
|
||||||
console.log("[Setup Flow] Skipping Claude setup");
|
console.log("[Setup Flow] Skipping Claude setup");
|
||||||
setSkipClaudeSetup(true);
|
setSkipClaudeSetup(true);
|
||||||
setCurrentStep("codex_detect");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSkipCodex = () => {
|
|
||||||
console.log("[Setup Flow] Skipping Codex setup");
|
|
||||||
setSkipCodexSetup(true);
|
|
||||||
setCurrentStep("complete");
|
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" && (
|
{currentStep === "complete" && (
|
||||||
<CompleteStep onFinish={handleFinish} />
|
<CompleteStep onFinish={handleFinish} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useCallback } from "react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface UseCliInstallationOptions {
|
interface UseCliInstallationOptions {
|
||||||
cliType: "claude" | "codex";
|
cliType: "claude";
|
||||||
installApi: () => Promise<any>;
|
installApi: () => Promise<any>;
|
||||||
onProgressEvent?: (callback: (progress: any) => void) => (() => void) | undefined;
|
onProgressEvent?: (callback: (progress: any) => void) => (() => void) | undefined;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
|
|
||||||
interface UseCliStatusOptions {
|
interface UseCliStatusOptions {
|
||||||
cliType: "claude" | "codex";
|
cliType: "claude";
|
||||||
statusApi: () => Promise<any>;
|
statusApi: () => Promise<any>;
|
||||||
setCliStatus: (status: any) => void;
|
setCliStatus: (status: any) => void;
|
||||||
setAuthStatus: (status: any) => void;
|
setAuthStatus: (status: any) => void;
|
||||||
@@ -33,65 +33,35 @@ export function useCliStatus({
|
|||||||
setCliStatus(cliStatus);
|
setCliStatus(cliStatus);
|
||||||
|
|
||||||
if (result.auth) {
|
if (result.auth) {
|
||||||
if (cliType === "claude") {
|
// Validate method is one of the expected values, default to "none"
|
||||||
// Validate method is one of the expected values, default to "none"
|
const validMethods = [
|
||||||
const validMethods = [
|
"oauth_token_env",
|
||||||
"oauth_token_env",
|
"oauth_token",
|
||||||
"oauth_token",
|
"api_key",
|
||||||
"api_key",
|
"api_key_env",
|
||||||
"api_key_env",
|
"credentials_file",
|
||||||
"credentials_file",
|
"cli_authenticated",
|
||||||
"cli_authenticated",
|
"none",
|
||||||
"none",
|
] as const;
|
||||||
] as const;
|
type AuthMethod = (typeof validMethods)[number];
|
||||||
type AuthMethod = (typeof validMethods)[number];
|
const method: AuthMethod = validMethods.includes(
|
||||||
const method: AuthMethod = validMethods.includes(
|
result.auth.method as AuthMethod
|
||||||
result.auth.method as AuthMethod
|
)
|
||||||
)
|
? (result.auth.method as AuthMethod)
|
||||||
? (result.auth.method as AuthMethod)
|
: "none";
|
||||||
: "none";
|
const authStatus = {
|
||||||
const authStatus = {
|
authenticated: result.auth.authenticated,
|
||||||
authenticated: result.auth.authenticated,
|
method,
|
||||||
method,
|
hasCredentialsFile: false,
|
||||||
hasCredentialsFile: false,
|
oauthTokenValid:
|
||||||
oauthTokenValid:
|
result.auth.hasStoredOAuthToken ||
|
||||||
result.auth.hasStoredOAuthToken ||
|
result.auth.hasEnvOAuthToken,
|
||||||
result.auth.hasEnvOAuthToken,
|
apiKeyValid:
|
||||||
apiKeyValid:
|
result.auth.hasStoredApiKey || result.auth.hasEnvApiKey,
|
||||||
result.auth.hasStoredApiKey || result.auth.hasEnvApiKey,
|
hasEnvOAuthToken: result.auth.hasEnvOAuthToken,
|
||||||
hasEnvOAuthToken: result.auth.hasEnvOAuthToken,
|
hasEnvApiKey: result.auth.hasEnvApiKey,
|
||||||
hasEnvApiKey: result.auth.hasEnvApiKey,
|
};
|
||||||
};
|
setAuthStatus(authStatus);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getElectronAPI } from "@/lib/electron";
|
|||||||
type AuthState = "idle" | "running" | "success" | "error" | "manual";
|
type AuthState = "idle" | "running" | "success" | "error" | "manual";
|
||||||
|
|
||||||
interface UseOAuthAuthenticationOptions {
|
interface UseOAuthAuthenticationOptions {
|
||||||
cliType: "claude" | "codex";
|
cliType: "claude";
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,11 +70,8 @@ export function useOAuthAuthentication({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Call the appropriate auth API based on cliType
|
// Call the auth API
|
||||||
const result =
|
const result = await api.setup.authClaude();
|
||||||
cliType === "claude"
|
|
||||||
? await api.setup.authClaude()
|
|
||||||
: await api.setup.authCodex?.();
|
|
||||||
|
|
||||||
// Cleanup subscription
|
// Cleanup subscription
|
||||||
if (unsubscribeRef.current) {
|
if (unsubscribeRef.current) {
|
||||||
|
|||||||
@@ -1,445 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { useSetupStore } from "@/store/setup-store";
|
|
||||||
import { useAppStore } from "@/store/app-store";
|
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
|
||||||
import {
|
|
||||||
CheckCircle2,
|
|
||||||
Loader2,
|
|
||||||
Terminal,
|
|
||||||
Key,
|
|
||||||
ArrowRight,
|
|
||||||
ArrowLeft,
|
|
||||||
ExternalLink,
|
|
||||||
Copy,
|
|
||||||
AlertCircle,
|
|
||||||
RefreshCw,
|
|
||||||
Download,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { StatusBadge, TerminalOutput } from "../components";
|
|
||||||
import {
|
|
||||||
useCliStatus,
|
|
||||||
useCliInstallation,
|
|
||||||
useTokenSave,
|
|
||||||
} from "../hooks";
|
|
||||||
|
|
||||||
interface CodexSetupStepProps {
|
|
||||||
onNext: () => void;
|
|
||||||
onBack: () => void;
|
|
||||||
onSkip: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CodexSetupStep({
|
|
||||||
onNext,
|
|
||||||
onBack,
|
|
||||||
onSkip,
|
|
||||||
}: CodexSetupStepProps) {
|
|
||||||
const {
|
|
||||||
codexCliStatus,
|
|
||||||
codexAuthStatus,
|
|
||||||
setCodexCliStatus,
|
|
||||||
setCodexAuthStatus,
|
|
||||||
setCodexInstallProgress,
|
|
||||||
} = useSetupStore();
|
|
||||||
const { setApiKeys, apiKeys } = useAppStore();
|
|
||||||
|
|
||||||
const [showApiKeyInput, setShowApiKeyInput] = useState(false);
|
|
||||||
const [apiKey, setApiKey] = useState("");
|
|
||||||
|
|
||||||
// Memoize API functions to prevent infinite loops
|
|
||||||
const statusApi = useCallback(
|
|
||||||
() => getElectronAPI().setup?.getCodexStatus() || Promise.reject(),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const installApi = useCallback(
|
|
||||||
() => getElectronAPI().setup?.installCodex() || Promise.reject(),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use custom hooks
|
|
||||||
const { isChecking, checkStatus } = useCliStatus({
|
|
||||||
cliType: "codex",
|
|
||||||
statusApi,
|
|
||||||
setCliStatus: setCodexCliStatus,
|
|
||||||
setAuthStatus: setCodexAuthStatus,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onInstallSuccess = useCallback(() => {
|
|
||||||
checkStatus();
|
|
||||||
}, [checkStatus]);
|
|
||||||
|
|
||||||
const { isInstalling, installProgress, install } = useCliInstallation({
|
|
||||||
cliType: "codex",
|
|
||||||
installApi,
|
|
||||||
onProgressEvent: getElectronAPI().setup?.onInstallProgress,
|
|
||||||
onSuccess: onInstallSuccess,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { isSaving: isSavingKey, saveToken: saveApiKeyToken } = useTokenSave({
|
|
||||||
provider: "openai",
|
|
||||||
onSuccess: () => {
|
|
||||||
setCodexAuthStatus({
|
|
||||||
authenticated: true,
|
|
||||||
method: "api_key",
|
|
||||||
apiKeyValid: true,
|
|
||||||
});
|
|
||||||
setApiKeys({ ...apiKeys, openai: apiKey });
|
|
||||||
setShowApiKeyInput(false);
|
|
||||||
checkStatus();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync install progress to store
|
|
||||||
useEffect(() => {
|
|
||||||
setCodexInstallProgress({
|
|
||||||
isInstalling,
|
|
||||||
output: installProgress.output,
|
|
||||||
});
|
|
||||||
}, [isInstalling, installProgress, setCodexInstallProgress]);
|
|
||||||
|
|
||||||
// Check status on mount
|
|
||||||
useEffect(() => {
|
|
||||||
checkStatus();
|
|
||||||
}, [checkStatus]);
|
|
||||||
|
|
||||||
const copyCommand = (command: string) => {
|
|
||||||
navigator.clipboard.writeText(command);
|
|
||||||
toast.success("Command copied to clipboard");
|
|
||||||
};
|
|
||||||
|
|
||||||
const isAuthenticated = codexAuthStatus?.authenticated || apiKeys.openai;
|
|
||||||
|
|
||||||
const getAuthMethodLabel = () => {
|
|
||||||
if (!isAuthenticated) return null;
|
|
||||||
if (apiKeys.openai) return "API Key (Manual)";
|
|
||||||
if (codexAuthStatus?.method === "api_key") return "API Key (Auth File)";
|
|
||||||
if (codexAuthStatus?.method === "env") return "API Key (Environment)";
|
|
||||||
if (codexAuthStatus?.method === "cli_verified")
|
|
||||||
return "CLI Login (ChatGPT)";
|
|
||||||
return "Authenticated";
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<div className="w-16 h-16 rounded-xl bg-green-500/10 flex items-center justify-center mx-auto mb-4">
|
|
||||||
<Terminal className="w-8 h-8 text-green-500" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold text-foreground mb-2">
|
|
||||||
Codex CLI Setup
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
OpenAI's GPT-5.1 Codex for advanced code generation
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Card */}
|
|
||||||
<Card className="bg-card border-border">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-lg">Installation Status</CardTitle>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={checkStatus}
|
|
||||||
disabled={isChecking}
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-foreground">CLI Installation</span>
|
|
||||||
{isChecking ? (
|
|
||||||
<StatusBadge status="checking" label="Checking..." />
|
|
||||||
) : codexCliStatus?.installed ? (
|
|
||||||
<StatusBadge status="installed" label="Installed" />
|
|
||||||
) : (
|
|
||||||
<StatusBadge status="not_installed" label="Not Installed" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{codexCliStatus?.version && (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">Version</span>
|
|
||||||
<span className="text-sm font-mono text-foreground">
|
|
||||||
{codexCliStatus.version}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-foreground">Authentication</span>
|
|
||||||
{isAuthenticated ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<StatusBadge status="authenticated" label="Authenticated" />
|
|
||||||
{getAuthMethodLabel() && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
({getAuthMethodLabel()})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<StatusBadge
|
|
||||||
status="not_authenticated"
|
|
||||||
label="Not Authenticated"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Installation Section */}
|
|
||||||
{!codexCliStatus?.installed && (
|
|
||||||
<Card className="bg-card border-border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
|
||||||
<Download className="w-5 h-5" />
|
|
||||||
Install Codex CLI
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Install via npm (Node.js required)
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm text-muted-foreground">
|
|
||||||
npm (Global installation)
|
|
||||||
</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
|
||||||
npm install -g @openai/codex
|
|
||||||
</code>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => copyCommand("npm install -g @openai/codex")}
|
|
||||||
>
|
|
||||||
<Copy className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isInstalling && (
|
|
||||||
<TerminalOutput lines={installProgress.output} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={install}
|
|
||||||
disabled={isInstalling}
|
|
||||||
className="flex-1 bg-green-500 hover:bg-green-600 text-white"
|
|
||||||
data-testid="install-codex-button"
|
|
||||||
>
|
|
||||||
{isInstalling ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Installing...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
Auto Install
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<AlertCircle className="w-4 h-4 text-yellow-500 mt-0.5" />
|
|
||||||
<p className="text-xs text-yellow-600 dark:text-yellow-400">
|
|
||||||
Requires Node.js to be installed. If the auto-install fails,
|
|
||||||
try running the command manually in your terminal.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Authentication Section */}
|
|
||||||
{!isAuthenticated && (
|
|
||||||
<Card className="bg-card border-border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
|
||||||
<Key className="w-5 h-5" />
|
|
||||||
Authentication
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>Codex requires an OpenAI API key</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{codexCliStatus?.installed && (
|
|
||||||
<div className="p-4 rounded-lg bg-muted/50 border border-border">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Terminal className="w-5 h-5 text-green-500 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-foreground">
|
|
||||||
Authenticate via CLI
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground mb-2">
|
|
||||||
Run this command in your terminal:
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="bg-muted px-3 py-1 rounded text-sm font-mono text-foreground">
|
|
||||||
codex auth login
|
|
||||||
</code>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => copyCommand("codex auth login")}
|
|
||||||
>
|
|
||||||
<Copy className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<span className="w-full border-t border-border" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
|
||||||
<span className="bg-card px-2 text-muted-foreground">
|
|
||||||
or enter API key
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showApiKeyInput ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="openai-key" className="text-foreground">
|
|
||||||
OpenAI API Key
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="openai-key"
|
|
||||||
type="password"
|
|
||||||
placeholder="sk-..."
|
|
||||||
value={apiKey}
|
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
|
||||||
className="bg-input border-border text-foreground"
|
|
||||||
data-testid="openai-api-key-input"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Get your API key from{" "}
|
|
||||||
<a
|
|
||||||
href="https://platform.openai.com/api-keys"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-green-500 hover:underline"
|
|
||||||
>
|
|
||||||
platform.openai.com
|
|
||||||
<ExternalLink className="w-3 h-3 inline ml-1" />
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowApiKeyInput(false)}
|
|
||||||
className="border-border"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => saveApiKeyToken(apiKey)}
|
|
||||||
disabled={isSavingKey || !apiKey.trim()}
|
|
||||||
className="flex-1 bg-green-500 hover:bg-green-600 text-white"
|
|
||||||
data-testid="save-openai-key-button"
|
|
||||||
>
|
|
||||||
{isSavingKey ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
"Save API Key"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowApiKeyInput(true)}
|
|
||||||
className="w-full border-border"
|
|
||||||
data-testid="use-openai-key-button"
|
|
||||||
>
|
|
||||||
<Key className="w-4 h-4 mr-2" />
|
|
||||||
Enter OpenAI API Key
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Success State */}
|
|
||||||
{isAuthenticated && (
|
|
||||||
<Card className="bg-green-500/5 border-green-500/20">
|
|
||||||
<CardContent className="py-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-12 h-12 rounded-full bg-green-500/10 flex items-center justify-center">
|
|
||||||
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-foreground">
|
|
||||||
Codex is ready to use!
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{getAuthMethodLabel() &&
|
|
||||||
`Authenticated via ${getAuthMethodLabel()}. `}
|
|
||||||
You can proceed to complete setup
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Navigation */}
|
|
||||||
<div className="flex justify-between pt-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={onBack}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={onSkip}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
|
||||||
Skip for now
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={onNext}
|
|
||||||
className="bg-green-500 hover:bg-green-600 text-white"
|
|
||||||
data-testid="codex-next-button"
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
<ArrowRight className="w-4 h-4 ml-2" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -14,16 +14,13 @@ interface CompleteStepProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CompleteStep({ onFinish }: CompleteStepProps) {
|
export function CompleteStep({ onFinish }: CompleteStepProps) {
|
||||||
const { claudeCliStatus, claudeAuthStatus, codexCliStatus, codexAuthStatus } =
|
const { claudeCliStatus, claudeAuthStatus } =
|
||||||
useSetupStore();
|
useSetupStore();
|
||||||
const { apiKeys } = useAppStore();
|
const { apiKeys } = useAppStore();
|
||||||
|
|
||||||
const claudeReady =
|
const claudeReady =
|
||||||
(claudeCliStatus?.installed && claudeAuthStatus?.authenticated) ||
|
(claudeCliStatus?.installed && claudeAuthStatus?.authenticated) ||
|
||||||
apiKeys.anthropic;
|
apiKeys.anthropic;
|
||||||
const codexReady =
|
|
||||||
(codexCliStatus?.installed && codexAuthStatus?.authenticated) ||
|
|
||||||
apiKeys.openai;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-center space-y-6">
|
<div className="text-center space-y-6">
|
||||||
@@ -41,7 +38,7 @@ export function CompleteStep({ onFinish }: CompleteStepProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<Card
|
||||||
className={`bg-card/50 border ${
|
className={`bg-card/50 border ${
|
||||||
claudeReady ? "border-green-500/50" : "border-yellow-500/50"
|
claudeReady ? "border-green-500/50" : "border-yellow-500/50"
|
||||||
@@ -63,28 +60,6 @@ export function CompleteStep({ onFinish }: CompleteStepProps) {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
||||||
|
|
||||||
<div className="p-4 rounded-lg bg-muted/50 border border-border max-w-md mx-auto">
|
<div className="p-4 rounded-lg bg-muted/50 border border-border max-w-md mx-auto">
|
||||||
|
|||||||
@@ -2,4 +2,3 @@
|
|||||||
export { WelcomeStep } from "./welcome-step";
|
export { WelcomeStep } from "./welcome-step";
|
||||||
export { CompleteStep } from "./complete-step";
|
export { CompleteStep } from "./complete-step";
|
||||||
export { ClaudeSetupStep } from "./claude-setup-step";
|
export { ClaudeSetupStep } from "./claude-setup-step";
|
||||||
export { CodexSetupStep } from "./codex-setup-step";
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<Card className="bg-card/50 border-border hover:border-brand-500/50 transition-colors">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
@@ -40,19 +40,6 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-card/50 border-border hover:border-brand-500/50 transition-colors">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
|
||||||
<Terminal className="w-5 h-5 text-green-500" />
|
|
||||||
Codex CLI
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
OpenAI's GPT-5.1 Codex for advanced code generation tasks
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ export function WikiView() {
|
|||||||
{
|
{
|
||||||
icon: Cpu,
|
icon: Cpu,
|
||||||
title: "Multi-Model Support",
|
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,
|
icon: Brain,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Dispatch, SetStateAction } from "react";
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import type { ApiKeys } from "@/store/app-store";
|
import type { ApiKeys } from "@/store/app-store";
|
||||||
|
|
||||||
export type ProviderKey = "anthropic" | "google" | "openai";
|
export type ProviderKey = "anthropic" | "google";
|
||||||
|
|
||||||
export interface ProviderConfig {
|
export interface ProviderConfig {
|
||||||
key: ProviderKey;
|
key: ProviderKey;
|
||||||
@@ -51,22 +51,12 @@ export interface ProviderConfigParams {
|
|||||||
onTest: () => Promise<void>;
|
onTest: () => Promise<void>;
|
||||||
result: { success: boolean; message: string } | null;
|
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 = ({
|
export const buildProviderConfigs = ({
|
||||||
apiKeys,
|
apiKeys,
|
||||||
anthropic,
|
anthropic,
|
||||||
google,
|
google,
|
||||||
openai,
|
|
||||||
}: ProviderConfigParams): ProviderConfig[] => [
|
}: ProviderConfigParams): ProviderConfig[] => [
|
||||||
{
|
{
|
||||||
key: "anthropic",
|
key: "anthropic",
|
||||||
@@ -121,29 +111,4 @@ export const buildProviderConfigs = ({
|
|||||||
descriptionLinkHref: "https://makersuite.google.com/app/apikey",
|
descriptionLinkHref: "https://makersuite.google.com/app/apikey",
|
||||||
descriptionLinkText: "makersuite.google.com",
|
descriptionLinkText: "makersuite.google.com",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "openai",
|
|
||||||
label: "OpenAI API Key (Codex/GPT)",
|
|
||||||
inputId: "openai-key",
|
|
||||||
placeholder: "sk-...",
|
|
||||||
value: openai.value,
|
|
||||||
setValue: openai.setValue,
|
|
||||||
showValue: openai.show,
|
|
||||||
setShowValue: openai.setShow,
|
|
||||||
hasStoredKey: apiKeys.openai,
|
|
||||||
inputTestId: "openai-api-key-input",
|
|
||||||
toggleTestId: "toggle-openai-visibility",
|
|
||||||
testButton: {
|
|
||||||
onClick: openai.onTest,
|
|
||||||
disabled: !openai.value || openai.testing,
|
|
||||||
loading: openai.testing,
|
|
||||||
testId: "test-openai-connection",
|
|
||||||
},
|
|
||||||
result: openai.result,
|
|
||||||
resultTestId: "openai-test-connection-result",
|
|
||||||
resultMessageTestId: "openai-test-connection-message",
|
|
||||||
descriptionPrefix: "Used for OpenAI Codex CLI and GPT models. Get your key at",
|
|
||||||
descriptionLinkHref: "https://platform.openai.com/api-keys",
|
|
||||||
descriptionLinkText: "platform.openai.com",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -287,22 +287,6 @@ export interface ElectronAPI {
|
|||||||
};
|
};
|
||||||
error?: string;
|
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?: {
|
model?: {
|
||||||
getAvailable: () => Promise<{
|
getAvailable: () => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -315,11 +299,6 @@ export interface ElectronAPI {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
testOpenAIConnection?: (apiKey?: string) => Promise<{
|
|
||||||
success: boolean;
|
|
||||||
message?: string;
|
|
||||||
error?: string;
|
|
||||||
}>;
|
|
||||||
worktree?: WorktreeAPI;
|
worktree?: WorktreeAPI;
|
||||||
git?: GitAPI;
|
git?: GitAPI;
|
||||||
suggestions?: SuggestionsAPI;
|
suggestions?: SuggestionsAPI;
|
||||||
@@ -347,32 +326,11 @@ export interface ElectronAPI {
|
|||||||
};
|
};
|
||||||
error?: string;
|
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<{
|
installClaude: () => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
installCodex: () => Promise<{
|
|
||||||
success: boolean;
|
|
||||||
message?: string;
|
|
||||||
error?: string;
|
|
||||||
}>;
|
|
||||||
authClaude: () => Promise<{
|
authClaude: () => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
token?: string;
|
token?: string;
|
||||||
@@ -383,12 +341,6 @@ export interface ElectronAPI {
|
|||||||
message?: string;
|
message?: string;
|
||||||
output?: string;
|
output?: string;
|
||||||
}>;
|
}>;
|
||||||
authCodex: (apiKey?: string) => Promise<{
|
|
||||||
success: boolean;
|
|
||||||
requiresManualAuth?: boolean;
|
|
||||||
command?: string;
|
|
||||||
error?: string;
|
|
||||||
}>;
|
|
||||||
storeApiKey: (
|
storeApiKey: (
|
||||||
provider: string,
|
provider: string,
|
||||||
apiKey: string
|
apiKey: string
|
||||||
@@ -396,12 +348,8 @@ export interface ElectronAPI {
|
|||||||
getApiKeys: () => Promise<{
|
getApiKeys: () => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
hasAnthropicKey: boolean;
|
hasAnthropicKey: boolean;
|
||||||
hasOpenAIKey: boolean;
|
|
||||||
hasGoogleKey: boolean;
|
hasGoogleKey: boolean;
|
||||||
}>;
|
}>;
|
||||||
configureCodexMcp: (
|
|
||||||
projectPath: string
|
|
||||||
) => Promise<{ success: boolean; configPath?: string; error?: string }>;
|
|
||||||
getPlatform: () => Promise<{
|
getPlatform: () => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
platform: string;
|
platform: string;
|
||||||
@@ -838,22 +786,11 @@ const getMockElectronAPI = (): ElectronAPI => {
|
|||||||
recommendation: "Claude CLI checks are unavailable in the web preview.",
|
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: {
|
model: {
|
||||||
getAvailable: async () => ({ success: true, models: [] }),
|
getAvailable: async () => ({ success: true, models: [] }),
|
||||||
checkProviders: async () => ({ success: true, providers: {} }),
|
checkProviders: async () => ({ success: true, providers: {} }),
|
||||||
},
|
},
|
||||||
|
|
||||||
testOpenAIConnection: async () => ({
|
|
||||||
success: false,
|
|
||||||
error: "OpenAI connection test is only available in the Electron app.",
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Mock Setup API
|
// Mock Setup API
|
||||||
setup: createMockSetupAPI(),
|
setup: createMockSetupAPI(),
|
||||||
|
|
||||||
@@ -903,32 +840,11 @@ interface SetupAPI {
|
|||||||
};
|
};
|
||||||
error?: string;
|
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<{
|
installClaude: () => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
installCodex: () => Promise<{
|
|
||||||
success: boolean;
|
|
||||||
message?: string;
|
|
||||||
error?: string;
|
|
||||||
}>;
|
|
||||||
authClaude: () => Promise<{
|
authClaude: () => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
token?: string;
|
token?: string;
|
||||||
@@ -939,12 +855,6 @@ interface SetupAPI {
|
|||||||
message?: string;
|
message?: string;
|
||||||
output?: string;
|
output?: string;
|
||||||
}>;
|
}>;
|
||||||
authCodex: (apiKey?: string) => Promise<{
|
|
||||||
success: boolean;
|
|
||||||
requiresManualAuth?: boolean;
|
|
||||||
command?: string;
|
|
||||||
error?: string;
|
|
||||||
}>;
|
|
||||||
storeApiKey: (
|
storeApiKey: (
|
||||||
provider: string,
|
provider: string,
|
||||||
apiKey: string
|
apiKey: string
|
||||||
@@ -952,12 +862,8 @@ interface SetupAPI {
|
|||||||
getApiKeys: () => Promise<{
|
getApiKeys: () => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
hasAnthropicKey: boolean;
|
hasAnthropicKey: boolean;
|
||||||
hasOpenAIKey: boolean;
|
|
||||||
hasGoogleKey: boolean;
|
hasGoogleKey: boolean;
|
||||||
}>;
|
}>;
|
||||||
configureCodexMcp: (
|
|
||||||
projectPath: string
|
|
||||||
) => Promise<{ success: boolean; configPath?: string; error?: string }>;
|
|
||||||
getPlatform: () => Promise<{
|
getPlatform: () => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
platform: string;
|
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 () => {
|
installClaude: async () => {
|
||||||
console.log("[Mock] Installing Claude CLI");
|
console.log("[Mock] Installing Claude CLI");
|
||||||
// Simulate installation delay
|
// 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 () => {
|
authClaude: async () => {
|
||||||
console.log("[Mock] Auth Claude CLI");
|
console.log("[Mock] Auth Claude CLI");
|
||||||
return {
|
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) => {
|
storeApiKey: async (provider: string, apiKey: string) => {
|
||||||
console.log("[Mock] Storing API key for:", provider);
|
console.log("[Mock] Storing API key for:", provider);
|
||||||
// In mock mode, we just pretend to store it (it's already in the app store)
|
// In mock mode, we just pretend to store it (it's already in the app store)
|
||||||
@@ -1058,19 +928,10 @@ function createMockSetupAPI(): SetupAPI {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
hasAnthropicKey: false,
|
hasAnthropicKey: false,
|
||||||
hasOpenAIKey: false,
|
|
||||||
hasGoogleKey: 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 () => {
|
getPlatform: async () => {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -371,25 +371,6 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
return this.get("/api/setup/claude-status");
|
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 API
|
||||||
model = {
|
model = {
|
||||||
getAvailable: async (): Promise<{
|
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 API
|
||||||
setup = {
|
setup = {
|
||||||
getClaudeStatus: (): Promise<{
|
getClaudeStatus: (): Promise<{
|
||||||
@@ -440,35 +413,12 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}> => this.get("/api/setup/claude-status"),
|
}> => 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<{
|
installClaude: (): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> => this.post("/api/setup/install-claude"),
|
}> => this.post("/api/setup/install-claude"),
|
||||||
|
|
||||||
installCodex: (): Promise<{
|
|
||||||
success: boolean;
|
|
||||||
message?: string;
|
|
||||||
error?: string;
|
|
||||||
}> => this.post("/api/setup/install-codex"),
|
|
||||||
|
|
||||||
authClaude: (): Promise<{
|
authClaude: (): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
token?: string;
|
token?: string;
|
||||||
@@ -480,15 +430,6 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
output?: string;
|
output?: string;
|
||||||
}> => this.post("/api/setup/auth-claude"),
|
}> => 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: (
|
storeApiKey: (
|
||||||
provider: string,
|
provider: string,
|
||||||
apiKey: string
|
apiKey: string
|
||||||
@@ -500,18 +441,9 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
getApiKeys: (): Promise<{
|
getApiKeys: (): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
hasAnthropicKey: boolean;
|
hasAnthropicKey: boolean;
|
||||||
hasOpenAIKey: boolean;
|
|
||||||
hasGoogleKey: boolean;
|
hasGoogleKey: boolean;
|
||||||
}> => this.get("/api/setup/api-keys"),
|
}> => 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<{
|
getPlatform: (): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
platform: string;
|
platform: string;
|
||||||
|
|||||||
@@ -6,26 +6,12 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
return twMerge(clsx(inputs))
|
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
|
* Determine if the current model supports extended thinking controls
|
||||||
*/
|
*/
|
||||||
export function modelSupportsThinking(model?: AgentModel | string): boolean {
|
export function modelSupportsThinking(model?: AgentModel | string): boolean {
|
||||||
if (!model) return true;
|
// All Claude models support thinking
|
||||||
return !isCodexModel(model);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,10 +22,6 @@ export function getModelDisplayName(model: AgentModel | string): string {
|
|||||||
haiku: "Claude Haiku",
|
haiku: "Claude Haiku",
|
||||||
sonnet: "Claude Sonnet",
|
sonnet: "Claude Sonnet",
|
||||||
opus: "Claude Opus",
|
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;
|
return displayNames[model] || model;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,19 +246,11 @@ export interface FeatureImagePath {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Available models for feature execution
|
// Available models for feature execution
|
||||||
// Claude models
|
|
||||||
export type ClaudeModel = "opus" | "sonnet" | "haiku";
|
export type ClaudeModel = "opus" | "sonnet" | "haiku";
|
||||||
// OpenAI/Codex models
|
export type AgentModel = ClaudeModel;
|
||||||
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;
|
|
||||||
|
|
||||||
// Model provider type
|
// Model provider type
|
||||||
export type ModelProvider = "claude" | "codex";
|
export type ModelProvider = "claude";
|
||||||
|
|
||||||
// Thinking level (budget_tokens) options
|
// Thinking level (budget_tokens) options
|
||||||
export type ThinkingLevel = "none" | "low" | "medium" | "high" | "ultrathink";
|
export type ThinkingLevel = "none" | "low" | "medium" | "high" | "ultrathink";
|
||||||
@@ -570,6 +562,7 @@ export interface AppActions {
|
|||||||
updateAIProfile: (id: string, updates: Partial<AIProfile>) => void;
|
updateAIProfile: (id: string, updates: Partial<AIProfile>) => void;
|
||||||
removeAIProfile: (id: string) => void;
|
removeAIProfile: (id: string) => void;
|
||||||
reorderAIProfiles: (oldIndex: number, newIndex: number) => void;
|
reorderAIProfiles: (oldIndex: number, newIndex: number) => void;
|
||||||
|
resetAIProfiles: () => void;
|
||||||
|
|
||||||
// Project Analysis actions
|
// Project Analysis actions
|
||||||
setProjectAnalysis: (analysis: ProjectAnalysis | null) => void;
|
setProjectAnalysis: (analysis: ProjectAnalysis | null) => void;
|
||||||
@@ -657,26 +650,6 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [
|
|||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
icon: "Zap",
|
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 = {
|
const initialState: AppState = {
|
||||||
@@ -1356,6 +1329,13 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
set({ aiProfiles: profiles });
|
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
|
// Project Analysis actions
|
||||||
setProjectAnalysis: (analysis) => set({ projectAnalysis: analysis }),
|
setProjectAnalysis: (analysis) => set({ projectAnalysis: analysis }),
|
||||||
setIsAnalyzing: (analyzing) => set({ isAnalyzing: analyzing }),
|
setIsAnalyzing: (analyzing) => set({ isAnalyzing: analyzing }),
|
||||||
|
|||||||
@@ -32,26 +32,6 @@ export interface ClaudeAuthStatus {
|
|||||||
error?: string;
|
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
|
// Installation Progress
|
||||||
export interface InstallProgress {
|
export interface InstallProgress {
|
||||||
isInstalling: boolean;
|
isInstalling: boolean;
|
||||||
@@ -65,8 +45,6 @@ export type SetupStep =
|
|||||||
| "welcome"
|
| "welcome"
|
||||||
| "claude_detect"
|
| "claude_detect"
|
||||||
| "claude_auth"
|
| "claude_auth"
|
||||||
| "codex_detect"
|
|
||||||
| "codex_auth"
|
|
||||||
| "complete";
|
| "complete";
|
||||||
|
|
||||||
export interface SetupState {
|
export interface SetupState {
|
||||||
@@ -80,14 +58,8 @@ export interface SetupState {
|
|||||||
claudeAuthStatus: ClaudeAuthStatus | null;
|
claudeAuthStatus: ClaudeAuthStatus | null;
|
||||||
claudeInstallProgress: InstallProgress;
|
claudeInstallProgress: InstallProgress;
|
||||||
|
|
||||||
// Codex CLI state
|
|
||||||
codexCliStatus: CliStatus | null;
|
|
||||||
codexAuthStatus: CodexAuthStatus | null;
|
|
||||||
codexInstallProgress: InstallProgress;
|
|
||||||
|
|
||||||
// Setup preferences
|
// Setup preferences
|
||||||
skipClaudeSetup: boolean;
|
skipClaudeSetup: boolean;
|
||||||
skipCodexSetup: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SetupActions {
|
export interface SetupActions {
|
||||||
@@ -103,15 +75,8 @@ export interface SetupActions {
|
|||||||
setClaudeInstallProgress: (progress: Partial<InstallProgress>) => void;
|
setClaudeInstallProgress: (progress: Partial<InstallProgress>) => void;
|
||||||
resetClaudeInstallProgress: () => void;
|
resetClaudeInstallProgress: () => void;
|
||||||
|
|
||||||
// Codex CLI
|
|
||||||
setCodexCliStatus: (status: CliStatus | null) => void;
|
|
||||||
setCodexAuthStatus: (status: CodexAuthStatus | null) => void;
|
|
||||||
setCodexInstallProgress: (progress: Partial<InstallProgress>) => void;
|
|
||||||
resetCodexInstallProgress: () => void;
|
|
||||||
|
|
||||||
// Preferences
|
// Preferences
|
||||||
setSkipClaudeSetup: (skip: boolean) => void;
|
setSkipClaudeSetup: (skip: boolean) => void;
|
||||||
setSkipCodexSetup: (skip: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialInstallProgress: InstallProgress = {
|
const initialInstallProgress: InstallProgress = {
|
||||||
@@ -130,12 +95,7 @@ const initialState: SetupState = {
|
|||||||
claudeAuthStatus: null,
|
claudeAuthStatus: null,
|
||||||
claudeInstallProgress: { ...initialInstallProgress },
|
claudeInstallProgress: { ...initialInstallProgress },
|
||||||
|
|
||||||
codexCliStatus: null,
|
|
||||||
codexAuthStatus: null,
|
|
||||||
codexInstallProgress: { ...initialInstallProgress },
|
|
||||||
|
|
||||||
skipClaudeSetup: false,
|
skipClaudeSetup: false,
|
||||||
skipCodexSetup: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSetupStore = create<SetupState & SetupActions>()(
|
export const useSetupStore = create<SetupState & SetupActions>()(
|
||||||
@@ -171,26 +131,8 @@ export const useSetupStore = create<SetupState & SetupActions>()(
|
|||||||
claudeInstallProgress: { ...initialInstallProgress },
|
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
|
// Preferences
|
||||||
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),
|
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),
|
||||||
|
|
||||||
setSkipCodexSetup: (skip) => set({ skipCodexSetup: skip }),
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "automaker-setup",
|
name: "automaker-setup",
|
||||||
@@ -198,7 +140,6 @@ export const useSetupStore = create<SetupState & SetupActions>()(
|
|||||||
isFirstRun: state.isFirstRun,
|
isFirstRun: state.isFirstRun,
|
||||||
setupComplete: state.setupComplete,
|
setupComplete: state.setupComplete,
|
||||||
skipClaudeSetup: state.skipClaudeSetup,
|
skipClaudeSetup: state.skipClaudeSetup,
|
||||||
skipCodexSetup: state.skipCodexSetup,
|
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
20
apps/app/src/types/electron.d.ts
vendored
20
apps/app/src/types/electron.d.ts
vendored
@@ -471,24 +471,6 @@ export interface ElectronAPI {
|
|||||||
error?: string;
|
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 Management APIs
|
||||||
model: {
|
model: {
|
||||||
// Get all available models from all providers
|
// Get all available models from all providers
|
||||||
@@ -641,7 +623,7 @@ export interface ModelDefinition {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
modelString: string;
|
modelString: string;
|
||||||
provider: "claude" | "codex";
|
provider: "claude";
|
||||||
description?: string;
|
description?: string;
|
||||||
tier?: "basic" | "standard" | "premium";
|
tier?: "basic" | "standard" | "premium";
|
||||||
default?: boolean;
|
default?: boolean;
|
||||||
|
|||||||
@@ -2437,16 +2437,7 @@ export async function setupFirstRun(page: Page): Promise<void> {
|
|||||||
progress: 0,
|
progress: 0,
|
||||||
output: [],
|
output: [],
|
||||||
},
|
},
|
||||||
codexCliStatus: null,
|
|
||||||
codexAuthStatus: null,
|
|
||||||
codexInstallProgress: {
|
|
||||||
isInstalling: false,
|
|
||||||
currentStep: "",
|
|
||||||
progress: 0,
|
|
||||||
output: [],
|
|
||||||
},
|
|
||||||
skipClaudeSetup: false,
|
skipClaudeSetup: false,
|
||||||
skipCodexSetup: false,
|
|
||||||
},
|
},
|
||||||
version: 0,
|
version: 0,
|
||||||
};
|
};
|
||||||
@@ -2460,7 +2451,7 @@ export async function setupFirstRun(page: Page): Promise<void> {
|
|||||||
currentProject: null,
|
currentProject: null,
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
sidebarOpen: true,
|
sidebarOpen: true,
|
||||||
apiKeys: { anthropic: "", google: "", openai: "" },
|
apiKeys: { anthropic: "", google: "" },
|
||||||
chatSessions: [],
|
chatSessions: [],
|
||||||
chatHistoryOpen: false,
|
chatHistoryOpen: false,
|
||||||
maxConcurrency: 3,
|
maxConcurrency: 3,
|
||||||
@@ -2488,7 +2479,6 @@ export async function setupComplete(page: Page): Promise<void> {
|
|||||||
setupComplete: true,
|
setupComplete: true,
|
||||||
currentStep: "complete",
|
currentStep: "complete",
|
||||||
skipClaudeSetup: false,
|
skipClaudeSetup: false,
|
||||||
skipCodexSetup: false,
|
|
||||||
},
|
},
|
||||||
version: 0,
|
version: 0,
|
||||||
};
|
};
|
||||||
@@ -2530,14 +2520,6 @@ export async function clickClaudeContinue(page: Page): Promise<void> {
|
|||||||
await button.click();
|
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
|
* Click finish on setup complete step
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -38,9 +38,6 @@ DATA_DIR=./data
|
|||||||
# OPTIONAL - Additional AI Providers
|
# OPTIONAL - Additional AI Providers
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# OpenAI API key (for Codex CLI support)
|
|
||||||
OPENAI_API_KEY=
|
|
||||||
|
|
||||||
# Google API key (for future Gemini support)
|
# Google API key (for future Gemini support)
|
||||||
GOOGLE_API_KEY=
|
GOOGLE_API_KEY=
|
||||||
|
|
||||||
|
|||||||
4
apps/server/.gitignore
vendored
4
apps/server/.gitignore
vendored
@@ -1,2 +1,4 @@
|
|||||||
.env
|
.env
|
||||||
data
|
data
|
||||||
|
node_modules
|
||||||
|
coverage
|
||||||
@@ -9,7 +9,13 @@
|
|||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"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": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.1.61",
|
"@anthropic-ai/claude-agent-sdk": "^0.1.61",
|
||||||
@@ -24,7 +30,10 @@
|
|||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
|
"@vitest/coverage-v8": "^4.0.15",
|
||||||
|
"@vitest/ui": "^4.0.15",
|
||||||
"tsx": "^4.19.4",
|
"tsx": "^4.19.4",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.0.15"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2267
apps/server/pnpm-lock.yaml
generated
Normal file
2267
apps/server/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
97
apps/server/src/lib/conversation-utils.ts
Normal file
97
apps/server/src/lib/conversation-utils.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Conversation history utilities for processing message history
|
||||||
|
*
|
||||||
|
* Provides standardized conversation history handling:
|
||||||
|
* - Extract text from content (string or array format)
|
||||||
|
* - Normalize content blocks to array format
|
||||||
|
* - Format history as plain text for CLI-based providers
|
||||||
|
* - Convert history to Claude SDK message format
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ConversationMessage } from "../providers/types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract plain text from message content (handles both string and array formats)
|
||||||
|
*
|
||||||
|
* @param content - Message content (string or array of content blocks)
|
||||||
|
* @returns Extracted text content
|
||||||
|
*/
|
||||||
|
export function extractTextFromContent(
|
||||||
|
content: string | Array<{ type: string; text?: string; source?: object }>
|
||||||
|
): string {
|
||||||
|
if (typeof content === "string") {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract text blocks only
|
||||||
|
return content
|
||||||
|
.filter((block) => block.type === "text")
|
||||||
|
.map((block) => block.text || "")
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize message content to array format
|
||||||
|
*
|
||||||
|
* @param content - Message content (string or array)
|
||||||
|
* @returns Content as array of blocks
|
||||||
|
*/
|
||||||
|
export function normalizeContentBlocks(
|
||||||
|
content: string | Array<{ type: string; text?: string; source?: object }>
|
||||||
|
): Array<{ type: string; text?: string; source?: object }> {
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
return [{ type: "text", text: content }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format conversation history as plain text for CLI-based providers
|
||||||
|
*
|
||||||
|
* @param history - Array of conversation messages
|
||||||
|
* @returns Formatted text with role labels
|
||||||
|
*/
|
||||||
|
export function formatHistoryAsText(history: ConversationMessage[]): string {
|
||||||
|
if (history.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
let historyText = "Previous conversation:\n\n";
|
||||||
|
|
||||||
|
for (const msg of history) {
|
||||||
|
const contentText = extractTextFromContent(msg.content);
|
||||||
|
const role = msg.role === "user" ? "User" : "Assistant";
|
||||||
|
historyText += `${role}: ${contentText}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
historyText += "---\n\n";
|
||||||
|
return historyText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert conversation history to Claude SDK message format
|
||||||
|
*
|
||||||
|
* @param history - Array of conversation messages
|
||||||
|
* @returns Array of Claude SDK formatted messages
|
||||||
|
*/
|
||||||
|
export function convertHistoryToMessages(
|
||||||
|
history: ConversationMessage[]
|
||||||
|
): Array<{
|
||||||
|
type: "user" | "assistant";
|
||||||
|
session_id: string;
|
||||||
|
message: {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: Array<{ type: string; text?: string; source?: object }>;
|
||||||
|
};
|
||||||
|
parent_tool_use_id: null;
|
||||||
|
}> {
|
||||||
|
return history.map((historyMsg) => ({
|
||||||
|
type: historyMsg.role,
|
||||||
|
session_id: "",
|
||||||
|
message: {
|
||||||
|
role: historyMsg.role,
|
||||||
|
content: normalizeContentBlocks(historyMsg.content),
|
||||||
|
},
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
104
apps/server/src/lib/error-handler.ts
Normal file
104
apps/server/src/lib/error-handler.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Error handling utilities for standardized error classification
|
||||||
|
*
|
||||||
|
* Provides utilities for:
|
||||||
|
* - Detecting abort/cancellation errors
|
||||||
|
* - Detecting authentication errors
|
||||||
|
* - Classifying errors by type
|
||||||
|
* - Generating user-friendly error messages
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error is an abort/cancellation error
|
||||||
|
*
|
||||||
|
* @param error - The error to check
|
||||||
|
* @returns True if the error is an abort error
|
||||||
|
*/
|
||||||
|
export function isAbortError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof Error &&
|
||||||
|
(error.name === "AbortError" || error.message.includes("abort"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error is an authentication/API key error
|
||||||
|
*
|
||||||
|
* @param errorMessage - The error message to check
|
||||||
|
* @returns True if the error is authentication-related
|
||||||
|
*/
|
||||||
|
export function isAuthenticationError(errorMessage: string): boolean {
|
||||||
|
return (
|
||||||
|
errorMessage.includes("Authentication failed") ||
|
||||||
|
errorMessage.includes("Invalid API key") ||
|
||||||
|
errorMessage.includes("authentication_failed") ||
|
||||||
|
errorMessage.includes("Fix external API key")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error type classification
|
||||||
|
*/
|
||||||
|
export type ErrorType = "authentication" | "abort" | "execution" | "unknown";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classified error information
|
||||||
|
*/
|
||||||
|
export interface ErrorInfo {
|
||||||
|
type: ErrorType;
|
||||||
|
message: string;
|
||||||
|
isAbort: boolean;
|
||||||
|
isAuth: boolean;
|
||||||
|
originalError: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify an error into a specific type
|
||||||
|
*
|
||||||
|
* @param error - The error to classify
|
||||||
|
* @returns Classified error information
|
||||||
|
*/
|
||||||
|
export function classifyError(error: unknown): ErrorInfo {
|
||||||
|
const message = error instanceof Error ? error.message : String(error || "Unknown error");
|
||||||
|
const isAbort = isAbortError(error);
|
||||||
|
const isAuth = isAuthenticationError(message);
|
||||||
|
|
||||||
|
let type: ErrorType;
|
||||||
|
if (isAuth) {
|
||||||
|
type = "authentication";
|
||||||
|
} else if (isAbort) {
|
||||||
|
type = "abort";
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
type = "execution";
|
||||||
|
} else {
|
||||||
|
type = "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
message,
|
||||||
|
isAbort,
|
||||||
|
isAuth,
|
||||||
|
originalError: error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a user-friendly error message
|
||||||
|
*
|
||||||
|
* @param error - The error to convert
|
||||||
|
* @returns User-friendly error message
|
||||||
|
*/
|
||||||
|
export function getUserFriendlyErrorMessage(error: unknown): string {
|
||||||
|
const info = classifyError(error);
|
||||||
|
|
||||||
|
if (info.isAbort) {
|
||||||
|
return "Operation was cancelled";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.isAuth) {
|
||||||
|
return "Authentication failed. Please check your API key.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return info.message;
|
||||||
|
}
|
||||||
135
apps/server/src/lib/image-handler.ts
Normal file
135
apps/server/src/lib/image-handler.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* Image handling utilities for processing image files
|
||||||
|
*
|
||||||
|
* Provides utilities for:
|
||||||
|
* - MIME type detection based on file extensions
|
||||||
|
* - Base64 encoding of image files
|
||||||
|
* - Content block generation for Claude SDK format
|
||||||
|
* - Path resolution (relative/absolute)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MIME type mapping for image file extensions
|
||||||
|
*/
|
||||||
|
const IMAGE_MIME_TYPES: Record<string, string> = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Image data with base64 encoding and metadata
|
||||||
|
*/
|
||||||
|
export interface ImageData {
|
||||||
|
base64: string;
|
||||||
|
mimeType: string;
|
||||||
|
filename: string;
|
||||||
|
originalPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content block for image (Claude SDK format)
|
||||||
|
*/
|
||||||
|
export interface ImageContentBlock {
|
||||||
|
type: "image";
|
||||||
|
source: {
|
||||||
|
type: "base64";
|
||||||
|
media_type: string;
|
||||||
|
data: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get MIME type for an image file based on extension
|
||||||
|
*
|
||||||
|
* @param imagePath - Path to the image file
|
||||||
|
* @returns MIME type string (defaults to "image/png" for unknown extensions)
|
||||||
|
*/
|
||||||
|
export function getMimeTypeForImage(imagePath: string): string {
|
||||||
|
const ext = path.extname(imagePath).toLowerCase();
|
||||||
|
return IMAGE_MIME_TYPES[ext] || "image/png";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read an image file and convert to base64 with metadata
|
||||||
|
*
|
||||||
|
* @param imagePath - Path to the image file
|
||||||
|
* @returns Promise resolving to image data with base64 encoding
|
||||||
|
* @throws Error if file cannot be read
|
||||||
|
*/
|
||||||
|
export async function readImageAsBase64(imagePath: string): Promise<ImageData> {
|
||||||
|
const imageBuffer = await fs.readFile(imagePath);
|
||||||
|
const base64Data = imageBuffer.toString("base64");
|
||||||
|
const mimeType = getMimeTypeForImage(imagePath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
base64: base64Data,
|
||||||
|
mimeType,
|
||||||
|
filename: path.basename(imagePath),
|
||||||
|
originalPath: imagePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert image paths to content blocks (Claude SDK format)
|
||||||
|
* Handles both relative and absolute paths
|
||||||
|
*
|
||||||
|
* @param imagePaths - Array of image file paths
|
||||||
|
* @param workDir - Optional working directory for resolving relative paths
|
||||||
|
* @returns Promise resolving to array of image content blocks
|
||||||
|
*/
|
||||||
|
export async function convertImagesToContentBlocks(
|
||||||
|
imagePaths: string[],
|
||||||
|
workDir?: string
|
||||||
|
): Promise<ImageContentBlock[]> {
|
||||||
|
const blocks: ImageContentBlock[] = [];
|
||||||
|
|
||||||
|
for (const imagePath of imagePaths) {
|
||||||
|
try {
|
||||||
|
// Resolve to absolute path if needed
|
||||||
|
const absolutePath = workDir && !path.isAbsolute(imagePath)
|
||||||
|
? path.join(workDir, imagePath)
|
||||||
|
: imagePath;
|
||||||
|
|
||||||
|
const imageData = await readImageAsBase64(absolutePath);
|
||||||
|
|
||||||
|
blocks.push({
|
||||||
|
type: "image",
|
||||||
|
source: {
|
||||||
|
type: "base64",
|
||||||
|
media_type: imageData.mimeType,
|
||||||
|
data: imageData.base64,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[ImageHandler] Failed to load image ${imagePath}:`, error);
|
||||||
|
// Continue processing other images
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a list of image paths for text prompts
|
||||||
|
* Formats image paths as a bulleted list for inclusion in text prompts
|
||||||
|
*
|
||||||
|
* @param imagePaths - Array of image file paths
|
||||||
|
* @returns Formatted string with image paths, or empty string if no images
|
||||||
|
*/
|
||||||
|
export function formatImagePathsForPrompt(imagePaths: string[]): string {
|
||||||
|
if (imagePaths.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = "\n\nAttached images:\n";
|
||||||
|
for (const imagePath of imagePaths) {
|
||||||
|
text += `- ${imagePath}\n`;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
80
apps/server/src/lib/model-resolver.ts
Normal file
80
apps/server/src/lib/model-resolver.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Model resolution utilities for handling model string mapping
|
||||||
|
*
|
||||||
|
* Provides centralized model resolution logic:
|
||||||
|
* - Maps Claude model aliases to full model strings
|
||||||
|
* - Provides default models per provider
|
||||||
|
* - Handles multiple model sources with priority
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model alias mapping for Claude models
|
||||||
|
*/
|
||||||
|
export const CLAUDE_MODEL_MAP: Record<string, string> = {
|
||||||
|
haiku: "claude-haiku-4-5",
|
||||||
|
sonnet: "claude-sonnet-4-20250514",
|
||||||
|
opus: "claude-opus-4-5-20251101",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default models per provider
|
||||||
|
*/
|
||||||
|
export const DEFAULT_MODELS = {
|
||||||
|
claude: "claude-opus-4-5-20251101",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a model key/alias to a full model string
|
||||||
|
*
|
||||||
|
* @param modelKey - Model key (e.g., "opus", "gpt-5.2", "claude-sonnet-4-20250514")
|
||||||
|
* @param defaultModel - Fallback model if modelKey is undefined
|
||||||
|
* @returns Full model string
|
||||||
|
*/
|
||||||
|
export function resolveModelString(
|
||||||
|
modelKey?: string,
|
||||||
|
defaultModel: string = DEFAULT_MODELS.claude
|
||||||
|
): string {
|
||||||
|
// No model specified - use default
|
||||||
|
if (!modelKey) {
|
||||||
|
return defaultModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full Claude model string - pass through unchanged
|
||||||
|
if (modelKey.includes("claude-")) {
|
||||||
|
console.log(`[ModelResolver] Using full Claude model string: ${modelKey}`);
|
||||||
|
return modelKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up Claude model alias
|
||||||
|
const resolved = CLAUDE_MODEL_MAP[modelKey];
|
||||||
|
if (resolved) {
|
||||||
|
console.log(`[ModelResolver] Resolved model alias: "${modelKey}" -> "${resolved}"`);
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown model key - use default
|
||||||
|
console.warn(
|
||||||
|
`[ModelResolver] Unknown model key "${modelKey}", using default: "${defaultModel}"`
|
||||||
|
);
|
||||||
|
return defaultModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the effective model from multiple sources
|
||||||
|
* Priority: explicit model > session model > default
|
||||||
|
*
|
||||||
|
* @param explicitModel - Explicitly provided model (highest priority)
|
||||||
|
* @param sessionModel - Model from session (medium priority)
|
||||||
|
* @param defaultModel - Fallback default model (lowest priority)
|
||||||
|
* @returns Resolved model string
|
||||||
|
*/
|
||||||
|
export function getEffectiveModel(
|
||||||
|
explicitModel?: string,
|
||||||
|
sessionModel?: string,
|
||||||
|
defaultModel?: string
|
||||||
|
): string {
|
||||||
|
return resolveModelString(
|
||||||
|
explicitModel || sessionModel,
|
||||||
|
defaultModel
|
||||||
|
);
|
||||||
|
}
|
||||||
79
apps/server/src/lib/prompt-builder.ts
Normal file
79
apps/server/src/lib/prompt-builder.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Prompt building utilities for constructing prompts with images
|
||||||
|
*
|
||||||
|
* Provides standardized prompt building that:
|
||||||
|
* - Combines text prompts with image attachments
|
||||||
|
* - Handles content block array generation
|
||||||
|
* - Optionally includes image paths in text
|
||||||
|
* - Supports both vision and non-vision models
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { convertImagesToContentBlocks, formatImagePathsForPrompt } from "./image-handler.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content that can be either simple text or structured blocks
|
||||||
|
*/
|
||||||
|
export type PromptContent = string | Array<{
|
||||||
|
type: string;
|
||||||
|
text?: string;
|
||||||
|
source?: object;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of building a prompt with optional images
|
||||||
|
*/
|
||||||
|
export interface PromptWithImages {
|
||||||
|
content: PromptContent;
|
||||||
|
hasImages: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a prompt with optional image attachments
|
||||||
|
*
|
||||||
|
* @param basePrompt - The text prompt
|
||||||
|
* @param imagePaths - Optional array of image file paths
|
||||||
|
* @param workDir - Optional working directory for resolving relative paths
|
||||||
|
* @param includeImagePaths - Whether to append image paths to the text (default: false)
|
||||||
|
* @returns Promise resolving to prompt content and metadata
|
||||||
|
*/
|
||||||
|
export async function buildPromptWithImages(
|
||||||
|
basePrompt: string,
|
||||||
|
imagePaths?: string[],
|
||||||
|
workDir?: string,
|
||||||
|
includeImagePaths: boolean = false
|
||||||
|
): Promise<PromptWithImages> {
|
||||||
|
// No images - return plain text
|
||||||
|
if (!imagePaths || imagePaths.length === 0) {
|
||||||
|
return { content: basePrompt, hasImages: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build text content with optional image path listing
|
||||||
|
let textContent = basePrompt;
|
||||||
|
if (includeImagePaths) {
|
||||||
|
textContent += formatImagePathsForPrompt(imagePaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build content blocks array
|
||||||
|
const contentBlocks: Array<{
|
||||||
|
type: string;
|
||||||
|
text?: string;
|
||||||
|
source?: object;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// Add text block if we have text
|
||||||
|
if (textContent.trim()) {
|
||||||
|
contentBlocks.push({ type: "text", text: textContent });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add image blocks
|
||||||
|
const imageBlocks = await convertImagesToContentBlocks(imagePaths, workDir);
|
||||||
|
contentBlocks.push(...imageBlocks);
|
||||||
|
|
||||||
|
// Return appropriate format
|
||||||
|
const content: PromptContent =
|
||||||
|
contentBlocks.length > 1 || contentBlocks[0]?.type === "image"
|
||||||
|
? contentBlocks
|
||||||
|
: textContent;
|
||||||
|
|
||||||
|
return { content, hasImages: true };
|
||||||
|
}
|
||||||
206
apps/server/src/lib/subprocess-manager.ts
Normal file
206
apps/server/src/lib/subprocess-manager.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* Subprocess management utilities for CLI providers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn, type ChildProcess } from "child_process";
|
||||||
|
import readline from "readline";
|
||||||
|
|
||||||
|
export interface SubprocessOptions {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
cwd: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
abortController?: AbortController;
|
||||||
|
timeout?: number; // Milliseconds of no output before timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubprocessResult {
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
exitCode: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawns a subprocess and streams JSONL output line-by-line
|
||||||
|
*/
|
||||||
|
export async function* spawnJSONLProcess(
|
||||||
|
options: SubprocessOptions
|
||||||
|
): AsyncGenerator<unknown> {
|
||||||
|
const { command, args, cwd, env, abortController, timeout = 30000 } = options;
|
||||||
|
|
||||||
|
const processEnv = {
|
||||||
|
...process.env,
|
||||||
|
...env,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[SubprocessManager] Spawning: ${command} ${args.slice(0, -1).join(" ")}`);
|
||||||
|
console.log(`[SubprocessManager] Working directory: ${cwd}`);
|
||||||
|
|
||||||
|
const childProcess: ChildProcess = spawn(command, args, {
|
||||||
|
cwd,
|
||||||
|
env: processEnv,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stderrOutput = "";
|
||||||
|
let lastOutputTime = Date.now();
|
||||||
|
let timeoutHandle: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
// Collect stderr for error reporting
|
||||||
|
if (childProcess.stderr) {
|
||||||
|
childProcess.stderr.on("data", (data: Buffer) => {
|
||||||
|
const text = data.toString();
|
||||||
|
stderrOutput += text;
|
||||||
|
console.error(`[SubprocessManager] stderr: ${text}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup timeout detection
|
||||||
|
const resetTimeout = () => {
|
||||||
|
lastOutputTime = Date.now();
|
||||||
|
if (timeoutHandle) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
}
|
||||||
|
timeoutHandle = setTimeout(() => {
|
||||||
|
const elapsed = Date.now() - lastOutputTime;
|
||||||
|
if (elapsed >= timeout) {
|
||||||
|
console.error(
|
||||||
|
`[SubprocessManager] Process timeout: no output for ${timeout}ms`
|
||||||
|
);
|
||||||
|
childProcess.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
}, timeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
resetTimeout();
|
||||||
|
|
||||||
|
// Setup abort handling
|
||||||
|
if (abortController) {
|
||||||
|
abortController.signal.addEventListener("abort", () => {
|
||||||
|
console.log("[SubprocessManager] Abort signal received, killing process");
|
||||||
|
if (timeoutHandle) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
}
|
||||||
|
childProcess.kill("SIGTERM");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse stdout as JSONL (one JSON object per line)
|
||||||
|
if (childProcess.stdout) {
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: childProcess.stdout,
|
||||||
|
crlfDelay: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const line of rl) {
|
||||||
|
resetTimeout();
|
||||||
|
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line);
|
||||||
|
yield parsed;
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error(
|
||||||
|
`[SubprocessManager] Failed to parse JSONL line: ${line}`,
|
||||||
|
parseError
|
||||||
|
);
|
||||||
|
// Yield error but continue processing
|
||||||
|
yield {
|
||||||
|
type: "error",
|
||||||
|
error: `Failed to parse output: ${line}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SubprocessManager] Error reading stdout:", error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (timeoutHandle) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for process to exit
|
||||||
|
const exitCode = await new Promise<number | null>((resolve) => {
|
||||||
|
childProcess.on("exit", (code) => {
|
||||||
|
console.log(`[SubprocessManager] Process exited with code: ${code}`);
|
||||||
|
resolve(code);
|
||||||
|
});
|
||||||
|
|
||||||
|
childProcess.on("error", (error) => {
|
||||||
|
console.error("[SubprocessManager] Process error:", error);
|
||||||
|
resolve(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle non-zero exit codes
|
||||||
|
if (exitCode !== 0 && exitCode !== null) {
|
||||||
|
const errorMessage = stderrOutput || `Process exited with code ${exitCode}`;
|
||||||
|
console.error(`[SubprocessManager] Process failed: ${errorMessage}`);
|
||||||
|
yield {
|
||||||
|
type: "error",
|
||||||
|
error: errorMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process completed successfully
|
||||||
|
if (exitCode === 0 && !stderrOutput) {
|
||||||
|
console.log("[SubprocessManager] Process completed successfully");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawns a subprocess and collects all output
|
||||||
|
*/
|
||||||
|
export async function spawnProcess(
|
||||||
|
options: SubprocessOptions
|
||||||
|
): Promise<SubprocessResult> {
|
||||||
|
const { command, args, cwd, env, abortController } = options;
|
||||||
|
|
||||||
|
const processEnv = {
|
||||||
|
...process.env,
|
||||||
|
...env,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const childProcess = spawn(command, args, {
|
||||||
|
cwd,
|
||||||
|
env: processEnv,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
|
||||||
|
if (childProcess.stdout) {
|
||||||
|
childProcess.stdout.on("data", (data: Buffer) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childProcess.stderr) {
|
||||||
|
childProcess.stderr.on("data", (data: Buffer) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup abort handling
|
||||||
|
if (abortController) {
|
||||||
|
abortController.signal.addEventListener("abort", () => {
|
||||||
|
childProcess.kill("SIGTERM");
|
||||||
|
reject(new Error("Process aborted"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
childProcess.on("exit", (code) => {
|
||||||
|
resolve({ stdout, stderr, exitCode: code });
|
||||||
|
});
|
||||||
|
|
||||||
|
childProcess.on("error", (error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
96
apps/server/src/providers/base-provider.ts
Normal file
96
apps/server/src/providers/base-provider.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Abstract base class for AI model providers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ProviderConfig,
|
||||||
|
ExecuteOptions,
|
||||||
|
ProviderMessage,
|
||||||
|
InstallationStatus,
|
||||||
|
ValidationResult,
|
||||||
|
ModelDefinition,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base provider class that all provider implementations must extend
|
||||||
|
*/
|
||||||
|
export abstract class BaseProvider {
|
||||||
|
protected config: ProviderConfig;
|
||||||
|
protected name: string;
|
||||||
|
|
||||||
|
constructor(config: ProviderConfig = {}) {
|
||||||
|
this.config = config;
|
||||||
|
this.name = this.getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the provider name (e.g., "claude", "cursor")
|
||||||
|
*/
|
||||||
|
abstract getName(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a query and stream responses
|
||||||
|
* @param options Execution options
|
||||||
|
* @returns AsyncGenerator yielding provider messages
|
||||||
|
*/
|
||||||
|
abstract executeQuery(
|
||||||
|
options: ExecuteOptions
|
||||||
|
): AsyncGenerator<ProviderMessage>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect if the provider is installed and configured
|
||||||
|
* @returns Installation status
|
||||||
|
*/
|
||||||
|
abstract detectInstallation(): Promise<InstallationStatus>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available models for this provider
|
||||||
|
* @returns Array of model definitions
|
||||||
|
*/
|
||||||
|
abstract getAvailableModels(): ModelDefinition[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the provider configuration
|
||||||
|
* @returns Validation result
|
||||||
|
*/
|
||||||
|
validateConfig(): ValidationResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// Base validation (can be overridden)
|
||||||
|
if (!this.config) {
|
||||||
|
errors.push("Provider config is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the provider supports a specific feature
|
||||||
|
* @param feature Feature name (e.g., "vision", "tools", "mcp")
|
||||||
|
* @returns Whether the feature is supported
|
||||||
|
*/
|
||||||
|
supportsFeature(feature: string): boolean {
|
||||||
|
// Default implementation - override in subclasses
|
||||||
|
const commonFeatures = ["tools", "text"];
|
||||||
|
return commonFeatures.includes(feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get provider configuration
|
||||||
|
*/
|
||||||
|
getConfig(): ProviderConfig {
|
||||||
|
return this.config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update provider configuration
|
||||||
|
*/
|
||||||
|
setConfig(config: Partial<ProviderConfig>): void {
|
||||||
|
this.config = { ...this.config, ...config };
|
||||||
|
}
|
||||||
|
}
|
||||||
192
apps/server/src/providers/claude-provider.ts
Normal file
192
apps/server/src/providers/claude-provider.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* Claude Provider - Executes queries using Claude Agent SDK
|
||||||
|
*
|
||||||
|
* Wraps the @anthropic-ai/claude-agent-sdk for seamless integration
|
||||||
|
* with the provider architecture.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
import { BaseProvider } from "./base-provider.js";
|
||||||
|
import { convertHistoryToMessages, normalizeContentBlocks } from "../lib/conversation-utils.js";
|
||||||
|
import type {
|
||||||
|
ExecuteOptions,
|
||||||
|
ProviderMessage,
|
||||||
|
InstallationStatus,
|
||||||
|
ModelDefinition,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export class ClaudeProvider extends BaseProvider {
|
||||||
|
getName(): string {
|
||||||
|
return "claude";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a query using Claude Agent SDK
|
||||||
|
*/
|
||||||
|
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||||
|
const {
|
||||||
|
prompt,
|
||||||
|
model,
|
||||||
|
cwd,
|
||||||
|
systemPrompt,
|
||||||
|
maxTurns = 20,
|
||||||
|
allowedTools,
|
||||||
|
abortController,
|
||||||
|
conversationHistory,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Build Claude SDK options
|
||||||
|
const sdkOptions: Options = {
|
||||||
|
model,
|
||||||
|
systemPrompt,
|
||||||
|
maxTurns,
|
||||||
|
cwd,
|
||||||
|
allowedTools: allowedTools || [
|
||||||
|
"Read",
|
||||||
|
"Write",
|
||||||
|
"Edit",
|
||||||
|
"Glob",
|
||||||
|
"Grep",
|
||||||
|
"Bash",
|
||||||
|
"WebSearch",
|
||||||
|
"WebFetch",
|
||||||
|
],
|
||||||
|
permissionMode: "acceptEdits",
|
||||||
|
sandbox: {
|
||||||
|
enabled: true,
|
||||||
|
autoAllowBashIfSandboxed: true,
|
||||||
|
},
|
||||||
|
abortController,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build prompt payload with conversation history
|
||||||
|
let promptPayload: string | AsyncGenerator<any, void, unknown>;
|
||||||
|
|
||||||
|
if (conversationHistory && conversationHistory.length > 0) {
|
||||||
|
// Multi-turn conversation with history
|
||||||
|
promptPayload = (async function* () {
|
||||||
|
// Yield history messages using utility
|
||||||
|
const historyMessages = convertHistoryToMessages(conversationHistory);
|
||||||
|
for (const msg of historyMessages) {
|
||||||
|
yield msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield current prompt
|
||||||
|
yield {
|
||||||
|
type: "user" as const,
|
||||||
|
session_id: "",
|
||||||
|
message: {
|
||||||
|
role: "user" as const,
|
||||||
|
content: normalizeContentBlocks(prompt),
|
||||||
|
},
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
} else if (Array.isArray(prompt)) {
|
||||||
|
// Multi-part prompt (with images) - no history
|
||||||
|
promptPayload = (async function* () {
|
||||||
|
yield {
|
||||||
|
type: "user" as const,
|
||||||
|
session_id: "",
|
||||||
|
message: {
|
||||||
|
role: "user" as const,
|
||||||
|
content: prompt,
|
||||||
|
},
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
} else {
|
||||||
|
// Simple text prompt - no history
|
||||||
|
promptPayload = prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute via Claude Agent SDK
|
||||||
|
const stream = query({ prompt: promptPayload, options: sdkOptions });
|
||||||
|
|
||||||
|
// Stream messages directly - they're already in the correct format
|
||||||
|
for await (const msg of stream) {
|
||||||
|
yield msg as ProviderMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect Claude SDK installation (always available via npm)
|
||||||
|
*/
|
||||||
|
async detectInstallation(): Promise<InstallationStatus> {
|
||||||
|
// Claude SDK is always available since it's a dependency
|
||||||
|
const hasApiKey =
|
||||||
|
!!process.env.ANTHROPIC_API_KEY || !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||||
|
|
||||||
|
return {
|
||||||
|
installed: true,
|
||||||
|
method: "sdk",
|
||||||
|
hasApiKey,
|
||||||
|
authenticated: hasApiKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available Claude models
|
||||||
|
*/
|
||||||
|
getAvailableModels(): ModelDefinition[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "claude-opus-4-5-20251101",
|
||||||
|
name: "Claude Opus 4.5",
|
||||||
|
modelString: "claude-opus-4-5-20251101",
|
||||||
|
provider: "anthropic",
|
||||||
|
description: "Most capable Claude model",
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxOutputTokens: 16000,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
tier: "premium",
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-sonnet-4-20250514",
|
||||||
|
name: "Claude Sonnet 4",
|
||||||
|
modelString: "claude-sonnet-4-20250514",
|
||||||
|
provider: "anthropic",
|
||||||
|
description: "Balanced performance and cost",
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxOutputTokens: 16000,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
tier: "standard",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-3-5-sonnet-20241022",
|
||||||
|
name: "Claude 3.5 Sonnet",
|
||||||
|
modelString: "claude-3-5-sonnet-20241022",
|
||||||
|
provider: "anthropic",
|
||||||
|
description: "Fast and capable",
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxOutputTokens: 8000,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
tier: "standard",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-3-5-haiku-20241022",
|
||||||
|
name: "Claude 3.5 Haiku",
|
||||||
|
modelString: "claude-3-5-haiku-20241022",
|
||||||
|
provider: "anthropic",
|
||||||
|
description: "Fastest Claude model",
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxOutputTokens: 8000,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
tier: "basic",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the provider supports a specific feature
|
||||||
|
*/
|
||||||
|
supportsFeature(feature: string): boolean {
|
||||||
|
const supportedFeatures = ["tools", "text", "vision", "thinking"];
|
||||||
|
return supportedFeatures.includes(feature);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
apps/server/src/providers/provider-factory.ts
Normal file
115
apps/server/src/providers/provider-factory.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Provider Factory - Routes model IDs to the appropriate provider
|
||||||
|
*
|
||||||
|
* This factory implements model-based routing to automatically select
|
||||||
|
* the correct provider based on the model string. This makes adding
|
||||||
|
* new providers (Cursor, OpenCode, etc.) trivial - just add one line.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BaseProvider } from "./base-provider.js";
|
||||||
|
import { ClaudeProvider } from "./claude-provider.js";
|
||||||
|
import type { InstallationStatus } from "./types.js";
|
||||||
|
|
||||||
|
export class ProviderFactory {
|
||||||
|
/**
|
||||||
|
* Get the appropriate provider for a given model ID
|
||||||
|
*
|
||||||
|
* @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "gpt-5.2", "cursor-fast")
|
||||||
|
* @returns Provider instance for the model
|
||||||
|
*/
|
||||||
|
static getProviderForModel(modelId: string): BaseProvider {
|
||||||
|
const lowerModel = modelId.toLowerCase();
|
||||||
|
|
||||||
|
// Claude models (claude-*, opus, sonnet, haiku)
|
||||||
|
if (
|
||||||
|
lowerModel.startsWith("claude-") ||
|
||||||
|
["haiku", "sonnet", "opus"].includes(lowerModel)
|
||||||
|
) {
|
||||||
|
return new ClaudeProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Future providers:
|
||||||
|
// if (lowerModel.startsWith("cursor-")) {
|
||||||
|
// return new CursorProvider();
|
||||||
|
// }
|
||||||
|
// if (lowerModel.startsWith("opencode-")) {
|
||||||
|
// return new OpenCodeProvider();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Default to Claude for unknown models
|
||||||
|
console.warn(
|
||||||
|
`[ProviderFactory] Unknown model prefix for "${modelId}", defaulting to Claude`
|
||||||
|
);
|
||||||
|
return new ClaudeProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available providers
|
||||||
|
*/
|
||||||
|
static getAllProviders(): BaseProvider[] {
|
||||||
|
return [
|
||||||
|
new ClaudeProvider(),
|
||||||
|
// Future providers...
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check installation status for all providers
|
||||||
|
*
|
||||||
|
* @returns Map of provider name to installation status
|
||||||
|
*/
|
||||||
|
static async checkAllProviders(): Promise<
|
||||||
|
Record<string, InstallationStatus>
|
||||||
|
> {
|
||||||
|
const providers = this.getAllProviders();
|
||||||
|
const statuses: Record<string, InstallationStatus> = {};
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
const name = provider.getName();
|
||||||
|
const status = await provider.detectInstallation();
|
||||||
|
statuses[name] = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
return statuses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get provider by name (for direct access if needed)
|
||||||
|
*
|
||||||
|
* @param name Provider name (e.g., "claude", "cursor")
|
||||||
|
* @returns Provider instance or null if not found
|
||||||
|
*/
|
||||||
|
static getProviderByName(name: string): BaseProvider | null {
|
||||||
|
const lowerName = name.toLowerCase();
|
||||||
|
|
||||||
|
switch (lowerName) {
|
||||||
|
case "claude":
|
||||||
|
case "anthropic":
|
||||||
|
return new ClaudeProvider();
|
||||||
|
|
||||||
|
// Future providers:
|
||||||
|
// case "cursor":
|
||||||
|
// return new CursorProvider();
|
||||||
|
// case "opencode":
|
||||||
|
// return new OpenCodeProvider();
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available models from all providers
|
||||||
|
*/
|
||||||
|
static getAllAvailableModels() {
|
||||||
|
const providers = this.getAllProviders();
|
||||||
|
const allModels = [];
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
const models = provider.getAvailableModels();
|
||||||
|
allModels.push(...models);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allModels;
|
||||||
|
}
|
||||||
|
}
|
||||||
103
apps/server/src/providers/types.ts
Normal file
103
apps/server/src/providers/types.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Shared types for AI model providers
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for a provider instance
|
||||||
|
*/
|
||||||
|
export interface ProviderConfig {
|
||||||
|
apiKey?: string;
|
||||||
|
cliPath?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message in conversation history
|
||||||
|
*/
|
||||||
|
export interface ConversationMessage {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string | Array<{ type: string; text?: string; source?: object }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for executing a query via a provider
|
||||||
|
*/
|
||||||
|
export interface ExecuteOptions {
|
||||||
|
prompt: string | Array<{ type: string; text?: string; source?: object }>;
|
||||||
|
model: string;
|
||||||
|
cwd: string;
|
||||||
|
systemPrompt?: string;
|
||||||
|
maxTurns?: number;
|
||||||
|
allowedTools?: string[];
|
||||||
|
mcpServers?: Record<string, unknown>;
|
||||||
|
abortController?: AbortController;
|
||||||
|
conversationHistory?: ConversationMessage[]; // Previous messages for context
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content block in a provider message (matches Claude SDK format)
|
||||||
|
*/
|
||||||
|
export interface ContentBlock {
|
||||||
|
type: "text" | "tool_use" | "thinking" | "tool_result";
|
||||||
|
text?: string;
|
||||||
|
thinking?: string;
|
||||||
|
name?: string;
|
||||||
|
input?: unknown;
|
||||||
|
tool_use_id?: string;
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message returned by a provider (matches Claude SDK streaming format)
|
||||||
|
*/
|
||||||
|
export interface ProviderMessage {
|
||||||
|
type: "assistant" | "user" | "error" | "result";
|
||||||
|
subtype?: "success" | "error";
|
||||||
|
session_id?: string;
|
||||||
|
message?: {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: ContentBlock[];
|
||||||
|
};
|
||||||
|
result?: string;
|
||||||
|
error?: string;
|
||||||
|
parent_tool_use_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installation status for a provider
|
||||||
|
*/
|
||||||
|
export interface InstallationStatus {
|
||||||
|
installed: boolean;
|
||||||
|
path?: string;
|
||||||
|
version?: string;
|
||||||
|
method?: "cli" | "npm" | "brew" | "sdk";
|
||||||
|
hasApiKey?: boolean;
|
||||||
|
authenticated?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation result
|
||||||
|
*/
|
||||||
|
export interface ValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
warnings?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model definition
|
||||||
|
*/
|
||||||
|
export interface ModelDefinition {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
modelString: string;
|
||||||
|
provider: string;
|
||||||
|
description: string;
|
||||||
|
contextWindow?: number;
|
||||||
|
maxOutputTokens?: number;
|
||||||
|
supportsVision?: boolean;
|
||||||
|
supportsTools?: boolean;
|
||||||
|
tier?: "basic" | "standard" | "premium";
|
||||||
|
default?: boolean;
|
||||||
|
}
|
||||||
@@ -40,11 +40,12 @@ export function createAgentRoutes(
|
|||||||
// Send a message
|
// Send a message
|
||||||
router.post("/send", async (req: Request, res: Response) => {
|
router.post("/send", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { sessionId, message, workingDirectory, imagePaths } = req.body as {
|
const { sessionId, message, workingDirectory, imagePaths, model } = req.body as {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
message: string;
|
message: string;
|
||||||
workingDirectory?: string;
|
workingDirectory?: string;
|
||||||
imagePaths?: string[];
|
imagePaths?: string[];
|
||||||
|
model?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!sessionId || !message) {
|
if (!sessionId || !message) {
|
||||||
@@ -61,6 +62,7 @@ export function createAgentRoutes(
|
|||||||
message,
|
message,
|
||||||
workingDirectory,
|
workingDirectory,
|
||||||
imagePaths,
|
imagePaths,
|
||||||
|
model,
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("[Agent Route] Error sending message:", 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;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Router, type Request, type Response } from "express";
|
import { Router, type Request, type Response } from "express";
|
||||||
|
import { ProviderFactory } from "../providers/provider-factory.js";
|
||||||
|
|
||||||
interface ModelDefinition {
|
interface ModelDefinition {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -63,33 +64,6 @@ export function createModelsRoutes(): Router {
|
|||||||
supportsVision: true,
|
supportsVision: true,
|
||||||
supportsTools: 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 });
|
res.json({ success: true, models });
|
||||||
@@ -102,14 +76,13 @@ export function createModelsRoutes(): Router {
|
|||||||
// Check provider status
|
// Check provider status
|
||||||
router.get("/providers", async (_req: Request, res: Response) => {
|
router.get("/providers", async (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const providers: Record<string, ProviderStatus> = {
|
// Get installation status from all providers
|
||||||
|
const statuses = await ProviderFactory.checkAllProviders();
|
||||||
|
|
||||||
|
const providers: Record<string, any> = {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
available: !!process.env.ANTHROPIC_API_KEY,
|
available: statuses.claude?.installed || false,
|
||||||
hasApiKey: !!process.env.ANTHROPIC_API_KEY,
|
hasApiKey: !!process.env.ANTHROPIC_API_KEY || !!process.env.CLAUDE_CODE_OAUTH_TOKEN,
|
||||||
},
|
|
||||||
openai: {
|
|
||||||
available: !!process.env.OPENAI_API_KEY,
|
|
||||||
hasApiKey: !!process.env.OPENAI_API_KEY,
|
|
||||||
},
|
},
|
||||||
google: {
|
google: {
|
||||||
available: !!process.env.GOOGLE_API_KEY,
|
available: !!process.env.GOOGLE_API_KEY,
|
||||||
|
|||||||
@@ -46,10 +46,11 @@ export function createSessionsRoutes(agentService: AgentService): Router {
|
|||||||
// Create a new session
|
// Create a new session
|
||||||
router.post("/", async (req: Request, res: Response) => {
|
router.post("/", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { name, projectPath, workingDirectory } = req.body as {
|
const { name, projectPath, workingDirectory, model } = req.body as {
|
||||||
name: string;
|
name: string;
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
workingDirectory?: string;
|
workingDirectory?: string;
|
||||||
|
model?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -60,7 +61,8 @@ export function createSessionsRoutes(agentService: AgentService): Router {
|
|||||||
const session = await agentService.createSession(
|
const session = await agentService.createSession(
|
||||||
name,
|
name,
|
||||||
projectPath,
|
projectPath,
|
||||||
workingDirectory
|
workingDirectory,
|
||||||
|
model
|
||||||
);
|
);
|
||||||
res.json({ success: true, session });
|
res.json({ success: true, session });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -73,12 +75,13 @@ export function createSessionsRoutes(agentService: AgentService): Router {
|
|||||||
router.put("/:sessionId", async (req: Request, res: Response) => {
|
router.put("/:sessionId", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { sessionId } = req.params;
|
const { sessionId } = req.params;
|
||||||
const { name, tags } = req.body as {
|
const { name, tags, model } = req.body as {
|
||||||
name?: string;
|
name?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
model?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const session = await agentService.updateSession(sessionId, { name, tags });
|
const session = await agentService.updateSession(sessionId, { name, tags, model });
|
||||||
if (!session) {
|
if (!session) {
|
||||||
res.status(404).json({ success: false, error: "Session not found" });
|
res.status(404).json({ success: false, error: "Session not found" });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -230,99 +230,6 @@ export function createSetupRoutes(): Router {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get Codex CLI status
|
|
||||||
router.get("/codex-status", async (_req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
let installed = false;
|
|
||||||
let version = "";
|
|
||||||
let cliPath = "";
|
|
||||||
let method = "none";
|
|
||||||
|
|
||||||
// Try to find Codex CLI
|
|
||||||
try {
|
|
||||||
const { stdout } = await execAsync("which codex || where codex 2>/dev/null");
|
|
||||||
cliPath = stdout.trim();
|
|
||||||
installed = true;
|
|
||||||
method = "path";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { stdout: versionOut } = await execAsync("codex --version");
|
|
||||||
version = versionOut.trim();
|
|
||||||
} catch {
|
|
||||||
// Version command might not be available
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Not found
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for OpenAI/Codex authentication
|
|
||||||
let auth = {
|
|
||||||
authenticated: false,
|
|
||||||
method: "none" as string,
|
|
||||||
hasAuthFile: false,
|
|
||||||
hasEnvKey: !!process.env.OPENAI_API_KEY,
|
|
||||||
hasStoredApiKey: !!apiKeys.openai,
|
|
||||||
hasEnvApiKey: !!process.env.OPENAI_API_KEY,
|
|
||||||
// Additional fields for subscription/account detection
|
|
||||||
hasSubscription: false,
|
|
||||||
cliLoggedIn: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check for OpenAI CLI auth file (~/.codex/auth.json or similar)
|
|
||||||
const codexAuthPaths = [
|
|
||||||
path.join(os.homedir(), ".codex", "auth.json"),
|
|
||||||
path.join(os.homedir(), ".openai", "credentials"),
|
|
||||||
path.join(os.homedir(), ".config", "openai", "credentials.json"),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const authPath of codexAuthPaths) {
|
|
||||||
try {
|
|
||||||
const authContent = await fs.readFile(authPath, "utf-8");
|
|
||||||
const authData = JSON.parse(authContent);
|
|
||||||
auth.hasAuthFile = true;
|
|
||||||
|
|
||||||
// Check for subscription/tokens
|
|
||||||
if (authData.subscription || authData.plan || authData.account_type) {
|
|
||||||
auth.hasSubscription = true;
|
|
||||||
auth.authenticated = true;
|
|
||||||
auth.method = "subscription"; // Codex subscription (Plus/Team)
|
|
||||||
} else if (authData.access_token || authData.api_key) {
|
|
||||||
auth.cliLoggedIn = true;
|
|
||||||
auth.authenticated = true;
|
|
||||||
auth.method = "cli_verified"; // CLI logged in with account
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
} catch {
|
|
||||||
// Auth file not found at this path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Environment variable has highest priority
|
|
||||||
if (auth.hasEnvApiKey) {
|
|
||||||
auth.authenticated = true;
|
|
||||||
auth.method = "env"; // OPENAI_API_KEY environment variable
|
|
||||||
}
|
|
||||||
|
|
||||||
// In-memory stored API key (from settings UI)
|
|
||||||
if (!auth.authenticated && apiKeys.openai) {
|
|
||||||
auth.authenticated = true;
|
|
||||||
auth.method = "api_key"; // Manually stored API key
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
status: installed ? "installed" : "not_installed",
|
|
||||||
method,
|
|
||||||
version,
|
|
||||||
path: cliPath,
|
|
||||||
auth,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
|
||||||
res.status(500).json({ success: false, error: message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Install Claude CLI
|
// Install Claude CLI
|
||||||
router.post("/install-claude", async (_req: Request, res: Response) => {
|
router.post("/install-claude", async (_req: Request, res: Response) => {
|
||||||
try {
|
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
|
// Auth Claude
|
||||||
router.post("/auth-claude", async (_req: Request, res: Response) => {
|
router.post("/auth-claude", async (_req: Request, res: Response) => {
|
||||||
try {
|
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
|
// Store API key
|
||||||
router.post("/store-api-key", async (req: Request, res: Response) => {
|
router.post("/store-api-key", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -416,9 +287,6 @@ export function createSetupRoutes(): Router {
|
|||||||
process.env.ANTHROPIC_API_KEY = apiKey;
|
process.env.ANTHROPIC_API_KEY = apiKey;
|
||||||
await persistApiKeyToEnv("ANTHROPIC_API_KEY", apiKey);
|
await persistApiKeyToEnv("ANTHROPIC_API_KEY", apiKey);
|
||||||
console.log("[Setup] Stored API key as ANTHROPIC_API_KEY");
|
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") {
|
} else if (provider === "google") {
|
||||||
process.env.GOOGLE_API_KEY = apiKey;
|
process.env.GOOGLE_API_KEY = apiKey;
|
||||||
await persistApiKeyToEnv("GOOGLE_API_KEY", apiKey);
|
await persistApiKeyToEnv("GOOGLE_API_KEY", apiKey);
|
||||||
@@ -437,7 +305,6 @@ export function createSetupRoutes(): Router {
|
|||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
hasAnthropicKey: !!apiKeys.anthropic || !!process.env.ANTHROPIC_API_KEY,
|
hasAnthropicKey: !!apiKeys.anthropic || !!process.env.ANTHROPIC_API_KEY,
|
||||||
hasOpenAIKey: !!apiKeys.openai || !!process.env.OPENAI_API_KEY,
|
|
||||||
hasGoogleKey: !!apiKeys.google || !!process.env.GOOGLE_API_KEY,
|
hasGoogleKey: !!apiKeys.google || !!process.env.GOOGLE_API_KEY,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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
|
// Get platform info
|
||||||
router.get("/platform", async (_req: Request, res: Response) => {
|
router.get("/platform", async (_req: Request, res: Response) => {
|
||||||
try {
|
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;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
* 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 path from "path";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import type { EventEmitter } from "../lib/events.js";
|
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 {
|
interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -26,6 +34,7 @@ interface Session {
|
|||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
abortController: AbortController | null;
|
abortController: AbortController | null;
|
||||||
workingDirectory: string;
|
workingDirectory: string;
|
||||||
|
model?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionMetadata {
|
interface SessionMetadata {
|
||||||
@@ -37,6 +46,7 @@ interface SessionMetadata {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
model?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AgentService {
|
export class AgentService {
|
||||||
@@ -91,11 +101,13 @@ export class AgentService {
|
|||||||
message,
|
message,
|
||||||
workingDirectory,
|
workingDirectory,
|
||||||
imagePaths,
|
imagePaths,
|
||||||
|
model,
|
||||||
}: {
|
}: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
message: string;
|
message: string;
|
||||||
workingDirectory?: string;
|
workingDirectory?: string;
|
||||||
imagePaths?: string[];
|
imagePaths?: string[];
|
||||||
|
model?: string;
|
||||||
}) {
|
}) {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -106,27 +118,22 @@ export class AgentService {
|
|||||||
throw new Error("Agent is already processing a message");
|
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
|
// Read images and convert to base64
|
||||||
const images: Message["images"] = [];
|
const images: Message["images"] = [];
|
||||||
if (imagePaths && imagePaths.length > 0) {
|
if (imagePaths && imagePaths.length > 0) {
|
||||||
for (const imagePath of imagePaths) {
|
for (const imagePath of imagePaths) {
|
||||||
try {
|
try {
|
||||||
const imageBuffer = await fs.readFile(imagePath);
|
const imageData = await readImageAsBase64(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";
|
|
||||||
|
|
||||||
images.push({
|
images.push({
|
||||||
data: base64Data,
|
data: imageData.base64,
|
||||||
mimeType: mediaType,
|
mimeType: imageData.mimeType,
|
||||||
filename: path.basename(imagePath),
|
filename: imageData.filename,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
|
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
|
||||||
@@ -143,6 +150,12 @@ export class AgentService {
|
|||||||
timestamp: new Date().toISOString(),
|
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.messages.push(userMessage);
|
||||||
session.isRunning = true;
|
session.isRunning = true;
|
||||||
session.abortController = new AbortController();
|
session.abortController = new AbortController();
|
||||||
@@ -156,11 +169,23 @@ export class AgentService {
|
|||||||
await this.saveSession(sessionId, session.messages);
|
await this.saveSession(sessionId, session.messages);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const options: Options = {
|
// Use session model, parameter model, or default
|
||||||
model: "claude-opus-4-5-20251101",
|
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(),
|
systemPrompt: this.getSystemPrompt(),
|
||||||
maxTurns: 20,
|
maxTurns: 20,
|
||||||
cwd: workingDirectory || session.workingDirectory,
|
|
||||||
allowedTools: [
|
allowedTools: [
|
||||||
"Read",
|
"Read",
|
||||||
"Write",
|
"Write",
|
||||||
@@ -171,73 +196,23 @@ export class AgentService {
|
|||||||
"WebSearch",
|
"WebSearch",
|
||||||
"WebFetch",
|
"WebFetch",
|
||||||
],
|
],
|
||||||
permissionMode: "acceptEdits",
|
|
||||||
sandbox: {
|
|
||||||
enabled: true,
|
|
||||||
autoAllowBashIfSandboxed: true,
|
|
||||||
},
|
|
||||||
abortController: session.abortController!,
|
abortController: session.abortController!,
|
||||||
|
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build prompt content
|
// Build prompt content with images
|
||||||
let promptContent: string | Array<{ type: string; text?: string; source?: object }> =
|
const { content: promptContent } = await buildPromptWithImages(
|
||||||
message;
|
message,
|
||||||
|
imagePaths,
|
||||||
|
undefined, // no workDir for agent service
|
||||||
|
true // include image paths in text
|
||||||
|
);
|
||||||
|
|
||||||
if (imagePaths && imagePaths.length > 0) {
|
// Set the prompt in options
|
||||||
const contentBlocks: Array<{ type: string; text?: string; source?: object }> = [];
|
options.prompt = promptContent;
|
||||||
|
|
||||||
if (message && message.trim()) {
|
// Execute via provider
|
||||||
contentBlocks.push({ type: "text", text: message });
|
const stream = provider.executeQuery(options);
|
||||||
}
|
|
||||||
|
|
||||||
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 });
|
|
||||||
|
|
||||||
let currentAssistantMessage: Message | null = null;
|
let currentAssistantMessage: Message | null = null;
|
||||||
let responseText = "";
|
let responseText = "";
|
||||||
@@ -245,7 +220,7 @@ export class AgentService {
|
|||||||
|
|
||||||
for await (const msg of stream) {
|
for await (const msg of stream) {
|
||||||
if (msg.type === "assistant") {
|
if (msg.type === "assistant") {
|
||||||
if (msg.message.content) {
|
if (msg.message?.content) {
|
||||||
for (const block of msg.message.content) {
|
for (const block of msg.message.content) {
|
||||||
if (block.type === "text") {
|
if (block.type === "text") {
|
||||||
responseText += block.text;
|
responseText += block.text;
|
||||||
@@ -270,7 +245,7 @@ export class AgentService {
|
|||||||
});
|
});
|
||||||
} else if (block.type === "tool_use") {
|
} else if (block.type === "tool_use") {
|
||||||
const toolUse = {
|
const toolUse = {
|
||||||
name: block.name,
|
name: block.name || "unknown",
|
||||||
input: block.input,
|
input: block.input,
|
||||||
};
|
};
|
||||||
toolUses.push(toolUse);
|
toolUses.push(toolUse);
|
||||||
@@ -309,7 +284,7 @@ export class AgentService {
|
|||||||
message: currentAssistantMessage,
|
message: currentAssistantMessage,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AbortError || (error as Error)?.name === "AbortError") {
|
if (isAbortError(error)) {
|
||||||
session.isRunning = false;
|
session.isRunning = false;
|
||||||
session.abortController = null;
|
session.abortController = null;
|
||||||
return { success: false, aborted: true };
|
return { success: false, aborted: true };
|
||||||
@@ -450,7 +425,8 @@ export class AgentService {
|
|||||||
async createSession(
|
async createSession(
|
||||||
name: string,
|
name: string,
|
||||||
projectPath?: string,
|
projectPath?: string,
|
||||||
workingDirectory?: string
|
workingDirectory?: string,
|
||||||
|
model?: string
|
||||||
): Promise<SessionMetadata> {
|
): Promise<SessionMetadata> {
|
||||||
const sessionId = this.generateId();
|
const sessionId = this.generateId();
|
||||||
const metadata = await this.loadMetadata();
|
const metadata = await this.loadMetadata();
|
||||||
@@ -462,6 +438,7 @@ export class AgentService {
|
|||||||
workingDirectory: workingDirectory || projectPath || process.cwd(),
|
workingDirectory: workingDirectory || projectPath || process.cwd(),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
model,
|
||||||
};
|
};
|
||||||
|
|
||||||
metadata[sessionId] = session;
|
metadata[sessionId] = session;
|
||||||
@@ -470,6 +447,16 @@ export class AgentService {
|
|||||||
return session;
|
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(
|
async updateSession(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
updates: Partial<SessionMetadata>
|
updates: Partial<SessionMetadata>
|
||||||
|
|||||||
@@ -9,16 +9,17 @@
|
|||||||
* - Verification and merge workflows
|
* - Verification and merge workflows
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { AbortError } from "@anthropic-ai/claude-agent-sdk";
|
||||||
query,
|
import { ProviderFactory } from "../providers/provider-factory.js";
|
||||||
AbortError,
|
import type { ExecuteOptions } from "../providers/types.js";
|
||||||
type Options,
|
|
||||||
} from "@anthropic-ai/claude-agent-sdk";
|
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import type { EventEmitter, EventType } from "../lib/events.js";
|
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);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
@@ -29,8 +30,9 @@ interface Feature {
|
|||||||
steps?: string[];
|
steps?: string[];
|
||||||
status: string;
|
status: string;
|
||||||
priority?: number;
|
priority?: number;
|
||||||
imagePaths?: Array<string | { path: string; [key: string]: unknown }>;
|
spec?: string;
|
||||||
[key: string]: unknown; // Allow additional fields
|
model?: string; // Model to use for this feature
|
||||||
|
imagePaths?: Array<string | { path: string; filename?: string; mimeType?: string; [key: string]: unknown }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RunningFeature {
|
interface RunningFeature {
|
||||||
@@ -222,17 +224,17 @@ export class AutoModeService {
|
|||||||
const prompt = this.buildFeaturePrompt(feature);
|
const prompt = this.buildFeaturePrompt(feature);
|
||||||
|
|
||||||
// Extract image paths from feature
|
// Extract image paths from feature
|
||||||
const imagePaths = this.extractImagePaths(feature.imagePaths, workDir);
|
const imagePaths = feature.imagePaths?.map((img) =>
|
||||||
|
typeof img === "string" ? img : img.path
|
||||||
// Run the agent with image paths
|
|
||||||
await this.runAgent(
|
|
||||||
workDir,
|
|
||||||
featureId,
|
|
||||||
prompt,
|
|
||||||
abortController,
|
|
||||||
imagePaths
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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
|
// Mark as waiting_approval for user review
|
||||||
await this.updateFeatureStatus(
|
await this.updateFeatureStatus(
|
||||||
projectPath,
|
projectPath,
|
||||||
@@ -249,10 +251,9 @@ export class AutoModeService {
|
|||||||
projectPath,
|
projectPath,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
const errorInfo = classifyError(error);
|
||||||
error instanceof AbortError ||
|
|
||||||
(error as Error)?.name === "AbortError"
|
if (errorInfo.isAbort) {
|
||||||
) {
|
|
||||||
this.emitAutoModeEvent("auto_mode_feature_complete", {
|
this.emitAutoModeEvent("auto_mode_feature_complete", {
|
||||||
featureId,
|
featureId,
|
||||||
passes: false,
|
passes: false,
|
||||||
@@ -260,18 +261,12 @@ export class AutoModeService {
|
|||||||
projectPath,
|
projectPath,
|
||||||
});
|
});
|
||||||
} else {
|
} 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);
|
console.error(`[AutoMode] Feature ${featureId} failed:`, error);
|
||||||
await this.updateFeatureStatus(projectPath, featureId, "backlog");
|
await this.updateFeatureStatus(projectPath, featureId, "backlog");
|
||||||
this.emitAutoModeEvent("auto_mode_error", {
|
this.emitAutoModeEvent("auto_mode_error", {
|
||||||
featureId,
|
featureId,
|
||||||
error: errorMessage,
|
error: errorInfo.message,
|
||||||
errorType: isAuthError ? "authentication" : "execution",
|
errorType: errorInfo.isAuth ? "authentication" : "execution",
|
||||||
projectPath,
|
projectPath,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -425,13 +420,93 @@ Address the follow-up instructions above. Review the previous work and make the
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.runAgent(
|
// Get model from feature (already loaded above)
|
||||||
workDir,
|
const model = resolveModelString(feature?.model, DEFAULT_MODELS.claude);
|
||||||
featureId,
|
console.log(`[AutoMode] Follow-up for feature ${featureId} using model: ${model}`);
|
||||||
fullPrompt,
|
|
||||||
abortController,
|
// Update feature status to in_progress
|
||||||
imagePaths
|
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
|
// Mark as waiting_approval for user review
|
||||||
await this.updateFeatureStatus(
|
await this.updateFeatureStatus(
|
||||||
@@ -447,7 +522,7 @@ Address the follow-up instructions above. Review the previous work and make the
|
|||||||
projectPath,
|
projectPath,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!(error instanceof AbortError)) {
|
if (!isAbortError(error)) {
|
||||||
this.emitAutoModeEvent("auto_mode_error", {
|
this.emitAutoModeEvent("auto_mode_error", {
|
||||||
featureId,
|
featureId,
|
||||||
error: (error as Error).message,
|
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.`;
|
Format your response as a structured markdown document.`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const options: Options = {
|
// Use default Claude model for analysis (can be overridden in the future)
|
||||||
model: "claude-sonnet-4-20250514",
|
const analysisModel = resolveModelString(undefined, DEFAULT_MODELS.claude);
|
||||||
|
const provider = ProviderFactory.getProviderForModel(analysisModel);
|
||||||
|
|
||||||
|
const options: ExecuteOptions = {
|
||||||
|
prompt,
|
||||||
|
model: analysisModel,
|
||||||
maxTurns: 5,
|
maxTurns: 5,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
allowedTools: ["Read", "Glob", "Grep"],
|
allowedTools: ["Read", "Glob", "Grep"],
|
||||||
permissionMode: "acceptEdits",
|
|
||||||
abortController,
|
abortController,
|
||||||
};
|
};
|
||||||
|
|
||||||
const stream = query({ prompt, options });
|
const stream = provider.executeQuery(options);
|
||||||
let analysisResult = "";
|
let analysisResult = "";
|
||||||
|
|
||||||
for await (const msg of stream) {
|
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) {
|
for (const block of msg.message.content) {
|
||||||
if (block.type === "text") {
|
if (block.type === "text") {
|
||||||
analysisResult = block.text;
|
analysisResult = block.text || "";
|
||||||
this.emitAutoModeEvent("auto_mode_progress", {
|
this.emitAutoModeEvent("auto_mode_progress", {
|
||||||
featureId: analysisFeatureId,
|
featureId: analysisFeatureId,
|
||||||
content: block.text,
|
content: block.text,
|
||||||
@@ -907,6 +986,34 @@ Format your response as a structured markdown document.`;
|
|||||||
**Description:** ${feature.description}
|
**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 += `
|
prompt += `
|
||||||
## Instructions
|
## Instructions
|
||||||
|
|
||||||
@@ -927,31 +1034,45 @@ When done, summarize what you implemented and any notes for the developer.`;
|
|||||||
featureId: string,
|
featureId: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
abortController: AbortController,
|
abortController: AbortController,
|
||||||
imagePaths?: string[]
|
imagePaths?: string[],
|
||||||
|
model?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const options: Options = {
|
const finalModel = resolveModelString(model, DEFAULT_MODELS.claude);
|
||||||
model: "claude-opus-4-5-20251101",
|
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,
|
maxTurns: 50,
|
||||||
cwd: workDir,
|
cwd: workDir,
|
||||||
allowedTools: ["Read", "Write", "Edit", "Glob", "Grep", "Bash"],
|
allowedTools: [
|
||||||
permissionMode: "acceptEdits",
|
"Read",
|
||||||
sandbox: {
|
"Write",
|
||||||
enabled: true,
|
"Edit",
|
||||||
autoAllowBashIfSandboxed: true,
|
"Glob",
|
||||||
},
|
"Grep",
|
||||||
|
"Bash",
|
||||||
|
],
|
||||||
abortController,
|
abortController,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build prompt - include image paths for the agent to read
|
// Execute via provider
|
||||||
let finalPrompt = prompt;
|
const stream = provider.executeQuery(options);
|
||||||
|
|
||||||
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 });
|
|
||||||
let responseText = "";
|
let responseText = "";
|
||||||
const outputPath = path.join(
|
const outputPath = path.join(
|
||||||
workDir,
|
workDir,
|
||||||
@@ -962,20 +1083,18 @@ When done, summarize what you implemented and any notes for the developer.`;
|
|||||||
);
|
);
|
||||||
|
|
||||||
for await (const msg of stream) {
|
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) {
|
for (const block of msg.message.content) {
|
||||||
if (block.type === "text") {
|
if (block.type === "text") {
|
||||||
responseText = block.text;
|
responseText = block.text || "";
|
||||||
|
|
||||||
// Check for authentication errors in the response
|
// Check for authentication errors in the response
|
||||||
if (
|
if (block.text && (block.text.includes("Invalid API key") ||
|
||||||
block.text.includes("Invalid API key") ||
|
block.text.includes("authentication_failed") ||
|
||||||
block.text.includes("authentication_failed") ||
|
block.text.includes("Fix external API key"))) {
|
||||||
block.text.includes("Fix external API key")
|
|
||||||
) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Authentication failed: Invalid or expired API key. " +
|
"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 (
|
} else if (msg.type === "error") {
|
||||||
msg.type === "assistant" &&
|
// Handle error messages
|
||||||
(msg as { error?: string }).error === "authentication_failed"
|
throw new Error(msg.error || "Unknown error");
|
||||||
) {
|
|
||||||
// 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 === "result" && msg.subtype === "success") {
|
} 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;
|
responseText = msg.result || responseText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
apps/server/tests/fixtures/configs.ts
vendored
Normal file
17
apps/server/tests/fixtures/configs.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Configuration fixtures for testing
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const tomlConfigFixture = `
|
||||||
|
experimental_use_rmcp_client = true
|
||||||
|
|
||||||
|
[mcp_servers.automaker-tools]
|
||||||
|
command = "node"
|
||||||
|
args = ["/path/to/server.js"]
|
||||||
|
startup_timeout_sec = 10
|
||||||
|
tool_timeout_sec = 60
|
||||||
|
enabled_tools = ["UpdateFeatureStatus"]
|
||||||
|
|
||||||
|
[mcp_servers.automaker-tools.env]
|
||||||
|
AUTOMAKER_PROJECT_PATH = "/path/to/project"
|
||||||
|
`;
|
||||||
14
apps/server/tests/fixtures/images.ts
vendored
Normal file
14
apps/server/tests/fixtures/images.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Image fixtures for testing image handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 1x1 transparent PNG base64 data
|
||||||
|
export const pngBase64Fixture =
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||||
|
|
||||||
|
export const imageDataFixture = {
|
||||||
|
base64: pngBase64Fixture,
|
||||||
|
mimeType: "image/png",
|
||||||
|
filename: "test.png",
|
||||||
|
originalPath: "/path/to/test.png",
|
||||||
|
};
|
||||||
39
apps/server/tests/fixtures/messages.ts
vendored
Normal file
39
apps/server/tests/fixtures/messages.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Message fixtures for testing providers and lib utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ConversationMessage,
|
||||||
|
ProviderMessage,
|
||||||
|
ContentBlock,
|
||||||
|
} from "../../src/providers/types.js";
|
||||||
|
|
||||||
|
export const conversationHistoryFixture: ConversationMessage[] = [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: "Hello, can you help me?",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: "Of course! How can I assist you today?",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "What is in this image?" },
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
source: { type: "base64", media_type: "image/png", data: "base64data" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const claudeProviderMessageFixture: ProviderMessage = {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "This is a test response" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
144
apps/server/tests/integration/helpers/git-test-repo.ts
Normal file
144
apps/server/tests/integration/helpers/git-test-repo.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* Helper for creating test git repositories for integration tests
|
||||||
|
*/
|
||||||
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import * as fs from "fs/promises";
|
||||||
|
import * as path from "path";
|
||||||
|
import * as os from "os";
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
export interface TestRepo {
|
||||||
|
path: string;
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a temporary git repository for testing
|
||||||
|
*/
|
||||||
|
export async function createTestGitRepo(): Promise<TestRepo> {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "automaker-test-"));
|
||||||
|
|
||||||
|
// Initialize git repo
|
||||||
|
await execAsync("git init", { cwd: tmpDir });
|
||||||
|
await execAsync('git config user.email "test@example.com"', { cwd: tmpDir });
|
||||||
|
await execAsync('git config user.name "Test User"', { cwd: tmpDir });
|
||||||
|
|
||||||
|
// Create initial commit
|
||||||
|
await fs.writeFile(path.join(tmpDir, "README.md"), "# Test Project\n");
|
||||||
|
await execAsync("git add .", { cwd: tmpDir });
|
||||||
|
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir });
|
||||||
|
|
||||||
|
// Create main branch explicitly
|
||||||
|
await execAsync("git branch -M main", { cwd: tmpDir });
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: tmpDir,
|
||||||
|
cleanup: async () => {
|
||||||
|
try {
|
||||||
|
// Remove all worktrees first
|
||||||
|
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||||
|
cwd: tmpDir,
|
||||||
|
}).catch(() => ({ stdout: "" }));
|
||||||
|
|
||||||
|
const worktrees = stdout
|
||||||
|
.split("\n\n")
|
||||||
|
.slice(1) // Skip main worktree
|
||||||
|
.map((block) => {
|
||||||
|
const pathLine = block.split("\n").find((line) => line.startsWith("worktree "));
|
||||||
|
return pathLine ? pathLine.replace("worktree ", "") : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
for (const worktreePath of worktrees) {
|
||||||
|
try {
|
||||||
|
await execAsync(`git worktree remove "${worktreePath}" --force`, {
|
||||||
|
cwd: tmpDir,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the repository
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to cleanup test repo:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a feature file in the test repo
|
||||||
|
*/
|
||||||
|
export async function createTestFeature(
|
||||||
|
repoPath: string,
|
||||||
|
featureId: string,
|
||||||
|
featureData: any
|
||||||
|
): Promise<void> {
|
||||||
|
const featuresDir = path.join(repoPath, ".automaker", "features");
|
||||||
|
const featureDir = path.join(featuresDir, featureId);
|
||||||
|
|
||||||
|
await fs.mkdir(featureDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(featureDir, "feature.json"),
|
||||||
|
JSON.stringify(featureData, null, 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of git branches
|
||||||
|
*/
|
||||||
|
export async function listBranches(repoPath: string): Promise<string[]> {
|
||||||
|
const { stdout } = await execAsync("git branch --list", { cwd: repoPath });
|
||||||
|
return stdout
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim().replace(/^[*+]\s*/, ""))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of git worktrees
|
||||||
|
*/
|
||||||
|
export async function listWorktrees(repoPath: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||||
|
cwd: repoPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
return stdout
|
||||||
|
.split("\n\n")
|
||||||
|
.slice(1) // Skip main worktree
|
||||||
|
.map((block) => {
|
||||||
|
const pathLine = block.split("\n").find((line) => line.startsWith("worktree "));
|
||||||
|
return pathLine ? pathLine.replace("worktree ", "") : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a branch exists
|
||||||
|
*/
|
||||||
|
export async function branchExists(
|
||||||
|
repoPath: string,
|
||||||
|
branchName: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const branches = await listBranches(repoPath);
|
||||||
|
return branches.includes(branchName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a worktree exists
|
||||||
|
*/
|
||||||
|
export async function worktreeExists(
|
||||||
|
repoPath: string,
|
||||||
|
worktreePath: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const worktrees = await listWorktrees(repoPath);
|
||||||
|
return worktrees.some((wt) => wt === worktreePath);
|
||||||
|
}
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { AutoModeService } from "@/services/auto-mode-service.js";
|
||||||
|
import { ProviderFactory } from "@/providers/provider-factory.js";
|
||||||
|
import { FeatureLoader } from "@/services/feature-loader.js";
|
||||||
|
import {
|
||||||
|
createTestGitRepo,
|
||||||
|
createTestFeature,
|
||||||
|
listBranches,
|
||||||
|
listWorktrees,
|
||||||
|
branchExists,
|
||||||
|
worktreeExists,
|
||||||
|
type TestRepo,
|
||||||
|
} from "../helpers/git-test-repo.js";
|
||||||
|
import * as fs from "fs/promises";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
vi.mock("@/providers/provider-factory.js");
|
||||||
|
|
||||||
|
describe("auto-mode-service.ts (integration)", () => {
|
||||||
|
let service: AutoModeService;
|
||||||
|
let testRepo: TestRepo;
|
||||||
|
let featureLoader: FeatureLoader;
|
||||||
|
const mockEvents = {
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
service = new AutoModeService(mockEvents as any);
|
||||||
|
featureLoader = new FeatureLoader();
|
||||||
|
testRepo = await createTestGitRepo();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Stop any running auto loops
|
||||||
|
await service.stopAutoLoop();
|
||||||
|
|
||||||
|
// Cleanup test repo
|
||||||
|
if (testRepo) {
|
||||||
|
await testRepo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("worktree operations", () => {
|
||||||
|
it("should create git worktree for feature", async () => {
|
||||||
|
// Create a test feature
|
||||||
|
await createTestFeature(testRepo.path, "test-feature-1", {
|
||||||
|
id: "test-feature-1",
|
||||||
|
category: "test",
|
||||||
|
description: "Test feature",
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock provider to complete quickly
|
||||||
|
const mockProvider = {
|
||||||
|
getName: () => "claude",
|
||||||
|
executeQuery: async function* () {
|
||||||
|
yield {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "Feature implemented" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
yield {
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||||
|
mockProvider as any
|
||||||
|
);
|
||||||
|
|
||||||
|
// Execute feature with worktrees enabled
|
||||||
|
await service.executeFeature(
|
||||||
|
testRepo.path,
|
||||||
|
"test-feature-1",
|
||||||
|
true, // useWorktrees
|
||||||
|
false // isAutoMode
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify branch was created
|
||||||
|
const branches = await listBranches(testRepo.path);
|
||||||
|
expect(branches).toContain("feature/test-feature-1");
|
||||||
|
|
||||||
|
// Note: Worktrees are not automatically cleaned up by the service
|
||||||
|
// This is expected behavior - manual cleanup is required
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it("should handle error gracefully", async () => {
|
||||||
|
await createTestFeature(testRepo.path, "test-feature-error", {
|
||||||
|
id: "test-feature-error",
|
||||||
|
category: "test",
|
||||||
|
description: "Test feature that errors",
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock provider that throws error
|
||||||
|
const mockProvider = {
|
||||||
|
getName: () => "claude",
|
||||||
|
executeQuery: async function* () {
|
||||||
|
throw new Error("Provider error");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||||
|
mockProvider as any
|
||||||
|
);
|
||||||
|
|
||||||
|
// Execute feature (should handle error)
|
||||||
|
await service.executeFeature(
|
||||||
|
testRepo.path,
|
||||||
|
"test-feature-error",
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify feature status was updated to backlog (error status)
|
||||||
|
const feature = await featureLoader.get(testRepo.path, "test-feature-error");
|
||||||
|
expect(feature?.status).toBe("backlog");
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it("should work without worktrees", async () => {
|
||||||
|
await createTestFeature(testRepo.path, "test-no-worktree", {
|
||||||
|
id: "test-no-worktree",
|
||||||
|
category: "test",
|
||||||
|
description: "Test without worktree",
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockProvider = {
|
||||||
|
getName: () => "claude",
|
||||||
|
executeQuery: async function* () {
|
||||||
|
yield {
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||||
|
mockProvider as any
|
||||||
|
);
|
||||||
|
|
||||||
|
// Execute without worktrees
|
||||||
|
await service.executeFeature(
|
||||||
|
testRepo.path,
|
||||||
|
"test-no-worktree",
|
||||||
|
false, // useWorktrees = false
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Feature should be updated successfully
|
||||||
|
const feature = await featureLoader.get(testRepo.path, "test-no-worktree");
|
||||||
|
expect(feature?.status).toBe("waiting_approval");
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("feature execution", () => {
|
||||||
|
it("should execute feature and update status", async () => {
|
||||||
|
await createTestFeature(testRepo.path, "feature-exec-1", {
|
||||||
|
id: "feature-exec-1",
|
||||||
|
category: "ui",
|
||||||
|
description: "Execute this feature",
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockProvider = {
|
||||||
|
getName: () => "claude",
|
||||||
|
executeQuery: async function* () {
|
||||||
|
yield {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "Implemented the feature" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
yield {
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||||
|
mockProvider as any
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.executeFeature(
|
||||||
|
testRepo.path,
|
||||||
|
"feature-exec-1",
|
||||||
|
false, // Don't use worktrees so agent output is saved to main project
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check feature status was updated
|
||||||
|
const feature = await featureLoader.get(testRepo.path, "feature-exec-1");
|
||||||
|
expect(feature?.status).toBe("waiting_approval");
|
||||||
|
|
||||||
|
// Check agent output was saved
|
||||||
|
const agentOutput = await featureLoader.getAgentOutput(
|
||||||
|
testRepo.path,
|
||||||
|
"feature-exec-1"
|
||||||
|
);
|
||||||
|
expect(agentOutput).toBeTruthy();
|
||||||
|
expect(agentOutput).toContain("Implemented the feature");
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it("should handle feature not found", async () => {
|
||||||
|
const mockProvider = {
|
||||||
|
getName: () => "claude",
|
||||||
|
executeQuery: async function* () {
|
||||||
|
yield {
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||||
|
mockProvider as any
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to execute non-existent feature
|
||||||
|
await service.executeFeature(
|
||||||
|
testRepo.path,
|
||||||
|
"nonexistent-feature",
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should emit error event
|
||||||
|
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
featureId: "nonexistent-feature",
|
||||||
|
error: expect.stringContaining("not found"),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it("should prevent duplicate feature execution", async () => {
|
||||||
|
await createTestFeature(testRepo.path, "feature-dup", {
|
||||||
|
id: "feature-dup",
|
||||||
|
category: "test",
|
||||||
|
description: "Duplicate test",
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockProvider = {
|
||||||
|
getName: () => "claude",
|
||||||
|
executeQuery: async function* () {
|
||||||
|
// Simulate slow execution
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
yield {
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||||
|
mockProvider as any
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start first execution
|
||||||
|
const promise1 = service.executeFeature(
|
||||||
|
testRepo.path,
|
||||||
|
"feature-dup",
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to start second execution (should throw)
|
||||||
|
await expect(
|
||||||
|
service.executeFeature(testRepo.path, "feature-dup", false, false)
|
||||||
|
).rejects.toThrow("already running");
|
||||||
|
|
||||||
|
await promise1;
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it("should use feature-specific model", async () => {
|
||||||
|
await createTestFeature(testRepo.path, "feature-model", {
|
||||||
|
id: "feature-model",
|
||||||
|
category: "test",
|
||||||
|
description: "Model test",
|
||||||
|
status: "pending",
|
||||||
|
model: "claude-sonnet-4-20250514",
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockProvider = {
|
||||||
|
getName: () => "claude",
|
||||||
|
executeQuery: async function* () {
|
||||||
|
yield {
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||||
|
mockProvider as any
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.executeFeature(
|
||||||
|
testRepo.path,
|
||||||
|
"feature-model",
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should have used claude-sonnet-4-20250514
|
||||||
|
expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith("claude-sonnet-4-20250514");
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auto loop", () => {
|
||||||
|
it("should start and stop auto loop", async () => {
|
||||||
|
const startPromise = service.startAutoLoop(testRepo.path, 2);
|
||||||
|
|
||||||
|
// Give it time to start
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Stop the loop
|
||||||
|
const runningCount = await service.stopAutoLoop();
|
||||||
|
|
||||||
|
expect(runningCount).toBe(0);
|
||||||
|
await startPromise.catch(() => {}); // Cleanup
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
it("should process pending features in auto loop", async () => {
|
||||||
|
// Create multiple pending features
|
||||||
|
await createTestFeature(testRepo.path, "auto-1", {
|
||||||
|
id: "auto-1",
|
||||||
|
category: "test",
|
||||||
|
description: "Auto feature 1",
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
|
||||||
|
await createTestFeature(testRepo.path, "auto-2", {
|
||||||
|
id: "auto-2",
|
||||||
|
category: "test",
|
||||||
|
description: "Auto feature 2",
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockProvider = {
|
||||||
|
getName: () => "claude",
|
||||||
|
executeQuery: async function* () {
|
||||||
|
yield {
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||||
|
mockProvider as any
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start auto loop
|
||||||
|
const startPromise = service.startAutoLoop(testRepo.path, 2);
|
||||||
|
|
||||||
|
// Wait for features to be processed
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||||
|
|
||||||
|
// Stop the loop
|
||||||
|
await service.stopAutoLoop();
|
||||||
|
await startPromise.catch(() => {});
|
||||||
|
|
||||||
|
// Check that features were updated
|
||||||
|
const feature1 = await featureLoader.get(testRepo.path, "auto-1");
|
||||||
|
const feature2 = await featureLoader.get(testRepo.path, "auto-2");
|
||||||
|
|
||||||
|
// At least one should have been processed
|
||||||
|
const processedCount = [feature1, feature2].filter(
|
||||||
|
(f) => f?.status === "waiting_approval" || f?.status === "in_progress"
|
||||||
|
).length;
|
||||||
|
|
||||||
|
expect(processedCount).toBeGreaterThan(0);
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
it("should respect max concurrency", async () => {
|
||||||
|
// Create 5 features
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
await createTestFeature(testRepo.path, `concurrent-${i}`, {
|
||||||
|
id: `concurrent-${i}`,
|
||||||
|
category: "test",
|
||||||
|
description: `Concurrent feature ${i}`,
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let concurrentCount = 0;
|
||||||
|
let maxConcurrent = 0;
|
||||||
|
|
||||||
|
const mockProvider = {
|
||||||
|
getName: () => "claude",
|
||||||
|
executeQuery: async function* () {
|
||||||
|
concurrentCount++;
|
||||||
|
maxConcurrent = Math.max(maxConcurrent, concurrentCount);
|
||||||
|
|
||||||
|
// Simulate work
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
concurrentCount--;
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||||
|
mockProvider as any
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start with max concurrency of 2
|
||||||
|
const startPromise = service.startAutoLoop(testRepo.path, 2);
|
||||||
|
|
||||||
|
// Wait for some features to be processed
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||||
|
|
||||||
|
await service.stopAutoLoop();
|
||||||
|
await startPromise.catch(() => {});
|
||||||
|
|
||||||
|
// Max concurrent should not exceed 2
|
||||||
|
expect(maxConcurrent).toBeLessThanOrEqual(2);
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
it("should emit auto mode events", async () => {
|
||||||
|
const startPromise = service.startAutoLoop(testRepo.path, 1);
|
||||||
|
|
||||||
|
// Wait for start event
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check start event was emitted
|
||||||
|
const startEvent = mockEvents.emit.mock.calls.find((call) =>
|
||||||
|
call[1]?.message?.includes("Auto mode started")
|
||||||
|
);
|
||||||
|
expect(startEvent).toBeTruthy();
|
||||||
|
|
||||||
|
await service.stopAutoLoop();
|
||||||
|
await startPromise.catch(() => {});
|
||||||
|
|
||||||
|
// Check stop event was emitted (auto_mode_complete event)
|
||||||
|
const stopEvent = mockEvents.emit.mock.calls.find((call) =>
|
||||||
|
call[1]?.type === "auto_mode_complete" || call[1]?.message?.includes("stopped")
|
||||||
|
);
|
||||||
|
expect(stopEvent).toBeTruthy();
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("error handling", () => {
|
||||||
|
it("should handle provider errors gracefully", async () => {
|
||||||
|
await createTestFeature(testRepo.path, "error-feature", {
|
||||||
|
id: "error-feature",
|
||||||
|
category: "test",
|
||||||
|
description: "Error test",
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockProvider = {
|
||||||
|
getName: () => "claude",
|
||||||
|
executeQuery: async function* () {
|
||||||
|
throw new Error("Provider execution failed");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||||
|
mockProvider as any
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
await service.executeFeature(
|
||||||
|
testRepo.path,
|
||||||
|
"error-feature",
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Feature should be marked as backlog (error status)
|
||||||
|
const feature = await featureLoader.get(testRepo.path, "error-feature");
|
||||||
|
expect(feature?.status).toBe("backlog");
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it("should continue auto loop after feature error", async () => {
|
||||||
|
await createTestFeature(testRepo.path, "fail-1", {
|
||||||
|
id: "fail-1",
|
||||||
|
category: "test",
|
||||||
|
description: "Will fail",
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
|
||||||
|
await createTestFeature(testRepo.path, "success-1", {
|
||||||
|
id: "success-1",
|
||||||
|
category: "test",
|
||||||
|
description: "Will succeed",
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
|
||||||
|
let callCount = 0;
|
||||||
|
const mockProvider = {
|
||||||
|
getName: () => "claude",
|
||||||
|
executeQuery: async function* () {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1) {
|
||||||
|
throw new Error("First feature fails");
|
||||||
|
}
|
||||||
|
yield {
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||||
|
mockProvider as any
|
||||||
|
);
|
||||||
|
|
||||||
|
const startPromise = service.startAutoLoop(testRepo.path, 1);
|
||||||
|
|
||||||
|
// Wait for both features to be attempted
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||||
|
|
||||||
|
await service.stopAutoLoop();
|
||||||
|
await startPromise.catch(() => {});
|
||||||
|
|
||||||
|
// Both features should have been attempted
|
||||||
|
expect(callCount).toBeGreaterThanOrEqual(1);
|
||||||
|
}, 15000);
|
||||||
|
});
|
||||||
|
});
|
||||||
16
apps/server/tests/setup.ts
Normal file
16
apps/server/tests/setup.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Vitest global setup file
|
||||||
|
* Runs before each test file
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// Set test environment variables
|
||||||
|
process.env.NODE_ENV = "test";
|
||||||
|
process.env.DATA_DIR = "/tmp/test-data";
|
||||||
|
process.env.ALLOWED_PROJECT_DIRS = "/tmp/test-projects";
|
||||||
|
|
||||||
|
// Reset all mocks before each test
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
116
apps/server/tests/unit/lib/auth.test.ts
Normal file
116
apps/server/tests/unit/lib/auth.test.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { createMockExpressContext } from "../../utils/mocks.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note: auth.ts reads AUTOMAKER_API_KEY at module load time.
|
||||||
|
* We need to reset modules and reimport for each test to get fresh state.
|
||||||
|
*/
|
||||||
|
describe("auth.ts", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("authMiddleware - no API key", () => {
|
||||||
|
it("should call next() when no API key is set", async () => {
|
||||||
|
delete process.env.AUTOMAKER_API_KEY;
|
||||||
|
|
||||||
|
const { authMiddleware } = await import("@/lib/auth.js");
|
||||||
|
const { req, res, next } = createMockExpressContext();
|
||||||
|
|
||||||
|
authMiddleware(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("authMiddleware - with API key", () => {
|
||||||
|
it("should reject request without API key header", async () => {
|
||||||
|
process.env.AUTOMAKER_API_KEY = "test-secret-key";
|
||||||
|
|
||||||
|
const { authMiddleware } = await import("@/lib/auth.js");
|
||||||
|
const { req, res, next } = createMockExpressContext();
|
||||||
|
|
||||||
|
authMiddleware(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
success: false,
|
||||||
|
error: "Authentication required. Provide X-API-Key header.",
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject request with invalid API key", async () => {
|
||||||
|
process.env.AUTOMAKER_API_KEY = "test-secret-key";
|
||||||
|
|
||||||
|
const { authMiddleware } = await import("@/lib/auth.js");
|
||||||
|
const { req, res, next } = createMockExpressContext();
|
||||||
|
req.headers["x-api-key"] = "wrong-key";
|
||||||
|
|
||||||
|
authMiddleware(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
success: false,
|
||||||
|
error: "Invalid API key.",
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call next() with valid API key", async () => {
|
||||||
|
process.env.AUTOMAKER_API_KEY = "test-secret-key";
|
||||||
|
|
||||||
|
const { authMiddleware } = await import("@/lib/auth.js");
|
||||||
|
const { req, res, next} = createMockExpressContext();
|
||||||
|
req.headers["x-api-key"] = "test-secret-key";
|
||||||
|
|
||||||
|
authMiddleware(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isAuthEnabled", () => {
|
||||||
|
it("should return false when no API key is set", async () => {
|
||||||
|
delete process.env.AUTOMAKER_API_KEY;
|
||||||
|
|
||||||
|
const { isAuthEnabled } = await import("@/lib/auth.js");
|
||||||
|
expect(isAuthEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true when API key is set", async () => {
|
||||||
|
process.env.AUTOMAKER_API_KEY = "test-key";
|
||||||
|
|
||||||
|
const { isAuthEnabled } = await import("@/lib/auth.js");
|
||||||
|
expect(isAuthEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAuthStatus", () => {
|
||||||
|
it("should return disabled status when no API key", async () => {
|
||||||
|
delete process.env.AUTOMAKER_API_KEY;
|
||||||
|
|
||||||
|
const { getAuthStatus } = await import("@/lib/auth.js");
|
||||||
|
const status = getAuthStatus();
|
||||||
|
|
||||||
|
expect(status).toEqual({
|
||||||
|
enabled: false,
|
||||||
|
method: "none",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return enabled status when API key is set", async () => {
|
||||||
|
process.env.AUTOMAKER_API_KEY = "test-key";
|
||||||
|
|
||||||
|
const { getAuthStatus } = await import("@/lib/auth.js");
|
||||||
|
const status = getAuthStatus();
|
||||||
|
|
||||||
|
expect(status).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
method: "api_key",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
226
apps/server/tests/unit/lib/conversation-utils.test.ts
Normal file
226
apps/server/tests/unit/lib/conversation-utils.test.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
extractTextFromContent,
|
||||||
|
normalizeContentBlocks,
|
||||||
|
formatHistoryAsText,
|
||||||
|
convertHistoryToMessages,
|
||||||
|
} from "@/lib/conversation-utils.js";
|
||||||
|
import { conversationHistoryFixture } from "../../fixtures/messages.js";
|
||||||
|
|
||||||
|
describe("conversation-utils.ts", () => {
|
||||||
|
describe("extractTextFromContent", () => {
|
||||||
|
it("should return string content as-is", () => {
|
||||||
|
const result = extractTextFromContent("Hello world");
|
||||||
|
expect(result).toBe("Hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should extract text from single text block", () => {
|
||||||
|
const content = [{ type: "text", text: "Hello" }];
|
||||||
|
const result = extractTextFromContent(content);
|
||||||
|
expect(result).toBe("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should extract and join multiple text blocks with newlines", () => {
|
||||||
|
const content = [
|
||||||
|
{ type: "text", text: "First block" },
|
||||||
|
{ type: "text", text: "Second block" },
|
||||||
|
{ type: "text", text: "Third block" },
|
||||||
|
];
|
||||||
|
const result = extractTextFromContent(content);
|
||||||
|
expect(result).toBe("First block\nSecond block\nThird block");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore non-text blocks", () => {
|
||||||
|
const content = [
|
||||||
|
{ type: "text", text: "Text content" },
|
||||||
|
{ type: "image", source: { type: "base64", data: "abc" } },
|
||||||
|
{ type: "text", text: "More text" },
|
||||||
|
{ type: "tool_use", name: "bash", input: {} },
|
||||||
|
];
|
||||||
|
const result = extractTextFromContent(content);
|
||||||
|
expect(result).toBe("Text content\nMore text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle blocks without text property", () => {
|
||||||
|
const content = [
|
||||||
|
{ type: "text", text: "Valid" },
|
||||||
|
{ type: "text" } as any,
|
||||||
|
{ type: "text", text: "Also valid" },
|
||||||
|
];
|
||||||
|
const result = extractTextFromContent(content);
|
||||||
|
expect(result).toBe("Valid\n\nAlso valid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty array", () => {
|
||||||
|
const result = extractTextFromContent([]);
|
||||||
|
expect(result).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle array with only non-text blocks", () => {
|
||||||
|
const content = [
|
||||||
|
{ type: "image", source: {} },
|
||||||
|
{ type: "tool_use", name: "test" },
|
||||||
|
];
|
||||||
|
const result = extractTextFromContent(content);
|
||||||
|
expect(result).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeContentBlocks", () => {
|
||||||
|
it("should convert string to content block array", () => {
|
||||||
|
const result = normalizeContentBlocks("Hello");
|
||||||
|
expect(result).toEqual([{ type: "text", text: "Hello" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return array content as-is", () => {
|
||||||
|
const content = [
|
||||||
|
{ type: "text", text: "Hello" },
|
||||||
|
{ type: "image", source: {} },
|
||||||
|
];
|
||||||
|
const result = normalizeContentBlocks(content);
|
||||||
|
expect(result).toBe(content);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty string", () => {
|
||||||
|
const result = normalizeContentBlocks("");
|
||||||
|
expect(result).toEqual([{ type: "text", text: "" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatHistoryAsText", () => {
|
||||||
|
it("should return empty string for empty history", () => {
|
||||||
|
const result = formatHistoryAsText([]);
|
||||||
|
expect(result).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format single user message", () => {
|
||||||
|
const history = [{ role: "user" as const, content: "Hello" }];
|
||||||
|
const result = formatHistoryAsText(history);
|
||||||
|
|
||||||
|
expect(result).toContain("Previous conversation:");
|
||||||
|
expect(result).toContain("User: Hello");
|
||||||
|
expect(result).toContain("---");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format single assistant message", () => {
|
||||||
|
const history = [{ role: "assistant" as const, content: "Hi there" }];
|
||||||
|
const result = formatHistoryAsText(history);
|
||||||
|
|
||||||
|
expect(result).toContain("Assistant: Hi there");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format multiple messages with correct roles", () => {
|
||||||
|
const history = conversationHistoryFixture.slice(0, 2);
|
||||||
|
const result = formatHistoryAsText(history);
|
||||||
|
|
||||||
|
expect(result).toContain("User: Hello, can you help me?");
|
||||||
|
expect(result).toContain("Assistant: Of course! How can I assist you today?");
|
||||||
|
expect(result).toContain("---");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle messages with array content (multipart)", () => {
|
||||||
|
const history = [conversationHistoryFixture[2]]; // Has text + image
|
||||||
|
const result = formatHistoryAsText(history);
|
||||||
|
|
||||||
|
expect(result).toContain("What is in this image?");
|
||||||
|
expect(result).not.toContain("base64"); // Should not include image data
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format all messages from fixture", () => {
|
||||||
|
const result = formatHistoryAsText(conversationHistoryFixture);
|
||||||
|
|
||||||
|
expect(result).toContain("Previous conversation:");
|
||||||
|
expect(result).toContain("User: Hello, can you help me?");
|
||||||
|
expect(result).toContain("Assistant: Of course!");
|
||||||
|
expect(result).toContain("User: What is in this image?");
|
||||||
|
expect(result).toContain("---");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should separate messages with double newlines", () => {
|
||||||
|
const history = [
|
||||||
|
{ role: "user" as const, content: "First" },
|
||||||
|
{ role: "assistant" as const, content: "Second" },
|
||||||
|
];
|
||||||
|
const result = formatHistoryAsText(history);
|
||||||
|
|
||||||
|
expect(result).toMatch(/User: First\n\nAssistant: Second/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("convertHistoryToMessages", () => {
|
||||||
|
it("should convert empty history", () => {
|
||||||
|
const result = convertHistoryToMessages([]);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should convert single message to SDK format", () => {
|
||||||
|
const history = [{ role: "user" as const, content: "Hello" }];
|
||||||
|
const result = convertHistoryToMessages(history);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
type: "user",
|
||||||
|
session_id: "",
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "text", text: "Hello" }],
|
||||||
|
},
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should normalize string content to array", () => {
|
||||||
|
const history = [{ role: "assistant" as const, content: "Response" }];
|
||||||
|
const result = convertHistoryToMessages(history);
|
||||||
|
|
||||||
|
expect(result[0].message.content).toEqual([
|
||||||
|
{ type: "text", text: "Response" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve array content", () => {
|
||||||
|
const history = [
|
||||||
|
{
|
||||||
|
role: "user" as const,
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Hello" },
|
||||||
|
{ type: "image", source: {} },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = convertHistoryToMessages(history);
|
||||||
|
|
||||||
|
expect(result[0].message.content).toHaveLength(2);
|
||||||
|
expect(result[0].message.content[0]).toEqual({ type: "text", text: "Hello" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should convert multiple messages", () => {
|
||||||
|
const history = conversationHistoryFixture.slice(0, 2);
|
||||||
|
const result = convertHistoryToMessages(history);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].type).toBe("user");
|
||||||
|
expect(result[1].type).toBe("assistant");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set correct fields for SDK format", () => {
|
||||||
|
const history = [{ role: "user" as const, content: "Test" }];
|
||||||
|
const result = convertHistoryToMessages(history);
|
||||||
|
|
||||||
|
expect(result[0].session_id).toBe("");
|
||||||
|
expect(result[0].parent_tool_use_id).toBeNull();
|
||||||
|
expect(result[0].type).toBe("user");
|
||||||
|
expect(result[0].message.role).toBe("user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle all messages from fixture", () => {
|
||||||
|
const result = convertHistoryToMessages(conversationHistoryFixture);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result[0].message.content).toBeInstanceOf(Array);
|
||||||
|
expect(result[1].message.content).toBeInstanceOf(Array);
|
||||||
|
expect(result[2].message.content).toBeInstanceOf(Array);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
146
apps/server/tests/unit/lib/error-handler.test.ts
Normal file
146
apps/server/tests/unit/lib/error-handler.test.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
isAbortError,
|
||||||
|
isAuthenticationError,
|
||||||
|
classifyError,
|
||||||
|
getUserFriendlyErrorMessage,
|
||||||
|
type ErrorType,
|
||||||
|
} from "@/lib/error-handler.js";
|
||||||
|
|
||||||
|
describe("error-handler.ts", () => {
|
||||||
|
describe("isAbortError", () => {
|
||||||
|
it("should detect AbortError by error name", () => {
|
||||||
|
const error = new Error("Operation cancelled");
|
||||||
|
error.name = "AbortError";
|
||||||
|
expect(isAbortError(error)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect abort error by message content", () => {
|
||||||
|
const error = new Error("Request was aborted");
|
||||||
|
expect(isAbortError(error)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for non-abort errors", () => {
|
||||||
|
const error = new Error("Something else went wrong");
|
||||||
|
expect(isAbortError(error)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for non-Error objects", () => {
|
||||||
|
expect(isAbortError("not an error")).toBe(false);
|
||||||
|
expect(isAbortError(null)).toBe(false);
|
||||||
|
expect(isAbortError(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isAuthenticationError", () => {
|
||||||
|
it("should detect 'Authentication failed' message", () => {
|
||||||
|
expect(isAuthenticationError("Authentication failed")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect 'Invalid API key' message", () => {
|
||||||
|
expect(isAuthenticationError("Invalid API key provided")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect 'authentication_failed' message", () => {
|
||||||
|
expect(isAuthenticationError("authentication_failed")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect 'Fix external API key' message", () => {
|
||||||
|
expect(isAuthenticationError("Fix external API key configuration")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for non-authentication errors", () => {
|
||||||
|
expect(isAuthenticationError("Network connection error")).toBe(false);
|
||||||
|
expect(isAuthenticationError("File not found")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be case sensitive", () => {
|
||||||
|
expect(isAuthenticationError("authentication Failed")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("classifyError", () => {
|
||||||
|
it("should classify authentication errors", () => {
|
||||||
|
const error = new Error("Authentication failed");
|
||||||
|
const result = classifyError(error);
|
||||||
|
|
||||||
|
expect(result.type).toBe("authentication");
|
||||||
|
expect(result.isAuth).toBe(true);
|
||||||
|
expect(result.isAbort).toBe(false);
|
||||||
|
expect(result.message).toBe("Authentication failed");
|
||||||
|
expect(result.originalError).toBe(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should classify abort errors", () => {
|
||||||
|
const error = new Error("Operation aborted");
|
||||||
|
error.name = "AbortError";
|
||||||
|
const result = classifyError(error);
|
||||||
|
|
||||||
|
expect(result.type).toBe("abort");
|
||||||
|
expect(result.isAbort).toBe(true);
|
||||||
|
expect(result.isAuth).toBe(false);
|
||||||
|
expect(result.message).toBe("Operation aborted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prioritize auth over abort if both match", () => {
|
||||||
|
const error = new Error("Authentication failed and aborted");
|
||||||
|
const result = classifyError(error);
|
||||||
|
|
||||||
|
expect(result.type).toBe("authentication");
|
||||||
|
expect(result.isAuth).toBe(true);
|
||||||
|
expect(result.isAbort).toBe(true); // Still detected as abort too
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should classify generic Error as execution error", () => {
|
||||||
|
const error = new Error("Something went wrong");
|
||||||
|
const result = classifyError(error);
|
||||||
|
|
||||||
|
expect(result.type).toBe("execution");
|
||||||
|
expect(result.isAuth).toBe(false);
|
||||||
|
expect(result.isAbort).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should classify non-Error objects as unknown", () => {
|
||||||
|
const error = "string error";
|
||||||
|
const result = classifyError(error);
|
||||||
|
|
||||||
|
expect(result.type).toBe("unknown");
|
||||||
|
expect(result.message).toBe("string error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle null and undefined", () => {
|
||||||
|
const nullResult = classifyError(null);
|
||||||
|
expect(nullResult.type).toBe("unknown");
|
||||||
|
expect(nullResult.message).toBe("Unknown error");
|
||||||
|
|
||||||
|
const undefinedResult = classifyError(undefined);
|
||||||
|
expect(undefinedResult.type).toBe("unknown");
|
||||||
|
expect(undefinedResult.message).toBe("Unknown error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getUserFriendlyErrorMessage", () => {
|
||||||
|
it("should return friendly message for abort errors", () => {
|
||||||
|
const error = new Error("abort");
|
||||||
|
const result = getUserFriendlyErrorMessage(error);
|
||||||
|
expect(result).toBe("Operation was cancelled");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return friendly message for authentication errors", () => {
|
||||||
|
const error = new Error("Authentication failed");
|
||||||
|
const result = getUserFriendlyErrorMessage(error);
|
||||||
|
expect(result).toBe("Authentication failed. Please check your API key.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return original message for other errors", () => {
|
||||||
|
const error = new Error("File not found");
|
||||||
|
const result = getUserFriendlyErrorMessage(error);
|
||||||
|
expect(result).toBe("File not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle non-Error objects", () => {
|
||||||
|
const result = getUserFriendlyErrorMessage("Custom error");
|
||||||
|
expect(result).toBe("Custom error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
130
apps/server/tests/unit/lib/events.test.ts
Normal file
130
apps/server/tests/unit/lib/events.test.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { createEventEmitter, type EventType } from "@/lib/events.js";
|
||||||
|
|
||||||
|
describe("events.ts", () => {
|
||||||
|
describe("createEventEmitter", () => {
|
||||||
|
it("should emit events to single subscriber", () => {
|
||||||
|
const emitter = createEventEmitter();
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
emitter.subscribe(callback);
|
||||||
|
emitter.emit("agent:stream", { message: "test" });
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledOnce();
|
||||||
|
expect(callback).toHaveBeenCalledWith("agent:stream", { message: "test" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit events to multiple subscribers", () => {
|
||||||
|
const emitter = createEventEmitter();
|
||||||
|
const callback1 = vi.fn();
|
||||||
|
const callback2 = vi.fn();
|
||||||
|
const callback3 = vi.fn();
|
||||||
|
|
||||||
|
emitter.subscribe(callback1);
|
||||||
|
emitter.subscribe(callback2);
|
||||||
|
emitter.subscribe(callback3);
|
||||||
|
emitter.emit("feature:started", { id: "123" });
|
||||||
|
|
||||||
|
expect(callback1).toHaveBeenCalledOnce();
|
||||||
|
expect(callback2).toHaveBeenCalledOnce();
|
||||||
|
expect(callback3).toHaveBeenCalledOnce();
|
||||||
|
expect(callback1).toHaveBeenCalledWith("feature:started", { id: "123" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support unsubscribe functionality", () => {
|
||||||
|
const emitter = createEventEmitter();
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
const unsubscribe = emitter.subscribe(callback);
|
||||||
|
emitter.emit("agent:stream", { test: 1 });
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
emitter.emit("agent:stream", { test: 2 });
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledOnce(); // Still called only once
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle errors in subscribers without crashing", () => {
|
||||||
|
const emitter = createEventEmitter();
|
||||||
|
const errorCallback = vi.fn(() => {
|
||||||
|
throw new Error("Subscriber error");
|
||||||
|
});
|
||||||
|
const normalCallback = vi.fn();
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
emitter.subscribe(errorCallback);
|
||||||
|
emitter.subscribe(normalCallback);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
emitter.emit("feature:error", { error: "test" });
|
||||||
|
}).not.toThrow();
|
||||||
|
|
||||||
|
expect(errorCallback).toHaveBeenCalledOnce();
|
||||||
|
expect(normalCallback).toHaveBeenCalledOnce();
|
||||||
|
expect(consoleSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit different event types", () => {
|
||||||
|
const emitter = createEventEmitter();
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
emitter.subscribe(callback);
|
||||||
|
|
||||||
|
const eventTypes: EventType[] = [
|
||||||
|
"agent:stream",
|
||||||
|
"auto-mode:started",
|
||||||
|
"feature:completed",
|
||||||
|
"project:analysis-progress",
|
||||||
|
];
|
||||||
|
|
||||||
|
eventTypes.forEach((type) => {
|
||||||
|
emitter.emit(type, { type });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle emitting without subscribers", () => {
|
||||||
|
const emitter = createEventEmitter();
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
emitter.emit("agent:stream", { test: true });
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow multiple subscriptions and unsubscriptions", () => {
|
||||||
|
const emitter = createEventEmitter();
|
||||||
|
const callback1 = vi.fn();
|
||||||
|
const callback2 = vi.fn();
|
||||||
|
const callback3 = vi.fn();
|
||||||
|
|
||||||
|
const unsub1 = emitter.subscribe(callback1);
|
||||||
|
const unsub2 = emitter.subscribe(callback2);
|
||||||
|
const unsub3 = emitter.subscribe(callback3);
|
||||||
|
|
||||||
|
emitter.emit("feature:started", { test: 1 });
|
||||||
|
expect(callback1).toHaveBeenCalledOnce();
|
||||||
|
expect(callback2).toHaveBeenCalledOnce();
|
||||||
|
expect(callback3).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
unsub2();
|
||||||
|
|
||||||
|
emitter.emit("feature:started", { test: 2 });
|
||||||
|
expect(callback1).toHaveBeenCalledTimes(2);
|
||||||
|
expect(callback2).toHaveBeenCalledOnce(); // Still just once
|
||||||
|
expect(callback3).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
unsub1();
|
||||||
|
unsub3();
|
||||||
|
|
||||||
|
emitter.emit("feature:started", { test: 3 });
|
||||||
|
expect(callback1).toHaveBeenCalledTimes(2);
|
||||||
|
expect(callback2).toHaveBeenCalledOnce();
|
||||||
|
expect(callback3).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
231
apps/server/tests/unit/lib/image-handler.test.ts
Normal file
231
apps/server/tests/unit/lib/image-handler.test.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
getMimeTypeForImage,
|
||||||
|
readImageAsBase64,
|
||||||
|
convertImagesToContentBlocks,
|
||||||
|
formatImagePathsForPrompt,
|
||||||
|
} from "@/lib/image-handler.js";
|
||||||
|
import { pngBase64Fixture } from "../../fixtures/images.js";
|
||||||
|
import * as fs from "fs/promises";
|
||||||
|
|
||||||
|
vi.mock("fs/promises");
|
||||||
|
|
||||||
|
describe("image-handler.ts", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getMimeTypeForImage", () => {
|
||||||
|
it("should return correct MIME type for .jpg", () => {
|
||||||
|
expect(getMimeTypeForImage("test.jpg")).toBe("image/jpeg");
|
||||||
|
expect(getMimeTypeForImage("/path/to/test.jpg")).toBe("image/jpeg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return correct MIME type for .jpeg", () => {
|
||||||
|
expect(getMimeTypeForImage("test.jpeg")).toBe("image/jpeg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return correct MIME type for .png", () => {
|
||||||
|
expect(getMimeTypeForImage("test.png")).toBe("image/png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return correct MIME type for .gif", () => {
|
||||||
|
expect(getMimeTypeForImage("test.gif")).toBe("image/gif");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return correct MIME type for .webp", () => {
|
||||||
|
expect(getMimeTypeForImage("test.webp")).toBe("image/webp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be case-insensitive", () => {
|
||||||
|
expect(getMimeTypeForImage("test.PNG")).toBe("image/png");
|
||||||
|
expect(getMimeTypeForImage("test.JPG")).toBe("image/jpeg");
|
||||||
|
expect(getMimeTypeForImage("test.GIF")).toBe("image/gif");
|
||||||
|
expect(getMimeTypeForImage("test.WEBP")).toBe("image/webp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should default to image/png for unknown extensions", () => {
|
||||||
|
expect(getMimeTypeForImage("test.unknown")).toBe("image/png");
|
||||||
|
expect(getMimeTypeForImage("test.txt")).toBe("image/png");
|
||||||
|
expect(getMimeTypeForImage("test")).toBe("image/png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle paths with multiple dots", () => {
|
||||||
|
expect(getMimeTypeForImage("my.image.file.jpg")).toBe("image/jpeg");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readImageAsBase64", () => {
|
||||||
|
it("should read image and return base64 data", async () => {
|
||||||
|
const mockBuffer = Buffer.from(pngBase64Fixture, "base64");
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||||
|
|
||||||
|
const result = await readImageAsBase64("/path/to/test.png");
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
base64: pngBase64Fixture,
|
||||||
|
mimeType: "image/png",
|
||||||
|
filename: "test.png",
|
||||||
|
originalPath: "/path/to/test.png",
|
||||||
|
});
|
||||||
|
expect(fs.readFile).toHaveBeenCalledWith("/path/to/test.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle different image formats", async () => {
|
||||||
|
const mockBuffer = Buffer.from("jpeg-data");
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||||
|
|
||||||
|
const result = await readImageAsBase64("/path/to/photo.jpg");
|
||||||
|
|
||||||
|
expect(result.mimeType).toBe("image/jpeg");
|
||||||
|
expect(result.filename).toBe("photo.jpg");
|
||||||
|
expect(result.base64).toBe(mockBuffer.toString("base64"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should extract filename from path", async () => {
|
||||||
|
const mockBuffer = Buffer.from("data");
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||||
|
|
||||||
|
const result = await readImageAsBase64("/deep/nested/path/image.webp");
|
||||||
|
|
||||||
|
expect(result.filename).toBe("image.webp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error if file cannot be read", async () => {
|
||||||
|
vi.mocked(fs.readFile).mockRejectedValue(new Error("File not found"));
|
||||||
|
|
||||||
|
await expect(readImageAsBase64("/nonexistent.png")).rejects.toThrow(
|
||||||
|
"File not found"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("convertImagesToContentBlocks", () => {
|
||||||
|
it("should convert single image to content block", async () => {
|
||||||
|
const mockBuffer = Buffer.from(pngBase64Fixture, "base64");
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||||
|
|
||||||
|
const result = await convertImagesToContentBlocks(["/path/test.png"]);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
type: "image",
|
||||||
|
source: {
|
||||||
|
type: "base64",
|
||||||
|
media_type: "image/png",
|
||||||
|
data: pngBase64Fixture,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should convert multiple images to content blocks", async () => {
|
||||||
|
const mockBuffer = Buffer.from("test-data");
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||||
|
|
||||||
|
const result = await convertImagesToContentBlocks([
|
||||||
|
"/a.png",
|
||||||
|
"/b.jpg",
|
||||||
|
"/c.webp",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result[0].source.media_type).toBe("image/png");
|
||||||
|
expect(result[1].source.media_type).toBe("image/jpeg");
|
||||||
|
expect(result[2].source.media_type).toBe("image/webp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resolve relative paths with workDir", async () => {
|
||||||
|
const mockBuffer = Buffer.from("data");
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||||
|
|
||||||
|
await convertImagesToContentBlocks(["relative.png"], "/work/dir");
|
||||||
|
|
||||||
|
// Use path-agnostic check since Windows uses backslashes
|
||||||
|
const calls = vi.mocked(fs.readFile).mock.calls;
|
||||||
|
expect(calls[0][0]).toMatch(/relative\.png$/);
|
||||||
|
expect(calls[0][0]).toContain("work");
|
||||||
|
expect(calls[0][0]).toContain("dir");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle absolute paths without workDir", async () => {
|
||||||
|
const mockBuffer = Buffer.from("data");
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||||
|
|
||||||
|
await convertImagesToContentBlocks(["/absolute/path.png"]);
|
||||||
|
|
||||||
|
expect(fs.readFile).toHaveBeenCalledWith("/absolute/path.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should continue processing on individual image errors", async () => {
|
||||||
|
vi.mocked(fs.readFile)
|
||||||
|
.mockResolvedValueOnce(Buffer.from("ok1"))
|
||||||
|
.mockRejectedValueOnce(new Error("Failed"))
|
||||||
|
.mockResolvedValueOnce(Buffer.from("ok2"));
|
||||||
|
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
const result = await convertImagesToContentBlocks([
|
||||||
|
"/a.png",
|
||||||
|
"/b.png",
|
||||||
|
"/c.png",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2); // Only successful images
|
||||||
|
expect(consoleSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty array for empty input", async () => {
|
||||||
|
const result = await convertImagesToContentBlocks([]);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle undefined workDir", async () => {
|
||||||
|
const mockBuffer = Buffer.from("data");
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||||
|
|
||||||
|
const result = await convertImagesToContentBlocks(["/test.png"], undefined);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(fs.readFile).toHaveBeenCalledWith("/test.png");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatImagePathsForPrompt", () => {
|
||||||
|
it("should format single image path as bulleted list", () => {
|
||||||
|
const result = formatImagePathsForPrompt(["/path/image.png"]);
|
||||||
|
|
||||||
|
expect(result).toContain("\n\nAttached images:");
|
||||||
|
expect(result).toContain("- /path/image.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format multiple image paths as bulleted list", () => {
|
||||||
|
const result = formatImagePathsForPrompt([
|
||||||
|
"/path/a.png",
|
||||||
|
"/path/b.jpg",
|
||||||
|
"/path/c.webp",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toContain("Attached images:");
|
||||||
|
expect(result).toContain("- /path/a.png");
|
||||||
|
expect(result).toContain("- /path/b.jpg");
|
||||||
|
expect(result).toContain("- /path/c.webp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty string for empty array", () => {
|
||||||
|
const result = formatImagePathsForPrompt([]);
|
||||||
|
expect(result).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should start with double newline", () => {
|
||||||
|
const result = formatImagePathsForPrompt(["/test.png"]);
|
||||||
|
expect(result.startsWith("\n\n")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle paths with special characters", () => {
|
||||||
|
const result = formatImagePathsForPrompt(["/path/with spaces/image.png"]);
|
||||||
|
expect(result).toContain("- /path/with spaces/image.png");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
143
apps/server/tests/unit/lib/model-resolver.test.ts
Normal file
143
apps/server/tests/unit/lib/model-resolver.test.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
resolveModelString,
|
||||||
|
getEffectiveModel,
|
||||||
|
CLAUDE_MODEL_MAP,
|
||||||
|
DEFAULT_MODELS,
|
||||||
|
} from "@/lib/model-resolver.js";
|
||||||
|
|
||||||
|
describe("model-resolver.ts", () => {
|
||||||
|
let consoleSpy: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
consoleSpy = {
|
||||||
|
log: vi.spyOn(console, "log").mockImplementation(() => {}),
|
||||||
|
warn: vi.spyOn(console, "warn").mockImplementation(() => {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleSpy.log.mockRestore();
|
||||||
|
consoleSpy.warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveModelString", () => {
|
||||||
|
it("should resolve 'haiku' alias to full model string", () => {
|
||||||
|
const result = resolveModelString("haiku");
|
||||||
|
expect(result).toBe("claude-haiku-4-5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resolve 'sonnet' alias to full model string", () => {
|
||||||
|
const result = resolveModelString("sonnet");
|
||||||
|
expect(result).toBe("claude-sonnet-4-20250514");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resolve 'opus' alias to full model string", () => {
|
||||||
|
const result = resolveModelString("opus");
|
||||||
|
expect(result).toBe("claude-opus-4-5-20251101");
|
||||||
|
expect(consoleSpy.log).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Resolved model alias: "opus"')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should treat unknown models as falling back to default", () => {
|
||||||
|
const models = ["o1", "o1-mini", "o3", "gpt-5.2", "unknown-model"];
|
||||||
|
models.forEach((model) => {
|
||||||
|
const result = resolveModelString(model);
|
||||||
|
// Should fall back to default since these aren't supported
|
||||||
|
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass through full Claude model strings", () => {
|
||||||
|
const models = [
|
||||||
|
"claude-opus-4-5-20251101",
|
||||||
|
"claude-sonnet-4-20250514",
|
||||||
|
"claude-haiku-4-5",
|
||||||
|
];
|
||||||
|
models.forEach((model) => {
|
||||||
|
const result = resolveModelString(model);
|
||||||
|
expect(result).toBe(model);
|
||||||
|
});
|
||||||
|
expect(consoleSpy.log).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Using full Claude model string")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return default model when modelKey is undefined", () => {
|
||||||
|
const result = resolveModelString(undefined);
|
||||||
|
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return custom default model when provided", () => {
|
||||||
|
const customDefault = "custom-model";
|
||||||
|
const result = resolveModelString(undefined, customDefault);
|
||||||
|
expect(result).toBe(customDefault);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return default for unknown model key", () => {
|
||||||
|
const result = resolveModelString("unknown-model");
|
||||||
|
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||||
|
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Unknown model key "unknown-model"')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty string", () => {
|
||||||
|
const result = resolveModelString("");
|
||||||
|
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getEffectiveModel", () => {
|
||||||
|
it("should prioritize explicit model over session and default", () => {
|
||||||
|
const result = getEffectiveModel("opus", "haiku", "gpt-5.2");
|
||||||
|
expect(result).toBe("claude-opus-4-5-20251101");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use session model when explicit is not provided", () => {
|
||||||
|
const result = getEffectiveModel(undefined, "sonnet", "gpt-5.2");
|
||||||
|
expect(result).toBe("claude-sonnet-4-20250514");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use default when neither explicit nor session is provided", () => {
|
||||||
|
const customDefault = "claude-haiku-4-5";
|
||||||
|
const result = getEffectiveModel(undefined, undefined, customDefault);
|
||||||
|
expect(result).toBe(customDefault);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use Claude default when no arguments provided", () => {
|
||||||
|
const result = getEffectiveModel();
|
||||||
|
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle explicit empty strings as undefined", () => {
|
||||||
|
const result = getEffectiveModel("", "haiku");
|
||||||
|
expect(result).toBe("claude-haiku-4-5");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CLAUDE_MODEL_MAP", () => {
|
||||||
|
it("should have haiku, sonnet, opus mappings", () => {
|
||||||
|
expect(CLAUDE_MODEL_MAP).toHaveProperty("haiku");
|
||||||
|
expect(CLAUDE_MODEL_MAP).toHaveProperty("sonnet");
|
||||||
|
expect(CLAUDE_MODEL_MAP).toHaveProperty("opus");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have valid Claude model strings", () => {
|
||||||
|
expect(CLAUDE_MODEL_MAP.haiku).toContain("haiku");
|
||||||
|
expect(CLAUDE_MODEL_MAP.sonnet).toContain("sonnet");
|
||||||
|
expect(CLAUDE_MODEL_MAP.opus).toContain("opus");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DEFAULT_MODELS", () => {
|
||||||
|
it("should have claude default", () => {
|
||||||
|
expect(DEFAULT_MODELS).toHaveProperty("claude");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have valid default model", () => {
|
||||||
|
expect(DEFAULT_MODELS.claude).toContain("claude");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
197
apps/server/tests/unit/lib/prompt-builder.test.ts
Normal file
197
apps/server/tests/unit/lib/prompt-builder.test.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { buildPromptWithImages } from "@/lib/prompt-builder.js";
|
||||||
|
import * as imageHandler from "@/lib/image-handler.js";
|
||||||
|
|
||||||
|
vi.mock("@/lib/image-handler.js");
|
||||||
|
|
||||||
|
describe("prompt-builder.ts", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildPromptWithImages", () => {
|
||||||
|
it("should return plain text when no images provided", async () => {
|
||||||
|
const result = await buildPromptWithImages("Hello world");
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
content: "Hello world",
|
||||||
|
hasImages: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return plain text when imagePaths is empty array", async () => {
|
||||||
|
const result = await buildPromptWithImages("Hello world", []);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
content: "Hello world",
|
||||||
|
hasImages: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should build content blocks with single image", async () => {
|
||||||
|
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
source: { type: "base64", media_type: "image/png", data: "base64data" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await buildPromptWithImages("Describe this image", [
|
||||||
|
"/test.png",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.hasImages).toBe(true);
|
||||||
|
expect(Array.isArray(result.content)).toBe(true);
|
||||||
|
const content = result.content as Array<any>;
|
||||||
|
expect(content).toHaveLength(2);
|
||||||
|
expect(content[0]).toEqual({ type: "text", text: "Describe this image" });
|
||||||
|
expect(content[1].type).toBe("image");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should build content blocks with multiple images", async () => {
|
||||||
|
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
source: { type: "base64", media_type: "image/png", data: "data1" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
source: { type: "base64", media_type: "image/jpeg", data: "data2" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await buildPromptWithImages("Analyze these", [
|
||||||
|
"/a.png",
|
||||||
|
"/b.jpg",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.hasImages).toBe(true);
|
||||||
|
const content = result.content as Array<any>;
|
||||||
|
expect(content).toHaveLength(3); // 1 text + 2 images
|
||||||
|
expect(content[0].type).toBe("text");
|
||||||
|
expect(content[1].type).toBe("image");
|
||||||
|
expect(content[2].type).toBe("image");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include image paths in text when requested", async () => {
|
||||||
|
vi.mocked(imageHandler.formatImagePathsForPrompt).mockReturnValue(
|
||||||
|
"\n\nAttached images:\n- /test.png"
|
||||||
|
);
|
||||||
|
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
source: { type: "base64", media_type: "image/png", data: "data" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await buildPromptWithImages(
|
||||||
|
"Base prompt",
|
||||||
|
["/test.png"],
|
||||||
|
undefined,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(imageHandler.formatImagePathsForPrompt).toHaveBeenCalledWith([
|
||||||
|
"/test.png",
|
||||||
|
]);
|
||||||
|
const content = result.content as Array<any>;
|
||||||
|
expect(content[0].text).toContain("Base prompt");
|
||||||
|
expect(content[0].text).toContain("Attached images:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not include image paths by default", async () => {
|
||||||
|
vi.mocked(imageHandler.formatImagePathsForPrompt).mockReturnValue(
|
||||||
|
"\n\nAttached images:\n- /test.png"
|
||||||
|
);
|
||||||
|
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
source: { type: "base64", media_type: "image/png", data: "data" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await buildPromptWithImages("Base prompt", ["/test.png"]);
|
||||||
|
|
||||||
|
expect(imageHandler.formatImagePathsForPrompt).not.toHaveBeenCalled();
|
||||||
|
const content = result.content as Array<any>;
|
||||||
|
expect(content[0].text).toBe("Base prompt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass workDir to convertImagesToContentBlocks", async () => {
|
||||||
|
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
source: { type: "base64", media_type: "image/png", data: "data" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await buildPromptWithImages("Test", ["/test.png"], "/work/dir");
|
||||||
|
|
||||||
|
expect(imageHandler.convertImagesToContentBlocks).toHaveBeenCalledWith(
|
||||||
|
["/test.png"],
|
||||||
|
"/work/dir"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty text content", async () => {
|
||||||
|
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
source: { type: "base64", media_type: "image/png", data: "data" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await buildPromptWithImages("", ["/test.png"]);
|
||||||
|
|
||||||
|
expect(result.hasImages).toBe(true);
|
||||||
|
// When text is empty/whitespace, should only have image blocks
|
||||||
|
const content = result.content as Array<any>;
|
||||||
|
expect(content.every((block) => block.type === "image")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should trim text content before checking if empty", async () => {
|
||||||
|
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
source: { type: "base64", media_type: "image/png", data: "data" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await buildPromptWithImages(" ", ["/test.png"]);
|
||||||
|
|
||||||
|
const content = result.content as Array<any>;
|
||||||
|
// Whitespace-only text should be excluded
|
||||||
|
expect(content.every((block) => block.type === "image")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return text when only one block and it's text", async () => {
|
||||||
|
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await buildPromptWithImages("Just text", ["/missing.png"]);
|
||||||
|
|
||||||
|
// If no images are successfully loaded, should return just the text
|
||||||
|
expect(result.content).toBe("Just text");
|
||||||
|
expect(result.hasImages).toBe(true); // Still true because images were requested
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle workDir with relative paths", async () => {
|
||||||
|
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
source: { type: "base64", media_type: "image/png", data: "data" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await buildPromptWithImages(
|
||||||
|
"Test",
|
||||||
|
["relative.png"],
|
||||||
|
"/absolute/work/dir"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(imageHandler.convertImagesToContentBlocks).toHaveBeenCalledWith(
|
||||||
|
["relative.png"],
|
||||||
|
"/absolute/work/dir"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
297
apps/server/tests/unit/lib/security.test.ts
Normal file
297
apps/server/tests/unit/lib/security.test.ts
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note: security.ts maintains module-level state (allowed paths Set).
|
||||||
|
* We need to reset modules and reimport for each test to get fresh state.
|
||||||
|
*/
|
||||||
|
describe("security.ts", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initAllowedPaths", () => {
|
||||||
|
it("should parse comma-separated directories from environment", async () => {
|
||||||
|
process.env.ALLOWED_PROJECT_DIRS = "/path1,/path2,/path3";
|
||||||
|
process.env.DATA_DIR = "";
|
||||||
|
|
||||||
|
const { initAllowedPaths, getAllowedPaths } = await import(
|
||||||
|
"@/lib/security.js"
|
||||||
|
);
|
||||||
|
initAllowedPaths();
|
||||||
|
|
||||||
|
const allowed = getAllowedPaths();
|
||||||
|
expect(allowed).toContain(path.resolve("/path1"));
|
||||||
|
expect(allowed).toContain(path.resolve("/path2"));
|
||||||
|
expect(allowed).toContain(path.resolve("/path3"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should trim whitespace from paths", async () => {
|
||||||
|
process.env.ALLOWED_PROJECT_DIRS = " /path1 , /path2 , /path3 ";
|
||||||
|
process.env.DATA_DIR = "";
|
||||||
|
|
||||||
|
const { initAllowedPaths, getAllowedPaths } = await import(
|
||||||
|
"@/lib/security.js"
|
||||||
|
);
|
||||||
|
initAllowedPaths();
|
||||||
|
|
||||||
|
const allowed = getAllowedPaths();
|
||||||
|
expect(allowed).toContain(path.resolve("/path1"));
|
||||||
|
expect(allowed).toContain(path.resolve("/path2"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should always include DATA_DIR if set", async () => {
|
||||||
|
process.env.ALLOWED_PROJECT_DIRS = "";
|
||||||
|
process.env.DATA_DIR = "/data/dir";
|
||||||
|
|
||||||
|
const { initAllowedPaths, getAllowedPaths } = await import(
|
||||||
|
"@/lib/security.js"
|
||||||
|
);
|
||||||
|
initAllowedPaths();
|
||||||
|
|
||||||
|
const allowed = getAllowedPaths();
|
||||||
|
expect(allowed).toContain(path.resolve("/data/dir"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty ALLOWED_PROJECT_DIRS", async () => {
|
||||||
|
process.env.ALLOWED_PROJECT_DIRS = "";
|
||||||
|
process.env.DATA_DIR = "/data";
|
||||||
|
|
||||||
|
const { initAllowedPaths, getAllowedPaths } = await import(
|
||||||
|
"@/lib/security.js"
|
||||||
|
);
|
||||||
|
initAllowedPaths();
|
||||||
|
|
||||||
|
const allowed = getAllowedPaths();
|
||||||
|
expect(allowed).toHaveLength(1);
|
||||||
|
expect(allowed[0]).toBe(path.resolve("/data"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should skip empty entries in comma list", async () => {
|
||||||
|
process.env.ALLOWED_PROJECT_DIRS = "/path1,,/path2, ,/path3";
|
||||||
|
process.env.DATA_DIR = "";
|
||||||
|
|
||||||
|
const { initAllowedPaths, getAllowedPaths } = await import(
|
||||||
|
"@/lib/security.js"
|
||||||
|
);
|
||||||
|
initAllowedPaths();
|
||||||
|
|
||||||
|
const allowed = getAllowedPaths();
|
||||||
|
expect(allowed).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addAllowedPath", () => {
|
||||||
|
it("should add path to allowed list", async () => {
|
||||||
|
process.env.ALLOWED_PROJECT_DIRS = "";
|
||||||
|
process.env.DATA_DIR = "";
|
||||||
|
|
||||||
|
const { initAllowedPaths, addAllowedPath, getAllowedPaths } =
|
||||||
|
await import("@/lib/security.js");
|
||||||
|
initAllowedPaths();
|
||||||
|
|
||||||
|
addAllowedPath("/new/path");
|
||||||
|
|
||||||
|
const allowed = getAllowedPaths();
|
||||||
|
expect(allowed).toContain(path.resolve("/new/path"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resolve relative paths before adding", async () => {
|
||||||
|
process.env.ALLOWED_PROJECT_DIRS = "";
|
||||||
|
process.env.DATA_DIR = "";
|
||||||
|
|
||||||
|
const { initAllowedPaths, addAllowedPath, getAllowedPaths } =
|
||||||
|
await import("@/lib/security.js");
|
||||||
|
initAllowedPaths();
|
||||||
|
|
||||||
|
addAllowedPath("./relative/path");
|
||||||
|
|
||||||
|
const allowed = getAllowedPaths();
|
||||||
|
const cwd = process.cwd();
|
||||||
|
expect(allowed).toContain(path.resolve(cwd, "./relative/path"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isPathAllowed", () => {
|
||||||
|
it("should allow paths under allowed directories", async () => {
|
||||||
|
process.env.ALLOWED_PROJECT_DIRS = "/allowed/project";
|
||||||
|
process.env.DATA_DIR = "";
|
||||||
|
|
||||||
|
const { initAllowedPaths, isPathAllowed } = await import(
|
||||||
|
"@/lib/security.js"
|
||||||
|
);
|
||||||
|
initAllowedPaths();
|
||||||
|
|
||||||
|
expect(isPathAllowed("/allowed/project/file.txt")).toBe(true);
|
||||||
|
expect(isPathAllowed("/allowed/project/subdir/file.txt")).toBe(true);
|
||||||
|
expect(isPathAllowed("/allowed/project/deep/nested/file.txt")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow the exact allowed directory", async () => {
|
||||||
|
process.env.ALLOWED_PROJECT_DIRS = "/allowed/project";
|
||||||
|
process.env.DATA_DIR = "";
|
||||||
|
|
||||||
|
const { initAllowedPaths, isPathAllowed } = await import(
|
||||||
|
"@/lib/security.js"
|
||||||
|
);
|
||||||
|
initAllowedPaths();
|
||||||
|
|
||||||
|
expect(isPathAllowed("/allowed/project")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject paths outside allowed directories", async () => {
|
||||||
|
process.env.ALLOWED_PROJECT_DIRS = "/allowed/project";
|
||||||
|
process.env.DATA_DIR = "";
|
||||||
|
|
||||||
|
const { initAllowedPaths, isPathAllowed } = await import(
|
||||||
|
"@/lib/security.js"
|
||||||
|
);
|
||||||
|
initAllowedPaths();
|
||||||
|
|
||||||
|
expect(isPathAllowed("/not/allowed/file.txt")).toBe(false);
|
||||||
|
expect(isPathAllowed("/tmp/file.txt")).toBe(false);
|
||||||
|
expect(isPathAllowed("/etc/passwd")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should block path traversal attempts", async () => {
|
||||||
|
process.env.ALLOWED_PROJECT_DIRS = "/allowed/project";
|
||||||
|
process.env.DATA_DIR = "";
|
||||||
|
|
||||||
|
const { initAllowedPaths, isPathAllowed } = await import(
|
||||||
|
"@/lib/security.js"
|
||||||
|
);
|
||||||
|
initAllowedPaths();
|
||||||
|
|
||||||
|
// These should resolve outside the allowed directory
|
||||||
|
expect(isPathAllowed("/allowed/project/../../../etc/passwd")).toBe(false);
|
||||||
|
expect(isPathAllowed("/allowed/project/../../other/file.txt")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resolve relative paths correctly", async () => {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
process.env.ALLOWED_PROJECT_DIRS = cwd;
|
||||||
|
process.env.DATA_DIR = "";
|
||||||
|
|
||||||
|
const { initAllowedPaths, isPathAllowed } = await import(
|
||||||
|
"@/lib/security.js"
|
||||||
|
);
|
||||||
|
initAllowedPaths();
|
||||||
|
|
||||||
|
expect(isPathAllowed("./file.txt")).toBe(true);
|
||||||
|
expect(isPathAllowed("./subdir/file.txt")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject paths that are parents of allowed directories", async () => {
|
||||||
|
process.env.ALLOWED_PROJECT_DIRS = "/allowed/project/subdir";
|
||||||
|
process.env.DATA_DIR = "";
|
||||||
|
|
||||||
|
const { initAllowedPaths, isPathAllowed } = await import(
|
||||||
|
"@/lib/security.js"
|
||||||
|
);
|
||||||
|
initAllowedPaths();
|
||||||
|
|
||||||
|
expect(isPathAllowed("/allowed/project")).toBe(false);
|
||||||
|
expect(isPathAllowed("/allowed")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle multiple allowed directories", async () => {
|
||||||
|
process.env.ALLOWED_PROJECT_DIRS = "/path1,/path2,/path3";
|
||||||
|
process.env.DATA_DIR = "";
|
||||||
|
|
||||||
|
const { initAllowedPaths, isPathAllowed } = await import(
|
||||||
|
"@/lib/security.js"
|
||||||
|
);
|
||||||
|
initAllowedPaths();
|
||||||
|
|
||||||
|
expect(isPathAllowed("/path1/file.txt")).toBe(true);
|
||||||
|
expect(isPathAllowed("/path2/file.txt")).toBe(true);
|
||||||
|
expect(isPathAllowed("/path3/file.txt")).toBe(true);
|
||||||
|
expect(isPathAllowed("/path4/file.txt")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validatePath", () => {
|
||||||
|
it("should return resolved path for allowed paths", async () => {
|
||||||
|
process.env.ALLOWED_PROJECT_DIRS = "/allowed";
|
||||||
|
process.env.DATA_DIR = "";
|
||||||
|
|
||||||
|
const { initAllowedPaths, validatePath } = await import(
|
||||||
|
"@/lib/security.js"
|
||||||
|
);
|
||||||
|
initAllowedPaths();
|
||||||
|
|
||||||
|
const result = validatePath("/allowed/file.txt");
|
||||||
|
expect(result).toBe(path.resolve("/allowed/file.txt"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error for disallowed paths", async () => {
|
||||||
|
process.env.ALLOWED_PROJECT_DIRS = "/allowed";
|
||||||
|
process.env.DATA_DIR = "";
|
||||||
|
|
||||||
|
const { initAllowedPaths, validatePath } = await import(
|
||||||
|
"@/lib/security.js"
|
||||||
|
);
|
||||||
|
initAllowedPaths();
|
||||||
|
|
||||||
|
expect(() => validatePath("/disallowed/file.txt")).toThrow("Access denied");
|
||||||
|
expect(() => validatePath("/disallowed/file.txt")).toThrow(
|
||||||
|
"not in an allowed directory"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include the file path in error message", async () => {
|
||||||
|
process.env.ALLOWED_PROJECT_DIRS = "/allowed";
|
||||||
|
process.env.DATA_DIR = "";
|
||||||
|
|
||||||
|
const { initAllowedPaths, validatePath } = await import(
|
||||||
|
"@/lib/security.js"
|
||||||
|
);
|
||||||
|
initAllowedPaths();
|
||||||
|
|
||||||
|
expect(() => validatePath("/bad/path.txt")).toThrow("/bad/path.txt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resolve paths before validation", async () => {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
process.env.ALLOWED_PROJECT_DIRS = cwd;
|
||||||
|
process.env.DATA_DIR = "";
|
||||||
|
|
||||||
|
const { initAllowedPaths, validatePath } = await import(
|
||||||
|
"@/lib/security.js"
|
||||||
|
);
|
||||||
|
initAllowedPaths();
|
||||||
|
|
||||||
|
const result = validatePath("./file.txt");
|
||||||
|
expect(result).toBe(path.resolve(cwd, "./file.txt"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAllowedPaths", () => {
|
||||||
|
it("should return array of allowed paths", async () => {
|
||||||
|
process.env.ALLOWED_PROJECT_DIRS = "/path1,/path2";
|
||||||
|
process.env.DATA_DIR = "/data";
|
||||||
|
|
||||||
|
const { initAllowedPaths, getAllowedPaths } = await import(
|
||||||
|
"@/lib/security.js"
|
||||||
|
);
|
||||||
|
initAllowedPaths();
|
||||||
|
|
||||||
|
const result = getAllowedPaths();
|
||||||
|
expect(Array.isArray(result)).toBe(true);
|
||||||
|
expect(result.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return resolved paths", async () => {
|
||||||
|
process.env.ALLOWED_PROJECT_DIRS = "/test";
|
||||||
|
process.env.DATA_DIR = "";
|
||||||
|
|
||||||
|
const { initAllowedPaths, getAllowedPaths } = await import(
|
||||||
|
"@/lib/security.js"
|
||||||
|
);
|
||||||
|
initAllowedPaths();
|
||||||
|
|
||||||
|
const result = getAllowedPaths();
|
||||||
|
expect(result[0]).toBe(path.resolve("/test"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
482
apps/server/tests/unit/lib/subprocess-manager.test.ts
Normal file
482
apps/server/tests/unit/lib/subprocess-manager.test.ts
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
spawnJSONLProcess,
|
||||||
|
spawnProcess,
|
||||||
|
type SubprocessOptions,
|
||||||
|
} from "@/lib/subprocess-manager.js";
|
||||||
|
import * as cp from "child_process";
|
||||||
|
import { EventEmitter } from "events";
|
||||||
|
import { Readable } from "stream";
|
||||||
|
import { collectAsyncGenerator } from "../../utils/helpers.js";
|
||||||
|
|
||||||
|
vi.mock("child_process");
|
||||||
|
|
||||||
|
describe("subprocess-manager.ts", () => {
|
||||||
|
let consoleSpy: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
consoleSpy = {
|
||||||
|
log: vi.spyOn(console, "log").mockImplementation(() => {}),
|
||||||
|
error: vi.spyOn(console, "error").mockImplementation(() => {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleSpy.log.mockRestore();
|
||||||
|
consoleSpy.error.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a mock ChildProcess with stdout/stderr streams
|
||||||
|
*/
|
||||||
|
function createMockProcess(config: {
|
||||||
|
stdoutLines?: string[];
|
||||||
|
stderrLines?: string[];
|
||||||
|
exitCode?: number;
|
||||||
|
error?: Error;
|
||||||
|
delayMs?: number;
|
||||||
|
}) {
|
||||||
|
const mockProcess = new EventEmitter() as any;
|
||||||
|
|
||||||
|
// Create readable streams for stdout and stderr
|
||||||
|
const stdout = new Readable({ read() {} });
|
||||||
|
const stderr = new Readable({ read() {} });
|
||||||
|
|
||||||
|
mockProcess.stdout = stdout;
|
||||||
|
mockProcess.stderr = stderr;
|
||||||
|
mockProcess.kill = vi.fn();
|
||||||
|
|
||||||
|
// Use process.nextTick to ensure readline interface is set up first
|
||||||
|
process.nextTick(() => {
|
||||||
|
// Emit stderr lines immediately
|
||||||
|
if (config.stderrLines) {
|
||||||
|
for (const line of config.stderrLines) {
|
||||||
|
stderr.emit("data", Buffer.from(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit stdout lines with small delays to ensure readline processes them
|
||||||
|
const emitLines = async () => {
|
||||||
|
if (config.stdoutLines) {
|
||||||
|
for (const line of config.stdoutLines) {
|
||||||
|
stdout.push(line + "\n");
|
||||||
|
// Small delay to allow readline to process
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay before ending stream
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
stdout.push(null); // End stdout
|
||||||
|
|
||||||
|
// Small delay before exit
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, config.delayMs ?? 10)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Emit exit or error
|
||||||
|
if (config.error) {
|
||||||
|
mockProcess.emit("error", config.error);
|
||||||
|
} else {
|
||||||
|
mockProcess.emit("exit", config.exitCode ?? 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
emitLines();
|
||||||
|
});
|
||||||
|
|
||||||
|
return mockProcess;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("spawnJSONLProcess", () => {
|
||||||
|
const baseOptions: SubprocessOptions = {
|
||||||
|
command: "test-command",
|
||||||
|
args: ["arg1", "arg2"],
|
||||||
|
cwd: "/test/dir",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should yield parsed JSONL objects line by line", async () => {
|
||||||
|
const mockProcess = createMockProcess({
|
||||||
|
stdoutLines: [
|
||||||
|
'{"type":"start","id":1}',
|
||||||
|
'{"type":"progress","value":50}',
|
||||||
|
'{"type":"complete","result":"success"}',
|
||||||
|
],
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
const generator = spawnJSONLProcess(baseOptions);
|
||||||
|
const results = await collectAsyncGenerator(generator);
|
||||||
|
|
||||||
|
expect(results).toHaveLength(3);
|
||||||
|
expect(results[0]).toEqual({ type: "start", id: 1 });
|
||||||
|
expect(results[1]).toEqual({ type: "progress", value: 50 });
|
||||||
|
expect(results[2]).toEqual({ type: "complete", result: "success" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should skip empty lines", async () => {
|
||||||
|
const mockProcess = createMockProcess({
|
||||||
|
stdoutLines: [
|
||||||
|
'{"type":"first"}',
|
||||||
|
"",
|
||||||
|
" ",
|
||||||
|
'{"type":"second"}',
|
||||||
|
],
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
const generator = spawnJSONLProcess(baseOptions);
|
||||||
|
const results = await collectAsyncGenerator(generator);
|
||||||
|
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(results[0]).toEqual({ type: "first" });
|
||||||
|
expect(results[1]).toEqual({ type: "second" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should yield error for malformed JSON and continue processing", async () => {
|
||||||
|
const mockProcess = createMockProcess({
|
||||||
|
stdoutLines: [
|
||||||
|
'{"type":"valid"}',
|
||||||
|
'{invalid json}',
|
||||||
|
'{"type":"also_valid"}',
|
||||||
|
],
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
const generator = spawnJSONLProcess(baseOptions);
|
||||||
|
const results = await collectAsyncGenerator(generator);
|
||||||
|
|
||||||
|
expect(results).toHaveLength(3);
|
||||||
|
expect(results[0]).toEqual({ type: "valid" });
|
||||||
|
expect(results[1]).toMatchObject({
|
||||||
|
type: "error",
|
||||||
|
error: expect.stringContaining("Failed to parse output"),
|
||||||
|
});
|
||||||
|
expect(results[2]).toEqual({ type: "also_valid" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should collect stderr output", async () => {
|
||||||
|
const mockProcess = createMockProcess({
|
||||||
|
stdoutLines: ['{"type":"test"}'],
|
||||||
|
stderrLines: ["Warning: something happened", "Error: critical issue"],
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
const generator = spawnJSONLProcess(baseOptions);
|
||||||
|
await collectAsyncGenerator(generator);
|
||||||
|
|
||||||
|
expect(consoleSpy.error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Warning: something happened")
|
||||||
|
);
|
||||||
|
expect(consoleSpy.error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Error: critical issue")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should yield error on non-zero exit code", async () => {
|
||||||
|
const mockProcess = createMockProcess({
|
||||||
|
stdoutLines: ['{"type":"started"}'],
|
||||||
|
stderrLines: ["Process failed with error"],
|
||||||
|
exitCode: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
const generator = spawnJSONLProcess(baseOptions);
|
||||||
|
const results = await collectAsyncGenerator(generator);
|
||||||
|
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(results[0]).toEqual({ type: "started" });
|
||||||
|
expect(results[1]).toMatchObject({
|
||||||
|
type: "error",
|
||||||
|
error: expect.stringContaining("Process failed with error"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should yield error with exit code when stderr is empty", async () => {
|
||||||
|
const mockProcess = createMockProcess({
|
||||||
|
stdoutLines: ['{"type":"test"}'],
|
||||||
|
exitCode: 127,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
const generator = spawnJSONLProcess(baseOptions);
|
||||||
|
const results = await collectAsyncGenerator(generator);
|
||||||
|
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(results[1]).toMatchObject({
|
||||||
|
type: "error",
|
||||||
|
error: "Process exited with code 127",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle process spawn errors", async () => {
|
||||||
|
const mockProcess = createMockProcess({
|
||||||
|
error: new Error("Command not found"),
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
const generator = spawnJSONLProcess(baseOptions);
|
||||||
|
const results = await collectAsyncGenerator(generator);
|
||||||
|
|
||||||
|
// When process.on('error') fires, exitCode is null
|
||||||
|
// The generator should handle this gracefully
|
||||||
|
expect(results).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should kill process on AbortController signal", async () => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const mockProcess = createMockProcess({
|
||||||
|
stdoutLines: ['{"type":"start"}'],
|
||||||
|
exitCode: 0,
|
||||||
|
delayMs: 100, // Delay to allow abort
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
const generator = spawnJSONLProcess({
|
||||||
|
...baseOptions,
|
||||||
|
abortController,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start consuming the generator
|
||||||
|
const promise = collectAsyncGenerator(generator);
|
||||||
|
|
||||||
|
// Abort after a short delay
|
||||||
|
setTimeout(() => abortController.abort(), 20);
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM");
|
||||||
|
expect(consoleSpy.log).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Abort signal received")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: Timeout behavior tests are omitted from unit tests as they involve
|
||||||
|
// complex timing interactions that are difficult to mock reliably.
|
||||||
|
// These scenarios are better covered by integration tests with real subprocesses.
|
||||||
|
|
||||||
|
it("should spawn process with correct arguments", async () => {
|
||||||
|
const mockProcess = createMockProcess({ exitCode: 0 });
|
||||||
|
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
const options: SubprocessOptions = {
|
||||||
|
command: "my-command",
|
||||||
|
args: ["--flag", "value"],
|
||||||
|
cwd: "/work/dir",
|
||||||
|
env: { CUSTOM_VAR: "test" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const generator = spawnJSONLProcess(options);
|
||||||
|
await collectAsyncGenerator(generator);
|
||||||
|
|
||||||
|
expect(cp.spawn).toHaveBeenCalledWith("my-command", ["--flag", "value"], {
|
||||||
|
cwd: "/work/dir",
|
||||||
|
env: expect.objectContaining({ CUSTOM_VAR: "test" }),
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should merge env with process.env", async () => {
|
||||||
|
const mockProcess = createMockProcess({ exitCode: 0 });
|
||||||
|
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
const options: SubprocessOptions = {
|
||||||
|
command: "test",
|
||||||
|
args: [],
|
||||||
|
cwd: "/test",
|
||||||
|
env: { CUSTOM: "value" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const generator = spawnJSONLProcess(options);
|
||||||
|
await collectAsyncGenerator(generator);
|
||||||
|
|
||||||
|
expect(cp.spawn).toHaveBeenCalledWith(
|
||||||
|
"test",
|
||||||
|
[],
|
||||||
|
expect.objectContaining({
|
||||||
|
env: expect.objectContaining({
|
||||||
|
CUSTOM: "value",
|
||||||
|
// Should also include existing process.env
|
||||||
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle complex JSON objects", async () => {
|
||||||
|
const complexObject = {
|
||||||
|
type: "complex",
|
||||||
|
nested: { deep: { value: [1, 2, 3] } },
|
||||||
|
array: [{ id: 1 }, { id: 2 }],
|
||||||
|
string: "with \"quotes\" and \\backslashes",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockProcess = createMockProcess({
|
||||||
|
stdoutLines: [JSON.stringify(complexObject)],
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
const generator = spawnJSONLProcess(baseOptions);
|
||||||
|
const results = await collectAsyncGenerator(generator);
|
||||||
|
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0]).toEqual(complexObject);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("spawnProcess", () => {
|
||||||
|
const baseOptions: SubprocessOptions = {
|
||||||
|
command: "test-command",
|
||||||
|
args: ["arg1"],
|
||||||
|
cwd: "/test",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should collect stdout and stderr", async () => {
|
||||||
|
const mockProcess = new EventEmitter() as any;
|
||||||
|
const stdout = new Readable({ read() {} });
|
||||||
|
const stderr = new Readable({ read() {} });
|
||||||
|
|
||||||
|
mockProcess.stdout = stdout;
|
||||||
|
mockProcess.stderr = stderr;
|
||||||
|
mockProcess.kill = vi.fn();
|
||||||
|
|
||||||
|
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
stdout.push("line 1\n");
|
||||||
|
stdout.push("line 2\n");
|
||||||
|
stdout.push(null);
|
||||||
|
|
||||||
|
stderr.push("error 1\n");
|
||||||
|
stderr.push("error 2\n");
|
||||||
|
stderr.push(null);
|
||||||
|
|
||||||
|
mockProcess.emit("exit", 0);
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
const result = await spawnProcess(baseOptions);
|
||||||
|
|
||||||
|
expect(result.stdout).toBe("line 1\nline 2\n");
|
||||||
|
expect(result.stderr).toBe("error 1\nerror 2\n");
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return correct exit code", async () => {
|
||||||
|
const mockProcess = new EventEmitter() as any;
|
||||||
|
mockProcess.stdout = new Readable({ read() {} });
|
||||||
|
mockProcess.stderr = new Readable({ read() {} });
|
||||||
|
mockProcess.kill = vi.fn();
|
||||||
|
|
||||||
|
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
mockProcess.stdout.push(null);
|
||||||
|
mockProcess.stderr.push(null);
|
||||||
|
mockProcess.emit("exit", 42);
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
const result = await spawnProcess(baseOptions);
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle process errors", async () => {
|
||||||
|
const mockProcess = new EventEmitter() as any;
|
||||||
|
mockProcess.stdout = new Readable({ read() {} });
|
||||||
|
mockProcess.stderr = new Readable({ read() {} });
|
||||||
|
mockProcess.kill = vi.fn();
|
||||||
|
|
||||||
|
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
mockProcess.emit("error", new Error("Spawn failed"));
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
await expect(spawnProcess(baseOptions)).rejects.toThrow("Spawn failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle AbortController signal", async () => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const mockProcess = new EventEmitter() as any;
|
||||||
|
mockProcess.stdout = new Readable({ read() {} });
|
||||||
|
mockProcess.stderr = new Readable({ read() {} });
|
||||||
|
mockProcess.kill = vi.fn();
|
||||||
|
|
||||||
|
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
setTimeout(() => abortController.abort(), 20);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
spawnProcess({ ...baseOptions, abortController })
|
||||||
|
).rejects.toThrow("Process aborted");
|
||||||
|
|
||||||
|
expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should spawn with correct options", async () => {
|
||||||
|
const mockProcess = new EventEmitter() as any;
|
||||||
|
mockProcess.stdout = new Readable({ read() {} });
|
||||||
|
mockProcess.stderr = new Readable({ read() {} });
|
||||||
|
mockProcess.kill = vi.fn();
|
||||||
|
|
||||||
|
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
mockProcess.stdout.push(null);
|
||||||
|
mockProcess.stderr.push(null);
|
||||||
|
mockProcess.emit("exit", 0);
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
const options: SubprocessOptions = {
|
||||||
|
command: "my-cmd",
|
||||||
|
args: ["--verbose"],
|
||||||
|
cwd: "/my/dir",
|
||||||
|
env: { MY_VAR: "value" },
|
||||||
|
};
|
||||||
|
|
||||||
|
await spawnProcess(options);
|
||||||
|
|
||||||
|
expect(cp.spawn).toHaveBeenCalledWith("my-cmd", ["--verbose"], {
|
||||||
|
cwd: "/my/dir",
|
||||||
|
env: expect.objectContaining({ MY_VAR: "value" }),
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty stdout and stderr", async () => {
|
||||||
|
const mockProcess = new EventEmitter() as any;
|
||||||
|
mockProcess.stdout = new Readable({ read() {} });
|
||||||
|
mockProcess.stderr = new Readable({ read() {} });
|
||||||
|
mockProcess.kill = vi.fn();
|
||||||
|
|
||||||
|
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
mockProcess.stdout.push(null);
|
||||||
|
mockProcess.stderr.push(null);
|
||||||
|
mockProcess.emit("exit", 0);
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
const result = await spawnProcess(baseOptions);
|
||||||
|
|
||||||
|
expect(result.stdout).toBe("");
|
||||||
|
expect(result.stderr).toBe("");
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
242
apps/server/tests/unit/providers/base-provider.test.ts
Normal file
242
apps/server/tests/unit/providers/base-provider.test.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { BaseProvider } from "@/providers/base-provider.js";
|
||||||
|
import type {
|
||||||
|
ProviderConfig,
|
||||||
|
ExecuteOptions,
|
||||||
|
ProviderMessage,
|
||||||
|
InstallationStatus,
|
||||||
|
ModelDefinition,
|
||||||
|
} from "@/providers/types.js";
|
||||||
|
|
||||||
|
// Concrete implementation for testing the abstract class
|
||||||
|
class TestProvider extends BaseProvider {
|
||||||
|
getName(): string {
|
||||||
|
return "test-provider";
|
||||||
|
}
|
||||||
|
|
||||||
|
async *executeQuery(
|
||||||
|
_options: ExecuteOptions
|
||||||
|
): AsyncGenerator<ProviderMessage> {
|
||||||
|
yield { type: "text", text: "test response" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async detectInstallation(): Promise<InstallationStatus> {
|
||||||
|
return { installed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
getAvailableModels(): ModelDefinition[] {
|
||||||
|
return [
|
||||||
|
{ id: "test-model-1", name: "Test Model 1", description: "A test model" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("base-provider.ts", () => {
|
||||||
|
describe("constructor", () => {
|
||||||
|
it("should initialize with empty config when none provided", () => {
|
||||||
|
const provider = new TestProvider();
|
||||||
|
expect(provider.getConfig()).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should initialize with provided config", () => {
|
||||||
|
const config: ProviderConfig = {
|
||||||
|
apiKey: "test-key",
|
||||||
|
baseUrl: "https://test.com",
|
||||||
|
};
|
||||||
|
const provider = new TestProvider(config);
|
||||||
|
expect(provider.getConfig()).toEqual(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call getName() during initialization", () => {
|
||||||
|
const provider = new TestProvider();
|
||||||
|
expect(provider.getName()).toBe("test-provider");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateConfig", () => {
|
||||||
|
it("should return valid when config exists", () => {
|
||||||
|
const provider = new TestProvider({ apiKey: "test" });
|
||||||
|
const result = provider.validateConfig();
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
expect(result.warnings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return invalid when config is undefined", () => {
|
||||||
|
// Create provider without config
|
||||||
|
const provider = new TestProvider();
|
||||||
|
// Manually set config to undefined to test edge case
|
||||||
|
(provider as any).config = undefined;
|
||||||
|
|
||||||
|
const result = provider.validateConfig();
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toContain("Provider config is missing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return valid for empty config object", () => {
|
||||||
|
const provider = new TestProvider({});
|
||||||
|
const result = provider.validateConfig();
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include warnings array in result", () => {
|
||||||
|
const provider = new TestProvider();
|
||||||
|
const result = provider.validateConfig();
|
||||||
|
|
||||||
|
expect(result).toHaveProperty("warnings");
|
||||||
|
expect(Array.isArray(result.warnings)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("supportsFeature", () => {
|
||||||
|
it("should support 'tools' feature", () => {
|
||||||
|
const provider = new TestProvider();
|
||||||
|
expect(provider.supportsFeature("tools")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support 'text' feature", () => {
|
||||||
|
const provider = new TestProvider();
|
||||||
|
expect(provider.supportsFeature("text")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not support unknown features", () => {
|
||||||
|
const provider = new TestProvider();
|
||||||
|
expect(provider.supportsFeature("vision")).toBe(false);
|
||||||
|
expect(provider.supportsFeature("mcp")).toBe(false);
|
||||||
|
expect(provider.supportsFeature("unknown")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be case-sensitive", () => {
|
||||||
|
const provider = new TestProvider();
|
||||||
|
expect(provider.supportsFeature("TOOLS")).toBe(false);
|
||||||
|
expect(provider.supportsFeature("Text")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getConfig", () => {
|
||||||
|
it("should return current config", () => {
|
||||||
|
const config: ProviderConfig = {
|
||||||
|
apiKey: "test-key",
|
||||||
|
model: "test-model",
|
||||||
|
};
|
||||||
|
const provider = new TestProvider(config);
|
||||||
|
|
||||||
|
expect(provider.getConfig()).toEqual(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return same reference", () => {
|
||||||
|
const config: ProviderConfig = { apiKey: "test" };
|
||||||
|
const provider = new TestProvider(config);
|
||||||
|
|
||||||
|
const retrieved1 = provider.getConfig();
|
||||||
|
const retrieved2 = provider.getConfig();
|
||||||
|
|
||||||
|
expect(retrieved1).toBe(retrieved2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setConfig", () => {
|
||||||
|
it("should merge partial config with existing config", () => {
|
||||||
|
const provider = new TestProvider({ apiKey: "original-key" });
|
||||||
|
|
||||||
|
provider.setConfig({ model: "new-model" });
|
||||||
|
|
||||||
|
expect(provider.getConfig()).toEqual({
|
||||||
|
apiKey: "original-key",
|
||||||
|
model: "new-model",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should override existing fields", () => {
|
||||||
|
const provider = new TestProvider({ apiKey: "old-key", model: "old-model" });
|
||||||
|
|
||||||
|
provider.setConfig({ apiKey: "new-key" });
|
||||||
|
|
||||||
|
expect(provider.getConfig()).toEqual({
|
||||||
|
apiKey: "new-key",
|
||||||
|
model: "old-model",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept empty object", () => {
|
||||||
|
const provider = new TestProvider({ apiKey: "test" });
|
||||||
|
const originalConfig = provider.getConfig();
|
||||||
|
|
||||||
|
provider.setConfig({});
|
||||||
|
|
||||||
|
expect(provider.getConfig()).toEqual(originalConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle multiple updates", () => {
|
||||||
|
const provider = new TestProvider();
|
||||||
|
|
||||||
|
provider.setConfig({ apiKey: "key1" });
|
||||||
|
provider.setConfig({ model: "model1" });
|
||||||
|
provider.setConfig({ baseUrl: "https://test.com" });
|
||||||
|
|
||||||
|
expect(provider.getConfig()).toEqual({
|
||||||
|
apiKey: "key1",
|
||||||
|
model: "model1",
|
||||||
|
baseUrl: "https://test.com",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve other fields when updating one field", () => {
|
||||||
|
const provider = new TestProvider({
|
||||||
|
apiKey: "key",
|
||||||
|
model: "model",
|
||||||
|
baseUrl: "https://test.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
provider.setConfig({ model: "new-model" });
|
||||||
|
|
||||||
|
expect(provider.getConfig()).toEqual({
|
||||||
|
apiKey: "key",
|
||||||
|
model: "new-model",
|
||||||
|
baseUrl: "https://test.com",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("abstract methods", () => {
|
||||||
|
it("should require getName implementation", () => {
|
||||||
|
const provider = new TestProvider();
|
||||||
|
expect(typeof provider.getName).toBe("function");
|
||||||
|
expect(provider.getName()).toBe("test-provider");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require executeQuery implementation", async () => {
|
||||||
|
const provider = new TestProvider();
|
||||||
|
expect(typeof provider.executeQuery).toBe("function");
|
||||||
|
|
||||||
|
const generator = provider.executeQuery({
|
||||||
|
prompt: "test",
|
||||||
|
projectDirectory: "/test",
|
||||||
|
});
|
||||||
|
const result = await generator.next();
|
||||||
|
|
||||||
|
expect(result.value).toEqual({ type: "text", text: "test response" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require detectInstallation implementation", async () => {
|
||||||
|
const provider = new TestProvider();
|
||||||
|
expect(typeof provider.detectInstallation).toBe("function");
|
||||||
|
|
||||||
|
const status = await provider.detectInstallation();
|
||||||
|
expect(status).toHaveProperty("installed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require getAvailableModels implementation", () => {
|
||||||
|
const provider = new TestProvider();
|
||||||
|
expect(typeof provider.getAvailableModels).toBe("function");
|
||||||
|
|
||||||
|
const models = provider.getAvailableModels();
|
||||||
|
expect(Array.isArray(models)).toBe(true);
|
||||||
|
expect(models.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
398
apps/server/tests/unit/providers/claude-provider.test.ts
Normal file
398
apps/server/tests/unit/providers/claude-provider.test.ts
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { ClaudeProvider } from "@/providers/claude-provider.js";
|
||||||
|
import * as sdk from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
import { collectAsyncGenerator } from "../../utils/helpers.js";
|
||||||
|
|
||||||
|
vi.mock("@anthropic-ai/claude-agent-sdk");
|
||||||
|
|
||||||
|
describe("claude-provider.ts", () => {
|
||||||
|
let provider: ClaudeProvider;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
provider = new ClaudeProvider();
|
||||||
|
delete process.env.ANTHROPIC_API_KEY;
|
||||||
|
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getName", () => {
|
||||||
|
it("should return 'claude' as provider name", () => {
|
||||||
|
expect(provider.getName()).toBe("claude");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("executeQuery", () => {
|
||||||
|
it("should execute simple text query", async () => {
|
||||||
|
const mockMessages = [
|
||||||
|
{ type: "text", text: "Response 1" },
|
||||||
|
{ type: "text", text: "Response 2" },
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(sdk.query).mockReturnValue(
|
||||||
|
(async function* () {
|
||||||
|
for (const msg of mockMessages) {
|
||||||
|
yield msg;
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
|
||||||
|
const generator = provider.executeQuery({
|
||||||
|
prompt: "Hello",
|
||||||
|
cwd: "/test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await collectAsyncGenerator(generator);
|
||||||
|
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(results[0]).toEqual({ type: "text", text: "Response 1" });
|
||||||
|
expect(results[1]).toEqual({ type: "text", text: "Response 2" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass correct options to SDK", async () => {
|
||||||
|
vi.mocked(sdk.query).mockReturnValue(
|
||||||
|
(async function* () {
|
||||||
|
yield { type: "text", text: "test" };
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
|
||||||
|
const generator = provider.executeQuery({
|
||||||
|
prompt: "Test prompt",
|
||||||
|
model: "claude-opus-4-5-20251101",
|
||||||
|
cwd: "/test/dir",
|
||||||
|
systemPrompt: "You are helpful",
|
||||||
|
maxTurns: 10,
|
||||||
|
allowedTools: ["Read", "Write"],
|
||||||
|
});
|
||||||
|
|
||||||
|
await collectAsyncGenerator(generator);
|
||||||
|
|
||||||
|
expect(sdk.query).toHaveBeenCalledWith({
|
||||||
|
prompt: "Test prompt",
|
||||||
|
options: expect.objectContaining({
|
||||||
|
model: "claude-opus-4-5-20251101",
|
||||||
|
systemPrompt: "You are helpful",
|
||||||
|
maxTurns: 10,
|
||||||
|
cwd: "/test/dir",
|
||||||
|
allowedTools: ["Read", "Write"],
|
||||||
|
permissionMode: "acceptEdits",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use default allowed tools when not specified", async () => {
|
||||||
|
vi.mocked(sdk.query).mockReturnValue(
|
||||||
|
(async function* () {
|
||||||
|
yield { type: "text", text: "test" };
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
|
||||||
|
const generator = provider.executeQuery({
|
||||||
|
prompt: "Test",
|
||||||
|
cwd: "/test",
|
||||||
|
});
|
||||||
|
|
||||||
|
await collectAsyncGenerator(generator);
|
||||||
|
|
||||||
|
expect(sdk.query).toHaveBeenCalledWith({
|
||||||
|
prompt: "Test",
|
||||||
|
options: expect.objectContaining({
|
||||||
|
allowedTools: [
|
||||||
|
"Read",
|
||||||
|
"Write",
|
||||||
|
"Edit",
|
||||||
|
"Glob",
|
||||||
|
"Grep",
|
||||||
|
"Bash",
|
||||||
|
"WebSearch",
|
||||||
|
"WebFetch",
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should enable sandbox by default", async () => {
|
||||||
|
vi.mocked(sdk.query).mockReturnValue(
|
||||||
|
(async function* () {
|
||||||
|
yield { type: "text", text: "test" };
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
|
||||||
|
const generator = provider.executeQuery({
|
||||||
|
prompt: "Test",
|
||||||
|
cwd: "/test",
|
||||||
|
});
|
||||||
|
|
||||||
|
await collectAsyncGenerator(generator);
|
||||||
|
|
||||||
|
expect(sdk.query).toHaveBeenCalledWith({
|
||||||
|
prompt: "Test",
|
||||||
|
options: expect.objectContaining({
|
||||||
|
sandbox: {
|
||||||
|
enabled: true,
|
||||||
|
autoAllowBashIfSandboxed: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass abortController if provided", async () => {
|
||||||
|
vi.mocked(sdk.query).mockReturnValue(
|
||||||
|
(async function* () {
|
||||||
|
yield { type: "text", text: "test" };
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
const generator = provider.executeQuery({
|
||||||
|
prompt: "Test",
|
||||||
|
cwd: "/test",
|
||||||
|
abortController,
|
||||||
|
});
|
||||||
|
|
||||||
|
await collectAsyncGenerator(generator);
|
||||||
|
|
||||||
|
expect(sdk.query).toHaveBeenCalledWith({
|
||||||
|
prompt: "Test",
|
||||||
|
options: expect.objectContaining({
|
||||||
|
abortController,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle conversation history", async () => {
|
||||||
|
vi.mocked(sdk.query).mockReturnValue(
|
||||||
|
(async function* () {
|
||||||
|
yield { type: "text", text: "test" };
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
|
||||||
|
const conversationHistory = [
|
||||||
|
{ role: "user" as const, content: "Previous message" },
|
||||||
|
{ role: "assistant" as const, content: "Previous response" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const generator = provider.executeQuery({
|
||||||
|
prompt: "Current message",
|
||||||
|
cwd: "/test",
|
||||||
|
conversationHistory,
|
||||||
|
});
|
||||||
|
|
||||||
|
await collectAsyncGenerator(generator);
|
||||||
|
|
||||||
|
// Should pass an async generator as prompt
|
||||||
|
const callArgs = vi.mocked(sdk.query).mock.calls[0][0];
|
||||||
|
expect(typeof callArgs.prompt).not.toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle array prompt (with images)", async () => {
|
||||||
|
vi.mocked(sdk.query).mockReturnValue(
|
||||||
|
(async function* () {
|
||||||
|
yield { type: "text", text: "test" };
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
|
||||||
|
const arrayPrompt = [
|
||||||
|
{ type: "text", text: "Describe this" },
|
||||||
|
{ type: "image", source: { type: "base64", data: "..." } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const generator = provider.executeQuery({
|
||||||
|
prompt: arrayPrompt as any,
|
||||||
|
cwd: "/test",
|
||||||
|
});
|
||||||
|
|
||||||
|
await collectAsyncGenerator(generator);
|
||||||
|
|
||||||
|
// Should pass an async generator as prompt for array inputs
|
||||||
|
const callArgs = vi.mocked(sdk.query).mock.calls[0][0];
|
||||||
|
expect(typeof callArgs.prompt).not.toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use maxTurns default of 20", async () => {
|
||||||
|
vi.mocked(sdk.query).mockReturnValue(
|
||||||
|
(async function* () {
|
||||||
|
yield { type: "text", text: "test" };
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
|
||||||
|
const generator = provider.executeQuery({
|
||||||
|
prompt: "Test",
|
||||||
|
cwd: "/test",
|
||||||
|
});
|
||||||
|
|
||||||
|
await collectAsyncGenerator(generator);
|
||||||
|
|
||||||
|
expect(sdk.query).toHaveBeenCalledWith({
|
||||||
|
prompt: "Test",
|
||||||
|
options: expect.objectContaining({
|
||||||
|
maxTurns: 20,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("detectInstallation", () => {
|
||||||
|
it("should return installed with SDK method", async () => {
|
||||||
|
const result = await provider.detectInstallation();
|
||||||
|
|
||||||
|
expect(result.installed).toBe(true);
|
||||||
|
expect(result.method).toBe("sdk");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect ANTHROPIC_API_KEY", async () => {
|
||||||
|
process.env.ANTHROPIC_API_KEY = "test-key";
|
||||||
|
|
||||||
|
const result = await provider.detectInstallation();
|
||||||
|
|
||||||
|
expect(result.hasApiKey).toBe(true);
|
||||||
|
expect(result.authenticated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect CLAUDE_CODE_OAUTH_TOKEN", async () => {
|
||||||
|
process.env.CLAUDE_CODE_OAUTH_TOKEN = "oauth-token";
|
||||||
|
|
||||||
|
const result = await provider.detectInstallation();
|
||||||
|
|
||||||
|
expect(result.hasApiKey).toBe(true);
|
||||||
|
expect(result.authenticated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return hasApiKey false when no keys present", async () => {
|
||||||
|
const result = await provider.detectInstallation();
|
||||||
|
|
||||||
|
expect(result.hasApiKey).toBe(false);
|
||||||
|
expect(result.authenticated).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAvailableModels", () => {
|
||||||
|
it("should return 4 Claude models", () => {
|
||||||
|
const models = provider.getAvailableModels();
|
||||||
|
|
||||||
|
expect(models).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Claude Opus 4.5", () => {
|
||||||
|
const models = provider.getAvailableModels();
|
||||||
|
|
||||||
|
const opus = models.find((m) => m.id === "claude-opus-4-5-20251101");
|
||||||
|
expect(opus).toBeDefined();
|
||||||
|
expect(opus?.name).toBe("Claude Opus 4.5");
|
||||||
|
expect(opus?.provider).toBe("anthropic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Claude Sonnet 4", () => {
|
||||||
|
const models = provider.getAvailableModels();
|
||||||
|
|
||||||
|
const sonnet = models.find((m) => m.id === "claude-sonnet-4-20250514");
|
||||||
|
expect(sonnet).toBeDefined();
|
||||||
|
expect(sonnet?.name).toBe("Claude Sonnet 4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Claude 3.5 Sonnet", () => {
|
||||||
|
const models = provider.getAvailableModels();
|
||||||
|
|
||||||
|
const sonnet35 = models.find(
|
||||||
|
(m) => m.id === "claude-3-5-sonnet-20241022"
|
||||||
|
);
|
||||||
|
expect(sonnet35).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Claude 3.5 Haiku", () => {
|
||||||
|
const models = provider.getAvailableModels();
|
||||||
|
|
||||||
|
const haiku = models.find((m) => m.id === "claude-3-5-haiku-20241022");
|
||||||
|
expect(haiku).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should mark Opus as default", () => {
|
||||||
|
const models = provider.getAvailableModels();
|
||||||
|
|
||||||
|
const opus = models.find((m) => m.id === "claude-opus-4-5-20251101");
|
||||||
|
expect(opus?.default).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should all support vision and tools", () => {
|
||||||
|
const models = provider.getAvailableModels();
|
||||||
|
|
||||||
|
models.forEach((model) => {
|
||||||
|
expect(model.supportsVision).toBe(true);
|
||||||
|
expect(model.supportsTools).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have correct context windows", () => {
|
||||||
|
const models = provider.getAvailableModels();
|
||||||
|
|
||||||
|
models.forEach((model) => {
|
||||||
|
expect(model.contextWindow).toBe(200000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have modelString field matching id", () => {
|
||||||
|
const models = provider.getAvailableModels();
|
||||||
|
|
||||||
|
models.forEach((model) => {
|
||||||
|
expect(model.modelString).toBe(model.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("supportsFeature", () => {
|
||||||
|
it("should support 'tools' feature", () => {
|
||||||
|
expect(provider.supportsFeature("tools")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support 'text' feature", () => {
|
||||||
|
expect(provider.supportsFeature("text")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support 'vision' feature", () => {
|
||||||
|
expect(provider.supportsFeature("vision")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support 'thinking' feature", () => {
|
||||||
|
expect(provider.supportsFeature("thinking")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not support 'mcp' feature", () => {
|
||||||
|
expect(provider.supportsFeature("mcp")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not support 'cli' feature", () => {
|
||||||
|
expect(provider.supportsFeature("cli")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not support unknown features", () => {
|
||||||
|
expect(provider.supportsFeature("unknown")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateConfig", () => {
|
||||||
|
it("should validate config from base class", () => {
|
||||||
|
const result = provider.validateConfig();
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("config management", () => {
|
||||||
|
it("should get and set config", () => {
|
||||||
|
provider.setConfig({ apiKey: "test-key" });
|
||||||
|
|
||||||
|
const config = provider.getConfig();
|
||||||
|
expect(config.apiKey).toBe("test-key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should merge config updates", () => {
|
||||||
|
provider.setConfig({ apiKey: "key1" });
|
||||||
|
provider.setConfig({ model: "model1" });
|
||||||
|
|
||||||
|
const config = provider.getConfig();
|
||||||
|
expect(config.apiKey).toBe("key1");
|
||||||
|
expect(config.model).toBe("model1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
234
apps/server/tests/unit/providers/provider-factory.test.ts
Normal file
234
apps/server/tests/unit/providers/provider-factory.test.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { ProviderFactory } from "@/providers/provider-factory.js";
|
||||||
|
import { ClaudeProvider } from "@/providers/claude-provider.js";
|
||||||
|
|
||||||
|
describe("provider-factory.ts", () => {
|
||||||
|
let consoleSpy: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
consoleSpy = {
|
||||||
|
warn: vi.spyOn(console, "warn").mockImplementation(() => {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleSpy.warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getProviderForModel", () => {
|
||||||
|
describe("Claude models (claude-* prefix)", () => {
|
||||||
|
it("should return ClaudeProvider for claude-opus-4-5-20251101", () => {
|
||||||
|
const provider = ProviderFactory.getProviderForModel(
|
||||||
|
"claude-opus-4-5-20251101"
|
||||||
|
);
|
||||||
|
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return ClaudeProvider for claude-sonnet-4-20250514", () => {
|
||||||
|
const provider = ProviderFactory.getProviderForModel(
|
||||||
|
"claude-sonnet-4-20250514"
|
||||||
|
);
|
||||||
|
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return ClaudeProvider for claude-haiku-4-5", () => {
|
||||||
|
const provider = ProviderFactory.getProviderForModel("claude-haiku-4-5");
|
||||||
|
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be case-insensitive for claude models", () => {
|
||||||
|
const provider = ProviderFactory.getProviderForModel(
|
||||||
|
"CLAUDE-OPUS-4-5-20251101"
|
||||||
|
);
|
||||||
|
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Claude aliases", () => {
|
||||||
|
it("should return ClaudeProvider for 'haiku'", () => {
|
||||||
|
const provider = ProviderFactory.getProviderForModel("haiku");
|
||||||
|
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return ClaudeProvider for 'sonnet'", () => {
|
||||||
|
const provider = ProviderFactory.getProviderForModel("sonnet");
|
||||||
|
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return ClaudeProvider for 'opus'", () => {
|
||||||
|
const provider = ProviderFactory.getProviderForModel("opus");
|
||||||
|
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be case-insensitive for aliases", () => {
|
||||||
|
const provider1 = ProviderFactory.getProviderForModel("HAIKU");
|
||||||
|
const provider2 = ProviderFactory.getProviderForModel("Sonnet");
|
||||||
|
const provider3 = ProviderFactory.getProviderForModel("Opus");
|
||||||
|
|
||||||
|
expect(provider1).toBeInstanceOf(ClaudeProvider);
|
||||||
|
expect(provider2).toBeInstanceOf(ClaudeProvider);
|
||||||
|
expect(provider3).toBeInstanceOf(ClaudeProvider);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Unknown models", () => {
|
||||||
|
it("should default to ClaudeProvider for unknown model", () => {
|
||||||
|
const provider = ProviderFactory.getProviderForModel("unknown-model-123");
|
||||||
|
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should warn when defaulting to Claude", () => {
|
||||||
|
ProviderFactory.getProviderForModel("random-model");
|
||||||
|
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Unknown model prefix")
|
||||||
|
);
|
||||||
|
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("random-model")
|
||||||
|
);
|
||||||
|
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("defaulting to Claude")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty string", () => {
|
||||||
|
const provider = ProviderFactory.getProviderForModel("");
|
||||||
|
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||||
|
expect(consoleSpy.warn).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should default to ClaudeProvider for gpt models (not supported)", () => {
|
||||||
|
const provider = ProviderFactory.getProviderForModel("gpt-5.2");
|
||||||
|
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||||
|
expect(consoleSpy.warn).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should default to ClaudeProvider for o-series models (not supported)", () => {
|
||||||
|
const provider = ProviderFactory.getProviderForModel("o1");
|
||||||
|
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||||
|
expect(consoleSpy.warn).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAllProviders", () => {
|
||||||
|
it("should return array of all providers", () => {
|
||||||
|
const providers = ProviderFactory.getAllProviders();
|
||||||
|
expect(Array.isArray(providers)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include ClaudeProvider", () => {
|
||||||
|
const providers = ProviderFactory.getAllProviders();
|
||||||
|
const hasClaudeProvider = providers.some(
|
||||||
|
(p) => p instanceof ClaudeProvider
|
||||||
|
);
|
||||||
|
expect(hasClaudeProvider).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return exactly 1 provider", () => {
|
||||||
|
const providers = ProviderFactory.getAllProviders();
|
||||||
|
expect(providers).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create new instances each time", () => {
|
||||||
|
const providers1 = ProviderFactory.getAllProviders();
|
||||||
|
const providers2 = ProviderFactory.getAllProviders();
|
||||||
|
|
||||||
|
expect(providers1[0]).not.toBe(providers2[0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkAllProviders", () => {
|
||||||
|
it("should return installation status for all providers", async () => {
|
||||||
|
const statuses = await ProviderFactory.checkAllProviders();
|
||||||
|
|
||||||
|
expect(statuses).toHaveProperty("claude");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call detectInstallation on each provider", async () => {
|
||||||
|
const statuses = await ProviderFactory.checkAllProviders();
|
||||||
|
|
||||||
|
expect(statuses.claude).toHaveProperty("installed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return correct provider names as keys", async () => {
|
||||||
|
const statuses = await ProviderFactory.checkAllProviders();
|
||||||
|
const keys = Object.keys(statuses);
|
||||||
|
|
||||||
|
expect(keys).toContain("claude");
|
||||||
|
expect(keys).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getProviderByName", () => {
|
||||||
|
it("should return ClaudeProvider for 'claude'", () => {
|
||||||
|
const provider = ProviderFactory.getProviderByName("claude");
|
||||||
|
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return ClaudeProvider for 'anthropic'", () => {
|
||||||
|
const provider = ProviderFactory.getProviderByName("anthropic");
|
||||||
|
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be case-insensitive", () => {
|
||||||
|
const provider1 = ProviderFactory.getProviderByName("CLAUDE");
|
||||||
|
const provider2 = ProviderFactory.getProviderByName("ANTHROPIC");
|
||||||
|
|
||||||
|
expect(provider1).toBeInstanceOf(ClaudeProvider);
|
||||||
|
expect(provider2).toBeInstanceOf(ClaudeProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for unknown provider", () => {
|
||||||
|
const provider = ProviderFactory.getProviderByName("unknown");
|
||||||
|
expect(provider).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for empty string", () => {
|
||||||
|
const provider = ProviderFactory.getProviderByName("");
|
||||||
|
expect(provider).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create new instance each time", () => {
|
||||||
|
const provider1 = ProviderFactory.getProviderByName("claude");
|
||||||
|
const provider2 = ProviderFactory.getProviderByName("claude");
|
||||||
|
|
||||||
|
expect(provider1).not.toBe(provider2);
|
||||||
|
expect(provider1).toBeInstanceOf(ClaudeProvider);
|
||||||
|
expect(provider2).toBeInstanceOf(ClaudeProvider);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAllAvailableModels", () => {
|
||||||
|
it("should return array of models", () => {
|
||||||
|
const models = ProviderFactory.getAllAvailableModels();
|
||||||
|
expect(Array.isArray(models)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include models from all providers", () => {
|
||||||
|
const models = ProviderFactory.getAllAvailableModels();
|
||||||
|
expect(models.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return models with required fields", () => {
|
||||||
|
const models = ProviderFactory.getAllAvailableModels();
|
||||||
|
|
||||||
|
models.forEach((model) => {
|
||||||
|
expect(model).toHaveProperty("id");
|
||||||
|
expect(model).toHaveProperty("name");
|
||||||
|
expect(typeof model.id).toBe("string");
|
||||||
|
expect(typeof model.name).toBe("string");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Claude models", () => {
|
||||||
|
const models = ProviderFactory.getAllAvailableModels();
|
||||||
|
|
||||||
|
// Claude models should include claude-* in their IDs
|
||||||
|
const hasClaudeModels = models.some((m) =>
|
||||||
|
m.id.toLowerCase().includes("claude")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(hasClaudeModels).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
361
apps/server/tests/unit/services/agent-service.test.ts
Normal file
361
apps/server/tests/unit/services/agent-service.test.ts
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { AgentService } from "@/services/agent-service.js";
|
||||||
|
import { ProviderFactory } from "@/providers/provider-factory.js";
|
||||||
|
import * as fs from "fs/promises";
|
||||||
|
import * as imageHandler from "@/lib/image-handler.js";
|
||||||
|
import * as promptBuilder from "@/lib/prompt-builder.js";
|
||||||
|
import { collectAsyncGenerator } from "../../utils/helpers.js";
|
||||||
|
|
||||||
|
vi.mock("fs/promises");
|
||||||
|
vi.mock("@/providers/provider-factory.js");
|
||||||
|
vi.mock("@/lib/image-handler.js");
|
||||||
|
vi.mock("@/lib/prompt-builder.js");
|
||||||
|
|
||||||
|
describe("agent-service.ts", () => {
|
||||||
|
let service: AgentService;
|
||||||
|
const mockEvents = {
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
service = new AgentService("/test/data", mockEvents as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initialize", () => {
|
||||||
|
it("should create state directory", async () => {
|
||||||
|
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await service.initialize();
|
||||||
|
|
||||||
|
expect(fs.mkdir).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("agent-sessions"),
|
||||||
|
{ recursive: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("startConversation", () => {
|
||||||
|
it("should create new session with empty messages", async () => {
|
||||||
|
const error: any = new Error("ENOENT");
|
||||||
|
error.code = "ENOENT";
|
||||||
|
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||||
|
|
||||||
|
const result = await service.startConversation({
|
||||||
|
sessionId: "session-1",
|
||||||
|
workingDirectory: "/test/dir",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.messages).toEqual([]);
|
||||||
|
expect(result.sessionId).toBe("session-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should load existing session", async () => {
|
||||||
|
const existingMessages = [
|
||||||
|
{
|
||||||
|
id: "msg-1",
|
||||||
|
role: "user",
|
||||||
|
content: "Hello",
|
||||||
|
timestamp: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValue(
|
||||||
|
JSON.stringify(existingMessages)
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.startConversation({
|
||||||
|
sessionId: "session-1",
|
||||||
|
workingDirectory: "/test/dir",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.messages).toEqual(existingMessages);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use process.cwd() if no working directory provided", async () => {
|
||||||
|
const error: any = new Error("ENOENT");
|
||||||
|
error.code = "ENOENT";
|
||||||
|
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||||
|
|
||||||
|
const result = await service.startConversation({
|
||||||
|
sessionId: "session-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reuse existing session if already started", async () => {
|
||||||
|
const error: any = new Error("ENOENT");
|
||||||
|
error.code = "ENOENT";
|
||||||
|
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||||
|
|
||||||
|
// Start session first time
|
||||||
|
await service.startConversation({
|
||||||
|
sessionId: "session-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start again with same ID
|
||||||
|
const result = await service.startConversation({
|
||||||
|
sessionId: "session-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
// Should only read file once
|
||||||
|
expect(fs.readFile).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendMessage", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const error: any = new Error("ENOENT");
|
||||||
|
error.code = "ENOENT";
|
||||||
|
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||||
|
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await service.startConversation({
|
||||||
|
sessionId: "session-1",
|
||||||
|
workingDirectory: "/test/dir",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw if session not found", async () => {
|
||||||
|
await expect(
|
||||||
|
service.sendMessage({
|
||||||
|
sessionId: "nonexistent",
|
||||||
|
message: "Hello",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("Session nonexistent not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it("should process message and stream responses", async () => {
|
||||||
|
const mockProvider = {
|
||||||
|
getName: () => "claude",
|
||||||
|
executeQuery: async function* () {
|
||||||
|
yield {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "Response" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
yield {
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||||
|
mockProvider as any
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
|
||||||
|
content: "Hello",
|
||||||
|
hasImages: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.sendMessage({
|
||||||
|
sessionId: "session-1",
|
||||||
|
message: "Hello",
|
||||||
|
workingDirectory: "/custom/dir",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(mockEvents.emit).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle images in message", async () => {
|
||||||
|
const mockProvider = {
|
||||||
|
getName: () => "claude",
|
||||||
|
executeQuery: async function* () {
|
||||||
|
yield {
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||||
|
mockProvider as any
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mocked(imageHandler.readImageAsBase64).mockResolvedValue({
|
||||||
|
base64: "base64data",
|
||||||
|
mimeType: "image/png",
|
||||||
|
filename: "test.png",
|
||||||
|
originalPath: "/path/test.png",
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
|
||||||
|
content: "Check image",
|
||||||
|
hasImages: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.sendMessage({
|
||||||
|
sessionId: "session-1",
|
||||||
|
message: "Check this",
|
||||||
|
imagePaths: ["/path/test.png"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(imageHandler.readImageAsBase64).toHaveBeenCalledWith(
|
||||||
|
"/path/test.png"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle failed image loading gracefully", async () => {
|
||||||
|
const mockProvider = {
|
||||||
|
getName: () => "claude",
|
||||||
|
executeQuery: async function* () {
|
||||||
|
yield {
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||||
|
mockProvider as any
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mocked(imageHandler.readImageAsBase64).mockRejectedValue(
|
||||||
|
new Error("Image not found")
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
|
||||||
|
content: "Check image",
|
||||||
|
hasImages: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await service.sendMessage({
|
||||||
|
sessionId: "session-1",
|
||||||
|
message: "Check this",
|
||||||
|
imagePaths: ["/path/test.png"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalled();
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use custom model if provided", async () => {
|
||||||
|
const mockProvider = {
|
||||||
|
getName: () => "claude",
|
||||||
|
executeQuery: async function* () {
|
||||||
|
yield {
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||||
|
mockProvider as any
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
|
||||||
|
content: "Hello",
|
||||||
|
hasImages: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.sendMessage({
|
||||||
|
sessionId: "session-1",
|
||||||
|
message: "Hello",
|
||||||
|
model: "claude-sonnet-4-20250514",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith("claude-sonnet-4-20250514");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should save session messages", async () => {
|
||||||
|
const mockProvider = {
|
||||||
|
getName: () => "claude",
|
||||||
|
executeQuery: async function* () {
|
||||||
|
yield {
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||||
|
mockProvider as any
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
|
||||||
|
content: "Hello",
|
||||||
|
hasImages: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.sendMessage({
|
||||||
|
sessionId: "session-1",
|
||||||
|
message: "Hello",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fs.writeFile).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stopExecution", () => {
|
||||||
|
it("should stop execution for a session", async () => {
|
||||||
|
const error: any = new Error("ENOENT");
|
||||||
|
error.code = "ENOENT";
|
||||||
|
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||||
|
|
||||||
|
await service.startConversation({
|
||||||
|
sessionId: "session-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should return success
|
||||||
|
const result = await service.stopExecution("session-1");
|
||||||
|
expect(result.success).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getHistory", () => {
|
||||||
|
it("should return message history", async () => {
|
||||||
|
const error: any = new Error("ENOENT");
|
||||||
|
error.code = "ENOENT";
|
||||||
|
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||||
|
|
||||||
|
await service.startConversation({
|
||||||
|
sessionId: "session-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const history = service.getHistory("session-1");
|
||||||
|
|
||||||
|
expect(history).toBeDefined();
|
||||||
|
expect(history?.messages).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle non-existent session", () => {
|
||||||
|
const history = service.getHistory("nonexistent");
|
||||||
|
expect(history).toBeDefined(); // Returns error object
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearSession", () => {
|
||||||
|
it("should clear session messages", async () => {
|
||||||
|
const error: any = new Error("ENOENT");
|
||||||
|
error.code = "ENOENT";
|
||||||
|
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||||
|
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await service.startConversation({
|
||||||
|
sessionId: "session-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.clearSession("session-1");
|
||||||
|
|
||||||
|
const history = service.getHistory("session-1");
|
||||||
|
expect(history?.messages).toEqual([]);
|
||||||
|
expect(fs.writeFile).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
71
apps/server/tests/unit/services/auto-mode-service.test.ts
Normal file
71
apps/server/tests/unit/services/auto-mode-service.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { AutoModeService } from "@/services/auto-mode-service.js";
|
||||||
|
|
||||||
|
describe("auto-mode-service.ts", () => {
|
||||||
|
let service: AutoModeService;
|
||||||
|
const mockEvents = {
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
service = new AutoModeService(mockEvents as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("constructor", () => {
|
||||||
|
it("should initialize with event emitter", () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("startAutoLoop", () => {
|
||||||
|
it("should throw if auto mode is already running", async () => {
|
||||||
|
// Start first loop
|
||||||
|
const promise1 = service.startAutoLoop("/test/project", 3);
|
||||||
|
|
||||||
|
// Try to start second loop
|
||||||
|
await expect(
|
||||||
|
service.startAutoLoop("/test/project", 3)
|
||||||
|
).rejects.toThrow("already running");
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await service.stopAutoLoop();
|
||||||
|
await promise1.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit auto mode start event", async () => {
|
||||||
|
const promise = service.startAutoLoop("/test/project", 3);
|
||||||
|
|
||||||
|
// Give it time to emit the event
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
message: expect.stringContaining("Auto mode started"),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await service.stopAutoLoop();
|
||||||
|
await promise.catch(() => {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stopAutoLoop", () => {
|
||||||
|
it("should stop the auto loop", async () => {
|
||||||
|
const promise = service.startAutoLoop("/test/project", 3);
|
||||||
|
|
||||||
|
const runningCount = await service.stopAutoLoop();
|
||||||
|
|
||||||
|
expect(runningCount).toBe(0);
|
||||||
|
await promise.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 0 when not running", async () => {
|
||||||
|
const runningCount = await service.stopAutoLoop();
|
||||||
|
expect(runningCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
446
apps/server/tests/unit/services/feature-loader.test.ts
Normal file
446
apps/server/tests/unit/services/feature-loader.test.ts
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { FeatureLoader } from "@/services/feature-loader.js";
|
||||||
|
import * as fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
vi.mock("fs/promises");
|
||||||
|
|
||||||
|
describe("feature-loader.ts", () => {
|
||||||
|
let loader: FeatureLoader;
|
||||||
|
const testProjectPath = "/test/project";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
loader = new FeatureLoader();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getFeaturesDir", () => {
|
||||||
|
it("should return features directory path", () => {
|
||||||
|
const result = loader.getFeaturesDir(testProjectPath);
|
||||||
|
expect(result).toContain("test");
|
||||||
|
expect(result).toContain("project");
|
||||||
|
expect(result).toContain(".automaker");
|
||||||
|
expect(result).toContain("features");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getFeatureImagesDir", () => {
|
||||||
|
it("should return feature images directory path", () => {
|
||||||
|
const result = loader.getFeatureImagesDir(testProjectPath, "feature-123");
|
||||||
|
expect(result).toContain("features");
|
||||||
|
expect(result).toContain("feature-123");
|
||||||
|
expect(result).toContain("images");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getFeatureDir", () => {
|
||||||
|
it("should return feature directory path", () => {
|
||||||
|
const result = loader.getFeatureDir(testProjectPath, "feature-123");
|
||||||
|
expect(result).toContain("features");
|
||||||
|
expect(result).toContain("feature-123");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getFeatureJsonPath", () => {
|
||||||
|
it("should return feature.json path", () => {
|
||||||
|
const result = loader.getFeatureJsonPath(testProjectPath, "feature-123");
|
||||||
|
expect(result).toContain("features");
|
||||||
|
expect(result).toContain("feature-123");
|
||||||
|
expect(result).toContain("feature.json");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAgentOutputPath", () => {
|
||||||
|
it("should return agent-output.md path", () => {
|
||||||
|
const result = loader.getAgentOutputPath(testProjectPath, "feature-123");
|
||||||
|
expect(result).toContain("features");
|
||||||
|
expect(result).toContain("feature-123");
|
||||||
|
expect(result).toContain("agent-output.md");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateFeatureId", () => {
|
||||||
|
it("should generate unique feature ID with timestamp", () => {
|
||||||
|
const id1 = loader.generateFeatureId();
|
||||||
|
const id2 = loader.generateFeatureId();
|
||||||
|
|
||||||
|
expect(id1).toMatch(/^feature-\d+-[a-z0-9]+$/);
|
||||||
|
expect(id2).toMatch(/^feature-\d+-[a-z0-9]+$/);
|
||||||
|
expect(id1).not.toBe(id2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should start with 'feature-'", () => {
|
||||||
|
const id = loader.generateFeatureId();
|
||||||
|
expect(id).toMatch(/^feature-/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAll", () => {
|
||||||
|
it("should return empty array when features directory doesn't exist", async () => {
|
||||||
|
vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"));
|
||||||
|
|
||||||
|
const result = await loader.getAll(testProjectPath);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should load all features from feature directories", async () => {
|
||||||
|
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(fs.readdir).mockResolvedValue([
|
||||||
|
{ name: "feature-1", isDirectory: () => true } as any,
|
||||||
|
{ name: "feature-2", isDirectory: () => true } as any,
|
||||||
|
{ name: "file.txt", isDirectory: () => false } as any,
|
||||||
|
]);
|
||||||
|
|
||||||
|
vi.mocked(fs.readFile)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
JSON.stringify({
|
||||||
|
id: "feature-1",
|
||||||
|
category: "ui",
|
||||||
|
description: "Feature 1",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
JSON.stringify({
|
||||||
|
id: "feature-2",
|
||||||
|
category: "backend",
|
||||||
|
description: "Feature 2",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await loader.getAll(testProjectPath);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].id).toBe("feature-1");
|
||||||
|
expect(result[1].id).toBe("feature-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should skip features without id field", async () => {
|
||||||
|
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(fs.readdir).mockResolvedValue([
|
||||||
|
{ name: "feature-1", isDirectory: () => true } as any,
|
||||||
|
{ name: "feature-2", isDirectory: () => true } as any,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
|
vi.mocked(fs.readFile)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
JSON.stringify({
|
||||||
|
category: "ui",
|
||||||
|
description: "Missing ID",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
JSON.stringify({
|
||||||
|
id: "feature-2",
|
||||||
|
category: "backend",
|
||||||
|
description: "Feature 2",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await loader.getAll(testProjectPath);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe("feature-2");
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("missing required 'id' field")
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should skip features with missing feature.json", async () => {
|
||||||
|
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(fs.readdir).mockResolvedValue([
|
||||||
|
{ name: "feature-1", isDirectory: () => true } as any,
|
||||||
|
{ name: "feature-2", isDirectory: () => true } as any,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const error: any = new Error("File not found");
|
||||||
|
error.code = "ENOENT";
|
||||||
|
|
||||||
|
vi.mocked(fs.readFile)
|
||||||
|
.mockRejectedValueOnce(error)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
JSON.stringify({
|
||||||
|
id: "feature-2",
|
||||||
|
category: "backend",
|
||||||
|
description: "Feature 2",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await loader.getAll(testProjectPath);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe("feature-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle malformed JSON gracefully", async () => {
|
||||||
|
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(fs.readdir).mockResolvedValue([
|
||||||
|
{ name: "feature-1", isDirectory: () => true } as any,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValue("invalid json{");
|
||||||
|
|
||||||
|
const result = await loader.getAll(testProjectPath);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(consoleSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should sort features by creation order (timestamp)", async () => {
|
||||||
|
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(fs.readdir).mockResolvedValue([
|
||||||
|
{ name: "feature-3", isDirectory: () => true } as any,
|
||||||
|
{ name: "feature-1", isDirectory: () => true } as any,
|
||||||
|
{ name: "feature-2", isDirectory: () => true } as any,
|
||||||
|
]);
|
||||||
|
|
||||||
|
vi.mocked(fs.readFile)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
JSON.stringify({
|
||||||
|
id: "feature-3000-xyz",
|
||||||
|
category: "ui",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
JSON.stringify({
|
||||||
|
id: "feature-1000-abc",
|
||||||
|
category: "ui",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
JSON.stringify({
|
||||||
|
id: "feature-2000-def",
|
||||||
|
category: "ui",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await loader.getAll(testProjectPath);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result[0].id).toBe("feature-1000-abc");
|
||||||
|
expect(result[1].id).toBe("feature-2000-def");
|
||||||
|
expect(result[2].id).toBe("feature-3000-xyz");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("get", () => {
|
||||||
|
it("should return feature by ID", async () => {
|
||||||
|
const featureData = {
|
||||||
|
id: "feature-123",
|
||||||
|
category: "ui",
|
||||||
|
description: "Test feature",
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(featureData));
|
||||||
|
|
||||||
|
const result = await loader.get(testProjectPath, "feature-123");
|
||||||
|
|
||||||
|
expect(result).toEqual(featureData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when feature doesn't exist", async () => {
|
||||||
|
const error: any = new Error("File not found");
|
||||||
|
error.code = "ENOENT";
|
||||||
|
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||||
|
|
||||||
|
const result = await loader.get(testProjectPath, "feature-123");
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw on other errors", async () => {
|
||||||
|
vi.mocked(fs.readFile).mockRejectedValue(new Error("Permission denied"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
loader.get(testProjectPath, "feature-123")
|
||||||
|
).rejects.toThrow("Permission denied");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("create", () => {
|
||||||
|
it("should create new feature", async () => {
|
||||||
|
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const featureData = {
|
||||||
|
category: "ui",
|
||||||
|
description: "New feature",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await loader.create(testProjectPath, featureData);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
category: "ui",
|
||||||
|
description: "New feature",
|
||||||
|
id: expect.stringMatching(/^feature-/),
|
||||||
|
});
|
||||||
|
expect(fs.writeFile).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use provided ID if given", async () => {
|
||||||
|
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await loader.create(testProjectPath, {
|
||||||
|
id: "custom-id",
|
||||||
|
category: "ui",
|
||||||
|
description: "Test",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.id).toBe("custom-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set default category if not provided", async () => {
|
||||||
|
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await loader.create(testProjectPath, {
|
||||||
|
description: "Test",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.category).toBe("Uncategorized");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("update", () => {
|
||||||
|
it("should update existing feature", async () => {
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValue(
|
||||||
|
JSON.stringify({
|
||||||
|
id: "feature-123",
|
||||||
|
category: "ui",
|
||||||
|
description: "Old description",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await loader.update(testProjectPath, "feature-123", {
|
||||||
|
description: "New description",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.description).toBe("New description");
|
||||||
|
expect(result.category).toBe("ui");
|
||||||
|
expect(fs.writeFile).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw if feature doesn't exist", async () => {
|
||||||
|
const error: any = new Error("File not found");
|
||||||
|
error.code = "ENOENT";
|
||||||
|
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
loader.update(testProjectPath, "feature-123", {})
|
||||||
|
).rejects.toThrow("not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("delete", () => {
|
||||||
|
it("should delete feature directory", async () => {
|
||||||
|
vi.mocked(fs.rm).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await loader.delete(testProjectPath, "feature-123");
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(fs.rm).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("feature-123"),
|
||||||
|
{ recursive: true, force: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false on error", async () => {
|
||||||
|
vi.mocked(fs.rm).mockRejectedValue(new Error("Permission denied"));
|
||||||
|
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
const result = await loader.delete(testProjectPath, "feature-123");
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAgentOutput", () => {
|
||||||
|
it("should return agent output content", async () => {
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValue("Agent output content");
|
||||||
|
|
||||||
|
const result = await loader.getAgentOutput(testProjectPath, "feature-123");
|
||||||
|
|
||||||
|
expect(result).toBe("Agent output content");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when file doesn't exist", async () => {
|
||||||
|
const error: any = new Error("File not found");
|
||||||
|
error.code = "ENOENT";
|
||||||
|
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||||
|
|
||||||
|
const result = await loader.getAgentOutput(testProjectPath, "feature-123");
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw on other errors", async () => {
|
||||||
|
vi.mocked(fs.readFile).mockRejectedValue(new Error("Permission denied"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
loader.getAgentOutput(testProjectPath, "feature-123")
|
||||||
|
).rejects.toThrow("Permission denied");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saveAgentOutput", () => {
|
||||||
|
it("should save agent output to file", async () => {
|
||||||
|
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await loader.saveAgentOutput(
|
||||||
|
testProjectPath,
|
||||||
|
"feature-123",
|
||||||
|
"Output content"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("agent-output.md"),
|
||||||
|
"Output content",
|
||||||
|
"utf-8"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteAgentOutput", () => {
|
||||||
|
it("should delete agent output file", async () => {
|
||||||
|
vi.mocked(fs.unlink).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await loader.deleteAgentOutput(testProjectPath, "feature-123");
|
||||||
|
|
||||||
|
expect(fs.unlink).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("agent-output.md")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle missing file gracefully", async () => {
|
||||||
|
const error: any = new Error("File not found");
|
||||||
|
error.code = "ENOENT";
|
||||||
|
vi.mocked(fs.unlink).mockRejectedValue(error);
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
await expect(
|
||||||
|
loader.deleteAgentOutput(testProjectPath, "feature-123")
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw on other errors", async () => {
|
||||||
|
vi.mocked(fs.unlink).mockRejectedValue(new Error("Permission denied"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
loader.deleteAgentOutput(testProjectPath, "feature-123")
|
||||||
|
).rejects.toThrow("Permission denied");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
567
apps/server/tests/unit/services/terminal-service.test.ts
Normal file
567
apps/server/tests/unit/services/terminal-service.test.ts
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { TerminalService, getTerminalService } from "@/services/terminal-service.js";
|
||||||
|
import * as pty from "node-pty";
|
||||||
|
import * as os from "os";
|
||||||
|
import * as fs from "fs";
|
||||||
|
|
||||||
|
vi.mock("node-pty");
|
||||||
|
vi.mock("fs");
|
||||||
|
vi.mock("os");
|
||||||
|
|
||||||
|
describe("terminal-service.ts", () => {
|
||||||
|
let service: TerminalService;
|
||||||
|
let mockPtyProcess: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
service = new TerminalService();
|
||||||
|
|
||||||
|
// Mock PTY process
|
||||||
|
mockPtyProcess = {
|
||||||
|
onData: vi.fn(),
|
||||||
|
onExit: vi.fn(),
|
||||||
|
write: vi.fn(),
|
||||||
|
resize: vi.fn(),
|
||||||
|
kill: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(pty.spawn).mockReturnValue(mockPtyProcess);
|
||||||
|
vi.mocked(os.homedir).mockReturnValue("/home/user");
|
||||||
|
vi.mocked(os.platform).mockReturnValue("linux");
|
||||||
|
vi.mocked(os.arch).mockReturnValue("x64");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
service.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("detectShell", () => {
|
||||||
|
it("should detect PowerShell Core on Windows when available", () => {
|
||||||
|
vi.mocked(os.platform).mockReturnValue("win32");
|
||||||
|
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
|
||||||
|
return path === "C:\\Program Files\\PowerShell\\7\\pwsh.exe";
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = service.detectShell();
|
||||||
|
|
||||||
|
expect(result.shell).toBe("C:\\Program Files\\PowerShell\\7\\pwsh.exe");
|
||||||
|
expect(result.args).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fall back to PowerShell on Windows if Core not available", () => {
|
||||||
|
vi.mocked(os.platform).mockReturnValue("win32");
|
||||||
|
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
|
||||||
|
return path === "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = service.detectShell();
|
||||||
|
|
||||||
|
expect(result.shell).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe");
|
||||||
|
expect(result.args).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fall back to cmd.exe on Windows if no PowerShell", () => {
|
||||||
|
vi.mocked(os.platform).mockReturnValue("win32");
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||||
|
|
||||||
|
const result = service.detectShell();
|
||||||
|
|
||||||
|
expect(result.shell).toBe("cmd.exe");
|
||||||
|
expect(result.args).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect user shell on macOS", () => {
|
||||||
|
vi.mocked(os.platform).mockReturnValue("darwin");
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/zsh" });
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
|
||||||
|
const result = service.detectShell();
|
||||||
|
|
||||||
|
expect(result.shell).toBe("/bin/zsh");
|
||||||
|
expect(result.args).toEqual(["--login"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fall back to zsh on macOS if user shell not available", () => {
|
||||||
|
vi.mocked(os.platform).mockReturnValue("darwin");
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({});
|
||||||
|
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
|
||||||
|
return path === "/bin/zsh";
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = service.detectShell();
|
||||||
|
|
||||||
|
expect(result.shell).toBe("/bin/zsh");
|
||||||
|
expect(result.args).toEqual(["--login"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fall back to bash on macOS if zsh not available", () => {
|
||||||
|
vi.mocked(os.platform).mockReturnValue("darwin");
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({});
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||||
|
|
||||||
|
const result = service.detectShell();
|
||||||
|
|
||||||
|
expect(result.shell).toBe("/bin/bash");
|
||||||
|
expect(result.args).toEqual(["--login"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect user shell on Linux", () => {
|
||||||
|
vi.mocked(os.platform).mockReturnValue("linux");
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
|
||||||
|
const result = service.detectShell();
|
||||||
|
|
||||||
|
expect(result.shell).toBe("/bin/bash");
|
||||||
|
expect(result.args).toEqual(["--login"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fall back to bash on Linux if user shell not available", () => {
|
||||||
|
vi.mocked(os.platform).mockReturnValue("linux");
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({});
|
||||||
|
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
|
||||||
|
return path === "/bin/bash";
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = service.detectShell();
|
||||||
|
|
||||||
|
expect(result.shell).toBe("/bin/bash");
|
||||||
|
expect(result.args).toEqual(["--login"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fall back to sh on Linux if bash not available", () => {
|
||||||
|
vi.mocked(os.platform).mockReturnValue("linux");
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({});
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||||
|
|
||||||
|
const result = service.detectShell();
|
||||||
|
|
||||||
|
expect(result.shell).toBe("/bin/sh");
|
||||||
|
expect(result.args).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect WSL and use appropriate shell", () => {
|
||||||
|
vi.mocked(os.platform).mockReturnValue("linux");
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.0-microsoft-standard-WSL2");
|
||||||
|
|
||||||
|
const result = service.detectShell();
|
||||||
|
|
||||||
|
expect(result.shell).toBe("/bin/bash");
|
||||||
|
expect(result.args).toEqual(["--login"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isWSL", () => {
|
||||||
|
it("should return true if /proc/version contains microsoft", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.0-microsoft-standard-WSL2");
|
||||||
|
|
||||||
|
expect(service.isWSL()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true if /proc/version contains wsl", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.0-wsl2");
|
||||||
|
|
||||||
|
expect(service.isWSL()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true if WSL_DISTRO_NAME is set", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({ WSL_DISTRO_NAME: "Ubuntu" });
|
||||||
|
|
||||||
|
expect(service.isWSL()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true if WSLENV is set", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({ WSLENV: "PATH/l" });
|
||||||
|
|
||||||
|
expect(service.isWSL()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false if not in WSL", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({});
|
||||||
|
|
||||||
|
expect(service.isWSL()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false if error reading /proc/version", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readFileSync).mockImplementation(() => {
|
||||||
|
throw new Error("Permission denied");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.isWSL()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getPlatformInfo", () => {
|
||||||
|
it("should return platform information", () => {
|
||||||
|
vi.mocked(os.platform).mockReturnValue("linux");
|
||||||
|
vi.mocked(os.arch).mockReturnValue("x64");
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||||
|
|
||||||
|
const info = service.getPlatformInfo();
|
||||||
|
|
||||||
|
expect(info.platform).toBe("linux");
|
||||||
|
expect(info.arch).toBe("x64");
|
||||||
|
expect(info.defaultShell).toBe("/bin/bash");
|
||||||
|
expect(typeof info.isWSL).toBe("boolean");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createSession", () => {
|
||||||
|
it("should create a new terminal session", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||||
|
|
||||||
|
const session = service.createSession({
|
||||||
|
cwd: "/test/dir",
|
||||||
|
cols: 100,
|
||||||
|
rows: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.id).toMatch(/^term-/);
|
||||||
|
expect(session.cwd).toBe("/test/dir");
|
||||||
|
expect(session.shell).toBe("/bin/bash");
|
||||||
|
expect(pty.spawn).toHaveBeenCalledWith(
|
||||||
|
"/bin/bash",
|
||||||
|
["--login"],
|
||||||
|
expect.objectContaining({
|
||||||
|
cwd: "/test/dir",
|
||||||
|
cols: 100,
|
||||||
|
rows: 30,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use default cols and rows if not provided", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||||
|
|
||||||
|
service.createSession();
|
||||||
|
|
||||||
|
expect(pty.spawn).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(Array),
|
||||||
|
expect.objectContaining({
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fall back to home directory if cwd does not exist", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.statSync).mockImplementation(() => {
|
||||||
|
throw new Error("ENOENT");
|
||||||
|
});
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||||
|
|
||||||
|
const session = service.createSession({
|
||||||
|
cwd: "/nonexistent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.cwd).toBe("/home/user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fall back to home directory if cwd is not a directory", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => false } as any);
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||||
|
|
||||||
|
const session = service.createSession({
|
||||||
|
cwd: "/file.txt",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.cwd).toBe("/home/user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fix double slashes in path", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||||
|
|
||||||
|
const session = service.createSession({
|
||||||
|
cwd: "//test/dir",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.cwd).toBe("/test/dir");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve WSL UNC paths", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||||
|
|
||||||
|
const session = service.createSession({
|
||||||
|
cwd: "//wsl$/Ubuntu/home",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.cwd).toBe("//wsl$/Ubuntu/home");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle data events from PTY", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||||
|
|
||||||
|
const dataCallback = vi.fn();
|
||||||
|
service.onData(dataCallback);
|
||||||
|
|
||||||
|
service.createSession();
|
||||||
|
|
||||||
|
// Simulate data event
|
||||||
|
const onDataHandler = mockPtyProcess.onData.mock.calls[0][0];
|
||||||
|
onDataHandler("test data");
|
||||||
|
|
||||||
|
// Wait for throttled output
|
||||||
|
vi.advanceTimersByTime(20);
|
||||||
|
|
||||||
|
expect(dataCallback).toHaveBeenCalled();
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle exit events from PTY", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||||
|
|
||||||
|
const exitCallback = vi.fn();
|
||||||
|
service.onExit(exitCallback);
|
||||||
|
|
||||||
|
const session = service.createSession();
|
||||||
|
|
||||||
|
// Simulate exit event
|
||||||
|
const onExitHandler = mockPtyProcess.onExit.mock.calls[0][0];
|
||||||
|
onExitHandler({ exitCode: 0 });
|
||||||
|
|
||||||
|
expect(exitCallback).toHaveBeenCalledWith(session.id, 0);
|
||||||
|
expect(service.getSession(session.id)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("write", () => {
|
||||||
|
it("should write data to existing session", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||||
|
|
||||||
|
const session = service.createSession();
|
||||||
|
const result = service.write(session.id, "ls\n");
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockPtyProcess.write).toHaveBeenCalledWith("ls\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for non-existent session", () => {
|
||||||
|
const result = service.write("nonexistent", "data");
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(mockPtyProcess.write).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resize", () => {
|
||||||
|
it("should resize existing session", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||||
|
|
||||||
|
const session = service.createSession();
|
||||||
|
const result = service.resize(session.id, 120, 40);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockPtyProcess.resize).toHaveBeenCalledWith(120, 40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for non-existent session", () => {
|
||||||
|
const result = service.resize("nonexistent", 120, 40);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(mockPtyProcess.resize).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle resize errors", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||||
|
mockPtyProcess.resize.mockImplementation(() => {
|
||||||
|
throw new Error("Resize failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = service.createSession();
|
||||||
|
const result = service.resize(session.id, 120, 40);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("killSession", () => {
|
||||||
|
it("should kill existing session", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||||
|
|
||||||
|
const session = service.createSession();
|
||||||
|
const result = service.killSession(session.id);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockPtyProcess.kill).toHaveBeenCalled();
|
||||||
|
expect(service.getSession(session.id)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for non-existent session", () => {
|
||||||
|
const result = service.killSession("nonexistent");
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle kill errors", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||||
|
mockPtyProcess.kill.mockImplementation(() => {
|
||||||
|
throw new Error("Kill failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = service.createSession();
|
||||||
|
const result = service.killSession(session.id);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSession", () => {
|
||||||
|
it("should return existing session", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||||
|
|
||||||
|
const session = service.createSession();
|
||||||
|
const retrieved = service.getSession(session.id);
|
||||||
|
|
||||||
|
expect(retrieved).toBe(session);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return undefined for non-existent session", () => {
|
||||||
|
const retrieved = service.getSession("nonexistent");
|
||||||
|
|
||||||
|
expect(retrieved).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getScrollback", () => {
|
||||||
|
it("should return scrollback buffer for existing session", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||||
|
|
||||||
|
const session = service.createSession();
|
||||||
|
session.scrollbackBuffer = "test scrollback";
|
||||||
|
|
||||||
|
const scrollback = service.getScrollback(session.id);
|
||||||
|
|
||||||
|
expect(scrollback).toBe("test scrollback");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for non-existent session", () => {
|
||||||
|
const scrollback = service.getScrollback("nonexistent");
|
||||||
|
|
||||||
|
expect(scrollback).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAllSessions", () => {
|
||||||
|
it("should return all active sessions", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||||
|
|
||||||
|
const session1 = service.createSession({ cwd: "/dir1" });
|
||||||
|
const session2 = service.createSession({ cwd: "/dir2" });
|
||||||
|
|
||||||
|
const sessions = service.getAllSessions();
|
||||||
|
|
||||||
|
expect(sessions).toHaveLength(2);
|
||||||
|
expect(sessions[0].id).toBe(session1.id);
|
||||||
|
expect(sessions[1].id).toBe(session2.id);
|
||||||
|
expect(sessions[0].cwd).toBe("/dir1");
|
||||||
|
expect(sessions[1].cwd).toBe("/dir2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty array if no sessions", () => {
|
||||||
|
const sessions = service.getAllSessions();
|
||||||
|
|
||||||
|
expect(sessions).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("onData and onExit", () => {
|
||||||
|
it("should allow subscribing and unsubscribing from data events", () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
const unsubscribe = service.onData(callback);
|
||||||
|
|
||||||
|
expect(typeof unsubscribe).toBe("function");
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow subscribing and unsubscribing from exit events", () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
const unsubscribe = service.onExit(callback);
|
||||||
|
|
||||||
|
expect(typeof unsubscribe).toBe("function");
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cleanup", () => {
|
||||||
|
it("should clean up all sessions", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||||
|
|
||||||
|
const session1 = service.createSession();
|
||||||
|
const session2 = service.createSession();
|
||||||
|
|
||||||
|
service.cleanup();
|
||||||
|
|
||||||
|
expect(service.getSession(session1.id)).toBeUndefined();
|
||||||
|
expect(service.getSession(session2.id)).toBeUndefined();
|
||||||
|
expect(service.getAllSessions()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle cleanup errors gracefully", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||||
|
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
|
||||||
|
mockPtyProcess.kill.mockImplementation(() => {
|
||||||
|
throw new Error("Kill failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
service.createSession();
|
||||||
|
|
||||||
|
expect(() => service.cleanup()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getTerminalService", () => {
|
||||||
|
it("should return singleton instance", () => {
|
||||||
|
const instance1 = getTerminalService();
|
||||||
|
const instance2 = getTerminalService();
|
||||||
|
|
||||||
|
expect(instance1).toBe(instance2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
38
apps/server/tests/utils/helpers.ts
Normal file
38
apps/server/tests/utils/helpers.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Test helper functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all values from an async generator
|
||||||
|
*/
|
||||||
|
export async function collectAsyncGenerator<T>(gen: AsyncGenerator<T>): Promise<T[]> {
|
||||||
|
const results: T[] = [];
|
||||||
|
for await (const item of gen) {
|
||||||
|
results.push(item);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a condition to be true
|
||||||
|
*/
|
||||||
|
export async function waitFor(
|
||||||
|
condition: () => boolean,
|
||||||
|
timeout = 1000,
|
||||||
|
interval = 10
|
||||||
|
): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (!condition()) {
|
||||||
|
if (Date.now() - start > timeout) {
|
||||||
|
throw new Error("Timeout waiting for condition");
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a temporary directory for tests
|
||||||
|
*/
|
||||||
|
export function createTempDir(): string {
|
||||||
|
return `/tmp/test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
}
|
||||||
107
apps/server/tests/utils/mocks.ts
Normal file
107
apps/server/tests/utils/mocks.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Mock utilities for testing
|
||||||
|
* Provides reusable mocks for common dependencies
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { vi } from "vitest";
|
||||||
|
import type { ChildProcess } from "child_process";
|
||||||
|
import { EventEmitter } from "events";
|
||||||
|
import type { Readable } from "stream";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock child_process.spawn for subprocess tests
|
||||||
|
*/
|
||||||
|
export function createMockChildProcess(options: {
|
||||||
|
stdout?: string[];
|
||||||
|
stderr?: string[];
|
||||||
|
exitCode?: number | null;
|
||||||
|
shouldError?: boolean;
|
||||||
|
}): ChildProcess {
|
||||||
|
const { stdout = [], stderr = [], exitCode = 0, shouldError = false } = options;
|
||||||
|
|
||||||
|
const mockProcess = new EventEmitter() as any;
|
||||||
|
|
||||||
|
// Create mock stdout stream
|
||||||
|
mockProcess.stdout = new EventEmitter() as Readable;
|
||||||
|
mockProcess.stderr = new EventEmitter() as Readable;
|
||||||
|
|
||||||
|
mockProcess.kill = vi.fn();
|
||||||
|
|
||||||
|
// Simulate async output
|
||||||
|
process.nextTick(() => {
|
||||||
|
// Emit stdout lines
|
||||||
|
for (const line of stdout) {
|
||||||
|
mockProcess.stdout.emit("data", Buffer.from(line + "\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit stderr lines
|
||||||
|
for (const line of stderr) {
|
||||||
|
mockProcess.stderr.emit("data", Buffer.from(line + "\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit exit or error
|
||||||
|
if (shouldError) {
|
||||||
|
mockProcess.emit("error", new Error("Process error"));
|
||||||
|
} else {
|
||||||
|
mockProcess.emit("exit", exitCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return mockProcess as ChildProcess;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock fs/promises for file system tests
|
||||||
|
*/
|
||||||
|
export function createMockFs() {
|
||||||
|
return {
|
||||||
|
readFile: vi.fn(),
|
||||||
|
writeFile: vi.fn(),
|
||||||
|
mkdir: vi.fn(),
|
||||||
|
access: vi.fn(),
|
||||||
|
stat: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock Express request/response/next for middleware tests
|
||||||
|
*/
|
||||||
|
export function createMockExpressContext() {
|
||||||
|
const req = {
|
||||||
|
headers: {},
|
||||||
|
body: {},
|
||||||
|
params: {},
|
||||||
|
query: {},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const res = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn().mockReturnThis(),
|
||||||
|
send: vi.fn().mockReturnThis(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
return { req, res, next };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock AbortController for async operation tests
|
||||||
|
*/
|
||||||
|
export function createMockAbortController() {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const originalAbort = controller.abort.bind(controller);
|
||||||
|
controller.abort = vi.fn(originalAbort);
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock Claude SDK query function
|
||||||
|
*/
|
||||||
|
export function createMockClaudeQuery(messages: any[] = []) {
|
||||||
|
return vi.fn(async function* ({ prompt, options }: any) {
|
||||||
|
for (const msg of messages) {
|
||||||
|
yield msg;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
10
apps/server/tsconfig.test.json
Normal file
10
apps/server/tsconfig.test.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["vitest/globals", "node"],
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"module": "ESNext"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "tests/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
37
apps/server/vitest.config.ts
Normal file
37
apps/server/vitest.config.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
reporters: ['verbose'],
|
||||||
|
globals: true,
|
||||||
|
environment: "node",
|
||||||
|
setupFiles: ["./tests/setup.ts"],
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
reporter: ["text", "json", "html", "lcov"],
|
||||||
|
include: ["src/**/*.ts"],
|
||||||
|
exclude: [
|
||||||
|
"src/**/*.d.ts",
|
||||||
|
"src/index.ts",
|
||||||
|
"src/routes/**", // Routes are better tested with integration tests
|
||||||
|
],
|
||||||
|
thresholds: {
|
||||||
|
lines: 65,
|
||||||
|
functions: 75,
|
||||||
|
branches: 58,
|
||||||
|
statements: 65,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: ["tests/**/*.test.ts", "tests/**/*.spec.ts"],
|
||||||
|
exclude: ["**/node_modules/**", "**/dist/**"],
|
||||||
|
mockReset: true,
|
||||||
|
restoreMocks: true,
|
||||||
|
clearMocks: true,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
786
docs/server/providers.md
Normal file
786
docs/server/providers.md
Normal file
@@ -0,0 +1,786 @@
|
|||||||
|
# Provider Architecture Reference
|
||||||
|
|
||||||
|
This document describes the modular provider architecture in `apps/server/src/providers/` that enables support for multiple AI model providers (Claude SDK, OpenAI Codex CLI, and future providers like Cursor, OpenCode, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Architecture Overview](#architecture-overview)
|
||||||
|
2. [Provider Interface](#provider-interface)
|
||||||
|
3. [Available Providers](#available-providers)
|
||||||
|
4. [Provider Factory](#provider-factory)
|
||||||
|
5. [Adding New Providers](#adding-new-providers)
|
||||||
|
6. [Provider Types](#provider-types)
|
||||||
|
7. [Best Practices](#best-practices)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
The provider architecture separates AI model execution logic from business logic, enabling clean abstraction and easy extensibility.
|
||||||
|
|
||||||
|
### Architecture Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ AgentService / AutoModeService │
|
||||||
|
│ (No provider logic) │
|
||||||
|
└──────────────────┬──────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────▼──────────┐
|
||||||
|
│ ProviderFactory │ Model-based routing
|
||||||
|
│ (Routes by model) │ "gpt-*" → Codex
|
||||||
|
└─────────┬──────────┘ "claude-*" → Claude
|
||||||
|
│
|
||||||
|
┌────────────┴────────────┐
|
||||||
|
│ │
|
||||||
|
┌─────▼──────┐ ┌──────▼──────┐
|
||||||
|
│ Claude │ │ Codex │
|
||||||
|
│ Provider │ │ Provider │
|
||||||
|
│ (Agent SDK)│ │ (CLI Spawn) │
|
||||||
|
└────────────┘ └─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Benefits
|
||||||
|
|
||||||
|
- ✅ **Adding new providers**: Only 1 new file + 1 line in factory
|
||||||
|
- ✅ **Services remain clean**: No provider-specific logic
|
||||||
|
- ✅ **All providers implement same interface**: Consistent behavior
|
||||||
|
- ✅ **Model prefix determines provider**: Automatic routing
|
||||||
|
- ✅ **Easy to test**: Each provider can be tested independently
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provider Interface
|
||||||
|
|
||||||
|
**Location**: `apps/server/src/providers/base-provider.ts`
|
||||||
|
|
||||||
|
All providers must extend `BaseProvider` and implement the required methods.
|
||||||
|
|
||||||
|
### BaseProvider Abstract Class
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export abstract class BaseProvider {
|
||||||
|
protected config: ProviderConfig;
|
||||||
|
|
||||||
|
constructor(config: ProviderConfig = {}) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get provider name (e.g., "claude", "codex")
|
||||||
|
*/
|
||||||
|
abstract getName(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a query and stream responses
|
||||||
|
*/
|
||||||
|
abstract executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect provider installation status
|
||||||
|
*/
|
||||||
|
abstract detectInstallation(): Promise<InstallationStatus>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available models for this provider
|
||||||
|
*/
|
||||||
|
abstract getAvailableModels(): ModelDefinition[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if provider supports a specific feature (optional)
|
||||||
|
*/
|
||||||
|
supportsFeature(feature: string): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shared Types
|
||||||
|
|
||||||
|
**Location**: `apps/server/src/providers/types.ts`
|
||||||
|
|
||||||
|
#### ExecuteOptions
|
||||||
|
|
||||||
|
Input configuration for executing queries:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface ExecuteOptions {
|
||||||
|
prompt: string | Array<{ type: string; text?: string; source?: object }>;
|
||||||
|
model: string;
|
||||||
|
cwd: string;
|
||||||
|
systemPrompt?: string;
|
||||||
|
maxTurns?: number;
|
||||||
|
allowedTools?: string[];
|
||||||
|
mcpServers?: Record<string, unknown>;
|
||||||
|
abortController?: AbortController;
|
||||||
|
conversationHistory?: ConversationMessage[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ProviderMessage
|
||||||
|
|
||||||
|
Output messages streamed from providers:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface ProviderMessage {
|
||||||
|
type: "assistant" | "user" | "error" | "result";
|
||||||
|
subtype?: "success" | "error";
|
||||||
|
message?: {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: ContentBlock[];
|
||||||
|
};
|
||||||
|
result?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ContentBlock
|
||||||
|
|
||||||
|
Individual content blocks in messages:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface ContentBlock {
|
||||||
|
type: "text" | "tool_use" | "thinking" | "tool_result";
|
||||||
|
text?: string;
|
||||||
|
thinking?: string;
|
||||||
|
name?: string;
|
||||||
|
input?: unknown;
|
||||||
|
tool_use_id?: string;
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Available Providers
|
||||||
|
|
||||||
|
### 1. Claude Provider (SDK-based)
|
||||||
|
|
||||||
|
**Location**: `apps/server/src/providers/claude-provider.ts`
|
||||||
|
|
||||||
|
Uses `@anthropic-ai/claude-agent-sdk` for direct SDK integration.
|
||||||
|
|
||||||
|
#### Features
|
||||||
|
|
||||||
|
- ✅ Native multi-turn conversation support
|
||||||
|
- ✅ Vision support (images)
|
||||||
|
- ✅ Tool use (Read, Write, Edit, Glob, Grep, Bash, WebSearch, WebFetch)
|
||||||
|
- ✅ Thinking blocks (extended thinking)
|
||||||
|
- ✅ Streaming responses
|
||||||
|
- ✅ No CLI installation required (npm dependency)
|
||||||
|
|
||||||
|
#### Model Detection
|
||||||
|
|
||||||
|
Routes models that:
|
||||||
|
- Start with `"claude-"` (e.g., `"claude-opus-4-5-20251101"`)
|
||||||
|
- Are Claude aliases: `"opus"`, `"sonnet"`, `"haiku"`
|
||||||
|
|
||||||
|
#### Authentication
|
||||||
|
|
||||||
|
Requires one of:
|
||||||
|
- `ANTHROPIC_API_KEY` environment variable
|
||||||
|
- `CLAUDE_CODE_OAUTH_TOKEN` environment variable
|
||||||
|
|
||||||
|
#### Example Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const provider = new ClaudeProvider();
|
||||||
|
|
||||||
|
const stream = provider.executeQuery({
|
||||||
|
prompt: "What is 2+2?",
|
||||||
|
model: "claude-opus-4-5-20251101",
|
||||||
|
cwd: "/project/path",
|
||||||
|
systemPrompt: "You are a helpful assistant.",
|
||||||
|
maxTurns: 20,
|
||||||
|
allowedTools: ["Read", "Write", "Bash"],
|
||||||
|
abortController: new AbortController(),
|
||||||
|
conversationHistory: [
|
||||||
|
{ role: "user", content: "Hello" },
|
||||||
|
{ role: "assistant", content: "Hi! How can I help?" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const msg of stream) {
|
||||||
|
if (msg.type === "assistant") {
|
||||||
|
console.log(msg.message?.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Conversation History Handling
|
||||||
|
|
||||||
|
Uses `convertHistoryToMessages()` utility to convert history to SDK format:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const historyMessages = convertHistoryToMessages(conversationHistory);
|
||||||
|
for (const msg of historyMessages) {
|
||||||
|
yield msg; // Yield to SDK
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Codex Provider (CLI-based)
|
||||||
|
|
||||||
|
**Location**: `apps/server/src/providers/codex-provider.ts`
|
||||||
|
|
||||||
|
Spawns OpenAI Codex CLI as a subprocess and converts JSONL output to provider format.
|
||||||
|
|
||||||
|
#### Features
|
||||||
|
|
||||||
|
- ✅ Subprocess execution (`codex exec --model <model> --json --full-auto`)
|
||||||
|
- ✅ JSONL stream parsing
|
||||||
|
- ✅ Supports GPT-5.1/5.2 Codex models
|
||||||
|
- ✅ Vision support (GPT-5.1, GPT-5.2)
|
||||||
|
- ✅ Tool use via MCP servers
|
||||||
|
- ✅ Timeout detection (30s no output)
|
||||||
|
- ✅ Abort signal handling
|
||||||
|
|
||||||
|
#### Model Detection
|
||||||
|
|
||||||
|
Routes models that:
|
||||||
|
- Start with `"gpt-"` (e.g., `"gpt-5.2"`, `"gpt-5.1-codex-max"`)
|
||||||
|
- Start with `"o"` (e.g., `"o1"`, `"o1-mini"`)
|
||||||
|
|
||||||
|
#### Available Models
|
||||||
|
|
||||||
|
| Model | Description | Context | Max Output | Vision |
|
||||||
|
|-------|-------------|---------|------------|--------|
|
||||||
|
| `gpt-5.2` | Latest Codex model | 256K | 32K | Yes |
|
||||||
|
| `gpt-5.1-codex-max` | Maximum capability | 256K | 32K | Yes |
|
||||||
|
| `gpt-5.1-codex` | Standard Codex | 256K | 32K | Yes |
|
||||||
|
| `gpt-5.1-codex-mini` | Lightweight | 256K | 16K | No |
|
||||||
|
| `gpt-5.1` | General-purpose | 256K | 32K | Yes |
|
||||||
|
|
||||||
|
#### Authentication
|
||||||
|
|
||||||
|
Supports two methods:
|
||||||
|
1. **CLI login**: `codex login` (OAuth tokens stored in `~/.codex/auth.json`)
|
||||||
|
2. **API key**: `OPENAI_API_KEY` environment variable
|
||||||
|
|
||||||
|
#### Installation Detection
|
||||||
|
|
||||||
|
Uses `CodexCliDetector` to check:
|
||||||
|
- PATH for `codex` command
|
||||||
|
- npm global: `npm list -g @openai/codex`
|
||||||
|
- Homebrew (macOS): `/opt/homebrew/bin/codex`
|
||||||
|
- Common paths: `~/.local/bin/codex`, `/usr/local/bin/codex`
|
||||||
|
|
||||||
|
#### Example Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const provider = new CodexProvider();
|
||||||
|
|
||||||
|
const stream = provider.executeQuery({
|
||||||
|
prompt: "Fix the bug in main.ts",
|
||||||
|
model: "gpt-5.2",
|
||||||
|
cwd: "/project/path",
|
||||||
|
systemPrompt: "You are an expert TypeScript developer.",
|
||||||
|
abortController: new AbortController()
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const msg of stream) {
|
||||||
|
if (msg.type === "assistant") {
|
||||||
|
console.log(msg.message?.content);
|
||||||
|
} else if (msg.type === "error") {
|
||||||
|
console.error(msg.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### JSONL Event Conversion
|
||||||
|
|
||||||
|
Codex CLI outputs JSONL events that get converted to `ProviderMessage` format:
|
||||||
|
|
||||||
|
| Codex Event | Provider Message |
|
||||||
|
|-------------|------------------|
|
||||||
|
| `item.completed` (reasoning) | `{ type: "assistant", content: [{ type: "thinking" }] }` |
|
||||||
|
| `item.completed` (agent_message) | `{ type: "assistant", content: [{ type: "text" }] }` |
|
||||||
|
| `item.completed` (command_execution) | `{ type: "assistant", content: [{ type: "text", text: "```bash\n...\n```" }] }` |
|
||||||
|
| `item.started` (command_execution) | `{ type: "assistant", content: [{ type: "tool_use" }] }` |
|
||||||
|
| `item.updated` (todo_list) | `{ type: "assistant", content: [{ type: "text", text: "**Updated Todo List:**..." }] }` |
|
||||||
|
| `thread.completed` | `{ type: "result", subtype: "success" }` |
|
||||||
|
| `error` | `{ type: "error", error: "..." }` |
|
||||||
|
|
||||||
|
#### Conversation History Handling
|
||||||
|
|
||||||
|
Uses `formatHistoryAsText()` utility to prepend history as text context (CLI doesn't support native multi-turn):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const historyText = formatHistoryAsText(conversationHistory);
|
||||||
|
combinedPrompt = `${historyText}Current request:\n${combinedPrompt}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MCP Server Configuration
|
||||||
|
|
||||||
|
**Location**: `apps/server/src/providers/codex-config-manager.ts`
|
||||||
|
|
||||||
|
Manages TOML configuration for MCP servers:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await codexConfigManager.configureMcpServer(cwd, mcpServerScriptPath);
|
||||||
|
```
|
||||||
|
|
||||||
|
Generates `.codex/config.toml`:
|
||||||
|
```toml
|
||||||
|
[mcp_servers.automaker-tools]
|
||||||
|
command = "node"
|
||||||
|
args = ["/path/to/mcp-server.js"]
|
||||||
|
enabled_tools = ["UpdateFeatureStatus"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provider Factory
|
||||||
|
|
||||||
|
**Location**: `apps/server/src/providers/provider-factory.ts`
|
||||||
|
|
||||||
|
Routes requests to the appropriate provider based on model string.
|
||||||
|
|
||||||
|
### Model-Based Routing
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class ProviderFactory {
|
||||||
|
/**
|
||||||
|
* Get provider for a specific model
|
||||||
|
*/
|
||||||
|
static getProviderForModel(modelId: string): BaseProvider {
|
||||||
|
const lowerModel = modelId.toLowerCase();
|
||||||
|
|
||||||
|
// OpenAI/Codex models
|
||||||
|
if (lowerModel.startsWith("gpt-") || lowerModel.startsWith("o")) {
|
||||||
|
return new CodexProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude models
|
||||||
|
if (lowerModel.startsWith("claude-") ||
|
||||||
|
["haiku", "sonnet", "opus"].includes(lowerModel)) {
|
||||||
|
return new ClaudeProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to Claude
|
||||||
|
return new ClaudeProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check installation status of all providers
|
||||||
|
*/
|
||||||
|
static async checkAllProviders(): Promise<Record<string, InstallationStatus>> {
|
||||||
|
const claude = new ClaudeProvider();
|
||||||
|
const codex = new CodexProvider();
|
||||||
|
|
||||||
|
return {
|
||||||
|
claude: await claude.detectInstallation(),
|
||||||
|
codex: await codex.detectInstallation(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage in Services
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ProviderFactory } from "../providers/provider-factory.js";
|
||||||
|
|
||||||
|
// In AgentService or AutoModeService
|
||||||
|
const provider = ProviderFactory.getProviderForModel(model);
|
||||||
|
const stream = provider.executeQuery(options);
|
||||||
|
|
||||||
|
for await (const msg of stream) {
|
||||||
|
// Handle messages (format is consistent across all providers)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding New Providers
|
||||||
|
|
||||||
|
### Step 1: Create Provider File
|
||||||
|
|
||||||
|
Create `apps/server/src/providers/[name]-provider.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BaseProvider } from "./base-provider.js";
|
||||||
|
import type {
|
||||||
|
ExecuteOptions,
|
||||||
|
ProviderMessage,
|
||||||
|
InstallationStatus,
|
||||||
|
ModelDefinition,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export class CursorProvider extends BaseProvider {
|
||||||
|
getName(): string {
|
||||||
|
return "cursor";
|
||||||
|
}
|
||||||
|
|
||||||
|
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||||
|
// Implementation here
|
||||||
|
// 1. Spawn cursor CLI or use SDK
|
||||||
|
// 2. Convert output to ProviderMessage format
|
||||||
|
// 3. Yield messages
|
||||||
|
}
|
||||||
|
|
||||||
|
async detectInstallation(): Promise<InstallationStatus> {
|
||||||
|
// Check if cursor is installed
|
||||||
|
// Return { installed: boolean, path?: string, version?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
getAvailableModels(): ModelDefinition[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "cursor-premium",
|
||||||
|
name: "Cursor Premium",
|
||||||
|
modelString: "cursor-premium",
|
||||||
|
provider: "cursor",
|
||||||
|
description: "Cursor's premium model",
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxOutputTokens: 8192,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
tier: "premium",
|
||||||
|
default: true,
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
supportsFeature(feature: string): boolean {
|
||||||
|
const supportedFeatures = ["tools", "text", "vision"];
|
||||||
|
return supportedFeatures.includes(feature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Add Routing in Factory
|
||||||
|
|
||||||
|
Update `apps/server/src/providers/provider-factory.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { CursorProvider } from "./cursor-provider.js";
|
||||||
|
|
||||||
|
static getProviderForModel(modelId: string): BaseProvider {
|
||||||
|
const lowerModel = modelId.toLowerCase();
|
||||||
|
|
||||||
|
// Cursor models
|
||||||
|
if (lowerModel.startsWith("cursor-")) {
|
||||||
|
return new CursorProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... existing routing
|
||||||
|
}
|
||||||
|
|
||||||
|
static async checkAllProviders() {
|
||||||
|
const cursor = new CursorProvider();
|
||||||
|
|
||||||
|
return {
|
||||||
|
claude: await claude.detectInstallation(),
|
||||||
|
codex: await codex.detectInstallation(),
|
||||||
|
cursor: await cursor.detectInstallation(), // NEW
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Update Models List
|
||||||
|
|
||||||
|
Update `apps/server/src/routes/models.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: "cursor-premium",
|
||||||
|
name: "Cursor Premium",
|
||||||
|
provider: "cursor",
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxOutputTokens: 8192,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Done!
|
||||||
|
|
||||||
|
No changes needed in:
|
||||||
|
- ✅ AgentService
|
||||||
|
- ✅ AutoModeService
|
||||||
|
- ✅ Any business logic
|
||||||
|
|
||||||
|
The provider architecture handles everything automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provider Types
|
||||||
|
|
||||||
|
### SDK-Based Providers (like Claude)
|
||||||
|
|
||||||
|
**Characteristics**:
|
||||||
|
- Direct SDK/library integration
|
||||||
|
- No subprocess spawning
|
||||||
|
- Native multi-turn support
|
||||||
|
- Streaming via async generators
|
||||||
|
|
||||||
|
**Example**: ClaudeProvider using `@anthropic-ai/claude-agent-sdk`
|
||||||
|
|
||||||
|
**Advantages**:
|
||||||
|
- Lower latency
|
||||||
|
- More control over options
|
||||||
|
- Easier error handling
|
||||||
|
- No CLI installation required
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### CLI-Based Providers (like Codex)
|
||||||
|
|
||||||
|
**Characteristics**:
|
||||||
|
- Subprocess spawning
|
||||||
|
- JSONL stream parsing
|
||||||
|
- Text-based conversation history
|
||||||
|
- Requires CLI installation
|
||||||
|
|
||||||
|
**Example**: CodexProvider using `codex exec --json`
|
||||||
|
|
||||||
|
**Advantages**:
|
||||||
|
- Access to CLI-only features
|
||||||
|
- No SDK dependency
|
||||||
|
- Can use any CLI tool
|
||||||
|
|
||||||
|
**Implementation Pattern**:
|
||||||
|
1. Use `spawnJSONLProcess()` from `subprocess-manager.ts`
|
||||||
|
2. Convert JSONL events to `ProviderMessage` format
|
||||||
|
3. Handle authentication (CLI login or API key)
|
||||||
|
4. Implement timeout detection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Message Format Consistency
|
||||||
|
|
||||||
|
All providers MUST output the same `ProviderMessage` format so services can handle them uniformly:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Correct - Consistent format
|
||||||
|
yield {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "Response" }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ❌ Incorrect - Provider-specific format
|
||||||
|
yield {
|
||||||
|
customType: "response",
|
||||||
|
data: "Response"
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Error Handling
|
||||||
|
|
||||||
|
Always yield error messages, never throw:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Correct
|
||||||
|
try {
|
||||||
|
// ...
|
||||||
|
} catch (error) {
|
||||||
|
yield {
|
||||||
|
type: "error",
|
||||||
|
error: (error as Error).message
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ Incorrect
|
||||||
|
throw new Error("Provider failed");
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Abort Signal Support
|
||||||
|
|
||||||
|
Respect the abort controller:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (abortController?.signal.aborted) {
|
||||||
|
yield { type: "error", error: "Operation cancelled" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Conversation History
|
||||||
|
|
||||||
|
- **SDK providers**: Use `convertHistoryToMessages()` and yield messages
|
||||||
|
- **CLI providers**: Use `formatHistoryAsText()` and prepend to prompt
|
||||||
|
|
||||||
|
### 5. Image Handling
|
||||||
|
|
||||||
|
- **Vision models**: Pass images as content blocks
|
||||||
|
- **Non-vision models**: Extract text only using utilities
|
||||||
|
|
||||||
|
### 6. Logging
|
||||||
|
|
||||||
|
Use consistent logging prefixes:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
console.log(`[${this.getName()}Provider] Operation started`);
|
||||||
|
console.error(`[${this.getName()}Provider] Error:`, error);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Installation Detection
|
||||||
|
|
||||||
|
Implement thorough detection:
|
||||||
|
- Check multiple installation methods
|
||||||
|
- Verify authentication
|
||||||
|
- Return detailed status
|
||||||
|
|
||||||
|
### 8. Model Definitions
|
||||||
|
|
||||||
|
Provide accurate model metadata:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: "model-id",
|
||||||
|
name: "Human-readable name",
|
||||||
|
modelString: "exact-model-string",
|
||||||
|
provider: "provider-name",
|
||||||
|
description: "What this model is good for",
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxOutputTokens: 8192,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
tier: "premium" | "standard" | "basic",
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Providers
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
Test each provider method independently:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe("ClaudeProvider", () => {
|
||||||
|
it("should detect installation", async () => {
|
||||||
|
const provider = new ClaudeProvider();
|
||||||
|
const status = await provider.detectInstallation();
|
||||||
|
|
||||||
|
expect(status.installed).toBe(true);
|
||||||
|
expect(status.method).toBe("sdk");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should stream messages correctly", async () => {
|
||||||
|
const provider = new ClaudeProvider();
|
||||||
|
const messages = [];
|
||||||
|
|
||||||
|
for await (const msg of provider.executeQuery(options)) {
|
||||||
|
messages.push(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(messages.length).toBeGreaterThan(0);
|
||||||
|
expect(messages[0].type).toBe("assistant");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
Test provider interaction with services:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe("Provider Integration", () => {
|
||||||
|
it("should work with AgentService", async () => {
|
||||||
|
const provider = ProviderFactory.getProviderForModel("claude-opus-4-5-20251101");
|
||||||
|
|
||||||
|
// Test full workflow
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### Claude Provider
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required (one of):
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
CLAUDE_CODE_OAUTH_TOKEN=...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Codex Provider
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required (one of):
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
# OR run: codex login
|
||||||
|
|
||||||
|
# Optional:
|
||||||
|
CODEX_CLI_PATH=/custom/path/to/codex
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Provider Not Found
|
||||||
|
|
||||||
|
**Problem**: `ProviderFactory.getProviderForModel()` returns wrong provider
|
||||||
|
|
||||||
|
**Solution**: Check model string prefix in factory routing
|
||||||
|
|
||||||
|
### Authentication Errors
|
||||||
|
|
||||||
|
**Problem**: Provider fails with auth error
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Check environment variables
|
||||||
|
2. For CLI providers, verify CLI login status
|
||||||
|
3. Check `detectInstallation()` output
|
||||||
|
|
||||||
|
### JSONL Parsing Errors (CLI providers)
|
||||||
|
|
||||||
|
**Problem**: Failed to parse JSONL line
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Check CLI output format
|
||||||
|
2. Verify JSON is valid
|
||||||
|
3. Add error handling for malformed lines
|
||||||
|
|
||||||
|
### Timeout Issues (CLI providers)
|
||||||
|
|
||||||
|
**Problem**: Subprocess hangs
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Increase timeout in `spawnJSONLProcess` options
|
||||||
|
2. Check CLI process for hangs
|
||||||
|
3. Verify abort signal handling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Provider Ideas
|
||||||
|
|
||||||
|
Potential providers to add:
|
||||||
|
|
||||||
|
1. **Cursor Provider** (`cursor-*`)
|
||||||
|
- CLI-based
|
||||||
|
- Code completion specialist
|
||||||
|
|
||||||
|
2. **OpenCode Provider** (`opencode-*`)
|
||||||
|
- SDK or CLI-based
|
||||||
|
- Open-source alternative
|
||||||
|
|
||||||
|
3. **Gemini Provider** (`gemini-*`)
|
||||||
|
- Google's AI models
|
||||||
|
- SDK-based via `@google/generative-ai`
|
||||||
|
|
||||||
|
4. **Ollama Provider** (`ollama-*`)
|
||||||
|
- Local model hosting
|
||||||
|
- CLI or HTTP API
|
||||||
|
|
||||||
|
Each would follow the same pattern:
|
||||||
|
1. Create `[name]-provider.ts` implementing `BaseProvider`
|
||||||
|
2. Add routing in `provider-factory.ts`
|
||||||
|
3. Update models list
|
||||||
|
4. Done! ✅
|
||||||
672
docs/server/utilities.md
Normal file
672
docs/server/utilities.md
Normal file
@@ -0,0 +1,672 @@
|
|||||||
|
# Server Utilities Reference
|
||||||
|
|
||||||
|
This document describes all utility modules available in `apps/server/src/lib/`. These utilities provide reusable functionality for image handling, prompt building, model resolution, conversation management, and error handling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Image Handler](#image-handler)
|
||||||
|
2. [Prompt Builder](#prompt-builder)
|
||||||
|
3. [Model Resolver](#model-resolver)
|
||||||
|
4. [Conversation Utils](#conversation-utils)
|
||||||
|
5. [Error Handler](#error-handler)
|
||||||
|
6. [Subprocess Manager](#subprocess-manager)
|
||||||
|
7. [Events](#events)
|
||||||
|
8. [Auth](#auth)
|
||||||
|
9. [Security](#security)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Image Handler
|
||||||
|
|
||||||
|
**Location**: `apps/server/src/lib/image-handler.ts`
|
||||||
|
|
||||||
|
Centralized utilities for processing image files, including MIME type detection, base64 encoding, and content block generation for Claude SDK format.
|
||||||
|
|
||||||
|
### Functions
|
||||||
|
|
||||||
|
#### `getMimeTypeForImage(imagePath: string): string`
|
||||||
|
|
||||||
|
Get MIME type for an image file based on its extension.
|
||||||
|
|
||||||
|
**Supported formats**:
|
||||||
|
- `.jpg`, `.jpeg` → `image/jpeg`
|
||||||
|
- `.png` → `image/png`
|
||||||
|
- `.gif` → `image/gif`
|
||||||
|
- `.webp` → `image/webp`
|
||||||
|
- Default: `image/png`
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
import { getMimeTypeForImage } from "../lib/image-handler.js";
|
||||||
|
|
||||||
|
const mimeType = getMimeTypeForImage("/path/to/image.jpg");
|
||||||
|
// Returns: "image/jpeg"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `readImageAsBase64(imagePath: string): Promise<ImageData>`
|
||||||
|
|
||||||
|
Read an image file and convert to base64 with metadata.
|
||||||
|
|
||||||
|
**Returns**: `ImageData`
|
||||||
|
```typescript
|
||||||
|
interface ImageData {
|
||||||
|
base64: string; // Base64-encoded image data
|
||||||
|
mimeType: string; // MIME type
|
||||||
|
filename: string; // File basename
|
||||||
|
originalPath: string; // Original file path
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
const imageData = await readImageAsBase64("/path/to/photo.png");
|
||||||
|
console.log(imageData.base64); // "iVBORw0KG..."
|
||||||
|
console.log(imageData.mimeType); // "image/png"
|
||||||
|
console.log(imageData.filename); // "photo.png"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `convertImagesToContentBlocks(imagePaths: string[], workDir?: string): Promise<ImageContentBlock[]>`
|
||||||
|
|
||||||
|
Convert image paths to content blocks in Claude SDK format. Handles both relative and absolute paths.
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `imagePaths` - Array of image file paths
|
||||||
|
- `workDir` - Optional working directory for resolving relative paths
|
||||||
|
|
||||||
|
**Returns**: Array of `ImageContentBlock`
|
||||||
|
```typescript
|
||||||
|
interface ImageContentBlock {
|
||||||
|
type: "image";
|
||||||
|
source: {
|
||||||
|
type: "base64";
|
||||||
|
media_type: string;
|
||||||
|
data: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
const imageBlocks = await convertImagesToContentBlocks(
|
||||||
|
["./screenshot.png", "/absolute/path/diagram.jpg"],
|
||||||
|
"/project/root"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use in prompt content
|
||||||
|
const contentBlocks = [
|
||||||
|
{ type: "text", text: "Analyze these images:" },
|
||||||
|
...imageBlocks
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `formatImagePathsForPrompt(imagePaths: string[]): string`
|
||||||
|
|
||||||
|
Format image paths as a bulleted list for inclusion in text prompts.
|
||||||
|
|
||||||
|
**Returns**: Formatted string with image paths, or empty string if no images.
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
const pathsList = formatImagePathsForPrompt([
|
||||||
|
"/screenshots/login.png",
|
||||||
|
"/diagrams/architecture.png"
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Returns:
|
||||||
|
// "\n\nAttached images:\n- /screenshots/login.png\n- /diagrams/architecture.png\n"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt Builder
|
||||||
|
|
||||||
|
**Location**: `apps/server/src/lib/prompt-builder.ts`
|
||||||
|
|
||||||
|
Standardized prompt building that combines text prompts with image attachments.
|
||||||
|
|
||||||
|
### Functions
|
||||||
|
|
||||||
|
#### `buildPromptWithImages(basePrompt: string, imagePaths?: string[], workDir?: string, includeImagePaths: boolean = false): Promise<PromptWithImages>`
|
||||||
|
|
||||||
|
Build a prompt with optional image attachments.
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `basePrompt` - The text prompt
|
||||||
|
- `imagePaths` - Optional array of image file paths
|
||||||
|
- `workDir` - Optional working directory for resolving relative paths
|
||||||
|
- `includeImagePaths` - Whether to append image paths to the text (default: false)
|
||||||
|
|
||||||
|
**Returns**: `PromptWithImages`
|
||||||
|
```typescript
|
||||||
|
interface PromptWithImages {
|
||||||
|
content: PromptContent; // string | Array<ContentBlock>
|
||||||
|
hasImages: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PromptContent = string | Array<{
|
||||||
|
type: string;
|
||||||
|
text?: string;
|
||||||
|
source?: object;
|
||||||
|
}>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
import { buildPromptWithImages } from "../lib/prompt-builder.js";
|
||||||
|
|
||||||
|
// Without images
|
||||||
|
const { content } = await buildPromptWithImages("What is 2+2?");
|
||||||
|
// content: "What is 2+2?" (simple string)
|
||||||
|
|
||||||
|
// With images
|
||||||
|
const { content, hasImages } = await buildPromptWithImages(
|
||||||
|
"Analyze this screenshot",
|
||||||
|
["/path/to/screenshot.png"],
|
||||||
|
"/project/root",
|
||||||
|
true // include image paths in text
|
||||||
|
);
|
||||||
|
// content: [
|
||||||
|
// { type: "text", text: "Analyze this screenshot\n\nAttached images:\n- /path/to/screenshot.png\n" },
|
||||||
|
// { type: "image", source: { type: "base64", media_type: "image/png", data: "..." } }
|
||||||
|
// ]
|
||||||
|
// hasImages: true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Cases**:
|
||||||
|
- **AgentService**: Set `includeImagePaths: true` to list paths for Read tool access
|
||||||
|
- **AutoModeService**: Set `includeImagePaths: false` to avoid duplication in feature descriptions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Model Resolver
|
||||||
|
|
||||||
|
**Location**: `apps/server/src/lib/model-resolver.ts`
|
||||||
|
|
||||||
|
Centralized model string mapping and resolution for handling model aliases and provider detection.
|
||||||
|
|
||||||
|
### Constants
|
||||||
|
|
||||||
|
#### `CLAUDE_MODEL_MAP`
|
||||||
|
|
||||||
|
Model alias mapping for Claude models.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const CLAUDE_MODEL_MAP: Record<string, string> = {
|
||||||
|
haiku: "claude-haiku-4-5",
|
||||||
|
sonnet: "claude-sonnet-4-20250514",
|
||||||
|
opus: "claude-opus-4-5-20251101",
|
||||||
|
} as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `DEFAULT_MODELS`
|
||||||
|
|
||||||
|
Default models per provider.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const DEFAULT_MODELS = {
|
||||||
|
claude: "claude-opus-4-5-20251101",
|
||||||
|
openai: "gpt-5.2",
|
||||||
|
} as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Functions
|
||||||
|
|
||||||
|
#### `resolveModelString(modelKey?: string, defaultModel: string = DEFAULT_MODELS.claude): string`
|
||||||
|
|
||||||
|
Resolve a model key/alias to a full model string.
|
||||||
|
|
||||||
|
**Logic**:
|
||||||
|
1. If `modelKey` is undefined → return `defaultModel`
|
||||||
|
2. If starts with `"gpt-"` or `"o"` → pass through (OpenAI/Codex model)
|
||||||
|
3. If includes `"claude-"` → pass through (full Claude model string)
|
||||||
|
4. If in `CLAUDE_MODEL_MAP` → return mapped value
|
||||||
|
5. Otherwise → return `defaultModel` with warning
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js";
|
||||||
|
|
||||||
|
resolveModelString("opus");
|
||||||
|
// Returns: "claude-opus-4-5-20251101"
|
||||||
|
// Logs: "[ModelResolver] Resolved model alias: "opus" -> "claude-opus-4-5-20251101""
|
||||||
|
|
||||||
|
resolveModelString("gpt-5.2");
|
||||||
|
// Returns: "gpt-5.2"
|
||||||
|
// Logs: "[ModelResolver] Using OpenAI/Codex model: gpt-5.2"
|
||||||
|
|
||||||
|
resolveModelString("claude-sonnet-4-20250514");
|
||||||
|
// Returns: "claude-sonnet-4-20250514"
|
||||||
|
// Logs: "[ModelResolver] Using full Claude model string: claude-sonnet-4-20250514"
|
||||||
|
|
||||||
|
resolveModelString("invalid-model");
|
||||||
|
// Returns: "claude-opus-4-5-20251101"
|
||||||
|
// Logs: "[ModelResolver] Unknown model key "invalid-model", using default: "claude-opus-4-5-20251101""
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `getEffectiveModel(explicitModel?: string, sessionModel?: string, defaultModel?: string): string`
|
||||||
|
|
||||||
|
Get the effective model from multiple sources with priority.
|
||||||
|
|
||||||
|
**Priority**: explicit model > session model > default model
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
import { getEffectiveModel } from "../lib/model-resolver.js";
|
||||||
|
|
||||||
|
// Explicit model takes precedence
|
||||||
|
getEffectiveModel("sonnet", "opus");
|
||||||
|
// Returns: "claude-sonnet-4-20250514"
|
||||||
|
|
||||||
|
// Falls back to session model
|
||||||
|
getEffectiveModel(undefined, "haiku");
|
||||||
|
// Returns: "claude-haiku-4-5"
|
||||||
|
|
||||||
|
// Falls back to default
|
||||||
|
getEffectiveModel(undefined, undefined, "gpt-5.2");
|
||||||
|
// Returns: "gpt-5.2"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conversation Utils
|
||||||
|
|
||||||
|
**Location**: `apps/server/src/lib/conversation-utils.ts`
|
||||||
|
|
||||||
|
Standardized conversation history processing for both SDK-based and CLI-based providers.
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { ConversationMessage } from "../providers/types.js";
|
||||||
|
|
||||||
|
interface ConversationMessage {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string | Array<{ type: string; text?: string; source?: object }>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Functions
|
||||||
|
|
||||||
|
#### `extractTextFromContent(content: string | Array<ContentBlock>): string`
|
||||||
|
|
||||||
|
Extract plain text from message content (handles both string and array formats).
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
import { extractTextFromContent } from "../lib/conversation-utils.js";
|
||||||
|
|
||||||
|
// String content
|
||||||
|
extractTextFromContent("Hello world");
|
||||||
|
// Returns: "Hello world"
|
||||||
|
|
||||||
|
// Array content
|
||||||
|
extractTextFromContent([
|
||||||
|
{ type: "text", text: "Hello" },
|
||||||
|
{ type: "image", source: {...} },
|
||||||
|
{ type: "text", text: "world" }
|
||||||
|
]);
|
||||||
|
// Returns: "Hello\nworld"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `normalizeContentBlocks(content: string | Array<ContentBlock>): Array<ContentBlock>`
|
||||||
|
|
||||||
|
Normalize message content to array format.
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
// String → array
|
||||||
|
normalizeContentBlocks("Hello");
|
||||||
|
// Returns: [{ type: "text", text: "Hello" }]
|
||||||
|
|
||||||
|
// Array → pass through
|
||||||
|
normalizeContentBlocks([{ type: "text", text: "Hello" }]);
|
||||||
|
// Returns: [{ type: "text", text: "Hello" }]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `formatHistoryAsText(history: ConversationMessage[]): string`
|
||||||
|
|
||||||
|
Format conversation history as plain text for CLI-based providers (e.g., Codex).
|
||||||
|
|
||||||
|
**Returns**: Formatted text with role labels, or empty string if no history.
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
const history = [
|
||||||
|
{ role: "user", content: "What is 2+2?" },
|
||||||
|
{ role: "assistant", content: "2+2 equals 4." }
|
||||||
|
];
|
||||||
|
|
||||||
|
const formatted = formatHistoryAsText(history);
|
||||||
|
// Returns:
|
||||||
|
// "Previous conversation:
|
||||||
|
//
|
||||||
|
// User: What is 2+2?
|
||||||
|
//
|
||||||
|
// Assistant: 2+2 equals 4.
|
||||||
|
//
|
||||||
|
// ---
|
||||||
|
//
|
||||||
|
// "
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `convertHistoryToMessages(history: ConversationMessage[]): Array<SDKMessage>`
|
||||||
|
|
||||||
|
Convert conversation history to Claude SDK message format.
|
||||||
|
|
||||||
|
**Returns**: Array of SDK-formatted messages ready to yield in async generator.
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
const history = [
|
||||||
|
{ role: "user", content: "Hello" },
|
||||||
|
{ role: "assistant", content: "Hi there!" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const messages = convertHistoryToMessages(history);
|
||||||
|
// Returns:
|
||||||
|
// [
|
||||||
|
// {
|
||||||
|
// type: "user",
|
||||||
|
// session_id: "",
|
||||||
|
// message: {
|
||||||
|
// role: "user",
|
||||||
|
// content: [{ type: "text", text: "Hello" }]
|
||||||
|
// },
|
||||||
|
// parent_tool_use_id: null
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// type: "assistant",
|
||||||
|
// session_id: "",
|
||||||
|
// message: {
|
||||||
|
// role: "assistant",
|
||||||
|
// content: [{ type: "text", text: "Hi there!" }]
|
||||||
|
// },
|
||||||
|
// parent_tool_use_id: null
|
||||||
|
// }
|
||||||
|
// ]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handler
|
||||||
|
|
||||||
|
**Location**: `apps/server/src/lib/error-handler.ts`
|
||||||
|
|
||||||
|
Standardized error classification and handling utilities.
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type ErrorType = "authentication" | "abort" | "execution" | "unknown";
|
||||||
|
|
||||||
|
export interface ErrorInfo {
|
||||||
|
type: ErrorType;
|
||||||
|
message: string;
|
||||||
|
isAbort: boolean;
|
||||||
|
isAuth: boolean;
|
||||||
|
originalError: unknown;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Functions
|
||||||
|
|
||||||
|
#### `isAbortError(error: unknown): boolean`
|
||||||
|
|
||||||
|
Check if an error is an abort/cancellation error.
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
import { isAbortError } from "../lib/error-handler.js";
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ... operation
|
||||||
|
} catch (error) {
|
||||||
|
if (isAbortError(error)) {
|
||||||
|
console.log("Operation was cancelled");
|
||||||
|
return { success: false, aborted: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `isAuthenticationError(errorMessage: string): boolean`
|
||||||
|
|
||||||
|
Check if an error is an authentication/API key error.
|
||||||
|
|
||||||
|
**Detects**:
|
||||||
|
- "Authentication failed"
|
||||||
|
- "Invalid API key"
|
||||||
|
- "authentication_failed"
|
||||||
|
- "Fix external API key"
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
if (isAuthenticationError(error.message)) {
|
||||||
|
console.error("Please check your API key configuration");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `classifyError(error: unknown): ErrorInfo`
|
||||||
|
|
||||||
|
Classify an error into a specific type.
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
import { classifyError } from "../lib/error-handler.js";
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ... operation
|
||||||
|
} catch (error) {
|
||||||
|
const errorInfo = classifyError(error);
|
||||||
|
|
||||||
|
switch (errorInfo.type) {
|
||||||
|
case "authentication":
|
||||||
|
// Handle auth errors
|
||||||
|
break;
|
||||||
|
case "abort":
|
||||||
|
// Handle cancellation
|
||||||
|
break;
|
||||||
|
case "execution":
|
||||||
|
// Handle other errors
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `getUserFriendlyErrorMessage(error: unknown): string`
|
||||||
|
|
||||||
|
Get a user-friendly error message.
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
// ... operation
|
||||||
|
} catch (error) {
|
||||||
|
const friendlyMessage = getUserFriendlyErrorMessage(error);
|
||||||
|
// "Operation was cancelled" for abort errors
|
||||||
|
// "Authentication failed. Please check your API key." for auth errors
|
||||||
|
// Original error message for other errors
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Subprocess Manager
|
||||||
|
|
||||||
|
**Location**: `apps/server/src/lib/subprocess-manager.ts`
|
||||||
|
|
||||||
|
Utilities for spawning CLI processes and parsing JSONL streams (used by Codex provider).
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface SubprocessOptions {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
cwd: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
abortController?: AbortController;
|
||||||
|
timeout?: number; // Milliseconds of no output before timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubprocessResult {
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
exitCode: number | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Functions
|
||||||
|
|
||||||
|
#### `async function* spawnJSONLProcess(options: SubprocessOptions): AsyncGenerator<unknown>`
|
||||||
|
|
||||||
|
Spawns a subprocess and streams JSONL output line-by-line.
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Parses each line as JSON
|
||||||
|
- Handles abort signals
|
||||||
|
- 30-second timeout detection for hanging processes
|
||||||
|
- Collects stderr for error reporting
|
||||||
|
- Continues processing other lines if one fails to parse
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
import { spawnJSONLProcess } from "../lib/subprocess-manager.js";
|
||||||
|
|
||||||
|
const stream = spawnJSONLProcess({
|
||||||
|
command: "codex",
|
||||||
|
args: ["exec", "--model", "gpt-5.2", "--json", "--full-auto", "Fix the bug"],
|
||||||
|
cwd: "/project/path",
|
||||||
|
env: { OPENAI_API_KEY: "sk-..." },
|
||||||
|
abortController: new AbortController(),
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const event of stream) {
|
||||||
|
console.log("Received event:", event);
|
||||||
|
// Process JSONL events
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `async function spawnProcess(options: SubprocessOptions): Promise<SubprocessResult>`
|
||||||
|
|
||||||
|
Spawns a subprocess and collects all output.
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
const result = await spawnProcess({
|
||||||
|
command: "git",
|
||||||
|
args: ["status"],
|
||||||
|
cwd: "/project/path"
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(result.stdout); // Git status output
|
||||||
|
console.log(result.exitCode); // 0 for success
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
**Location**: `apps/server/src/lib/events.ts`
|
||||||
|
|
||||||
|
Event emitter system for WebSocket communication.
|
||||||
|
|
||||||
|
**Documented separately** - see existing codebase for event types and usage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
**Location**: `apps/server/src/lib/auth.ts`
|
||||||
|
|
||||||
|
Authentication utilities for API endpoints.
|
||||||
|
|
||||||
|
**Documented separately** - see existing codebase for authentication flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
**Location**: `apps/server/src/lib/security.ts`
|
||||||
|
|
||||||
|
Security utilities for input validation and sanitization.
|
||||||
|
|
||||||
|
**Documented separately** - see existing codebase for security patterns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### When to Use Which Utility
|
||||||
|
|
||||||
|
1. **Image handling** → Always use `image-handler.ts` utilities
|
||||||
|
- ✅ Do: `convertImagesToContentBlocks(imagePaths, workDir)`
|
||||||
|
- ❌ Don't: Manually read files and encode base64
|
||||||
|
|
||||||
|
2. **Prompt building** → Use `prompt-builder.ts` for consistency
|
||||||
|
- ✅ Do: `buildPromptWithImages(text, images, workDir, includePathsInText)`
|
||||||
|
- ❌ Don't: Manually construct content block arrays
|
||||||
|
|
||||||
|
3. **Model resolution** → Use `model-resolver.ts` for all model handling
|
||||||
|
- ✅ Do: `resolveModelString(feature.model, DEFAULT_MODELS.claude)`
|
||||||
|
- ❌ Don't: Inline model mapping logic
|
||||||
|
|
||||||
|
4. **Error handling** → Use `error-handler.ts` for classification
|
||||||
|
- ✅ Do: `if (isAbortError(error)) { ... }`
|
||||||
|
- ❌ Don't: `if (error instanceof AbortError || error.name === "AbortError") { ... }`
|
||||||
|
|
||||||
|
### Importing Utilities
|
||||||
|
|
||||||
|
Always use `.js` extension in imports for ESM compatibility:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Correct
|
||||||
|
import { buildPromptWithImages } from "../lib/prompt-builder.js";
|
||||||
|
|
||||||
|
// ❌ Incorrect
|
||||||
|
import { buildPromptWithImages } from "../lib/prompt-builder";
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Utilities
|
||||||
|
|
||||||
|
When writing tests for utilities:
|
||||||
|
|
||||||
|
1. **Unit tests** - Test each function in isolation
|
||||||
|
2. **Integration tests** - Test utilities working together
|
||||||
|
3. **Mock external dependencies** - File system, child processes
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```typescript
|
||||||
|
describe("image-handler", () => {
|
||||||
|
it("should detect MIME type correctly", () => {
|
||||||
|
expect(getMimeTypeForImage("photo.jpg")).toBe("image/jpeg");
|
||||||
|
expect(getMimeTypeForImage("diagram.png")).toBe("image/png");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
1894
package-lock.json
generated
1894
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,8 @@
|
|||||||
"lint": "npm run lint --workspace=apps/app",
|
"lint": "npm run lint --workspace=apps/app",
|
||||||
"test": "npm run test --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",
|
||||||
"dev:marketing": "npm run dev --workspace=apps/marketing"
|
"dev:marketing": "npm run dev --workspace=apps/marketing"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user