Bug fixes and stability improvements (#815)

* fix(copilot): correct tool.execution_complete event handling

The CopilotProvider was using incorrect event type and data structure
for tool execution completion events from the @github/copilot-sdk,
causing tool call outputs to be empty.

Changes:
- Update event type from 'tool.execution_end' to 'tool.execution_complete'
- Fix data structure to use nested result.content instead of flat result
- Fix error structure to use error.message instead of flat error
- Add success field to match SDK event structure
- Add tests for empty and missing result handling

This aligns with the official @github/copilot-sdk v0.1.16 types
defined in session-events.d.ts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(copilot): add edge case test for error with code field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(copilot): improve error handling and code quality

Code review improvements:
- Extract magic string '[ERROR]' to TOOL_ERROR_PREFIX constant
- Add null-safe error handling with direct error variable assignment
- Include error codes in error messages for better debugging
- Add JSDoc documentation for tool.execution_complete handler
- Update tests to verify error codes are displayed
- Add missing tool_use_id assertion in error test

These changes improve:
- Code maintainability (no magic strings)
- Debugging experience (error codes now visible)
- Type safety (explicit null checks)
- Test coverage (verify error code formatting)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Changes from fix/bug-fixes-1-0

* test(copilot): add edge case test for error with code field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Changes from fix/bug-fixes-1-0

* fix: Handle detached HEAD state in worktree discovery and recovery

* fix: Remove unused isDevServerStarting prop and md: breakpoint classes

* fix: Add missing dependency and sanitize persisted cache data

* feat: Ensure NODE_ENV is set to test in vitest configs

* feat: Configure Playwright to run only E2E tests

* fix: Improve PR tracking and dev server lifecycle management

* feat: Add settings-based defaults for planning mode, model config, and custom providers. Fixes #816

* feat: Add worktree and branch selector to graph view

* fix: Add timeout and error handling for worktree HEAD ref resolution

* fix: use absolute icon path and place icon outside asar on Linux

The hicolor icon theme index only lists sizes up to 512x512, so an icon
installed only at 1024x1024 is invisible to GNOME/KDE's theme resolver,
causing both the app launcher and taskbar to show a generic icon.
Additionally, BrowserWindow.icon cannot be read by the window manager
when the file is inside app.asar.

- extraResources: copy logo_larger.png to resources/ (outside asar) so
  it lands at /opt/Automaker/resources/logo_larger.png on install
- linux.desktop.Icon: set to the absolute resources path, bypassing the
  hicolor theme lookup and its size constraints entirely
- icon-manager.ts: on Linux production use process.resourcesPath so
  BrowserWindow receives a real filesystem path the WM can read directly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: use linux.desktop.entry for custom desktop Icon field

electron-builder v26 rejects arbitrary keys in linux.desktop — the
correct schema wraps custom .desktop overrides inside desktop.entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: set desktop name on Linux so taskbar uses the correct app icon

Without app.setDesktopName(), the window manager cannot associate the
running Electron process with automaker.desktop. GNOME/KDE fall back to
_NET_WM_ICON which defaults to Electron's own bundled icon.

Calling app.setDesktopName('automaker.desktop') before any window is
created sets the _GTK_APPLICATION_ID hint and XDG app_id so the WM
picks up the desktop entry's Icon for the taskbar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: memory and context views mobile friendly (#818)

* Changes from fix/memory-and-context-mobile-friendly

* fix: Improve file extension detection and add path traversal protection

* refactor: Extract file extension utilities and add path traversal guards

Code review improvements:
- Extract isMarkdownFilename and isImageFilename to shared image-utils.ts
- Remove duplicated code from context-view.tsx and memory-view.tsx
- Add path traversal guard for context fixture utilities (matching memory)
- Add 7 new tests for context fixture path traversal protection
- Total 61 tests pass

Addresses code review feedback from PR #813

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: Add e2e tests for profiles crud and board background persistence

* Update apps/ui/playwright.config.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Add robust test navigation handling and file filtering

* fix: Format NODE_OPTIONS configuration on single line

* test: Update profiles and board background persistence tests

* test: Replace iPhone 13 Pro with Pixel 5 for mobile test consistency

* Update apps/ui/src/components/views/context-view.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* chore: Remove test project directory

* feat: Filter context files by type and improve mobile menu visibility

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Improve test reliability and localhost handling

* chore: Use explicit TEST_USE_EXTERNAL_BACKEND env var for server cleanup

* feat: Add E2E/CI mock mode for provider factory and auth verification

* feat: Add remoteBranch parameter to pull and rebase operations

* chore: Enhance E2E testing setup with worker isolation and auth state management

- Updated .gitignore to include worker-specific test fixtures.
- Modified e2e-tests.yml to implement test sharding for improved CI performance.
- Refactored global setup to authenticate once and save session state for reuse across tests.
- Introduced worker-isolated fixture paths to prevent conflicts during parallel test execution.
- Improved test navigation and loading handling for better reliability.
- Updated various test files to utilize new auth state management and fixture paths.

* fix: Update Playwright configuration and improve test reliability

- Increased the number of workers in Playwright configuration for better parallelism in CI environments.
- Enhanced the board background persistence test to ensure dropdown stability by waiting for the list to populate before interaction, improving test reliability.

* chore: Simplify E2E test configuration and enhance mock implementations

- Updated e2e-tests.yml to run tests in a single shard for streamlined CI execution.
- Enhanced unit tests for worktree list handling by introducing a mock for execGitCommand, improving test reliability and coverage.
- Refactored setup functions to better manage command mocks for git operations in tests.
- Improved error handling in mkdirSafe function to account for undefined stats in certain environments.

* refactor: Improve test configurations and enhance error handling

- Updated Playwright configuration to clear VITE_SERVER_URL, ensuring the frontend uses the Vite proxy and preventing cookie domain mismatches.
- Enhanced MergeRebaseDialog logic to normalize selectedBranch for better handling of various ref formats.
- Improved global setup with a more robust backend health check, throwing an error if the backend is not healthy after retries.
- Refactored project creation tests to handle file existence checks more reliably.
- Added error handling for missing E2E source fixtures to guide setup process.
- Enhanced memory navigation to handle sandbox dialog visibility more effectively.

* refactor: Enhance Git command execution and improve test configurations

- Updated Git command execution to merge environment paths correctly, ensuring proper command execution context.
- Refactored the Git initialization process to handle errors more gracefully and ensure user configuration is set before creating the initial commit.
- Improved test configurations by updating Playwright test identifiers for better clarity and consistency across different project states.
- Enhanced cleanup functions in tests to handle directory removal more robustly, preventing errors during test execution.

* fix: Resolve React hooks errors from duplicate instances in dependency tree

* style: Format alias configuration for improved readability

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: DhanushSantosh <dhanushsantoshs05@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
gsxdsm
2026-02-27 17:03:29 -08:00
committed by GitHub
parent 70d400793b
commit 0196911d59
234 changed files with 15881 additions and 2916 deletions

View File

@@ -0,0 +1,336 @@
/**
* Unit tests for terminal-theme-colors
* Tests the terminal theme color definitions and override logic
*/
import { describe, it, expect } from 'vitest';
import { terminalThemeColors, getTerminalThemeColors } from '../src/terminal-theme-colors';
import type { ThemeMode } from '@automaker/types';
describe('terminal-theme-colors', () => {
describe('terminalThemeColors', () => {
it('should have dark theme with correct colors', () => {
const theme = terminalThemeColors.dark;
expect(theme.background).toBe('#000000');
expect(theme.foreground).toBe('#ffffff');
expect(theme.cursor).toBe('#ffffff');
});
it('should have light theme with correct colors', () => {
const theme = terminalThemeColors.light;
expect(theme.background).toBe('#ffffff');
expect(theme.foreground).toBe('#383a42');
expect(theme.cursor).toBe('#383a42');
});
it('should have dracula theme with correct colors', () => {
const theme = terminalThemeColors.dracula;
expect(theme.background).toBe('#282a36');
expect(theme.foreground).toBe('#f8f8f2');
expect(theme.cursor).toBe('#f8f8f2');
});
it('should have nord theme with correct colors', () => {
const theme = terminalThemeColors.nord;
expect(theme.background).toBe('#2e3440');
expect(theme.foreground).toBe('#d8dee9');
});
it('should have catppuccin theme with correct colors', () => {
const theme = terminalThemeColors.catppuccin;
expect(theme.background).toBe('#1e1e2e');
expect(theme.foreground).toBe('#cdd6f4');
});
it('should have all required ANSI color properties for each theme', () => {
const requiredColors = [
'black',
'red',
'green',
'yellow',
'blue',
'magenta',
'cyan',
'white',
'brightBlack',
'brightRed',
'brightGreen',
'brightYellow',
'brightBlue',
'brightMagenta',
'brightCyan',
'brightWhite',
];
// Test a few key themes
const themesToTest: ThemeMode[] = ['dark', 'light', 'dracula', 'nord', 'catppuccin'];
for (const themeName of themesToTest) {
const theme = terminalThemeColors[themeName];
for (const color of requiredColors) {
expect(theme, `Theme ${themeName} should have ${color}`).toHaveProperty(color);
expect(typeof theme[color as keyof typeof theme]).toBe('string');
}
}
});
it('should have core theme properties for each theme', () => {
const coreProperties = [
'background',
'foreground',
'cursor',
'cursorAccent',
'selectionBackground',
];
const themesToTest: ThemeMode[] = ['dark', 'light', 'dracula', 'nord', 'catppuccin'];
for (const themeName of themesToTest) {
const theme = terminalThemeColors[themeName];
for (const prop of coreProperties) {
expect(theme, `Theme ${themeName} should have ${prop}`).toHaveProperty(prop);
expect(typeof theme[prop as keyof typeof theme]).toBe('string');
}
}
});
it('should map alias themes to their base themes correctly', () => {
// Forest is mapped to gruvbox
expect(terminalThemeColors.forest).toBe(terminalThemeColors.gruvbox);
// Ocean is mapped to nord
expect(terminalThemeColors.ocean).toBe(terminalThemeColors.nord);
// Ember is mapped to monokai
expect(terminalThemeColors.ember).toBe(terminalThemeColors.monokai);
// Light theme aliases
expect(terminalThemeColors.solarizedlight).toBe(terminalThemeColors.light);
expect(terminalThemeColors.github).toBe(terminalThemeColors.light);
// Cream theme aliases
expect(terminalThemeColors.sand).toBe(terminalThemeColors.cream);
expect(terminalThemeColors.peach).toBe(terminalThemeColors.cream);
});
});
describe('getTerminalThemeColors', () => {
it('should return dark theme by default', () => {
const theme = getTerminalThemeColors('dark');
expect(theme.background).toBe('#000000');
expect(theme.foreground).toBe('#ffffff');
});
it('should return dark theme for unknown theme (fallback)', () => {
const theme = getTerminalThemeColors('unknown-theme' as ThemeMode);
expect(theme.background).toBe('#000000');
expect(theme.foreground).toBe('#ffffff');
});
it('should return light theme for light mode', () => {
const theme = getTerminalThemeColors('light');
expect(theme.background).toBe('#ffffff');
expect(theme.foreground).toBe('#383a42');
});
it('should return correct theme for various theme modes', () => {
const testCases: { theme: ThemeMode; expectedBg: string; expectedFg: string }[] = [
{ theme: 'dark', expectedBg: '#000000', expectedFg: '#ffffff' },
{ theme: 'light', expectedBg: '#ffffff', expectedFg: '#383a42' },
{ theme: 'dracula', expectedBg: '#282a36', expectedFg: '#f8f8f2' },
{ theme: 'nord', expectedBg: '#2e3440', expectedFg: '#d8dee9' },
{ theme: 'tokyonight', expectedBg: '#1a1b26', expectedFg: '#a9b1d6' },
{ theme: 'solarized', expectedBg: '#002b36', expectedFg: '#93a1a1' },
{ theme: 'gruvbox', expectedBg: '#282828', expectedFg: '#ebdbb2' },
{ theme: 'catppuccin', expectedBg: '#1e1e2e', expectedFg: '#cdd6f4' },
{ theme: 'onedark', expectedBg: '#282c34', expectedFg: '#abb2bf' },
{ theme: 'monokai', expectedBg: '#272822', expectedFg: '#f8f8f2' },
{ theme: 'retro', expectedBg: '#000000', expectedFg: '#39ff14' },
{ theme: 'synthwave', expectedBg: '#262335', expectedFg: '#ffffff' },
{ theme: 'red', expectedBg: '#1a0a0a', expectedFg: '#c8b0b0' },
{ theme: 'cream', expectedBg: '#f5f3ee', expectedFg: '#5a4a3a' },
{ theme: 'sunset', expectedBg: '#1e1a24', expectedFg: '#f2e8dd' },
{ theme: 'gray', expectedBg: '#2a2d32', expectedFg: '#d0d0d5' },
];
for (const { theme, expectedBg, expectedFg } of testCases) {
const result = getTerminalThemeColors(theme);
expect(result.background, `Theme ${theme} background`).toBe(expectedBg);
expect(result.foreground, `Theme ${theme} foreground`).toBe(expectedFg);
}
});
});
describe('Custom color override scenario', () => {
it('should allow creating custom theme with background override', () => {
const baseTheme = getTerminalThemeColors('dark');
const customBgColor = '#1a1a2e';
// Simulate the override logic from terminal-panel.tsx
const customTheme = {
...baseTheme,
background: customBgColor,
};
expect(customTheme.background).toBe('#1a1a2e');
expect(customTheme.foreground).toBe('#ffffff'); // Should keep original
expect(customTheme.cursor).toBe('#ffffff'); // Should preserve cursor
});
it('should allow creating custom theme with foreground override', () => {
const baseTheme = getTerminalThemeColors('dark');
const customFgColor = '#e0e0e0';
const customTheme = {
...baseTheme,
foreground: customFgColor,
};
expect(customTheme.background).toBe('#000000'); // Should keep original
expect(customTheme.foreground).toBe('#e0e0e0');
});
it('should allow creating custom theme with both overrides', () => {
const baseTheme = getTerminalThemeColors('dark');
const customBgColor = '#1a1a2e';
const customFgColor = '#e0e0e0';
const customTheme = {
...baseTheme,
background: customBgColor,
foreground: customFgColor,
};
expect(customTheme.background).toBe('#1a1a2e');
expect(customTheme.foreground).toBe('#e0e0e0');
expect(customTheme.cursor).toBe('#ffffff'); // Should preserve cursor
expect(customTheme.red).toBe('#f44747'); // Should preserve ANSI colors
});
it('should handle null custom colors (use base theme)', () => {
const baseTheme = getTerminalThemeColors('dark');
const customBgColor: string | null = null;
const customFgColor: string | null = null;
// Simulate the override logic from terminal-panel.tsx
const customTheme =
customBgColor || customFgColor
? {
...baseTheme,
...(customBgColor && { background: customBgColor }),
...(customFgColor && { foreground: customFgColor }),
}
: baseTheme;
expect(customTheme.background).toBe('#000000'); // Should use base
expect(customTheme.foreground).toBe('#ffffff'); // Should use base
});
it('should handle only background color set', () => {
const baseTheme = getTerminalThemeColors('dark');
const customBgColor = '#1a1a2e';
const customFgColor: string | null = null;
const customTheme =
customBgColor || customFgColor
? {
...baseTheme,
...(customBgColor && { background: customBgColor }),
...(customFgColor && { foreground: customFgColor }),
}
: baseTheme;
expect(customTheme.background).toBe('#1a1a2e');
expect(customTheme.foreground).toBe('#ffffff'); // Should keep base
});
it('should handle only foreground color set', () => {
const baseTheme = getTerminalThemeColors('dark');
const customBgColor: string | null = null;
const customFgColor = '#e0e0e0';
const customTheme =
customBgColor || customFgColor
? {
...baseTheme,
...(customBgColor && { background: customBgColor }),
...(customFgColor && { foreground: customFgColor }),
}
: baseTheme;
expect(customTheme.background).toBe('#000000'); // Should keep base
expect(customTheme.foreground).toBe('#e0e0e0');
});
it('should work with light theme as base', () => {
const baseTheme = getTerminalThemeColors('light');
const customBgColor = '#f0f0f0';
const customFgColor = '#333333';
const customTheme = {
...baseTheme,
background: customBgColor,
foreground: customFgColor,
};
expect(customTheme.background).toBe('#f0f0f0');
expect(customTheme.foreground).toBe('#333333');
expect(customTheme.cursor).toBe('#383a42'); // Should preserve light theme cursor
});
it('should preserve all theme properties when overriding', () => {
const baseTheme = getTerminalThemeColors('dracula');
const customBgColor = '#1a1a2e';
const customFgColor = '#e0e0e0';
const customTheme = {
...baseTheme,
background: customBgColor,
foreground: customFgColor,
};
// Verify all other properties are preserved
expect(customTheme.cursor).toBe('#f8f8f2'); // Dracula cursor
expect(customTheme.cursorAccent).toBe('#282a36');
expect(customTheme.selectionBackground).toBe('#44475a');
expect(customTheme.red).toBe('#ff5555');
expect(customTheme.green).toBe('#50fa7b');
expect(customTheme.blue).toBe('#bd93f9');
});
it('should handle race condition scenario: read from store takes priority', () => {
// This test documents the fix for the race condition where:
// 1. Terminal component mounts
// 2. useShallow subscription might have stale values (null)
// 3. Store is hydrated with actual values from server
// 4. Reading from getState() gives us the latest values
const baseTheme = getTerminalThemeColors('dark');
// Simulate stale subscription values (null)
const staleSubscriptionBg: string | null = null;
const staleSubscriptionFg: string | null = null;
// Simulate fresh store values (actual colors)
const freshStoreBg = '#1a1a2e';
const freshStoreFg = '#e0e0e0';
// The fix: prioritize store values over subscription values
const actualBg = freshStoreBg; // Use store value, not subscription
const actualFg = freshStoreFg; // Use store value, not subscription
const customTheme =
actualBg || actualFg
? {
...baseTheme,
...(actualBg && { background: actualBg }),
...(actualFg && { foreground: actualFg }),
}
: baseTheme;
// Verify we get the fresh store values, not the stale subscription values
expect(customTheme.background).toBe('#1a1a2e');
expect(customTheme.foreground).toBe('#e0e0e0');
});
});
});

View File

@@ -254,6 +254,9 @@ This feature depends on: {{dependencies}}
**CRITICAL - Port Protection:**
NEVER kill or terminate processes running on ports ${STATIC_PORT} or ${SERVER_PORT}. These are reserved for the Automaker application. Killing these ports will crash Automaker and terminate this session.
**CRITICAL - Process Protection:**
NEVER run \`pkill -f "vite"\` or \`pkill -f "tsx"\` or any broad process-killing commands targeting development server processes. These commands will kill the Automaker application itself and terminate your session. If you need to debug tests, use targeted approaches such as running specific test files, using test runner flags, or restarting individual processes through proper channels.
`;
export const DEFAULT_AUTO_MODE_FOLLOW_UP_PROMPT_TEMPLATE = `## Follow-up on Feature Implementation
@@ -365,6 +368,9 @@ You have access to several tools:
**CRITICAL - Port Protection:**
NEVER kill or terminate processes running on ports ${STATIC_PORT} or ${SERVER_PORT}. These are reserved for the Automaker application itself. Killing these ports will crash Automaker and terminate your session.
**CRITICAL - Process Protection:**
NEVER run \`pkill -f "vite"\` or \`pkill -f "tsx"\` or any broad process-killing commands targeting development server processes. These commands will kill the Automaker application itself and terminate your session. If you need to debug tests, use targeted approaches such as running specific test files, using test runner flags, or restarting individual processes through proper channels.
Remember: You're a collaborative partner in the development process. Be helpful, clear, and thorough.`;
/**

View File

@@ -7,7 +7,8 @@
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"watch": "tsc --watch"
"watch": "tsc --watch",
"test": "vitest"
},
"keywords": [
"automaker",
@@ -20,6 +21,7 @@
},
"devDependencies": {
"@types/node": "22.19.3",
"typescript": "5.9.3"
"typescript": "5.9.3",
"vitest": "^3.0.0"
}
}

View File

@@ -46,6 +46,7 @@ export type EventType =
| 'worktree:init-started'
| 'worktree:init-output'
| 'worktree:init-completed'
| 'dev-server:starting'
| 'dev-server:started'
| 'dev-server:output'
| 'dev-server:url-detected'

View File

@@ -98,6 +98,7 @@ export interface Feature {
excludedPipelineSteps?: string[]; // Array of pipeline step IDs to skip for this feature
thinkingLevel?: ThinkingLevel;
reasoningEffort?: ReasoningEffort;
providerId?: string;
planningMode?: PlanningMode;
requirePlanApproval?: boolean;
planSpec?: PlanSpec;

View File

@@ -94,6 +94,7 @@ export {
CODEX_MODEL_IDS,
REASONING_CAPABLE_MODELS,
supportsReasoningEffort,
normalizeReasoningEffortForModel,
getAllCodexModelIds,
DEFAULT_MODELS,
type ClaudeCanonicalId,
@@ -285,6 +286,7 @@ export {
normalizeModelString,
validateBareModelId,
supportsStructuredOutput,
PROVIDER_PREFIX_EXCEPTIONS,
} from './provider-utils.js';
// Model migration utilities

View File

@@ -60,6 +60,8 @@ export interface IssueValidationInput {
issueTitle: string;
issueBody: string;
issueLabels?: string[];
/** Optional Claude-compatible provider ID (for custom providers like GLM/MiniMax) */
providerId?: string;
/** Comments to include in validation analysis */
comments?: GitHubComment[];
/** Linked pull requests for this issue */

View File

@@ -101,6 +101,20 @@ export function supportsReasoningEffort(modelId: string): boolean {
return REASONING_CAPABLE_MODELS.has(modelId as any);
}
/**
* Normalize a selected reasoning effort level to a value supported by the target model.
* Returns 'none' for models that do not support reasoning effort.
*/
export function normalizeReasoningEffortForModel(
model: string,
reasoningEffort: import('./provider.js').ReasoningEffort | undefined
): import('./provider.js').ReasoningEffort {
if (!supportsReasoningEffort(model)) {
return 'none';
}
return reasoningEffort || 'none';
}
/**
* Get all Codex model IDs as an array
*/

View File

@@ -32,6 +32,7 @@ export function isPipelineStatus(status: string | null | undefined): status is P
export type FeatureStatusWithPipeline =
| 'backlog'
| 'merge_conflict'
| 'ready'
| 'in_progress'
| 'interrupted'

View File

@@ -26,6 +26,26 @@ export const PROVIDER_PREFIXES = {
copilot: 'copilot-',
} as const;
/**
* Provider prefix exceptions map
*
* Some providers legitimately use model IDs that start with other providers' prefixes.
* For example, Cursor's Gemini models (e.g., "gemini-3-pro") start with "gemini-" prefix
* but are Cursor models, not Gemini models.
*
* Key: The provider receiving the model (expectedProvider)
* Value: Array of provider prefixes to skip validation for
*
* @example
* // Cursor provider can receive model IDs starting with "gemini-" prefix
* PROVIDER_PREFIX_EXCEPTIONS.cursor.includes('gemini') === true
*/
export const PROVIDER_PREFIX_EXCEPTIONS: Partial<
Record<ModelProvider, readonly (keyof typeof PROVIDER_PREFIXES)[]>
> = {
cursor: ['gemini'],
};
/**
* Check if a model string represents a Cursor model
*
@@ -399,20 +419,45 @@ export function supportsStructuredOutput(model: string | undefined | null): bool
* This validation ensures the ProviderFactory properly stripped prefixes before
* passing models to providers.
*
* NOTE: Some providers use model IDs that may start with other providers' prefixes
* (e.g., Cursor's "gemini-3-pro" starts with "gemini-" but is a Cursor model, not a Gemini model).
* These exceptions are configured in PROVIDER_PREFIX_EXCEPTIONS.
*
* @param model - Model ID to validate
* @param providerName - Name of the provider for error messages
* @throws Error if model contains a provider prefix
* @param providerName - Name of the provider receiving this model (for error messages)
* @param expectedProvider - The provider type expected to receive this model (e.g., "cursor", "gemini")
* @throws Error if model contains a provider prefix that doesn't match the expected provider
* @returns void
*
* @example
* validateBareModelId("gpt-5.1-codex-max", "CodexProvider"); // ✅ OK
* validateBareModelId("codex-gpt-5.1-codex-max", "CodexProvider"); // ❌ Throws error
* validateBareModelId("gpt-5.1-codex-max", "CodexProvider", "codex"); // ✅ OK
* validateBareModelId("codex-gpt-5.1-codex-max", "CodexProvider", "codex"); // ❌ Throws error
* validateBareModelId("gemini-3-pro", "CursorProvider", "cursor"); // ✅ OK (Cursor Gemini model)
* validateBareModelId("gemini-3-pro", "GeminiProvider", "gemini"); // ✅ OK (Gemini model)
*/
export function validateBareModelId(model: string, providerName: string): void {
export function validateBareModelId(
model: string,
providerName: string,
expectedProvider?: ModelProvider
): void {
if (!model || typeof model !== 'string') {
throw new Error(`[${providerName}] Invalid model ID: expected string, got ${typeof model}`);
}
for (const [provider, prefix] of Object.entries(PROVIDER_PREFIXES)) {
for (const provider of Object.keys(PROVIDER_PREFIXES) as Array<keyof typeof PROVIDER_PREFIXES>) {
const prefix = PROVIDER_PREFIXES[provider];
// Skip validation for configured provider prefix exceptions
// (e.g., Cursor provider can receive models with "gemini-" prefix for Cursor Gemini models)
if (expectedProvider && PROVIDER_PREFIX_EXCEPTIONS[expectedProvider]?.includes(provider)) {
continue;
}
// Skip validation if the model has the expected provider's own prefix
// (e.g., Gemini provider can receive models with "gemini-" prefix)
if (expectedProvider && provider === expectedProvider) {
continue;
}
if (model.startsWith(prefix)) {
throw new Error(
`[${providerName}] Model ID should not contain provider prefix '${prefix}'. ` +

View File

@@ -0,0 +1,19 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`provider-utils.ts > validateBareModelId > with expectedProvider parameter > should reject non-matching provider prefixes even with expectedProvider set 1`] = `[Error: [CursorProvider] Model ID should not contain provider prefix 'codex-'. Got: 'codex-gpt-4'. This is likely a bug in ProviderFactory - it should strip the 'codex' prefix before passing the model to the provider.]`;
exports[`provider-utils.ts > validateBareModelId > with expectedProvider parameter > should reject non-matching provider prefixes even with expectedProvider set 2`] = `[Error: [GeminiProvider] Model ID should not contain provider prefix 'cursor-'. Got: 'cursor-gpt-4'. This is likely a bug in ProviderFactory - it should strip the 'cursor' prefix before passing the model to the provider.]`;
exports[`provider-utils.ts > validateBareModelId > without expectedProvider parameter > should reject model IDs with codex- prefix 1`] = `[Error: [TestProvider] Model ID should not contain provider prefix 'codex-'. Got: 'codex-gpt-4'. This is likely a bug in ProviderFactory - it should strip the 'codex' prefix before passing the model to the provider.]`;
exports[`provider-utils.ts > validateBareModelId > without expectedProvider parameter > should reject model IDs with copilot- prefix 1`] = `[Error: [TestProvider] Model ID should not contain provider prefix 'copilot-'. Got: 'copilot-gpt-4'. This is likely a bug in ProviderFactory - it should strip the 'copilot' prefix before passing the model to the provider.]`;
exports[`provider-utils.ts > validateBareModelId > without expectedProvider parameter > should reject model IDs with cursor- prefix 1`] = `[Error: [TestProvider] Model ID should not contain provider prefix 'cursor-'. Got: 'cursor-gpt-4'. This is likely a bug in ProviderFactory - it should strip the 'cursor' prefix before passing the model to the provider.]`;
exports[`provider-utils.ts > validateBareModelId > without expectedProvider parameter > should reject model IDs with cursor- prefix 2`] = `[Error: [TestProvider] Model ID should not contain provider prefix 'cursor-'. Got: 'cursor-composer-1'. This is likely a bug in ProviderFactory - it should strip the 'cursor' prefix before passing the model to the provider.]`;
exports[`provider-utils.ts > validateBareModelId > without expectedProvider parameter > should reject model IDs with gemini- prefix 1`] = `[Error: [TestProvider] Model ID should not contain provider prefix 'gemini-'. Got: 'gemini-2.5-flash'. This is likely a bug in ProviderFactory - it should strip the 'gemini' prefix before passing the model to the provider.]`;
exports[`provider-utils.ts > validateBareModelId > without expectedProvider parameter > should reject model IDs with gemini- prefix 2`] = `[Error: [TestProvider] Model ID should not contain provider prefix 'gemini-'. Got: 'gemini-2.5-pro'. This is likely a bug in ProviderFactory - it should strip the 'gemini' prefix before passing the model to the provider.]`;
exports[`provider-utils.ts > validateBareModelId > without expectedProvider parameter > should reject model IDs with opencode- prefix 1`] = `[Error: [TestProvider] Model ID should not contain provider prefix 'opencode-'. Got: 'opencode-gpt-4'. This is likely a bug in ProviderFactory - it should strip the 'opencode' prefix before passing the model to the provider.]`;

View File

@@ -0,0 +1,285 @@
import { describe, it, expect } from 'vitest';
import {
validateBareModelId,
stripProviderPrefix,
isCursorModel,
isGeminiModel,
isCodexModel,
isCopilotModel,
isOpencodeModel,
PROVIDER_PREFIXES,
type ModelProvider,
} from '@automaker/types';
describe('provider-utils.ts', () => {
describe('validateBareModelId', () => {
describe('without expectedProvider parameter', () => {
it('should accept valid bare model IDs', () => {
expect(() => validateBareModelId('gpt-4', 'TestProvider')).not.toThrow();
expect(() => validateBareModelId('claude-3-opus', 'TestProvider')).not.toThrow();
expect(() => validateBareModelId('2.5-flash', 'TestProvider')).not.toThrow();
expect(() => validateBareModelId('composer-1', 'TestProvider')).not.toThrow();
});
it('should reject model IDs with cursor- prefix', () => {
expect(() =>
validateBareModelId('cursor-gpt-4', 'TestProvider')
).toThrowErrorMatchingSnapshot();
expect(() =>
validateBareModelId('cursor-composer-1', 'TestProvider')
).toThrowErrorMatchingSnapshot();
});
it('should reject model IDs with gemini- prefix', () => {
expect(() =>
validateBareModelId('gemini-2.5-flash', 'TestProvider')
).toThrowErrorMatchingSnapshot();
expect(() =>
validateBareModelId('gemini-2.5-pro', 'TestProvider')
).toThrowErrorMatchingSnapshot();
});
it('should reject model IDs with codex- prefix', () => {
expect(() =>
validateBareModelId('codex-gpt-4', 'TestProvider')
).toThrowErrorMatchingSnapshot();
});
it('should reject model IDs with copilot- prefix', () => {
expect(() =>
validateBareModelId('copilot-gpt-4', 'TestProvider')
).toThrowErrorMatchingSnapshot();
});
it('should reject model IDs with opencode- prefix', () => {
expect(() =>
validateBareModelId('opencode-gpt-4', 'TestProvider')
).toThrowErrorMatchingSnapshot();
});
it('should throw error for non-string model ID', () => {
// @ts-expect-error - testing invalid input
expect(() => validateBareModelId(null, 'TestProvider')).toThrow(
'[TestProvider] Invalid model ID: expected string, got object'
);
// @ts-expect-error - testing invalid input
expect(() => validateBareModelId(undefined, 'TestProvider')).toThrow(
'[TestProvider] Invalid model ID: expected string, got undefined'
);
// @ts-expect-error - testing invalid input
expect(() => validateBareModelId(123, 'TestProvider')).toThrow(
'[TestProvider] Invalid model ID: expected string, got number'
);
});
it('should throw error for empty string model ID', () => {
expect(() => validateBareModelId('', 'TestProvider')).toThrow(
'[TestProvider] Invalid model ID: expected string, got string'
);
});
});
describe('with expectedProvider parameter', () => {
it('should allow cursor- prefixed models when expectedProvider is "cursor"', () => {
expect(() => validateBareModelId('cursor-gpt-4', 'CursorProvider', 'cursor')).not.toThrow();
expect(() =>
validateBareModelId('cursor-composer-1', 'CursorProvider', 'cursor')
).not.toThrow();
});
it('should allow gemini- prefixed models when expectedProvider is "gemini"', () => {
expect(() =>
validateBareModelId('gemini-2.5-flash', 'GeminiProvider', 'gemini')
).not.toThrow();
expect(() =>
validateBareModelId('gemini-2.5-pro', 'GeminiProvider', 'gemini')
).not.toThrow();
});
it('should allow codex- prefixed models when expectedProvider is "codex"', () => {
expect(() => validateBareModelId('codex-gpt-4', 'CodexProvider', 'codex')).not.toThrow();
});
it('should allow copilot- prefixed models when expectedProvider is "copilot"', () => {
expect(() =>
validateBareModelId('copilot-gpt-4', 'CopilotProvider', 'copilot')
).not.toThrow();
});
it('should allow opencode- prefixed models when expectedProvider is "opencode"', () => {
expect(() =>
validateBareModelId('opencode-gpt-4', 'OpencodeProvider', 'opencode')
).not.toThrow();
});
describe('Cursor Gemini models edge case', () => {
it('should allow gemini- prefixed models for Cursor provider when expectedProvider is "cursor"', () => {
// This is the key fix for Cursor Gemini models
// Cursor's Gemini models have bare IDs like "gemini-3-pro" that start with "gemini-"
// but they're Cursor models, not Gemini models
expect(() =>
validateBareModelId('gemini-3-pro', 'CursorProvider', 'cursor')
).not.toThrow();
expect(() =>
validateBareModelId('gemini-3-flash', 'CursorProvider', 'cursor')
).not.toThrow();
});
it('should still reject other provider prefixes for Cursor provider', () => {
// Cursor should NOT receive models with codex- prefix
expect(() => validateBareModelId('codex-gpt-4', 'CursorProvider', 'cursor')).toThrow();
// Cursor should NOT receive models with copilot- prefix
expect(() => validateBareModelId('copilot-gpt-4', 'CursorProvider', 'cursor')).toThrow();
});
it('should allow gemini- prefixed models for Gemini provider when expectedProvider is "gemini"', () => {
// Gemini provider should also be able to receive its own prefixed models
expect(() =>
validateBareModelId('gemini-2.5-flash', 'GeminiProvider', 'gemini')
).not.toThrow();
expect(() =>
validateBareModelId('gemini-2.5-pro', 'GeminiProvider', 'gemini')
).not.toThrow();
});
});
it('should reject non-matching provider prefixes even with expectedProvider set', () => {
// Even with expectedProvider set to "cursor", should still reject "codex-" prefix
expect(() =>
validateBareModelId('codex-gpt-4', 'CursorProvider', 'cursor')
).toThrowErrorMatchingSnapshot();
// Even with expectedProvider set to "gemini", should still reject "cursor-" prefix
expect(() =>
validateBareModelId('cursor-gpt-4', 'GeminiProvider', 'gemini')
).toThrowErrorMatchingSnapshot();
});
});
});
describe('stripProviderPrefix', () => {
it('should strip cursor- prefix from Cursor models', () => {
expect(stripProviderPrefix('cursor-gpt-4')).toBe('gpt-4');
expect(stripProviderPrefix('cursor-composer-1')).toBe('composer-1');
expect(stripProviderPrefix('cursor-gemini-3-pro')).toBe('gemini-3-pro');
});
it('should strip gemini- prefix from Gemini models', () => {
expect(stripProviderPrefix('gemini-2.5-flash')).toBe('2.5-flash');
expect(stripProviderPrefix('gemini-2.5-pro')).toBe('2.5-pro');
});
it('should strip codex- prefix from Codex models', () => {
expect(stripProviderPrefix('codex-gpt-4')).toBe('gpt-4');
});
it('should strip copilot- prefix from Copilot models', () => {
expect(stripProviderPrefix('copilot-gpt-4')).toBe('gpt-4');
});
it('should strip opencode- prefix from Opencode models', () => {
expect(stripProviderPrefix('opencode-gpt-4')).toBe('gpt-4');
});
it('should return unchanged model ID if no provider prefix', () => {
expect(stripProviderPrefix('gpt-4')).toBe('gpt-4');
expect(stripProviderPrefix('claude-3-opus')).toBe('claude-3-opus');
expect(stripProviderPrefix('2.5-flash')).toBe('2.5-flash');
});
it('should only strip the first matching prefix', () => {
// cursor-gemini-3-pro has both cursor- and gemini- prefixes
// Should strip cursor- first (it's checked first in PROVIDER_PREFIXES)
expect(stripProviderPrefix('cursor-gemini-3-pro')).toBe('gemini-3-pro');
});
it('should handle empty string', () => {
expect(stripProviderPrefix('')).toBe('');
});
});
describe('Model identification functions', () => {
describe('isCursorModel', () => {
it('should return true for Cursor models', () => {
expect(isCursorModel('cursor-gpt-4')).toBe(true);
expect(isCursorModel('cursor-composer-1')).toBe(true);
expect(isCursorModel('cursor-gemini-3-pro')).toBe(true);
expect(isCursorModel('cursor-gemini-3-flash')).toBe(true);
});
it('should return false for non-Cursor models', () => {
expect(isCursorModel('gpt-4')).toBe(false);
expect(isCursorModel('gemini-2.5-flash')).toBe(false);
expect(isCursorModel('codex-gpt-4')).toBe(false);
});
});
describe('isGeminiModel', () => {
it('should return true for Gemini models', () => {
expect(isGeminiModel('gemini-2.5-flash')).toBe(true);
expect(isGeminiModel('gemini-2.5-pro')).toBe(true);
expect(isGeminiModel('gemini-1.5-flash')).toBe(true);
});
it('should return false for Cursor Gemini models (they are Cursor models, not Gemini models)', () => {
expect(isGeminiModel('cursor-gemini-3-pro')).toBe(false);
expect(isGeminiModel('cursor-gemini-3-flash')).toBe(false);
});
it('should return false for non-Gemini models', () => {
expect(isGeminiModel('gpt-4')).toBe(false);
expect(isGeminiModel('cursor-gpt-4')).toBe(false);
expect(isGeminiModel('codex-gpt-4')).toBe(false);
});
});
describe('isCodexModel', () => {
it('should return true for Codex models', () => {
expect(isCodexModel('codex-gpt-4')).toBe(true);
expect(isCodexModel('codex-gpt-5.1-codex-max')).toBe(true);
});
it('should return false for non-Codex models', () => {
// Note: gpt- models ARE Codex models according to the implementation
// because bare gpt models go to Codex, not Cursor
expect(isCodexModel('cursor-gpt-4')).toBe(false);
expect(isCodexModel('gemini-2.5-flash')).toBe(false);
expect(isCodexModel('claude-3-opus')).toBe(false);
});
});
describe('isCopilotModel', () => {
it('should return true for Copilot models', () => {
expect(isCopilotModel('copilot-gpt-4')).toBe(true);
});
it('should return false for non-Copilot models', () => {
expect(isCopilotModel('gpt-4')).toBe(false);
expect(isCopilotModel('cursor-gpt-4')).toBe(false);
});
});
describe('isOpencodeModel', () => {
it('should return true for Opencode models', () => {
expect(isOpencodeModel('opencode-gpt-4')).toBe(true);
});
it('should return false for non-Opencode models', () => {
expect(isOpencodeModel('gpt-4')).toBe(false);
expect(isOpencodeModel('cursor-gpt-4')).toBe(false);
});
});
});
describe('PROVIDER_PREFIXES', () => {
it('should contain all expected provider prefixes', () => {
expect(PROVIDER_PREFIXES).toEqual({
cursor: 'cursor-',
gemini: 'gemini-',
codex: 'codex-',
copilot: 'copilot-',
opencode: 'opencode-',
});
});
});
});

View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
name: 'types',
reporters: ['verbose'],
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.d.ts'],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
});

View File

@@ -98,17 +98,15 @@ export async function atomicWriteJson<T>(
data: T,
options: AtomicWriteOptions = {}
): Promise<void> {
const { indent = 2, createDirs = false, backupCount = 0 } = options;
const { indent = 2, backupCount = 0 } = options;
const resolvedPath = path.resolve(filePath);
// Use timestamp + random suffix to ensure uniqueness even for concurrent writes
const uniqueSuffix = `${Date.now()}.${crypto.randomBytes(4).toString('hex')}`;
const tempPath = `${resolvedPath}.tmp.${uniqueSuffix}`;
// Create parent directories if requested
if (createDirs) {
const dirPath = path.dirname(resolvedPath);
await mkdirSafe(dirPath);
}
// Always ensure parent directories exist before writing the temp file
const dirPath = path.dirname(resolvedPath);
await mkdirSafe(dirPath);
const content = JSON.stringify(data, null, indent);

View File

@@ -15,12 +15,16 @@ export async function mkdirSafe(dirPath: string): Promise<void> {
// Check if path already exists using lstat (doesn't follow symlinks)
try {
const stats = await secureFs.lstat(resolvedPath);
// Path exists - if it's a directory or symlink, consider it success
if (stats.isDirectory() || stats.isSymbolicLink()) {
// Guard: some environments (e.g. mocked fs) may return undefined
if (stats == null) {
// Treat as path does not exist, fall through to create
} else if (stats.isDirectory() || stats.isSymbolicLink()) {
// Path exists - if it's a directory or symlink, consider it success
return;
} else {
// It's a file - can't create directory
throw new Error(`Path exists and is not a directory: ${resolvedPath}`);
}
// It's a file - can't create directory
throw new Error(`Path exists and is not a directory: ${resolvedPath}`);
} catch (error: any) {
// ENOENT means path doesn't exist - we should create it
if (error.code !== 'ENOENT') {

View File

@@ -42,6 +42,11 @@ describe('atomic-writer.ts', () => {
// Create a temporary directory for integration tests
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'atomic-writer-test-'));
vi.clearAllMocks();
// Default: parent directory exists (atomicWriteJson always ensures parent dir)
(secureFs.lstat as unknown as MockInstance).mockResolvedValue({
isDirectory: () => true,
isSymbolicLink: () => false,
});
});
afterEach(async () => {
@@ -173,20 +178,25 @@ describe('atomic-writer.ts', () => {
expect((secureFs.writeFile as unknown as MockInstance).mock.calls[2][1]).toBe('123');
});
it('should create directories when createDirs is true', async () => {
it('should always create parent directories before writing', async () => {
const filePath = path.join(tempDir, 'nested', 'deep', 'test.json');
const data = { key: 'value' };
// Mock lstat to throw ENOENT (directory doesn't exist)
const enoentError = new Error('Not found') as NodeJS.ErrnoException;
enoentError.code = 'ENOENT';
(secureFs.lstat as unknown as MockInstance).mockRejectedValue(enoentError);
(secureFs.mkdir as unknown as MockInstance).mockResolvedValue(undefined);
(secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined);
(secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined);
// Mock lstat to indicate directory already exists
(secureFs.lstat as unknown as MockInstance).mockResolvedValue({
isDirectory: () => true,
isSymbolicLink: () => false,
});
await atomicWriteJson(filePath, data, { createDirs: true });
await atomicWriteJson(filePath, data);
// Should have called mkdir to create parent directories
expect(secureFs.mkdir).toHaveBeenCalledWith(
path.resolve(path.join(tempDir, 'nested', 'deep')),
{ recursive: true }
);
expect(secureFs.writeFile).toHaveBeenCalled();
});
});