Merge pull request #841 from AutoMaker-Org/v1.0.0rc

V1.0.0rc
This commit is contained in:
Web Dev Cody
2026-03-15 12:58:24 -04:00
committed by GitHub
323 changed files with 33607 additions and 2173 deletions

14
.geminiignore Normal file
View File

@@ -0,0 +1,14 @@
# Auto-generated by Automaker to speed up Gemini CLI startup
# Prevents Gemini CLI from scanning large directories during context discovery
.git
node_modules
dist
build
.next
.nuxt
coverage
.automaker
.worktrees
.vscode
.idea
*.lock

View File

@@ -13,6 +13,13 @@ jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
# shardIndex: [1, 2, 3]
# shardTotal: [3]
shardIndex: [1]
shardTotal: [1]
steps:
- name: Checkout code
@@ -91,7 +98,7 @@ jobs:
curl -s http://localhost:3108/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3108/api/health 2>/dev/null || echo 'No response')"
exit 0
fi
# Check if server process is still running
if ! kill -0 $SERVER_PID 2>/dev/null; then
echo "ERROR: Server process died during wait!"
@@ -99,7 +106,7 @@ jobs:
cat backend.log
exit 1
fi
echo "Waiting... ($i/60)"
sleep 1
done
@@ -127,17 +134,23 @@ jobs:
exit 1
- name: Run E2E tests
- name: Run E2E tests (shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
# Playwright automatically starts the Vite frontend via webServer config
# (see apps/ui/playwright.config.ts) - no need to start it manually
run: npm run test --workspace=apps/ui
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
working-directory: apps/ui
env:
CI: true
VITE_SERVER_URL: http://localhost:3108
SERVER_URL: http://localhost:3108
VITE_SKIP_SETUP: 'true'
# Keep UI-side login/defaults consistent
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
# Backend is already started above - Playwright config sets
# AUTOMAKER_SERVER_PORT so the Vite proxy forwards /api/* to the backend.
# Do NOT set VITE_SERVER_URL here: it bypasses the Vite proxy and causes
# a cookie domain mismatch (cookies are bound to 127.0.0.1, but
# VITE_SERVER_URL=http://localhost:3108 makes the frontend call localhost).
TEST_USE_EXTERNAL_BACKEND: 'true'
TEST_SERVER_PORT: 3108
- name: Print backend logs on failure
if: failure()
@@ -155,7 +168,7 @@ jobs:
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
name: playwright-report-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }}
path: apps/ui/playwright-report/
retention-days: 7
@@ -163,12 +176,21 @@ jobs:
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
name: test-results-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }}
path: |
apps/ui/test-results/
retention-days: 7
if-no-files-found: ignore
- name: Upload blob report for merging
uses: actions/upload-artifact@v4
if: always()
with:
name: blob-report-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }}
path: apps/ui/blob-report/
retention-days: 1
if-no-files-found: ignore
- name: Cleanup - Kill backend server
if: always()
run: |

12
.gitignore vendored
View File

@@ -65,6 +65,17 @@ coverage/
*.lcov
playwright-report/
blob-report/
test/**/test-project-[0-9]*/
test/opus-thinking-*/
test/agent-session-test-*/
test/feature-backlog-test-*/
test/running-task-display-test-*/
test/agent-output-modal-responsive-*/
test/fixtures/
test/board-bg-test-*/
test/edit-feature-test-*/
test/open-project-test-*/
# Environment files (keep .example)
.env
@@ -102,3 +113,4 @@ data/
.planning/
.mcp.json
.planning
.bg-shell/

View File

@@ -1,6 +1,6 @@
{
"name": "@automaker/server",
"version": "0.15.0",
"version": "1.0.0",
"description": "Backend server for Automaker - provides API for both web and Electron modes",
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
@@ -32,7 +32,7 @@
"@automaker/prompts": "1.0.0",
"@automaker/types": "1.0.0",
"@automaker/utils": "1.0.0",
"@github/copilot-sdk": "^0.1.16",
"@github/copilot-sdk": "0.1.16",
"@modelcontextprotocol/sdk": "1.25.2",
"@openai/codex-sdk": "^0.98.0",
"cookie-parser": "1.4.7",

View File

@@ -261,7 +261,10 @@ morgan.token('status-colored', (_req, res) => {
app.use(
morgan(':method :url :status-colored', {
// Skip when request logging is disabled or for health check endpoints
skip: (req) => !requestLoggingEnabled || req.url === '/api/health',
skip: (req) =>
!requestLoggingEnabled ||
req.url === '/api/health' ||
req.url === '/api/auto-mode/context-exists',
})
);
// CORS configuration
@@ -349,7 +352,9 @@ const ideationService = new IdeationService(events, settingsService, featureLoad
// Initialize DevServerService with event emitter for real-time log streaming
const devServerService = getDevServerService();
devServerService.setEventEmitter(events);
devServerService.initialize(DATA_DIR, events).catch((err) => {
logger.error('Failed to initialize DevServerService:', err);
});
// Initialize Notification Service with event emitter for real-time updates
const notificationService = getNotificationService();
@@ -434,21 +439,18 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur
logger.info('[STARTUP] Feature state reconciliation complete - no stale states found');
}
// Resume interrupted features in the background after reconciliation.
// This uses the saved execution state to identify features that were running
// before the restart (their statuses have been reset to ready/backlog by
// reconciliation above). Running in background so it doesn't block startup.
if (totalReconciled > 0) {
for (const project of globalSettings.projects) {
autoModeService.resumeInterruptedFeatures(project.path).catch((err) => {
logger.warn(
`[STARTUP] Failed to resume interrupted features for ${project.path}:`,
err
);
});
}
logger.info('[STARTUP] Initiated background resume of interrupted features');
// Resume interrupted features in the background for all projects.
// This handles features stuck in transient states (in_progress, pipeline_*)
// or explicitly marked as interrupted. Running in background so it doesn't block startup.
for (const project of globalSettings.projects) {
autoModeService.resumeInterruptedFeatures(project.path).catch((err) => {
logger.warn(
`[STARTUP] Failed to resume interrupted features for ${project.path}:`,
err
);
});
}
logger.info('[STARTUP] Initiated background resume of interrupted features');
}
} catch (err) {
logger.warn('[STARTUP] Failed to reconcile feature states:', err);
@@ -494,7 +496,7 @@ app.use(
);
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
app.use('/api/worktree', createWorktreeRoutes(events, settingsService, featureLoader));
app.use('/api/git', createGitRoutes());
app.use('/api/models', createModelsRoutes());
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
@@ -596,24 +598,23 @@ wss.on('connection', (ws: WebSocket) => {
// Subscribe to all events and forward to this client
const unsubscribe = events.subscribe((type, payload) => {
logger.info('Event received:', {
// Use debug level for high-frequency events to avoid log spam
// that causes progressive memory growth and server slowdown
const isHighFrequency =
type === 'dev-server:output' || type === 'test-runner:output' || type === 'feature:progress';
const log = isHighFrequency ? logger.debug.bind(logger) : logger.info.bind(logger);
log('Event received:', {
type,
hasPayload: !!payload,
payloadKeys: payload ? Object.keys(payload) : [],
wsReadyState: ws.readyState,
wsOpen: ws.readyState === WebSocket.OPEN,
});
if (ws.readyState === WebSocket.OPEN) {
const message = JSON.stringify({ type, payload });
logger.info('Sending event to client:', {
type,
messageLength: message.length,
sessionId: (payload as Record<string, unknown>)?.sessionId,
});
ws.send(message);
} else {
logger.info('WARNING: Cannot send event, WebSocket not open. ReadyState:', ws.readyState);
logger.warn('Cannot send event, WebSocket not open. ReadyState:', ws.readyState);
}
});

View File

@@ -13,6 +13,27 @@ import { createLogger } from '@automaker/utils';
const logger = createLogger('GitLib');
// Extended PATH so git is found when the process does not inherit a full shell PATH
// (e.g. Electron, some CI, or IDE-launched processes).
const pathSeparator = process.platform === 'win32' ? ';' : ':';
const extraPaths: string[] =
process.platform === 'win32'
? ([
process.env.LOCALAPPDATA && `${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`,
process.env.PROGRAMFILES && `${process.env.PROGRAMFILES}\\Git\\cmd`,
process.env['ProgramFiles(x86)'] && `${process.env['ProgramFiles(x86)']}\\Git\\cmd`,
].filter(Boolean) as string[])
: [
'/opt/homebrew/bin',
'/usr/local/bin',
'/usr/bin',
'/home/linuxbrew/.linuxbrew/bin',
process.env.HOME ? `${process.env.HOME}/.local/bin` : '',
].filter(Boolean);
const extendedPath = [process.env.PATH, ...extraPaths].filter(Boolean).join(pathSeparator);
const gitEnv = { ...process.env, PATH: extendedPath };
// ============================================================================
// Secure Command Execution
// ============================================================================
@@ -65,7 +86,14 @@ export async function execGitCommand(
command: 'git',
args,
cwd,
...(env !== undefined ? { env } : {}),
env:
env !== undefined
? {
...gitEnv,
...env,
PATH: [gitEnv.PATH, env.PATH].filter(Boolean).join(pathSeparator),
}
: gitEnv,
...(abortController !== undefined ? { abortController } : {}),
});

View File

@@ -689,6 +689,145 @@ export interface ProviderByModelIdResult {
resolvedModel: string | undefined;
}
/** Result from resolveProviderContext */
export interface ProviderContextResult {
/** The provider configuration */
provider: ClaudeCompatibleProvider | undefined;
/** Credentials for API key resolution */
credentials: Credentials | undefined;
/** The resolved Claude model ID for SDK configuration */
resolvedModel: string | undefined;
/** The original model config from the provider if found */
modelConfig: import('@automaker/types').ProviderModel | undefined;
}
/**
* Checks if a provider is enabled.
* Providers with enabled: undefined are treated as enabled (default state).
* Only explicitly set enabled: false means the provider is disabled.
*/
function isProviderEnabled(provider: ClaudeCompatibleProvider): boolean {
return provider.enabled !== false;
}
/**
* Finds a model config in a provider's models array by ID (case-insensitive).
*/
function findModelInProvider(
provider: ClaudeCompatibleProvider,
modelId: string
): import('@automaker/types').ProviderModel | undefined {
return provider.models?.find(
(m) => m.id === modelId || m.id.toLowerCase() === modelId.toLowerCase()
);
}
/**
* Resolves the provider and Claude-compatible model configuration.
*
* This is the central logic for resolving provider context, supporting:
* 1. Explicit lookup by providerId (most reliable for persistence)
* 2. Fallback lookup by modelId across all enabled providers
* 3. Resolution of mapsToClaudeModel for SDK configuration
*
* @param settingsService - Settings service instance
* @param modelId - The model ID to resolve
* @param providerId - Optional explicit provider ID
* @param logPrefix - Prefix for log messages
* @returns Promise resolving to the provider context
*/
export async function resolveProviderContext(
settingsService: SettingsService,
modelId: string,
providerId?: string,
logPrefix = '[SettingsHelper]'
): Promise<ProviderContextResult> {
try {
const globalSettings = await settingsService.getGlobalSettings();
const credentials = await settingsService.getCredentials();
const providers = globalSettings.claudeCompatibleProviders || [];
logger.debug(
`${logPrefix} Resolving provider context: modelId="${modelId}", providerId="${providerId ?? 'none'}", providers count=${providers.length}`
);
let provider: ClaudeCompatibleProvider | undefined;
let modelConfig: import('@automaker/types').ProviderModel | undefined;
// 1. Try resolving by explicit providerId first (most reliable)
if (providerId) {
provider = providers.find((p) => p.id === providerId);
if (provider) {
if (!isProviderEnabled(provider)) {
logger.warn(
`${logPrefix} Explicitly requested provider "${provider.name}" (${providerId}) is disabled (enabled=${provider.enabled})`
);
} else {
logger.debug(
`${logPrefix} Found provider "${provider.name}" (${providerId}), enabled=${provider.enabled ?? 'undefined (treated as enabled)'}`
);
// Find the model config within this provider to check for mappings
modelConfig = findModelInProvider(provider, modelId);
if (!modelConfig && provider.models && provider.models.length > 0) {
logger.debug(
`${logPrefix} Model "${modelId}" not found in provider "${provider.name}". Available models: ${provider.models.map((m) => m.id).join(', ')}`
);
}
}
} else {
logger.warn(
`${logPrefix} Explicitly requested provider "${providerId}" not found. Available providers: ${providers.map((p) => p.id).join(', ')}`
);
}
}
// 2. Fallback to model-based lookup across all providers if modelConfig not found
// Note: We still search even if provider was found, to get the modelConfig for mapping
if (!modelConfig) {
for (const p of providers) {
if (!isProviderEnabled(p) || p.id === providerId) continue; // Skip disabled or already checked
const config = findModelInProvider(p, modelId);
if (config) {
// Only override provider if we didn't find one by explicit ID
if (!provider) {
provider = p;
}
modelConfig = config;
logger.debug(`${logPrefix} Found model "${modelId}" in provider "${p.name}" (fallback)`);
break;
}
}
}
// 3. Resolve the mapped Claude model if specified
let resolvedModel: string | undefined;
if (modelConfig?.mapsToClaudeModel) {
const { resolveModelString } = await import('@automaker/model-resolver');
resolvedModel = resolveModelString(modelConfig.mapsToClaudeModel);
logger.debug(
`${logPrefix} Model "${modelId}" maps to Claude model "${modelConfig.mapsToClaudeModel}" -> "${resolvedModel}"`
);
}
// Log final result for debugging
logger.debug(
`${logPrefix} Provider context resolved: provider=${provider?.name ?? 'none'}, modelConfig=${modelConfig ? 'found' : 'not found'}, resolvedModel=${resolvedModel ?? modelId}`
);
return { provider, credentials, resolvedModel, modelConfig };
} catch (error) {
logger.error(`${logPrefix} Failed to resolve provider context:`, error);
return {
provider: undefined,
credentials: undefined,
resolvedModel: undefined,
modelConfig: undefined,
};
}
}
/**
* Find a ClaudeCompatibleProvider by one of its model IDs.
* Searches through all enabled providers to find one that contains the specified model.

View File

@@ -2,7 +2,7 @@
* Version utility - Reads version from package.json
*/
import { readFileSync } from 'fs';
import { readFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { createLogger } from '@automaker/utils';
@@ -24,7 +24,20 @@ export function getVersion(): string {
}
try {
const packageJsonPath = join(__dirname, '..', '..', 'package.json');
const candidatePaths = [
// Development via tsx: src/lib -> project root
join(__dirname, '..', '..', 'package.json'),
// Packaged/build output: lib -> server bundle root
join(__dirname, '..', 'package.json'),
];
const packageJsonPath = candidatePaths.find((candidate) => existsSync(candidate));
if (!packageJsonPath) {
throw new Error(
`package.json not found in any expected location: ${candidatePaths.join(', ')}`
);
}
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
const version = packageJson.version || '0.0.0';
cachedVersion = version;

View File

@@ -188,6 +188,7 @@ export class ClaudeProvider extends BaseProvider {
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
// Validate that model doesn't have a provider prefix
// AgentService should strip prefixes before passing to providers
// Claude doesn't use a provider prefix, so we don't need to specify an expected provider
validateBareModelId(options.model, 'ClaudeProvider');
const {

View File

@@ -739,9 +739,9 @@ export class CodexProvider extends BaseProvider {
}
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
// Validate that model doesn't have a provider prefix
// Validate that model doesn't have a provider prefix (except codex- which should already be stripped)
// AgentService should strip prefixes before passing to providers
validateBareModelId(options.model, 'CodexProvider');
validateBareModelId(options.model, 'CodexProvider', 'codex');
try {
const mcpServers = options.mcpServers ?? {};

View File

@@ -76,13 +76,18 @@ interface SdkToolExecutionStartEvent extends SdkEvent {
};
}
interface SdkToolExecutionEndEvent extends SdkEvent {
type: 'tool.execution_end';
interface SdkToolExecutionCompleteEvent extends SdkEvent {
type: 'tool.execution_complete';
data: {
toolName: string;
toolCallId: string;
result?: string;
error?: string;
success: boolean;
result?: {
content: string;
};
error?: {
message: string;
code?: string;
};
};
}
@@ -94,6 +99,16 @@ interface SdkSessionErrorEvent extends SdkEvent {
};
}
// =============================================================================
// Constants
// =============================================================================
/**
* Prefix for error messages in tool results
* Consistent with GeminiProvider's error formatting
*/
const TOOL_ERROR_PREFIX = '[ERROR]' as const;
// =============================================================================
// Error Codes
// =============================================================================
@@ -357,12 +372,19 @@ export class CopilotProvider extends CliProvider {
};
}
case 'tool.execution_end': {
const toolResultEvent = sdkEvent as SdkToolExecutionEndEvent;
const isError = !!toolResultEvent.data.error;
const content = isError
? `[ERROR] ${toolResultEvent.data.error}`
: toolResultEvent.data.result || '';
/**
* Tool execution completed event
* Handles both successful results and errors from tool executions
* Error messages optionally include error codes for better debugging
*/
case 'tool.execution_complete': {
const toolResultEvent = sdkEvent as SdkToolExecutionCompleteEvent;
const error = toolResultEvent.data.error;
// Format error message with optional code for better debugging
const content = error
? `${TOOL_ERROR_PREFIX} ${error.message}${error.code ? ` (${error.code})` : ''}`
: toolResultEvent.data.result?.content || '';
return {
type: 'assistant',
@@ -628,7 +650,7 @@ export class CopilotProvider extends CliProvider {
sessionComplete = true;
pushEvent(event);
} else {
// Push all other events (tool.execution_start, tool.execution_end, assistant.message, etc.)
// Push all other events (tool.execution_start, tool.execution_complete, assistant.message, etc.)
pushEvent(event);
}
});

View File

@@ -843,9 +843,10 @@ export class CursorProvider extends CliProvider {
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
this.ensureCliDetected();
// Validate that model doesn't have a provider prefix
// Validate that model doesn't have a provider prefix (except cursor- which should already be stripped)
// AgentService should strip prefixes before passing to providers
validateBareModelId(options.model, 'CursorProvider');
// Note: Cursor's Gemini models (e.g., "gemini-3-pro") legitimately start with "gemini-"
validateBareModelId(options.model, 'CursorProvider', 'cursor');
if (!this.cliPath) {
throw this.createError(

View File

@@ -546,8 +546,8 @@ export class GeminiProvider extends CliProvider {
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
this.ensureCliDetected();
// Validate that model doesn't have a provider prefix
validateBareModelId(options.model, 'GeminiProvider');
// Validate that model doesn't have a provider prefix (except gemini- which should already be stripped)
validateBareModelId(options.model, 'GeminiProvider', 'gemini');
if (!this.cliPath) {
throw this.createError(

View File

@@ -0,0 +1,53 @@
/**
* Mock Provider - No-op AI provider for E2E and CI testing
*
* When AUTOMAKER_MOCK_AGENT=true, the server uses this provider instead of
* real backends (Claude, Codex, etc.) so tests never call external APIs.
*/
import type { ExecuteOptions } from '@automaker/types';
import { BaseProvider } from './base-provider.js';
import type { ProviderMessage, InstallationStatus, ModelDefinition } from './types.js';
const MOCK_TEXT = 'Mock agent output for testing.';
export class MockProvider extends BaseProvider {
getName(): string {
return 'mock';
}
async *executeQuery(_options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
yield {
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: MOCK_TEXT }],
},
};
yield {
type: 'result',
subtype: 'success',
};
}
async detectInstallation(): Promise<InstallationStatus> {
return {
installed: true,
method: 'sdk',
hasApiKey: true,
authenticated: true,
};
}
getAvailableModels(): ModelDefinition[] {
return [
{
id: 'mock-model',
name: 'Mock Model',
modelString: 'mock-model',
provider: 'mock',
description: 'Mock model for testing',
},
];
}
}

View File

@@ -1189,8 +1189,26 @@ export class OpencodeProvider extends CliProvider {
* Format a display name for a model
*/
private formatModelDisplayName(model: OpenCodeModelInfo): string {
// Extract the last path segment for nested model IDs
// e.g., "arcee-ai/trinity-large-preview:free" → "trinity-large-preview:free"
let rawName = model.name;
if (rawName.includes('/')) {
rawName = rawName.split('/').pop()!;
}
// Strip tier/pricing suffixes like ":free", ":extended"
const colonIdx = rawName.indexOf(':');
let suffix = '';
if (colonIdx !== -1) {
const tierPart = rawName.slice(colonIdx + 1);
if (/^(free|extended|beta|preview)$/i.test(tierPart)) {
suffix = ` (${tierPart.charAt(0).toUpperCase() + tierPart.slice(1)})`;
}
rawName = rawName.slice(0, colonIdx);
}
// Capitalize and format the model name
const formattedName = model.name
const formattedName = rawName
.split('-')
.map((part) => {
// Handle version numbers like "4-5" -> "4.5"
@@ -1218,7 +1236,7 @@ export class OpencodeProvider extends CliProvider {
};
const providerDisplay = providerNames[model.provider] || model.provider;
return `${formattedName} (${providerDisplay})`;
return `${formattedName}${suffix} (${providerDisplay})`;
}
/**

View File

@@ -67,6 +67,16 @@ export function registerProvider(name: string, registration: ProviderRegistratio
providerRegistry.set(name.toLowerCase(), registration);
}
/** Cached mock provider instance when AUTOMAKER_MOCK_AGENT is set (E2E/CI). */
let mockProviderInstance: BaseProvider | null = null;
function getMockProvider(): BaseProvider {
if (!mockProviderInstance) {
mockProviderInstance = new MockProvider();
}
return mockProviderInstance;
}
export class ProviderFactory {
/**
* Determine which provider to use for a given model
@@ -75,6 +85,9 @@ export class ProviderFactory {
* @returns Provider name (ModelProvider type)
*/
static getProviderNameForModel(model: string): ModelProvider {
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
return 'claude' as ModelProvider; // Name only; getProviderForModel returns MockProvider
}
const lowerModel = model.toLowerCase();
// Get all registered providers sorted by priority (descending)
@@ -113,6 +126,9 @@ export class ProviderFactory {
modelId: string,
options: { throwOnDisconnected?: boolean } = {}
): BaseProvider {
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
return getMockProvider();
}
const { throwOnDisconnected = true } = options;
const providerName = this.getProviderForModelName(modelId);
@@ -142,6 +158,9 @@ export class ProviderFactory {
* Get the provider name for a given model ID (without creating provider instance)
*/
static getProviderForModelName(modelId: string): string {
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
return 'claude';
}
const lowerModel = modelId.toLowerCase();
// Get all registered providers sorted by priority (descending)
@@ -272,6 +291,7 @@ export class ProviderFactory {
// =============================================================================
// Import providers for registration side-effects
import { MockProvider } from './mock-provider.js';
import { ClaudeProvider } from './claude-provider.js';
import { CursorProvider } from './cursor-provider.js';
import { CodexProvider } from './codex-provider.js';

View File

@@ -323,7 +323,7 @@ Your entire response should be valid JSON starting with { and ending with }. No
}
}
await parseAndCreateFeatures(projectPath, contentForParsing, events);
await parseAndCreateFeatures(projectPath, contentForParsing, events, settingsService);
logger.debug('========== generateFeaturesFromSpec() completed ==========');
}

View File

@@ -9,13 +9,16 @@ import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/
import { getFeaturesDir } from '@automaker/platform';
import { extractJsonWithArray } from '../../lib/json-extractor.js';
import { getNotificationService } from '../../services/notification-service.js';
import type { SettingsService } from '../../services/settings-service.js';
import { resolvePhaseModel } from '@automaker/model-resolver';
const logger = createLogger('SpecRegeneration');
export async function parseAndCreateFeatures(
projectPath: string,
content: string,
events: EventEmitter
events: EventEmitter,
settingsService?: SettingsService
): Promise<void> {
logger.info('========== parseAndCreateFeatures() started ==========');
logger.info(`Content length: ${content.length} chars`);
@@ -23,6 +26,37 @@ export async function parseAndCreateFeatures(
logger.info(content);
logger.info('========== END CONTENT ==========');
// Load default model and planning settings from settingsService
let defaultModel: string | undefined;
let defaultPlanningMode: string = 'skip';
let defaultRequirePlanApproval = false;
if (settingsService) {
try {
const globalSettings = await settingsService.getGlobalSettings();
const projectSettings = await settingsService.getProjectSettings(projectPath);
const defaultModelEntry =
projectSettings.defaultFeatureModel ?? globalSettings.defaultFeatureModel;
if (defaultModelEntry) {
const resolved = resolvePhaseModel(defaultModelEntry);
defaultModel = resolved.model;
}
defaultPlanningMode = globalSettings.defaultPlanningMode ?? 'skip';
defaultRequirePlanApproval = globalSettings.defaultRequirePlanApproval ?? false;
logger.info(
`[parseAndCreateFeatures] Using defaults: model=${defaultModel ?? 'none'}, planningMode=${defaultPlanningMode}, requirePlanApproval=${defaultRequirePlanApproval}`
);
} catch (settingsError) {
logger.warn(
'[parseAndCreateFeatures] Failed to load settings, using defaults:',
settingsError
);
}
}
try {
// Extract JSON from response using shared utility
logger.info('Extracting JSON from response using extractJsonWithArray...');
@@ -61,7 +95,7 @@ export async function parseAndCreateFeatures(
const featureDir = path.join(featuresDir, feature.id);
await secureFs.mkdir(featureDir, { recursive: true });
const featureData = {
const featureData: Record<string, unknown> = {
id: feature.id,
category: feature.category || 'Uncategorized',
title: feature.title,
@@ -70,10 +104,20 @@ export async function parseAndCreateFeatures(
priority: feature.priority || 2,
complexity: feature.complexity || 'moderate',
dependencies: feature.dependencies || [],
planningMode: defaultPlanningMode,
requirePlanApproval:
defaultPlanningMode === 'skip' || defaultPlanningMode === 'lite'
? false
: defaultRequirePlanApproval,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// Apply default model if available from settings
if (defaultModel) {
featureData.model = defaultModel;
}
// Use atomic write with backup support for crash protection
await atomicWriteJson(path.join(featureDir, 'feature.json'), featureData, {
backupCount: DEFAULT_BACKUP_COUNT,

View File

@@ -25,7 +25,7 @@ export function createBacklogPlanRoutes(
);
router.post('/stop', createStopHandler());
router.get('/status', validatePathParams('projectPath'), createStatusHandler());
router.post('/apply', validatePathParams('projectPath'), createApplyHandler());
router.post('/apply', validatePathParams('projectPath'), createApplyHandler(settingsService));
router.post('/clear', validatePathParams('projectPath'), createClearHandler());
return router;

View File

@@ -3,13 +3,23 @@
*/
import type { Request, Response } from 'express';
import type { BacklogPlanResult } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import type { BacklogPlanResult, PhaseModelEntry, PlanningMode } from '@automaker/types';
import { FeatureLoader } from '../../../services/feature-loader.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { clearBacklogPlan, getErrorMessage, logError, logger } from '../common.js';
const featureLoader = new FeatureLoader();
export function createApplyHandler() {
function normalizePhaseModelEntry(
entry: PhaseModelEntry | string | undefined | null
): PhaseModelEntry | undefined {
if (!entry) return undefined;
if (typeof entry === 'string') return { model: entry };
return entry;
}
export function createApplyHandler(settingsService?: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const {
@@ -38,6 +48,23 @@ export function createApplyHandler() {
return;
}
let defaultPlanningMode: PlanningMode = 'skip';
let defaultRequirePlanApproval = false;
let defaultModelEntry: PhaseModelEntry | undefined;
if (settingsService) {
const globalSettings = await settingsService.getGlobalSettings();
const projectSettings = await settingsService.getProjectSettings(projectPath);
defaultPlanningMode = globalSettings.defaultPlanningMode ?? 'skip';
defaultRequirePlanApproval = globalSettings.defaultRequirePlanApproval ?? false;
defaultModelEntry = normalizePhaseModelEntry(
projectSettings.defaultFeatureModel ?? globalSettings.defaultFeatureModel
);
}
const resolvedDefaultModel = resolvePhaseModel(defaultModelEntry);
const appliedChanges: string[] = [];
// Load current features for dependency validation
@@ -88,6 +115,12 @@ export function createApplyHandler() {
if (!change.feature) continue;
try {
const effectivePlanningMode = change.feature.planningMode ?? defaultPlanningMode;
const effectiveRequirePlanApproval =
effectivePlanningMode === 'skip' || effectivePlanningMode === 'lite'
? false
: (change.feature.requirePlanApproval ?? defaultRequirePlanApproval);
// Create the new feature - use the AI-generated ID if provided
const newFeature = await featureLoader.create(projectPath, {
id: change.feature.id, // Use descriptive ID from AI if provided
@@ -97,6 +130,12 @@ export function createApplyHandler() {
dependencies: change.feature.dependencies,
priority: change.feature.priority,
status: 'backlog',
model: change.feature.model ?? resolvedDefaultModel.model,
thinkingLevel: change.feature.thinkingLevel ?? resolvedDefaultModel.thinkingLevel,
reasoningEffort: change.feature.reasoningEffort ?? resolvedDefaultModel.reasoningEffort,
providerId: change.feature.providerId ?? resolvedDefaultModel.providerId,
planningMode: effectivePlanningMode,
requirePlanApproval: effectiveRequirePlanApproval,
branchName,
});

View File

@@ -19,6 +19,11 @@ import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent
import { createGenerateTitleHandler } from './routes/generate-title.js';
import { createExportHandler } from './routes/export.js';
import { createImportHandler, createConflictCheckHandler } from './routes/import.js';
import {
createOrphanedListHandler,
createOrphanedResolveHandler,
createOrphanedBulkResolveHandler,
} from './routes/orphaned.js';
export function createFeaturesRoutes(
featureLoader: FeatureLoader,
@@ -70,6 +75,21 @@ export function createFeaturesRoutes(
validatePathParams('projectPath'),
createConflictCheckHandler(featureLoader)
);
router.post(
'/orphaned',
validatePathParams('projectPath'),
createOrphanedListHandler(featureLoader, autoModeService)
);
router.post(
'/orphaned/resolve',
validatePathParams('projectPath'),
createOrphanedResolveHandler(featureLoader, autoModeService)
);
router.post(
'/orphaned/bulk-resolve',
validatePathParams('projectPath'),
createOrphanedBulkResolveHandler(featureLoader)
);
return router;
}

View File

@@ -46,7 +46,7 @@ export function createListHandler(
// Note: detectOrphanedFeatures handles errors internally and always resolves
if (autoModeService) {
autoModeService
.detectOrphanedFeatures(projectPath)
.detectOrphanedFeatures(projectPath, features)
.then((orphanedFeatures) => {
if (orphanedFeatures.length > 0) {
logger.info(

View File

@@ -0,0 +1,287 @@
/**
* POST /orphaned endpoint - Detect orphaned features (features with missing branches)
* POST /orphaned/resolve endpoint - Resolve an orphaned feature (delete, create-worktree, or move-to-branch)
* POST /orphaned/bulk-resolve endpoint - Resolve multiple orphaned features at once
*/
import crypto from 'crypto';
import path from 'path';
import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { getErrorMessage, logError } from '../common.js';
import { execGitCommand } from '../../../lib/git.js';
import { deleteWorktreeMetadata } from '../../../lib/worktree-metadata.js';
import { createLogger } from '@automaker/utils';
const logger = createLogger('OrphanedFeatures');
type ResolveAction = 'delete' | 'create-worktree' | 'move-to-branch';
const VALID_ACTIONS: ResolveAction[] = ['delete', 'create-worktree', 'move-to-branch'];
export function createOrphanedListHandler(
featureLoader: FeatureLoader,
autoModeService?: AutoModeServiceCompat
) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!autoModeService) {
res.status(500).json({ success: false, error: 'Auto-mode service not available' });
return;
}
const orphanedFeatures = await autoModeService.detectOrphanedFeatures(projectPath);
res.json({ success: true, orphanedFeatures });
} catch (error) {
logError(error, 'Detect orphaned features failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
export function createOrphanedResolveHandler(
featureLoader: FeatureLoader,
_autoModeService?: AutoModeServiceCompat
) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, action, targetBranch } = req.body as {
projectPath: string;
featureId: string;
action: ResolveAction;
targetBranch?: string | null;
};
if (!projectPath || !featureId || !action) {
res.status(400).json({
success: false,
error: 'projectPath, featureId, and action are required',
});
return;
}
if (!VALID_ACTIONS.includes(action)) {
res.status(400).json({
success: false,
error: `action must be one of: ${VALID_ACTIONS.join(', ')}`,
});
return;
}
const result = await resolveOrphanedFeature(
featureLoader,
projectPath,
featureId,
action,
targetBranch
);
if (!result.success) {
res.status(result.error === 'Feature not found' ? 404 : 500).json(result);
return;
}
res.json(result);
} catch (error) {
logError(error, 'Resolve orphaned feature failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
interface BulkResolveResult {
featureId: string;
success: boolean;
action?: string;
error?: string;
}
async function resolveOrphanedFeature(
featureLoader: FeatureLoader,
projectPath: string,
featureId: string,
action: ResolveAction,
targetBranch?: string | null
): Promise<BulkResolveResult> {
try {
const feature = await featureLoader.get(projectPath, featureId);
if (!feature) {
return { featureId, success: false, error: 'Feature not found' };
}
const missingBranch = feature.branchName;
switch (action) {
case 'delete': {
if (missingBranch) {
try {
await deleteWorktreeMetadata(projectPath, missingBranch);
} catch {
// Non-fatal
}
}
const success = await featureLoader.delete(projectPath, featureId);
if (!success) {
return { featureId, success: false, error: 'Deletion failed' };
}
logger.info(`Deleted orphaned feature ${featureId} (branch: ${missingBranch})`);
return { featureId, success: true, action: 'deleted' };
}
case 'create-worktree': {
if (!missingBranch) {
return { featureId, success: false, error: 'Feature has no branch name to recreate' };
}
const sanitizedName = missingBranch.replace(/[^a-zA-Z0-9_-]/g, '-');
const hash = crypto.createHash('sha1').update(missingBranch).digest('hex').slice(0, 8);
const worktreesDir = path.join(projectPath, '.worktrees');
const worktreePath = path.join(worktreesDir, `${sanitizedName}-${hash}`);
try {
await execGitCommand(['worktree', 'add', '-b', missingBranch, worktreePath], projectPath);
} catch (error) {
const msg = getErrorMessage(error);
if (msg.includes('already exists')) {
try {
await execGitCommand(['worktree', 'add', worktreePath, missingBranch], projectPath);
} catch (innerError) {
return {
featureId,
success: false,
error: `Failed to create worktree: ${getErrorMessage(innerError)}`,
};
}
} else {
return { featureId, success: false, error: `Failed to create worktree: ${msg}` };
}
}
logger.info(
`Created worktree for orphaned feature ${featureId} at ${worktreePath} (branch: ${missingBranch})`
);
return { featureId, success: true, action: 'worktree-created' };
}
case 'move-to-branch': {
// Move the feature to a different branch (or clear branch to use main worktree)
const newBranch = targetBranch || null;
// Validate that the target branch exists if one is specified
if (newBranch) {
try {
await execGitCommand(['rev-parse', '--verify', newBranch], projectPath);
} catch {
return {
featureId,
success: false,
error: `Target branch "${newBranch}" does not exist`,
};
}
}
await featureLoader.update(projectPath, featureId, {
branchName: newBranch,
status: 'pending',
});
// Clean up old worktree metadata
if (missingBranch) {
try {
await deleteWorktreeMetadata(projectPath, missingBranch);
} catch {
// Non-fatal
}
}
const destination = newBranch ?? 'main worktree';
logger.info(
`Moved orphaned feature ${featureId} to ${destination} (was: ${missingBranch})`
);
return { featureId, success: true, action: 'moved' };
}
}
} catch (error) {
return { featureId, success: false, error: getErrorMessage(error) };
}
}
export function createOrphanedBulkResolveHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureIds, action, targetBranch } = req.body as {
projectPath: string;
featureIds: string[];
action: ResolveAction;
targetBranch?: string | null;
};
if (
!projectPath ||
!featureIds ||
!Array.isArray(featureIds) ||
featureIds.length === 0 ||
!action
) {
res.status(400).json({
success: false,
error: 'projectPath, featureIds (non-empty array), and action are required',
});
return;
}
if (!VALID_ACTIONS.includes(action)) {
res.status(400).json({
success: false,
error: `action must be one of: ${VALID_ACTIONS.join(', ')}`,
});
return;
}
// Process sequentially for worktree creation (git operations shouldn't race),
// in parallel for delete/move-to-branch
const results: BulkResolveResult[] = [];
if (action === 'create-worktree') {
for (const featureId of featureIds) {
const result = await resolveOrphanedFeature(
featureLoader,
projectPath,
featureId,
action,
targetBranch
);
results.push(result);
}
} else {
const batchResults = await Promise.all(
featureIds.map((featureId) =>
resolveOrphanedFeature(featureLoader, projectPath, featureId, action, targetBranch)
)
);
results.push(...batchResults);
}
const successCount = results.filter((r) => r.success).length;
const failedCount = results.length - successCount;
res.json({
success: failedCount === 0,
resolvedCount: successCount,
failedCount,
results,
});
} catch (error) {
logError(error, 'Bulk resolve orphaned features failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -43,7 +43,11 @@ export function createUpdateHandler(featureLoader: FeatureLoader, events?: Event
// Get the current feature to detect status changes
const currentFeature = await featureLoader.get(projectPath, featureId);
const previousStatus = currentFeature?.status as FeatureStatus | undefined;
if (!currentFeature) {
res.status(404).json({ success: false, error: `Feature ${featureId} not found` });
return;
}
const previousStatus = currentFeature.status as FeatureStatus;
const newStatus = updates.status as FeatureStatus | undefined;
const updated = await featureLoader.update(

View File

@@ -3,16 +3,29 @@
*/
import type { Request, Response } from 'express';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
// Optional files that are expected to not exist in new projects
// Don't log ENOENT errors for these to reduce noise
const OPTIONAL_FILES = ['categories.json', 'app_spec.txt'];
const OPTIONAL_FILES = ['categories.json', 'app_spec.txt', 'context-metadata.json'];
function isOptionalFile(filePath: string): boolean {
return OPTIONAL_FILES.some((optionalFile) => filePath.endsWith(optionalFile));
const basename = path.basename(filePath);
if (OPTIONAL_FILES.some((optionalFile) => basename === optionalFile)) {
return true;
}
// Context and memory files may not exist yet during create/delete or test races
if (filePath.includes('.automaker/context/') || filePath.includes('.automaker/memory/')) {
const name = path.basename(filePath);
const lower = name.toLowerCase();
if (lower.endsWith('.md') || lower.endsWith('.txt') || lower.endsWith('.markdown')) {
return true;
}
}
return false;
}
function isENOENT(error: unknown): boolean {
@@ -39,12 +52,14 @@ export function createReadHandler() {
return;
}
// Don't log ENOENT errors for optional files (expected to be missing in new projects)
const shouldLog = !(isENOENT(error) && isOptionalFile(req.body?.filePath || ''));
if (shouldLog) {
const filePath = req.body?.filePath || '';
const optionalMissing = isENOENT(error) && isOptionalFile(filePath);
if (!optionalMissing) {
logError(error, 'Read file failed');
}
res.status(500).json({ success: false, error: getErrorMessage(error) });
// Return 404 for missing optional files so clients can handle "not found"
const status = optionalMissing ? 404 : 500;
res.status(status).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -35,6 +35,16 @@ export function createStatHandler() {
return;
}
// File or directory does not exist - return 404 so UI can handle missing paths
const code =
error && typeof error === 'object' && 'code' in error
? (error as { code: string }).code
: '';
if (code === 'ENOENT') {
res.status(404).json({ success: false, error: 'File or directory not found' });
return;
}
logError(error, 'Get file stats failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}

View File

@@ -38,7 +38,7 @@ import {
import {
getPromptCustomization,
getAutoLoadClaudeMdSetting,
getProviderByModelId,
resolveProviderContext,
} from '../../../lib/settings-helpers.js';
import {
trySetValidationRunning,
@@ -64,6 +64,8 @@ interface ValidateIssueRequestBody {
thinkingLevel?: ThinkingLevel;
/** Reasoning effort for Codex models (ignored for non-Codex models) */
reasoningEffort?: ReasoningEffort;
/** Optional Claude-compatible provider ID for custom providers (e.g., GLM, MiniMax) */
providerId?: string;
/** Comments to include in validation analysis */
comments?: GitHubComment[];
/** Linked pull requests for this issue */
@@ -87,6 +89,7 @@ async function runValidation(
events: EventEmitter,
abortController: AbortController,
settingsService?: SettingsService,
providerId?: string,
comments?: ValidationComment[],
linkedPRs?: ValidationLinkedPR[],
thinkingLevel?: ThinkingLevel,
@@ -176,7 +179,12 @@ ${basePrompt}`;
let credentials = await settingsService?.getCredentials();
if (settingsService) {
const providerResult = await getProviderByModelId(model, settingsService, '[ValidateIssue]');
const providerResult = await resolveProviderContext(
settingsService,
model,
providerId,
'[ValidateIssue]'
);
if (providerResult.provider) {
claudeCompatibleProvider = providerResult.provider;
providerResolvedModel = providerResult.resolvedModel;
@@ -312,10 +320,16 @@ export function createValidateIssueHandler(
model = 'opus',
thinkingLevel,
reasoningEffort,
providerId,
comments: rawComments,
linkedPRs: rawLinkedPRs,
} = req.body as ValidateIssueRequestBody;
const normalizedProviderId =
typeof providerId === 'string' && providerId.trim().length > 0
? providerId.trim()
: undefined;
// Transform GitHubComment[] to ValidationComment[] if provided
const validationComments: ValidationComment[] | undefined = rawComments?.map((c) => ({
author: c.author?.login || 'ghost',
@@ -364,12 +378,14 @@ export function createValidateIssueHandler(
isClaudeModel(model) ||
isCursorModel(model) ||
isCodexModel(model) ||
isOpencodeModel(model);
isOpencodeModel(model) ||
!!normalizedProviderId;
if (!isValidModel) {
res.status(400).json({
success: false,
error: 'Invalid model. Must be a Claude, Cursor, Codex, or OpenCode model ID (or alias).',
error:
'Invalid model. Must be a Claude, Cursor, Codex, or OpenCode model ID (or alias), or provide a valid providerId for custom Claude-compatible models.',
});
return;
}
@@ -398,6 +414,7 @@ export function createValidateIssueHandler(
events,
abortController,
settingsService,
normalizedProviderId,
validationComments,
validationLinkedPRs,
thinkingLevel,

View File

@@ -80,6 +80,12 @@ function containsAuthError(text: string): boolean {
export function createVerifyClaudeAuthHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
// In E2E/CI mock mode, skip real API calls
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
res.json({ success: true, authenticated: true });
return;
}
// Get the auth method and optional API key from the request body
const { authMethod, apiKey } = req.body as {
authMethod?: 'cli' | 'api_key';

View File

@@ -82,6 +82,12 @@ function isRateLimitError(text: string): boolean {
export function createVerifyCodexAuthHandler() {
return async (req: Request, res: Response): Promise<void> => {
// In E2E/CI mock mode, skip real API calls
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
res.json({ success: true, authenticated: true });
return;
}
const { authMethod, apiKey } = req.body as {
authMethod?: 'cli' | 'api_key';
apiKey?: string;

View File

@@ -71,10 +71,12 @@ import { createSetTrackingHandler } from './routes/set-tracking.js';
import { createSyncHandler } from './routes/sync.js';
import { createUpdatePRNumberHandler } from './routes/update-pr-number.js';
import type { SettingsService } from '../../services/settings-service.js';
import type { FeatureLoader } from '../../services/feature-loader.js';
export function createWorktreeRoutes(
events: EventEmitter,
settingsService?: SettingsService
settingsService?: SettingsService,
featureLoader?: FeatureLoader
): Router {
const router = Router();
@@ -94,7 +96,11 @@ export function createWorktreeRoutes(
validatePathParams('projectPath'),
createCreateHandler(events, settingsService)
);
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
router.post(
'/delete',
validatePathParams('projectPath', 'worktreePath'),
createDeleteHandler(events, featureLoader)
);
router.post('/create-pr', createCreatePRHandler());
router.post('/pr-info', createPRInfoHandler());
router.post(

View File

@@ -10,11 +10,13 @@ import { isGitRepo } from '@automaker/git-utils';
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
import { execGitCommand } from '../../../lib/git.js';
import { createLogger } from '@automaker/utils';
import type { FeatureLoader } from '../../../services/feature-loader.js';
import type { EventEmitter } from '../../../lib/events.js';
const execAsync = promisify(exec);
const logger = createLogger('Worktree');
export function createDeleteHandler() {
export function createDeleteHandler(events: EventEmitter, featureLoader?: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, worktreePath, deleteBranch } = req.body as {
@@ -134,12 +136,65 @@ export function createDeleteHandler() {
}
}
// Emit worktree:deleted event after successful deletion
events.emit('worktree:deleted', {
worktreePath,
projectPath,
branchName,
branchDeleted,
});
// Move features associated with the deleted branch to the main worktree
// This prevents features from being orphaned when a worktree is deleted
let featuresMovedToMain = 0;
if (featureLoader && branchName) {
try {
const allFeatures = await featureLoader.getAll(projectPath);
const affectedFeatures = allFeatures.filter((f) => f.branchName === branchName);
for (const feature of affectedFeatures) {
try {
await featureLoader.update(projectPath, feature.id, {
branchName: null,
});
featuresMovedToMain++;
// Emit feature:migrated event for each successfully migrated feature
events.emit('feature:migrated', {
featureId: feature.id,
status: 'migrated',
fromBranch: branchName,
toWorktreeId: null, // migrated to main worktree (no specific worktree)
projectPath,
});
} catch (featureUpdateError) {
// Non-fatal: log per-feature failure but continue migrating others
logger.warn('Failed to move feature to main worktree after deletion', {
error: getErrorMessage(featureUpdateError),
featureId: feature.id,
branchName,
});
}
}
if (featuresMovedToMain > 0) {
logger.info(
`Moved ${featuresMovedToMain} feature(s) to main worktree after deleting worktree with branch: ${branchName}`
);
}
} catch (featureError) {
// Non-fatal: log but don't fail the deletion (getAll failed)
logger.warn('Failed to load features for migration to main worktree after deletion', {
error: getErrorMessage(featureError),
branchName,
});
}
}
res.json({
success: true,
deleted: {
worktreePath,
branch: branchDeleted ? branchName : null,
branchDeleted,
featuresMovedToMain,
},
});
} catch (error) {

View File

@@ -44,13 +44,79 @@ export function createInitGitHandler() {
}
// Initialize git with 'main' as the default branch (matching GitHub's standard since 2020)
// and create an initial empty commit
await execAsync(
`git init --initial-branch=main && git commit --allow-empty -m "Initial commit"`,
{
cwd: projectPath,
// Run commands sequentially so failures can be handled and partial state cleaned up.
let gitDirCreated = false;
try {
// Step 1: initialize the repository
try {
await execAsync(`git init --initial-branch=main`, { cwd: projectPath });
} catch (initError: unknown) {
const stderr =
initError && typeof initError === 'object' && 'stderr' in initError
? String((initError as { stderr?: string }).stderr)
: '';
// Idempotent: if .git was created by a concurrent request or a stale lock exists,
// treat as "repo already exists" instead of failing
if (
/could not lock config file.*File exists|fatal: could not set 'core\.repositoryformatversion'/.test(
stderr
)
) {
try {
await secureFs.access(gitDirPath);
res.json({
success: true,
result: {
initialized: false,
message: 'Git repository already exists',
},
});
return;
} catch {
// .git still missing, rethrow original error
}
}
throw initError;
}
);
gitDirCreated = true;
// Step 2: ensure user.name and user.email are set so the commit can succeed.
// Check the global/system config first; only set locally if missing.
let userName = '';
let userEmail = '';
try {
({ stdout: userName } = await execAsync(`git config user.name`, { cwd: projectPath }));
} catch {
// not set globally will configure locally below
}
try {
({ stdout: userEmail } = await execAsync(`git config user.email`, {
cwd: projectPath,
}));
} catch {
// not set globally will configure locally below
}
if (!userName.trim()) {
await execAsync(`git config user.name "Automaker"`, { cwd: projectPath });
}
if (!userEmail.trim()) {
await execAsync(`git config user.email "automaker@localhost"`, { cwd: projectPath });
}
// Step 3: create the initial empty commit
await execAsync(`git commit --allow-empty -m "Initial commit"`, { cwd: projectPath });
} catch (error: unknown) {
// Clean up the partial .git directory so subsequent runs behave deterministically
if (gitDirCreated) {
try {
await secureFs.rm(gitDirPath, { recursive: true, force: true });
} catch {
// best-effort cleanup; ignore errors
}
}
throw error;
}
res.json({
success: true,

View File

@@ -6,12 +6,11 @@
*/
import type { Request, Response } from 'express';
import { exec, execFile } from 'child_process';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logWorktreeError } from '../common.js';
import { getErrorMessage, logWorktreeError, execGitCommand } from '../common.js';
import { getRemotesWithBranch } from '../../../services/worktree-service.js';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
interface BranchInfo {
@@ -36,18 +35,18 @@ export function createListBranchesHandler() {
return;
}
// Get current branch
const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
});
// Get current branch (execGitCommand avoids spawning /bin/sh; works in sandboxed CI)
const currentBranchOutput = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],
worktreePath
);
const currentBranch = currentBranchOutput.trim();
// List all local branches
// Use double quotes around the format string for cross-platform compatibility
// Single quotes are preserved literally on Windows; double quotes work on both
const { stdout: branchesOutput } = await execAsync('git branch --format="%(refname:short)"', {
cwd: worktreePath,
});
const branchesOutput = await execGitCommand(
['branch', '--format=%(refname:short)'],
worktreePath
);
const branches: BranchInfo[] = branchesOutput
.trim()
@@ -68,18 +67,15 @@ export function createListBranchesHandler() {
try {
// Fetch latest remote refs (silently, don't fail if offline)
try {
await execAsync('git fetch --all --quiet', {
cwd: worktreePath,
timeout: 10000, // 10 second timeout
});
await execGitCommand(['fetch', '--all', '--quiet'], worktreePath);
} catch {
// Ignore fetch errors - we'll use cached remote refs
}
// List remote branches
const { stdout: remoteBranchesOutput } = await execAsync(
'git branch -r --format="%(refname:short)"',
{ cwd: worktreePath }
const remoteBranchesOutput = await execGitCommand(
['branch', '-r', '--format=%(refname:short)'],
worktreePath
);
const localBranchNames = new Set(branches.map((b) => b.name));
@@ -118,9 +114,7 @@ export function createListBranchesHandler() {
// Check if any remotes are configured for this repository
let hasAnyRemotes = false;
try {
const { stdout: remotesOutput } = await execAsync('git remote', {
cwd: worktreePath,
});
const remotesOutput = await execGitCommand(['remote'], worktreePath);
hasAnyRemotes = remotesOutput.trim().length > 0;
} catch {
// If git remote fails, assume no remotes

View File

@@ -13,7 +13,14 @@ import { promisify } from 'util';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { isGitRepo } from '@automaker/git-utils';
import { getErrorMessage, logError, normalizePath, execEnv, isGhCliAvailable } from '../common.js';
import {
getErrorMessage,
logError,
normalizePath,
execEnv,
isGhCliAvailable,
execGitCommand,
} from '../common.js';
import {
readAllWorktreeMetadata,
updateWorktreePRInfo,
@@ -29,6 +36,22 @@ import {
const execAsync = promisify(exec);
const logger = createLogger('Worktree');
/** True when git (or shell) could not be spawned (e.g. ENOENT in sandboxed CI). */
function isSpawnENOENT(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const e = error as { code?: string; errno?: number; syscall?: string };
// Accept ENOENT with or without syscall so wrapped/reexported errors are handled.
// Node may set syscall to 'spawn' or 'spawn git' (or other command name).
if (e.code === 'ENOENT' || e.errno === -2) {
return (
e.syscall === 'spawn' ||
(typeof e.syscall === 'string' && e.syscall.startsWith('spawn')) ||
e.syscall === undefined
);
}
return false;
}
/**
* Cache for GitHub remote status per project path.
* This prevents repeated "no git remotes found" warnings when polling
@@ -64,6 +87,8 @@ interface WorktreeInfo {
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
/** List of files with conflicts */
conflictFiles?: string[];
/** Source branch involved in merge/rebase/cherry-pick, when resolvable */
conflictSourceBranch?: string;
}
/**
@@ -75,13 +100,11 @@ async function detectConflictState(worktreePath: string): Promise<{
hasConflicts: boolean;
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
conflictFiles?: string[];
conflictSourceBranch?: string;
}> {
try {
// Find the canonical .git directory for this worktree
const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', {
cwd: worktreePath,
timeout: 15000,
});
// Find the canonical .git directory for this worktree (execGitCommand avoids /bin/sh in CI)
const gitDirRaw = await execGitCommand(['rev-parse', '--git-dir'], worktreePath);
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
// Check for merge, rebase, and cherry-pick state files/directories
@@ -121,10 +144,10 @@ async function detectConflictState(worktreePath: string): Promise<{
// Get list of conflicted files using machine-readable git status
let conflictFiles: string[] = [];
try {
const { stdout: statusOutput } = await execAsync('git diff --name-only --diff-filter=U', {
cwd: worktreePath,
timeout: 15000,
});
const statusOutput = await execGitCommand(
['diff', '--name-only', '--diff-filter=U'],
worktreePath
);
conflictFiles = statusOutput
.trim()
.split('\n')
@@ -133,10 +156,84 @@ async function detectConflictState(worktreePath: string): Promise<{
// Fall back to empty list if diff fails
}
// Detect the source branch involved in the conflict
let conflictSourceBranch: string | undefined;
try {
if (conflictType === 'merge' && mergeHeadExists) {
// For merges, resolve MERGE_HEAD to a branch name
const mergeHead = (
(await secureFs.readFile(path.join(gitDir, 'MERGE_HEAD'), 'utf-8')) as string
).trim();
try {
const branchName = await execGitCommand(
['name-rev', '--name-only', '--refs=refs/heads/*', mergeHead],
worktreePath
);
const cleaned = branchName.trim().replace(/~\d+$/, '');
if (cleaned && cleaned !== 'undefined') {
conflictSourceBranch = cleaned;
}
} catch {
// Could not resolve to branch name
}
} else if (conflictType === 'rebase') {
// For rebases, read the onto branch from rebase-merge/head-name or rebase-apply/head-name
const headNamePath = rebaseMergeExists
? path.join(gitDir, 'rebase-merge', 'onto-name')
: path.join(gitDir, 'rebase-apply', 'onto-name');
try {
const ontoName = ((await secureFs.readFile(headNamePath, 'utf-8')) as string).trim();
if (ontoName) {
conflictSourceBranch = ontoName.replace(/^refs\/heads\//, '');
}
} catch {
// onto-name may not exist; try to resolve the onto commit
try {
const ontoPath = rebaseMergeExists
? path.join(gitDir, 'rebase-merge', 'onto')
: path.join(gitDir, 'rebase-apply', 'onto');
const ontoCommit = ((await secureFs.readFile(ontoPath, 'utf-8')) as string).trim();
if (ontoCommit) {
const branchName = await execGitCommand(
['name-rev', '--name-only', '--refs=refs/heads/*', ontoCommit],
worktreePath
);
const cleaned = branchName.trim().replace(/~\d+$/, '');
if (cleaned && cleaned !== 'undefined') {
conflictSourceBranch = cleaned;
}
}
} catch {
// Could not resolve onto commit
}
}
} else if (conflictType === 'cherry-pick' && cherryPickHeadExists) {
// For cherry-picks, try to resolve CHERRY_PICK_HEAD to a branch name
const cherryPickHead = (
(await secureFs.readFile(path.join(gitDir, 'CHERRY_PICK_HEAD'), 'utf-8')) as string
).trim();
try {
const branchName = await execGitCommand(
['name-rev', '--name-only', '--refs=refs/heads/*', cherryPickHead],
worktreePath
);
const cleaned = branchName.trim().replace(/~\d+$/, '');
if (cleaned && cleaned !== 'undefined') {
conflictSourceBranch = cleaned;
}
} catch {
// Could not resolve to branch name
}
}
} catch {
// Ignore source branch detection errors
}
return {
hasConflicts: conflictFiles.length > 0,
conflictType,
conflictFiles,
conflictSourceBranch,
};
} catch {
// If anything fails, assume no conflicts
@@ -146,13 +243,69 @@ async function detectConflictState(worktreePath: string): Promise<{
async function getCurrentBranch(cwd: string): Promise<string> {
try {
const { stdout } = await execAsync('git branch --show-current', { cwd });
const stdout = await execGitCommand(['branch', '--show-current'], cwd);
return stdout.trim();
} catch {
return '';
}
}
function normalizeBranchFromHeadRef(headRef: string): string | null {
let normalized = headRef.trim();
const prefixes = ['refs/heads/', 'refs/remotes/origin/', 'refs/remotes/', 'refs/'];
for (const prefix of prefixes) {
if (normalized.startsWith(prefix)) {
normalized = normalized.slice(prefix.length);
break;
}
}
// Return the full branch name, including any slashes (e.g., "feature/my-branch")
return normalized || null;
}
/**
* Attempt to recover the branch name for a worktree in detached HEAD state.
* This happens during rebase operations where git detaches HEAD from the branch.
* We look at git state files (rebase-merge/head-name, rebase-apply/head-name)
* to determine which branch the operation is targeting.
*
* Note: merge conflicts do NOT detach HEAD, so `git worktree list --porcelain`
* still includes the `branch` line for merge conflicts. This recovery is
* specifically for rebase and cherry-pick operations.
*/
async function recoverBranchForDetachedWorktree(worktreePath: string): Promise<string | null> {
try {
const gitDirRaw = await execGitCommand(['rev-parse', '--git-dir'], worktreePath);
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
// During a rebase, the original branch is stored in rebase-merge/head-name
try {
const headNamePath = path.join(gitDir, 'rebase-merge', 'head-name');
const headName = (await secureFs.readFile(headNamePath, 'utf-8')) as string;
const branch = normalizeBranchFromHeadRef(headName);
if (branch) return branch;
} catch {
// Not a rebase-merge
}
// rebase-apply also stores the original branch in head-name
try {
const headNamePath = path.join(gitDir, 'rebase-apply', 'head-name');
const headName = (await secureFs.readFile(headNamePath, 'utf-8')) as string;
const branch = normalizeBranchFromHeadRef(headName);
if (branch) return branch;
} catch {
// Not a rebase-apply
}
return null;
} catch {
return null;
}
}
/**
* Scan the .worktrees directory to discover worktrees that may exist on disk
* but are not registered with git (e.g., created externally or corrupted state).
@@ -204,22 +357,36 @@ async function scanWorktreesDirectory(
});
} else {
// Try to get branch from HEAD if branch --show-current fails (detached HEAD)
let headBranch: string | null = null;
try {
const { stdout: headRef } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
});
const headBranch = headRef.trim();
if (headBranch && headBranch !== 'HEAD') {
logger.info(
`Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${headBranch})`
);
discovered.push({
path: normalizedPath,
branch: headBranch,
});
const headRef = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],
worktreePath
);
const ref = headRef.trim();
if (ref && ref !== 'HEAD') {
headBranch = ref;
}
} catch {
// Can't determine branch, skip this directory
} catch (error) {
// Can't determine branch from HEAD ref (including timeout) - fall back to detached HEAD recovery
logger.debug(
`Failed to resolve HEAD ref for ${worktreePath}: ${getErrorMessage(error)}`
);
}
// If HEAD is detached (rebase/merge in progress), try recovery from git state files
if (!headBranch) {
headBranch = await recoverBranchForDetachedWorktree(worktreePath);
}
if (headBranch) {
logger.info(
`Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${headBranch})`
);
discovered.push({
path: normalizedPath,
branch: headBranch,
});
}
}
}
@@ -378,15 +545,14 @@ export function createListHandler() {
// Get current branch in main directory
const currentBranch = await getCurrentBranch(projectPath);
// Get actual worktrees from git
const { stdout } = await execAsync('git worktree list --porcelain', {
cwd: projectPath,
});
// Get actual worktrees from git (execGitCommand avoids /bin/sh in sandboxed CI)
const stdout = await execGitCommand(['worktree', 'list', '--porcelain'], projectPath);
const worktrees: WorktreeInfo[] = [];
const removedWorktrees: Array<{ path: string; branch: string }> = [];
let hasMissingWorktree = false;
const lines = stdout.split('\n');
let current: { path?: string; branch?: string } = {};
let current: { path?: string; branch?: string; isDetached?: boolean } = {};
let isFirst = true;
// First pass: detect removed worktrees
@@ -395,8 +561,11 @@ export function createListHandler() {
current.path = normalizePath(line.slice(9));
} else if (line.startsWith('branch ')) {
current.branch = line.slice(7).replace('refs/heads/', '');
} else if (line.startsWith('detached')) {
// Worktree is in detached HEAD state (e.g., during rebase)
current.isDetached = true;
} else if (line === '') {
if (current.path && current.branch) {
if (current.path) {
const isMainWorktree = isFirst;
// Check if the worktree directory actually exists
// Skip checking/pruning the main worktree (projectPath itself)
@@ -407,14 +576,19 @@ export function createListHandler() {
} catch {
worktreeExists = false;
}
if (!isMainWorktree && !worktreeExists) {
hasMissingWorktree = true;
// Worktree directory doesn't exist - it was manually deleted
removedWorktrees.push({
path: current.path,
branch: current.branch,
});
} else {
// Worktree exists (or is main worktree), add it to the list
// Only add to removed list if we know the branch name
if (current.branch) {
removedWorktrees.push({
path: current.path,
branch: current.branch,
});
}
} else if (current.branch) {
// Normal case: worktree with a known branch
worktrees.push({
path: current.path,
branch: current.branch,
@@ -423,16 +597,29 @@ export function createListHandler() {
hasWorktree: true,
});
isFirst = false;
} else if (current.isDetached && worktreeExists) {
// Detached HEAD (e.g., rebase in progress) - try to recover branch name.
// This is critical: without this, worktrees undergoing rebase/merge
// operations would silently disappear from the UI.
const recoveredBranch = await recoverBranchForDetachedWorktree(current.path);
worktrees.push({
path: current.path,
branch: recoveredBranch || `(detached)`,
isMain: isMainWorktree,
isCurrent: false,
hasWorktree: true,
});
isFirst = false;
}
}
current = {};
}
}
// Prune removed worktrees from git (only if any were detected)
if (removedWorktrees.length > 0) {
// Prune removed worktrees from git (only if any missing worktrees were detected)
if (hasMissingWorktree) {
try {
await execAsync('git worktree prune', { cwd: projectPath });
await execGitCommand(['worktree', 'prune'], projectPath);
} catch {
// Prune failed, but we'll still report the removed worktrees
}
@@ -461,9 +648,7 @@ export function createListHandler() {
if (includeDetails) {
for (const worktree of worktrees) {
try {
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
cwd: worktree.path,
});
const statusOutput = await execGitCommand(['status', '--porcelain'], worktree.path);
const changedFiles = statusOutput
.trim()
.split('\n')
@@ -486,13 +671,14 @@ export function createListHandler() {
// hasConflicts is true only when there are actual unresolved files
worktree.hasConflicts = conflictState.hasConflicts;
worktree.conflictFiles = conflictState.conflictFiles;
worktree.conflictSourceBranch = conflictState.conflictSourceBranch;
} catch {
// Ignore conflict detection errors
}
}
}
// Assign PR info to each worktree, preferring fresh GitHub data over cached metadata.
// Assign PR info to each worktree.
// Only fetch GitHub PRs if includeDetails is requested (performance optimization).
// Uses --state all to detect merged/closed PRs, limited to 1000 recent PRs.
const githubPRs = includeDetails
@@ -510,14 +696,27 @@ export function createListHandler() {
const metadata = allMetadata.get(worktree.branch);
const githubPR = githubPRs.get(worktree.branch);
if (githubPR) {
// Prefer fresh GitHub data (it has the current state)
const metadataPR = metadata?.pr;
// Preserve explicit user-selected PR tracking from metadata when it differs
// from branch-derived GitHub PR lookup. This allows "Change PR Number" to
// persist instead of being overwritten by gh pr list for the branch.
const hasManualOverride =
!!metadataPR && !!githubPR && metadataPR.number !== githubPR.number;
if (hasManualOverride) {
worktree.pr = metadataPR;
} else if (githubPR) {
// Use fresh GitHub data when there is no explicit override.
worktree.pr = githubPR;
// Sync metadata with GitHub state when:
// 1. No metadata exists for this PR (PR created externally)
// 2. State has changed (e.g., merged/closed on GitHub)
const needsSync = !metadata?.pr || metadata.pr.state !== githubPR.state;
// Sync metadata when missing or stale so fallback data stays current.
const needsSync =
!metadataPR ||
metadataPR.number !== githubPR.number ||
metadataPR.state !== githubPR.state ||
metadataPR.title !== githubPR.title ||
metadataPR.url !== githubPR.url ||
metadataPR.createdAt !== githubPR.createdAt;
if (needsSync) {
// Fire and forget - don't block the response
updateWorktreePRInfo(projectPath, worktree.branch, githubPR).catch((err) => {
@@ -526,9 +725,9 @@ export function createListHandler() {
);
});
}
} else if (metadata?.pr && metadata.pr.state === 'OPEN') {
} else if (metadataPR && metadataPR.state === 'OPEN') {
// Fall back to stored metadata only if the PR is still OPEN
worktree.pr = metadata.pr;
worktree.pr = metadataPR;
}
}
@@ -538,6 +737,26 @@ export function createListHandler() {
removedWorktrees: removedWorktrees.length > 0 ? removedWorktrees : undefined,
});
} catch (error) {
// When git is unavailable (e.g. sandboxed E2E, PATH without git), return minimal list so UI still loads
if (isSpawnENOENT(error)) {
const projectPathFromBody = (req.body as { projectPath?: string })?.projectPath;
const mainPath = projectPathFromBody ? normalizePath(projectPathFromBody) : undefined;
if (mainPath) {
res.json({
success: true,
worktrees: [
{
path: mainPath,
branch: 'main',
isMain: true,
isCurrent: true,
hasWorktree: true,
},
],
});
return;
}
}
logError(error, 'List worktrees failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}

View File

@@ -23,9 +23,11 @@ import type { PullResult } from '../../../services/pull-service.js';
export function createPullHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, remote, stashIfNeeded } = req.body as {
const { worktreePath, remote, remoteBranch, stashIfNeeded } = req.body as {
worktreePath: string;
remote?: string;
/** Specific remote branch to pull (e.g. 'main'). When provided, pulls this branch from the remote regardless of tracking config. */
remoteBranch?: string;
/** When true, automatically stash local changes before pulling and reapply after */
stashIfNeeded?: boolean;
};
@@ -39,7 +41,7 @@ export function createPullHandler() {
}
// Execute the pull via the service
const result = await performPull(worktreePath, { remote, stashIfNeeded });
const result = await performPull(worktreePath, { remote, remoteBranch, stashIfNeeded });
// Map service result to HTTP response
mapResultToResponse(res, result);

View File

@@ -44,6 +44,8 @@ export interface AgentExecutionOptions {
specAlreadyDetected?: boolean;
existingApprovedPlanContent?: string;
persistedTasks?: ParsedTask[];
/** Feature status - used to check if pipeline summary extraction is required */
status?: string;
}
export interface AgentExecutionResult {

View File

@@ -4,6 +4,7 @@
import path from 'path';
import type { ExecuteOptions, ParsedTask } from '@automaker/types';
import { isPipelineStatus } from '@automaker/types';
import { buildPromptWithImages, createLogger, isAuthenticationError } from '@automaker/utils';
import { getFeatureDir } from '@automaker/platform';
import * as secureFs from '../lib/secure-fs.js';
@@ -91,6 +92,7 @@ export class AgentExecutor {
existingApprovedPlanContent,
persistedTasks,
credentials,
status, // Feature status for pipeline summary check
claudeCompatibleProvider,
mcpServers,
sdkSessionId,
@@ -207,6 +209,17 @@ export class AgentExecutor {
if (writeTimeout) clearTimeout(writeTimeout);
if (rawWriteTimeout) clearTimeout(rawWriteTimeout);
await writeToFile();
// Extract and save summary from the new content generated in this session
await this.extractAndSaveSessionSummary(
projectPath,
featureId,
result.responseText,
previousContent,
callbacks,
status
);
return {
responseText: result.responseText,
specDetected: true,
@@ -340,9 +353,78 @@ export class AgentExecutor {
}
}
}
// Capture summary if it hasn't been captured by handleSpecGenerated or executeTasksLoop
// or if we're in a simple execution mode (planningMode='skip')
await this.extractAndSaveSessionSummary(
projectPath,
featureId,
responseText,
previousContent,
callbacks,
status
);
return { responseText, specDetected, tasksCompleted, aborted };
}
/**
* Strip the follow-up session scaffold marker from content.
* The scaffold is added when resuming a session with previous content:
* "\n\n---\n\n## Follow-up Session\n\n"
* This ensures fallback summaries don't include the scaffold header.
*
* The regex pattern handles variations in whitespace while matching the
* scaffold structure: dashes followed by "## Follow-up Session" at the
* start of the content.
*/
private static stripFollowUpScaffold(content: string): string {
// Pattern matches: ^\s*---\s*##\s*Follow-up Session\s*
// - ^ = start of content (scaffold is always at the beginning of sessionContent)
// - \s* = any whitespace (handles \n\n before ---, spaces/tabs between markers)
// - --- = literal dashes
// - \s* = whitespace between dashes and heading
// - ## = heading marker
// - \s* = whitespace before "Follow-up"
// - Follow-up Session = literal heading text
// - \s* = trailing whitespace/newlines after heading
const scaffoldPattern = /^\s*---\s*##\s*Follow-up Session\s*/;
return content.replace(scaffoldPattern, '');
}
/**
* Extract summary ONLY from the new content generated in this session
* and save it via the provided callback.
*/
private async extractAndSaveSessionSummary(
projectPath: string,
featureId: string,
responseText: string,
previousContent: string | undefined,
callbacks: AgentExecutorCallbacks,
status?: string
): Promise<void> {
const sessionContent = responseText.substring(previousContent ? previousContent.length : 0);
const summary = extractSummary(sessionContent);
if (summary) {
await callbacks.saveFeatureSummary(projectPath, featureId, summary);
return;
}
// If we're in a pipeline step, a summary is expected. Use a fallback if extraction fails.
if (isPipelineStatus(status)) {
// Strip any follow-up session scaffold before using as fallback
const cleanSessionContent = AgentExecutor.stripFollowUpScaffold(sessionContent);
const fallback = cleanSessionContent.trim();
if (fallback) {
await callbacks.saveFeatureSummary(projectPath, featureId, fallback);
}
logger.warn(
`[AgentExecutor] Mandatory summary extraction failed for pipeline feature ${featureId} (status="${status}")`
);
}
}
private async executeTasksLoop(
options: AgentExecutionOptions,
tasks: ParsedTask[],
@@ -439,14 +521,15 @@ export class AgentExecutor {
}
}
if (!taskCompleteDetected) {
const cid = detectTaskCompleteMarker(taskOutput);
if (cid) {
const completeMarker = detectTaskCompleteMarker(taskOutput);
if (completeMarker) {
taskCompleteDetected = true;
await this.featureStateManager.updateTaskStatus(
projectPath,
featureId,
cid,
'completed'
completeMarker.id,
'completed',
completeMarker.summary
);
}
}
@@ -524,8 +607,6 @@ export class AgentExecutor {
}
}
}
const summary = extractSummary(responseText);
if (summary) await callbacks.saveFeatureSummary(projectPath, featureId, summary);
return { responseText, tasksCompleted, aborted: false };
}
@@ -722,8 +803,6 @@ export class AgentExecutor {
);
responseText = r.responseText;
}
const summary = extractSummary(responseText);
if (summary) await callbacks.saveFeatureSummary(projectPath, featureId, summary);
return { responseText, tasksCompleted };
}

View File

@@ -15,6 +15,12 @@ const logger = createLogger('AutoLoopCoordinator');
const CONSECUTIVE_FAILURE_THRESHOLD = 3;
const FAILURE_WINDOW_MS = 60000;
// Sleep intervals for the auto-loop (in milliseconds)
const SLEEP_INTERVAL_CAPACITY_MS = 5000;
const SLEEP_INTERVAL_IDLE_MS = 10000;
const SLEEP_INTERVAL_NORMAL_MS = 2000;
const SLEEP_INTERVAL_ERROR_MS = 5000;
export interface AutoModeConfig {
maxConcurrency: number;
useWorktrees: boolean;
@@ -169,20 +175,32 @@ export class AutoLoopCoordinator {
// presence is accounted for when deciding whether to dispatch new auto-mode tasks.
const runningCount = await this.getRunningCountForWorktree(projectPath, branchName);
if (runningCount >= projectState.config.maxConcurrency) {
await this.sleep(5000, projectState.abortController.signal);
await this.sleep(SLEEP_INTERVAL_CAPACITY_MS, projectState.abortController.signal);
continue;
}
const pendingFeatures = await this.loadPendingFeaturesFn(projectPath, branchName);
if (pendingFeatures.length === 0) {
if (runningCount === 0 && !projectState.hasEmittedIdleEvent) {
this.eventBus.emitAutoModeEvent('auto_mode_idle', {
message: 'No pending features - auto mode idle',
// Double-check that we have no features in 'in_progress' state that might
// have been released from the concurrency manager but not yet updated to
// their final status. This prevents auto_mode_idle from firing prematurely
// when features are transitioning states (e.g., during status update).
const hasInProgressFeatures = await this.hasInProgressFeaturesForWorktree(
projectPath,
branchName,
});
projectState.hasEmittedIdleEvent = true;
branchName
);
// Only emit auto_mode_idle if we're truly done with all features
if (!hasInProgressFeatures) {
this.eventBus.emitAutoModeEvent('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath,
branchName,
});
projectState.hasEmittedIdleEvent = true;
}
}
await this.sleep(10000, projectState.abortController.signal);
await this.sleep(SLEEP_INTERVAL_IDLE_MS, projectState.abortController.signal);
continue;
}
@@ -228,10 +246,10 @@ export class AutoLoopCoordinator {
}
});
}
await this.sleep(2000, projectState.abortController.signal);
await this.sleep(SLEEP_INTERVAL_NORMAL_MS, projectState.abortController.signal);
} catch {
if (projectState.abortController.signal.aborted) break;
await this.sleep(5000, projectState.abortController.signal);
await this.sleep(SLEEP_INTERVAL_ERROR_MS, projectState.abortController.signal);
}
}
projectState.isRunning = false;
@@ -462,4 +480,48 @@ export class AutoLoopCoordinator {
signal?.addEventListener('abort', onAbort);
});
}
/**
* Check if a feature belongs to the current worktree based on branch name.
* For main worktree (branchName === null or 'main'): includes features with no branchName or branchName === 'main'.
* For feature worktrees (branchName !== null and !== 'main'): only includes features with matching branchName.
*/
private featureBelongsToWorktree(feature: Feature, branchName: string | null): boolean {
const isMainWorktree = branchName === null || branchName === 'main';
if (isMainWorktree) {
// Main worktree: include features with no branchName or branchName === 'main'
return !feature.branchName || feature.branchName === 'main';
} else {
// Feature worktree: only include exact branch match
return feature.branchName === branchName;
}
}
/**
* Check if there are features in 'in_progress' status for the current worktree.
* This prevents auto_mode_idle from firing prematurely when features are
* transitioning states (e.g., during status update from in_progress to completed).
*/
private async hasInProgressFeaturesForWorktree(
projectPath: string,
branchName: string | null
): Promise<boolean> {
if (!this.loadAllFeaturesFn) {
return false;
}
try {
const allFeatures = await this.loadAllFeaturesFn(projectPath);
return allFeatures.some(
(f) => f.status === 'in_progress' && this.featureBelongsToWorktree(f, branchName)
);
} catch (error) {
const errorInfo = classifyError(error);
logger.warn(
`Failed to load all features for idle check (projectPath=${projectPath}, branchName=${branchName}): ${errorInfo.message}`,
error
);
return false;
}
}
}

View File

@@ -232,9 +232,10 @@ export class AutoModeServiceCompat {
}
async detectOrphanedFeatures(
projectPath: string
projectPath: string,
preloadedFeatures?: Feature[]
): Promise<Array<{ feature: Feature; missingBranch: string }>> {
const facade = this.createFacade(projectPath);
return facade.detectOrphanedFeatures();
return facade.detectOrphanedFeatures(preloadedFeatures);
}
}

View File

@@ -15,7 +15,12 @@ import path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import type { Feature, PlanningMode, ThinkingLevel, ReasoningEffort } from '@automaker/types';
import { DEFAULT_MAX_CONCURRENCY, DEFAULT_MODELS, stripProviderPrefix } from '@automaker/types';
import {
DEFAULT_MAX_CONCURRENCY,
DEFAULT_MODELS,
stripProviderPrefix,
isPipelineStatus,
} from '@automaker/types';
import { resolveModelString } from '@automaker/model-resolver';
import { createLogger, loadContextFiles, classifyError } from '@automaker/utils';
import { getFeatureDir } from '@automaker/platform';
@@ -23,7 +28,7 @@ import * as secureFs from '../../lib/secure-fs.js';
import { validateWorkingDirectory, createAutoModeOptions } from '../../lib/sdk-options.js';
import {
getPromptCustomization,
getProviderByModelId,
resolveProviderContext,
getMCPServersFromSettings,
getDefaultMaxTurnsSetting,
} from '../../lib/settings-helpers.js';
@@ -79,6 +84,37 @@ export class AutoModeServiceFacade {
private readonly settingsService: SettingsService | null
) {}
/**
* Determine if a feature is eligible to be picked up by the auto-mode loop.
*
* @param feature - The feature to check
* @param branchName - The current worktree branch name (null for main)
* @param primaryBranch - The resolved primary branch name for the project
* @returns True if the feature is eligible for auto-dispatch
*/
public static isFeatureEligibleForAutoMode(
feature: Feature,
branchName: string | null,
primaryBranch: string | null
): boolean {
const isEligibleStatus =
feature.status === 'backlog' ||
feature.status === 'ready' ||
feature.status === 'interrupted' ||
isPipelineStatus(feature.status);
if (!isEligibleStatus) return false;
// Filter by branch/worktree alignment
if (branchName === null) {
// For main worktree, include features with no branch or matching primary branch
return !feature.branchName || (primaryBranch != null && feature.branchName === primaryBranch);
} else {
// For named worktrees, only include features matching that branch
return feature.branchName === branchName;
}
}
/**
* Classify and log an error at the facade boundary.
* Emits an error event to the UI so failures are surfaced to the user.
@@ -190,8 +226,7 @@ export class AutoModeServiceFacade {
/**
* Shared agent-run helper used by both PipelineOrchestrator and ExecutionService.
*
* Resolves the model string, looks up the custom provider/credentials via
* getProviderByModelId, then delegates to agentExecutor.execute with the
* Resolves provider/model context, then delegates to agentExecutor.execute with the
* full payload. The opts parameter uses an index-signature union so it
* accepts both the typed ExecutionService opts object and the looser
* Record<string, unknown> used by PipelineOrchestrator without requiring
@@ -217,6 +252,7 @@ export class AutoModeServiceFacade {
thinkingLevel?: ThinkingLevel;
reasoningEffort?: ReasoningEffort;
branchName?: string | null;
status?: string; // Feature status for pipeline summary check
[key: string]: unknown;
}
): Promise<void> => {
@@ -229,16 +265,19 @@ export class AutoModeServiceFacade {
| import('@automaker/types').ClaudeCompatibleProvider
| undefined;
let credentials: import('@automaker/types').Credentials | undefined;
let providerResolvedModel: string | undefined;
if (settingsService) {
const providerResult = await getProviderByModelId(
resolvedModel,
const providerId = opts?.providerId as string | undefined;
const result = await resolveProviderContext(
settingsService,
resolvedModel,
providerId,
'[AutoModeFacade]'
);
if (providerResult.provider) {
claudeCompatibleProvider = providerResult.provider;
credentials = providerResult.credentials;
}
claudeCompatibleProvider = result.provider;
credentials = result.credentials;
providerResolvedModel = result.resolvedModel;
}
// Build sdkOptions with proper maxTurns and allowedTools for auto-mode.
@@ -264,7 +303,7 @@ export class AutoModeServiceFacade {
const sdkOpts = createAutoModeOptions({
cwd: workDir,
model: resolvedModel,
model: providerResolvedModel || resolvedModel,
systemPrompt: opts?.systemPrompt,
abortController,
autoLoadClaudeMd,
@@ -276,8 +315,14 @@ export class AutoModeServiceFacade {
| undefined,
});
if (!sdkOpts) {
logger.error(
`[createRunAgentFn] sdkOpts is UNDEFINED! createAutoModeOptions type: ${typeof createAutoModeOptions}`
);
}
logger.info(
`[createRunAgentFn] Feature ${featureId}: model=${resolvedModel}, ` +
`[createRunAgentFn] Feature ${featureId}: model=${resolvedModel} (resolved=${providerResolvedModel || resolvedModel}), ` +
`maxTurns=${sdkOpts.maxTurns}, allowedTools=${(sdkOpts.allowedTools as string[])?.length ?? 'default'}, ` +
`provider=${provider.getName()}`
);
@@ -300,6 +345,7 @@ export class AutoModeServiceFacade {
thinkingLevel: opts?.thinkingLevel as ThinkingLevel | undefined,
reasoningEffort: opts?.reasoningEffort as ReasoningEffort | undefined,
branchName: opts?.branchName as string | null | undefined,
status: opts?.status as string | undefined,
provider,
effectiveBareModel,
credentials,
@@ -373,12 +419,8 @@ export class AutoModeServiceFacade {
if (branchName === null) {
primaryBranch = await worktreeResolver.getCurrentBranch(pPath);
}
return features.filter(
(f) =>
(f.status === 'backlog' || f.status === 'ready') &&
(branchName === null
? !f.branchName || (primaryBranch && f.branchName === primaryBranch)
: f.branchName === branchName)
return features.filter((f) =>
AutoModeServiceFacade.isFeatureEligibleForAutoMode(f, branchName, primaryBranch)
);
},
(pPath, branchName, maxConcurrency) =>
@@ -421,9 +463,25 @@ export class AutoModeServiceFacade {
(pPath, featureId, status) =>
featureStateManager.updateFeatureStatus(pPath, featureId, status),
(pPath, featureId) => featureStateManager.loadFeature(pPath, featureId),
async (_feature) => {
// getPlanningPromptPrefixFn - planning prompts handled by AutoModeService
return '';
async (feature) => {
// getPlanningPromptPrefixFn - select appropriate planning prompt based on feature's planningMode
if (!feature.planningMode || feature.planningMode === 'skip') {
return '';
}
const prompts = await getPromptCustomization(settingsService, '[PlanningPromptPrefix]');
const autoModePrompts = prompts.autoMode;
switch (feature.planningMode) {
case 'lite':
return feature.requirePlanApproval
? autoModePrompts.planningLiteWithApproval + '\n\n'
: autoModePrompts.planningLite + '\n\n';
case 'spec':
return autoModePrompts.planningSpec + '\n\n';
case 'full':
return autoModePrompts.planningFull + '\n\n';
default:
return '';
}
},
(pPath, featureId, summary) =>
featureStateManager.saveFeatureSummary(pPath, featureId, summary),
@@ -1075,12 +1133,13 @@ export class AutoModeServiceFacade {
/**
* Detect orphaned features (features with missing branches)
* @param preloadedFeatures - Optional pre-loaded features to avoid redundant disk reads
*/
async detectOrphanedFeatures(): Promise<OrphanedFeatureInfo[]> {
async detectOrphanedFeatures(preloadedFeatures?: Feature[]): Promise<OrphanedFeatureInfo[]> {
const orphanedFeatures: OrphanedFeatureInfo[] = [];
try {
const allFeatures = await this.featureLoader.getAll(this.projectPath);
const allFeatures = preloadedFeatures ?? (await this.featureLoader.getAll(this.projectPath));
const featuresWithBranches = allFeatures.filter(
(f) => f.branchName && f.branchName.trim() !== ''
);

View File

@@ -193,7 +193,11 @@ export class CodexModelCacheService {
* Infer tier from model ID
*/
private inferTier(modelId: string): 'premium' | 'standard' | 'basic' {
if (modelId.includes('max') || modelId.includes('gpt-5.2-codex')) {
if (
modelId.includes('max') ||
modelId.includes('gpt-5.2-codex') ||
modelId.includes('gpt-5.3-codex')
) {
return 'premium';
}
if (modelId.includes('mini')) {

View File

@@ -13,6 +13,8 @@ import path from 'path';
import net from 'net';
import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../lib/events.js';
import fs from 'fs/promises';
import { constants } from 'fs';
const logger = createLogger('DevServerService');
@@ -86,9 +88,13 @@ const PORT_PATTERNS: Array<{ pattern: RegExp; description: string }> = [
},
];
// Throttle output to prevent overwhelming WebSocket under heavy load
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive feedback
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
// Throttle output to prevent overwhelming WebSocket under heavy load.
// 100ms (~10fps) is sufficient for readable log streaming while keeping
// WebSocket traffic manageable. The previous 4ms rate (~250fps) generated
// up to 250 events/sec which caused progressive browser slowdown from
// accumulated console logs, JSON serialization overhead, and React re-renders.
const OUTPUT_THROTTLE_MS = 100; // ~10fps max update rate
const OUTPUT_BATCH_SIZE = 8192; // Larger batches to compensate for lower frequency
export interface DevServerInfo {
worktreePath: string;
@@ -110,6 +116,21 @@ export interface DevServerInfo {
urlDetected: boolean;
// Timer for URL detection timeout fallback
urlDetectionTimeout: NodeJS.Timeout | null;
// Custom command used to start the server
customCommand?: string;
}
/**
* Persistable subset of DevServerInfo for survival across server restarts
*/
interface PersistedDevServerInfo {
worktreePath: string;
allocatedPort: number;
port: number;
url: string;
startedAt: string;
urlDetected: boolean;
customCommand?: string;
}
// Port allocation starts at 3001 to avoid conflicts with common dev ports
@@ -121,8 +142,20 @@ const LIVERELOAD_PORTS = [35729, 35730, 35731] as const;
class DevServerService {
private runningServers: Map<string, DevServerInfo> = new Map();
private startingServers: Set<string> = new Set();
private allocatedPorts: Set<number> = new Set();
private emitter: EventEmitter | null = null;
private dataDir: string | null = null;
private saveQueue: Promise<void> = Promise.resolve();
/**
* Initialize the service with data directory for persistence
*/
async initialize(dataDir: string, emitter: EventEmitter): Promise<void> {
this.dataDir = dataDir;
this.emitter = emitter;
await this.loadState();
}
/**
* Set the event emitter for streaming log events
@@ -132,6 +165,131 @@ class DevServerService {
this.emitter = emitter;
}
/**
* Save the current state of running servers to disk
*/
private async saveState(): Promise<void> {
if (!this.dataDir) return;
// Queue the save operation to prevent concurrent writes
this.saveQueue = this.saveQueue
.then(async () => {
if (!this.dataDir) return;
try {
const statePath = path.join(this.dataDir, 'dev-servers.json');
const persistedInfo: PersistedDevServerInfo[] = Array.from(
this.runningServers.values()
).map((s) => ({
worktreePath: s.worktreePath,
allocatedPort: s.allocatedPort,
port: s.port,
url: s.url,
startedAt: s.startedAt.toISOString(),
urlDetected: s.urlDetected,
customCommand: s.customCommand,
}));
await fs.writeFile(statePath, JSON.stringify(persistedInfo, null, 2));
logger.debug(`Saved dev server state to ${statePath}`);
} catch (error) {
logger.error('Failed to save dev server state:', error);
}
})
.catch((error) => {
logger.error('Error in save queue:', error);
});
return this.saveQueue;
}
/**
* Load the state of running servers from disk
*/
private async loadState(): Promise<void> {
if (!this.dataDir) return;
try {
const statePath = path.join(this.dataDir, 'dev-servers.json');
try {
await fs.access(statePath, constants.F_OK);
} catch {
// File doesn't exist, which is fine
return;
}
const content = await fs.readFile(statePath, 'utf-8');
const rawParsed: unknown = JSON.parse(content);
if (!Array.isArray(rawParsed)) {
logger.warn('Dev server state file is not an array, skipping load');
return;
}
const persistedInfo: PersistedDevServerInfo[] = rawParsed.filter((entry: unknown) => {
if (entry === null || typeof entry !== 'object') {
logger.warn('Dropping invalid dev server entry (not an object):', entry);
return false;
}
const e = entry as Record<string, unknown>;
const valid =
typeof e.worktreePath === 'string' &&
e.worktreePath.length > 0 &&
typeof e.allocatedPort === 'number' &&
Number.isInteger(e.allocatedPort) &&
e.allocatedPort >= 1 &&
e.allocatedPort <= 65535 &&
typeof e.port === 'number' &&
Number.isInteger(e.port) &&
e.port >= 1 &&
e.port <= 65535 &&
typeof e.url === 'string' &&
typeof e.startedAt === 'string' &&
typeof e.urlDetected === 'boolean' &&
(e.customCommand === undefined || typeof e.customCommand === 'string');
if (!valid) {
logger.warn('Dropping malformed dev server entry:', e);
}
return valid;
}) as PersistedDevServerInfo[];
logger.info(`Loading ${persistedInfo.length} dev servers from state`);
for (const info of persistedInfo) {
// Check if the process is still running on the port
// Since we can't reliably re-attach to the process for output,
// we'll just check if the port is in use.
const portInUse = !(await this.isPortAvailable(info.port));
if (portInUse) {
logger.info(`Re-attached to dev server on port ${info.port} for ${info.worktreePath}`);
const serverInfo: DevServerInfo = {
...info,
startedAt: new Date(info.startedAt),
process: null, // Process object is lost, but we know it's running
scrollbackBuffer: '',
outputBuffer: '',
flushTimeout: null,
stopping: false,
urlDetectionTimeout: null,
};
this.runningServers.set(info.worktreePath, serverInfo);
this.allocatedPorts.add(info.allocatedPort);
} else {
logger.info(
`Dev server on port ${info.port} for ${info.worktreePath} is no longer running`
);
}
}
// Cleanup stale entries from the file if any
if (this.runningServers.size !== persistedInfo.length) {
await this.saveState();
}
} catch (error) {
logger.error('Failed to load dev server state:', error);
}
}
/**
* Prune a stale server entry whose process has exited without cleanup.
* Clears any pending timers, removes the port from allocatedPorts, deletes
@@ -148,6 +306,10 @@ class DevServerService {
// been mutated by detectUrlFromOutput to reflect the actual detected port.
this.allocatedPorts.delete(server.allocatedPort);
this.runningServers.delete(worktreePath);
// Persist state change
this.saveState().catch((err) => logger.error('Failed to save state in pruneStaleServer:', err));
if (this.emitter) {
this.emitter.emit('dev-server:stopped', {
worktreePath,
@@ -249,7 +411,7 @@ class DevServerService {
* - PHP: "Development Server (http://localhost:8000) started"
* - Generic: Any localhost URL with a port
*/
private detectUrlFromOutput(server: DevServerInfo, content: string): void {
private async detectUrlFromOutput(server: DevServerInfo, content: string): Promise<void> {
// Skip if URL already detected
if (server.urlDetected) {
return;
@@ -304,6 +466,11 @@ class DevServerService {
logger.info(`Detected server URL via ${description}: ${detectedUrl}`);
// Persist state change
await this.saveState().catch((err) =>
logger.error('Failed to save state in detectUrlFromOutput:', err)
);
// Emit URL update event
if (this.emitter) {
this.emitter.emit('dev-server:url-detected', {
@@ -346,6 +513,11 @@ class DevServerService {
logger.info(`Detected server port via ${description}: ${detectedPort}${detectedUrl}`);
// Persist state change
await this.saveState().catch((err) =>
logger.error('Failed to save state in detectUrlFromOutput Phase 2:', err)
);
// Emit URL update event
if (this.emitter) {
this.emitter.emit('dev-server:url-detected', {
@@ -365,7 +537,7 @@ class DevServerService {
* Handle incoming stdout/stderr data from dev server process
* Buffers data for scrollback replay and schedules throttled emission
*/
private handleProcessOutput(server: DevServerInfo, data: Buffer): void {
private async handleProcessOutput(server: DevServerInfo, data: Buffer): Promise<void> {
// Skip output if server is stopping
if (server.stopping) {
return;
@@ -374,7 +546,7 @@ class DevServerService {
const content = data.toString();
// Try to detect actual server URL from output
this.detectUrlFromOutput(server, content);
await this.detectUrlFromOutput(server, content);
// Append to scrollback buffer for replay on reconnect
this.appendToScrollback(server, content);
@@ -594,261 +766,305 @@ class DevServerService {
};
error?: string;
}> {
// Check if already running
if (this.runningServers.has(worktreePath)) {
const existing = this.runningServers.get(worktreePath)!;
return {
success: true,
result: {
worktreePath: existing.worktreePath,
port: existing.port,
url: existing.url,
message: `Dev server already running on port ${existing.port}`,
},
};
}
// Verify the worktree exists
if (!(await this.fileExists(worktreePath))) {
// Check if already running or starting
if (this.runningServers.has(worktreePath) || this.startingServers.has(worktreePath)) {
const existing = this.runningServers.get(worktreePath);
if (existing) {
return {
success: true,
result: {
worktreePath: existing.worktreePath,
port: existing.port,
url: existing.url,
message: `Dev server already running on port ${existing.port}`,
},
};
}
return {
success: false,
error: `Worktree path does not exist: ${worktreePath}`,
error: 'Dev server is already starting',
};
}
// Determine the dev command to use
let devCommand: { cmd: string; args: string[] };
this.startingServers.add(worktreePath);
// Normalize custom command: trim whitespace and treat empty strings as undefined
const normalizedCustomCommand = customCommand?.trim();
if (normalizedCustomCommand) {
// Use the provided custom command
devCommand = this.parseCustomCommand(normalizedCustomCommand);
if (!devCommand.cmd) {
return {
success: false,
error: 'Invalid custom command: command cannot be empty',
};
}
logger.debug(`Using custom command: ${normalizedCustomCommand}`);
} else {
// Check for package.json when auto-detecting
const packageJsonPath = path.join(worktreePath, 'package.json');
if (!(await this.fileExists(packageJsonPath))) {
return {
success: false,
error: `No package.json found in: ${worktreePath}`,
};
}
// Get dev command from package manager detection
const detectedCommand = await this.getDevCommand(worktreePath);
if (!detectedCommand) {
return {
success: false,
error: `Could not determine dev command for: ${worktreePath}`,
};
}
devCommand = detectedCommand;
}
// Find available port
let port: number;
try {
port = await this.findAvailablePort();
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Port allocation failed',
};
}
// Reserve the port (port was already force-killed in findAvailablePort)
this.allocatedPorts.add(port);
// Also kill common related ports (livereload, etc.)
// Some dev servers use fixed ports for HMR/livereload regardless of main port
for (const relatedPort of LIVERELOAD_PORTS) {
this.killProcessOnPort(relatedPort);
}
// Small delay to ensure related ports are freed
await new Promise((resolve) => setTimeout(resolve, 100));
logger.info(`Starting dev server on port ${port}`);
logger.debug(`Working directory (cwd): ${worktreePath}`);
logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`);
// Spawn the dev process with PORT environment variable
// FORCE_COLOR enables colored output even when not running in a TTY
const env = {
...process.env,
PORT: String(port),
FORCE_COLOR: '1',
// Some tools use these additional env vars for color detection
COLORTERM: 'truecolor',
TERM: 'xterm-256color',
};
const devProcess = spawn(devCommand.cmd, devCommand.args, {
cwd: worktreePath,
env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
// Track if process failed early using object to work around TypeScript narrowing
const status = { error: null as string | null, exited: false };
// Create server info early so we can reference it in handlers
// We'll add it to runningServers after verifying the process started successfully
const hostname = process.env.HOSTNAME || 'localhost';
const serverInfo: DevServerInfo = {
worktreePath,
allocatedPort: port, // Immutable: records which port we reserved; never changed after this point
port,
url: `http://${hostname}:${port}`, // Initial URL, may be updated by detectUrlFromOutput
process: devProcess,
startedAt: new Date(),
scrollbackBuffer: '',
outputBuffer: '',
flushTimeout: null,
stopping: false,
urlDetected: false, // Will be set to true when actual URL is detected from output
urlDetectionTimeout: null, // Will be set after server starts successfully
};
// Capture stdout with buffer management and event emission
if (devProcess.stdout) {
devProcess.stdout.on('data', (data: Buffer) => {
this.handleProcessOutput(serverInfo, data);
});
}
// Capture stderr with buffer management and event emission
if (devProcess.stderr) {
devProcess.stderr.on('data', (data: Buffer) => {
this.handleProcessOutput(serverInfo, data);
});
}
// Helper to clean up resources and emit stop event
const cleanupAndEmitStop = (exitCode: number | null, errorMessage?: string) => {
if (serverInfo.flushTimeout) {
clearTimeout(serverInfo.flushTimeout);
serverInfo.flushTimeout = null;
// Verify the worktree exists
if (!(await this.fileExists(worktreePath))) {
return {
success: false,
error: `Worktree path does not exist: ${worktreePath}`,
};
}
// Clear URL detection timeout to prevent stale fallback emission
if (serverInfo.urlDetectionTimeout) {
clearTimeout(serverInfo.urlDetectionTimeout);
serverInfo.urlDetectionTimeout = null;
// Determine the dev command to use
let devCommand: { cmd: string; args: string[] };
// Normalize custom command: trim whitespace and treat empty strings as undefined
const normalizedCustomCommand = customCommand?.trim();
if (normalizedCustomCommand) {
// Use the provided custom command
devCommand = this.parseCustomCommand(normalizedCustomCommand);
if (!devCommand.cmd) {
return {
success: false,
error: 'Invalid custom command: command cannot be empty',
};
}
logger.debug(`Using custom command: ${normalizedCustomCommand}`);
} else {
// Check for package.json when auto-detecting
const packageJsonPath = path.join(worktreePath, 'package.json');
if (!(await this.fileExists(packageJsonPath))) {
return {
success: false,
error: `No package.json found in: ${worktreePath}`,
};
}
// Get dev command from package manager detection
const detectedCommand = await this.getDevCommand(worktreePath);
if (!detectedCommand) {
return {
success: false,
error: `Could not determine dev command for: ${worktreePath}`,
};
}
devCommand = detectedCommand;
}
// Emit stopped event (only if not already stopping - prevents duplicate events)
if (this.emitter && !serverInfo.stopping) {
this.emitter.emit('dev-server:stopped', {
// Find available port
let port: number;
try {
port = await this.findAvailablePort();
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Port allocation failed',
};
}
// Reserve the port (port was already force-killed in findAvailablePort)
this.allocatedPorts.add(port);
// Also kill common related ports (livereload, etc.)
// Some dev servers use fixed ports for HMR/livereload regardless of main port
for (const relatedPort of LIVERELOAD_PORTS) {
this.killProcessOnPort(relatedPort);
}
// Small delay to ensure related ports are freed
await new Promise((resolve) => setTimeout(resolve, 100));
logger.info(`Starting dev server on port ${port}`);
logger.debug(`Working directory (cwd): ${worktreePath}`);
logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`);
// Emit starting only after preflight checks pass to avoid dangling starting state.
if (this.emitter) {
this.emitter.emit('dev-server:starting', {
worktreePath,
port: serverInfo.port, // Use the detected port (may differ from allocated port if detectUrlFromOutput updated it)
exitCode,
error: errorMessage,
timestamp: new Date().toISOString(),
});
}
this.allocatedPorts.delete(serverInfo.allocatedPort);
this.runningServers.delete(worktreePath);
};
devProcess.on('error', (error) => {
logger.error(`Process error:`, error);
status.error = error.message;
cleanupAndEmitStop(null, error.message);
});
devProcess.on('exit', (code) => {
logger.info(`Process for ${worktreePath} exited with code ${code}`);
status.exited = true;
cleanupAndEmitStop(code);
});
// Wait a moment to see if the process fails immediately
await new Promise((resolve) => setTimeout(resolve, 500));
if (status.error) {
return {
success: false,
error: `Failed to start dev server: ${status.error}`,
// Spawn the dev process with PORT environment variable
// FORCE_COLOR enables colored output even when not running in a TTY
const env = {
...process.env,
PORT: String(port),
FORCE_COLOR: '1',
// Some tools use these additional env vars for color detection
COLORTERM: 'truecolor',
TERM: 'xterm-256color',
};
}
if (status.exited) {
return {
success: false,
error: `Dev server process exited immediately. Check server logs for details.`,
};
}
// Server started successfully - add to running servers map
this.runningServers.set(worktreePath, serverInfo);
// Emit started event for WebSocket subscribers
if (this.emitter) {
this.emitter.emit('dev-server:started', {
worktreePath,
port,
url: serverInfo.url,
timestamp: new Date().toISOString(),
const devProcess = spawn(devCommand.cmd, devCommand.args, {
cwd: worktreePath,
env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
}
// Set up URL detection timeout fallback.
// If URL detection hasn't succeeded after URL_DETECTION_TIMEOUT_MS, check if
// the allocated port is actually in use (server probably started successfully)
// and emit a url-detected event with the allocated port as fallback.
// Also re-scan the scrollback buffer in case the URL was printed before
// our patterns could match (e.g., it was split across multiple data chunks).
serverInfo.urlDetectionTimeout = setTimeout(() => {
serverInfo.urlDetectionTimeout = null;
// Track if process failed early using object to work around TypeScript narrowing
const status = { error: null as string | null, exited: false };
// Only run fallback if server is still running and URL wasn't detected
if (serverInfo.stopping || serverInfo.urlDetected || !this.runningServers.has(worktreePath)) {
return;
// Create server info early so we can reference it in handlers
// We'll add it to runningServers after verifying the process started successfully
const fallbackHost = 'localhost';
const serverInfo: DevServerInfo = {
worktreePath,
allocatedPort: port, // Immutable: records which port we reserved; never changed after this point
port,
url: `http://${fallbackHost}:${port}`, // Initial URL, may be updated by detectUrlFromOutput
process: devProcess,
startedAt: new Date(),
scrollbackBuffer: '',
outputBuffer: '',
flushTimeout: null,
stopping: false,
urlDetected: false, // Will be set to true when actual URL is detected from output
urlDetectionTimeout: null, // Will be set after server starts successfully
customCommand: normalizedCustomCommand,
};
// Capture stdout with buffer management and event emission
if (devProcess.stdout) {
devProcess.stdout.on('data', (data: Buffer) => {
this.handleProcessOutput(serverInfo, data).catch((error: unknown) => {
logger.error('Failed to handle dev server stdout output:', error);
});
});
}
// Re-scan the entire scrollback buffer for URL patterns
// This catches cases where the URL was split across multiple output chunks
logger.info(`URL detection timeout for ${worktreePath}, re-scanning scrollback buffer`);
this.detectUrlFromOutput(serverInfo, serverInfo.scrollbackBuffer);
// Capture stderr with buffer management and event emission
if (devProcess.stderr) {
devProcess.stderr.on('data', (data: Buffer) => {
this.handleProcessOutput(serverInfo, data).catch((error: unknown) => {
logger.error('Failed to handle dev server stderr output:', error);
});
});
}
// If still not detected after full rescan, use the allocated port as fallback
if (!serverInfo.urlDetected) {
logger.info(`URL detection fallback: using allocated port ${port} for ${worktreePath}`);
const fallbackUrl = `http://${hostname}:${port}`;
serverInfo.url = fallbackUrl;
serverInfo.urlDetected = true;
// Helper to clean up resources and emit stop event
const cleanupAndEmitStop = (exitCode: number | null, errorMessage?: string) => {
if (serverInfo.flushTimeout) {
clearTimeout(serverInfo.flushTimeout);
serverInfo.flushTimeout = null;
}
if (this.emitter) {
this.emitter.emit('dev-server:url-detected', {
// Clear URL detection timeout to prevent stale fallback emission
if (serverInfo.urlDetectionTimeout) {
clearTimeout(serverInfo.urlDetectionTimeout);
serverInfo.urlDetectionTimeout = null;
}
// Emit stopped event (only if not already stopping - prevents duplicate events)
if (this.emitter && !serverInfo.stopping) {
this.emitter.emit('dev-server:stopped', {
worktreePath,
url: fallbackUrl,
port,
port: serverInfo.port, // Use the detected port (may differ from allocated port if detectUrlFromOutput updated it)
exitCode,
error: errorMessage,
timestamp: new Date().toISOString(),
});
}
}
}, URL_DETECTION_TIMEOUT_MS);
return {
success: true,
result: {
worktreePath,
port,
url: `http://${hostname}:${port}`,
message: `Dev server started on port ${port}`,
},
};
this.allocatedPorts.delete(serverInfo.allocatedPort);
this.runningServers.delete(worktreePath);
// Persist state change
this.saveState().catch((err) => logger.error('Failed to save state in cleanup:', err));
};
devProcess.on('error', (error) => {
logger.error(`Process error:`, error);
status.error = error.message;
cleanupAndEmitStop(null, error.message);
});
devProcess.on('exit', (code) => {
logger.info(`Process for ${worktreePath} exited with code ${code}`);
status.exited = true;
cleanupAndEmitStop(code);
});
// Wait a moment to see if the process fails immediately
await new Promise((resolve) => setTimeout(resolve, 500));
if (status.error) {
return {
success: false,
error: `Failed to start dev server: ${status.error}`,
};
}
if (status.exited) {
return {
success: false,
error: `Dev server process exited immediately. Check server logs for details.`,
};
}
// Server started successfully - add to running servers map
this.runningServers.set(worktreePath, serverInfo);
// Persist state change
await this.saveState().catch((err) =>
logger.error('Failed to save state in startDevServer:', err)
);
// Emit started event for WebSocket subscribers
if (this.emitter) {
this.emitter.emit('dev-server:started', {
worktreePath,
port,
url: serverInfo.url,
timestamp: new Date().toISOString(),
});
}
// Set up URL detection timeout fallback.
// If URL detection hasn't succeeded after URL_DETECTION_TIMEOUT_MS, check if
// the allocated port is actually in use (server probably started successfully)
// and emit a url-detected event with the allocated port as fallback.
// Also re-scan the scrollback buffer in case the URL was printed before
// our patterns could match (e.g., it was split across multiple data chunks).
serverInfo.urlDetectionTimeout = setTimeout(async () => {
serverInfo.urlDetectionTimeout = null;
// Only run fallback if server is still running and URL wasn't detected
if (
serverInfo.stopping ||
serverInfo.urlDetected ||
!this.runningServers.has(worktreePath)
) {
return;
}
// Re-scan the entire scrollback buffer for URL patterns
// This catches cases where the URL was split across multiple output chunks
logger.info(`URL detection timeout for ${worktreePath}, re-scanning scrollback buffer`);
await this.detectUrlFromOutput(serverInfo, serverInfo.scrollbackBuffer).catch((err) =>
logger.error('Failed to re-scan scrollback buffer:', err)
);
// If still not detected after full rescan, use the allocated port as fallback
if (!serverInfo.urlDetected) {
logger.info(`URL detection fallback: using allocated port ${port} for ${worktreePath}`);
const fallbackUrl = `http://${fallbackHost}:${port}`;
serverInfo.url = fallbackUrl;
serverInfo.urlDetected = true;
// Persist state change
await this.saveState().catch((err) =>
logger.error('Failed to save state in URL detection fallback:', err)
);
if (this.emitter) {
this.emitter.emit('dev-server:url-detected', {
worktreePath: serverInfo.worktreePath,
url: fallbackUrl,
port,
timestamp: new Date().toISOString(),
});
}
}
}, URL_DETECTION_TIMEOUT_MS);
return {
success: true,
result: {
worktreePath: serverInfo.worktreePath,
port: serverInfo.port,
url: serverInfo.url,
message: `Dev server started on port ${port}`,
},
};
} finally {
this.startingServers.delete(worktreePath);
}
}
/**
@@ -904,9 +1120,11 @@ class DevServerService {
});
}
// Kill the process
// Kill the process; persisted/re-attached entries may not have a process handle.
if (server.process && !server.process.killed) {
server.process.kill('SIGTERM');
} else {
this.killProcessOnPort(server.port);
}
// Free the originally-reserved port slot (allocatedPort is immutable and always
@@ -915,6 +1133,11 @@ class DevServerService {
this.allocatedPorts.delete(server.allocatedPort);
this.runningServers.delete(worktreePath);
// Persist state change
await this.saveState().catch((err) =>
logger.error('Failed to save state in stopDevServer:', err)
);
return {
success: true,
result: {

View File

@@ -27,7 +27,11 @@ import type {
EventHookTrigger,
EventHookShellAction,
EventHookHttpAction,
EventHookNtfyAction,
NtfyEndpointConfig,
EventHookContext,
} from '@automaker/types';
import { ntfyService, type NtfyContext } from './ntfy-service.js';
const execAsync = promisify(exec);
const logger = createLogger('EventHooks');
@@ -38,19 +42,8 @@ const DEFAULT_SHELL_TIMEOUT = 30000;
/** Default timeout for HTTP requests (10 seconds) */
const DEFAULT_HTTP_TIMEOUT = 10000;
/**
* Context available for variable substitution in hooks
*/
interface HookContext {
featureId?: string;
featureName?: string;
projectPath?: string;
projectName?: string;
error?: string;
errorType?: string;
timestamp: string;
eventType: EventHookTrigger;
}
// Use the shared EventHookContext type (aliased locally as HookContext for clarity)
type HookContext = EventHookContext;
/**
* Auto-mode event payload structure
@@ -451,6 +444,8 @@ export class EventHookService {
await this.executeShellHook(hook.action, context, hookName);
} else if (hook.action.type === 'http') {
await this.executeHttpHook(hook.action, context, hookName);
} else if (hook.action.type === 'ntfy') {
await this.executeNtfyHook(hook.action, context, hookName);
}
} catch (error) {
logger.error(`Hook "${hookName}" failed:`, error);
@@ -558,6 +553,86 @@ export class EventHookService {
}
}
/**
* Execute an ntfy.sh notification hook
*/
private async executeNtfyHook(
action: EventHookNtfyAction,
context: HookContext,
hookName: string
): Promise<void> {
if (!this.settingsService) {
logger.warn('Settings service not available for ntfy hook');
return;
}
// Get the endpoint configuration
const settings = await this.settingsService.getGlobalSettings();
const endpoints = settings.ntfyEndpoints || [];
const endpoint = endpoints.find((e) => e.id === action.endpointId);
if (!endpoint) {
logger.error(`Ntfy hook "${hookName}" references unknown endpoint: ${action.endpointId}`);
return;
}
// Convert HookContext to NtfyContext
const ntfyContext: NtfyContext = {
featureId: context.featureId,
featureName: context.featureName,
projectPath: context.projectPath,
projectName: context.projectName,
error: context.error,
errorType: context.errorType,
timestamp: context.timestamp,
eventType: context.eventType,
};
// Resolve click URL: action-level overrides endpoint default
let clickUrl = action.clickUrl || endpoint.defaultClickUrl;
// Apply deep-link parameters to the resolved click URL
if (clickUrl && context.projectPath) {
try {
const url = new URL(clickUrl);
url.pathname = '/board';
// Add projectPath so the UI can switch to the correct project
url.searchParams.set('projectPath', context.projectPath);
// Add featureId as query param for deep linking to board with feature output modal
if (context.featureId) {
url.searchParams.set('featureId', context.featureId);
}
clickUrl = url.toString();
} catch (error) {
// If URL parsing fails, log warning and use as-is
logger.warn(
`Failed to parse click URL "${clickUrl}" for deep linking: ${error instanceof Error ? error.message : String(error)}`
);
}
}
logger.info(`Executing ntfy hook "${hookName}" to endpoint "${endpoint.name}"`);
const result = await ntfyService.sendNotification(
endpoint,
{
title: action.title,
body: action.body,
tags: action.tags,
emoji: action.emoji,
clickUrl,
priority: action.priority,
},
ntfyContext
);
if (!result.success) {
logger.warn(`Ntfy hook "${hookName}" failed: ${result.error}`);
} else {
logger.info(`Ntfy hook "${hookName}" completed successfully`);
}
}
/**
* Substitute {{variable}} placeholders in a string
*/

View File

@@ -108,16 +108,14 @@ export class ExecutionService {
return firstLine.length <= 60 ? firstLine : firstLine.substring(0, 57) + '...';
}
buildFeaturePrompt(
feature: Feature,
taskExecutionPrompts: {
implementationInstructions: string;
playwrightVerificationInstructions: string;
}
): string {
/**
* Build feature description section (without implementation instructions).
* Used when planning mode is active — the planning prompt provides its own instructions.
*/
buildFeatureDescription(feature: Feature): string {
const title = this.extractTitleFromDescription(feature.description);
let prompt = `## Feature Implementation Task
let prompt = `## Feature Task
**Feature ID:** ${feature.id}
**Title:** ${title}
@@ -146,6 +144,18 @@ ${feature.spec}
prompt += `\n**Context Images Attached:**\n${feature.imagePaths.length} image(s) attached:\n${imagesList}\n`;
}
return prompt;
}
buildFeaturePrompt(
feature: Feature,
taskExecutionPrompts: {
implementationInstructions: string;
playwrightVerificationInstructions: string;
}
): string {
let prompt = this.buildFeatureDescription(feature);
prompt += feature.skipTests
? `\n${taskExecutionPrompts.implementationInstructions}`
: `\n${taskExecutionPrompts.implementationInstructions}\n\n${taskExecutionPrompts.playwrightVerificationInstructions}`;
@@ -169,6 +179,7 @@ ${feature.spec}
const abortController = tempRunningFeature.abortController;
if (isAutoMode) await this.saveExecutionStateFn(projectPath);
let feature: Feature | null = null;
let pipelineCompleted = false;
try {
validateWorkingDirectory(projectPath);
@@ -214,7 +225,12 @@ ${feature.spec}
const branchName = feature.branchName;
if (!worktreePath && useWorktrees && branchName) {
worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName);
if (worktreePath) logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
if (!worktreePath) {
throw new Error(
`Worktree enabled but no worktree found for feature branch "${branchName}".`
);
}
logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
}
const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
validateWorkingDirectory(workDir);
@@ -268,9 +284,15 @@ ${feature.spec}
if (options?.continuationPrompt) {
prompt = options.continuationPrompt;
} else {
prompt =
(await this.getPlanningPromptPrefixFn(feature)) +
this.buildFeaturePrompt(feature, prompts.taskExecution);
const planningPrefix = await this.getPlanningPromptPrefixFn(feature);
if (planningPrefix) {
// Planning mode active: use planning instructions + feature description only.
// Do NOT include implementationInstructions — they conflict with the planning
// prompt's "DO NOT proceed with implementation until approval" directive.
prompt = planningPrefix + '\n\n' + this.buildFeatureDescription(feature);
} else {
prompt = this.buildFeaturePrompt(feature, prompts.taskExecution);
}
if (feature.planningMode && feature.planningMode !== 'skip') {
this.eventBus.emitAutoModeEvent('planning_started', {
featureId: feature.id,
@@ -304,6 +326,7 @@ ${feature.spec}
useClaudeCodeSystemPrompt,
thinkingLevel: feature.thinkingLevel,
reasoningEffort: feature.reasoningEffort,
providerId: feature.providerId,
branchName: feature.branchName ?? null,
}
);
@@ -370,6 +393,7 @@ Please continue from where you left off and complete all remaining tasks. Use th
useClaudeCodeSystemPrompt,
thinkingLevel: feature.thinkingLevel,
reasoningEffort: feature.reasoningEffort,
providerId: feature.providerId,
branchName: feature.branchName ?? null,
}
);
@@ -408,6 +432,7 @@ Please continue from where you left off and complete all remaining tasks. Use th
testAttempts: 0,
maxTestAttempts: 5,
});
pipelineCompleted = true;
// Check if pipeline set a terminal status (e.g., merge_conflict) — don't overwrite it
const refreshed = await this.loadFeatureFn(projectPath, featureId);
if (refreshed?.status === 'merge_conflict') {
@@ -461,7 +486,10 @@ Please continue from where you left off and complete all remaining tasks. Use th
const hasIncompleteTasks = totalTasks > 0 && completedTasks < totalTasks;
try {
if (agentOutput) {
// Only save summary if feature doesn't already have one (e.g., accumulated from pipeline steps)
// This prevents overwriting accumulated summaries with just the last step's output
// The agent-executor already extracts and saves summaries during execution
if (agentOutput && !completedFeature?.summary) {
const summary = extractSummary(agentOutput);
if (summary) await this.saveFeatureSummaryFn(projectPath, featureId, summary);
}
@@ -515,7 +543,30 @@ Please continue from where you left off and complete all remaining tasks. Use th
}
} else {
logger.error(`Feature ${featureId} failed:`, error);
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog');
// If pipeline steps completed successfully, don't send the feature back to backlog.
// The pipeline work is done — set to waiting_approval so the user can review.
const fallbackStatus = pipelineCompleted ? 'waiting_approval' : 'backlog';
if (pipelineCompleted) {
logger.info(
`[executeFeature] Feature ${featureId} failed after pipeline completed. ` +
`Setting status to waiting_approval instead of backlog to preserve pipeline work.`
);
}
// Don't overwrite terminal states like 'merge_conflict' that were set during pipeline execution
let currentStatus: string | undefined;
try {
const currentFeature = await this.loadFeatureFn(projectPath, featureId);
currentStatus = currentFeature?.status;
} catch (loadErr) {
// If loading fails, log it and proceed with the status update anyway
logger.warn(
`[executeFeature] Failed to reload feature ${featureId} for status check:`,
loadErr
);
}
if (currentStatus !== 'merge_conflict') {
await this.updateFeatureStatusFn(projectPath, featureId, fallbackStatus);
}
this.eventBus.emitAutoModeEvent('auto_mode_error', {
featureId,
featureName: feature?.title,

View File

@@ -34,6 +34,7 @@ export type RunAgentFn = (
useClaudeCodeSystemPrompt?: boolean;
thinkingLevel?: ThinkingLevel;
reasoningEffort?: ReasoningEffort;
providerId?: string;
branchName?: string | null;
}
) => Promise<void>;

View File

@@ -378,6 +378,7 @@ export class FeatureLoader {
description: featureData.description || '',
...featureData,
id: featureId,
createdAt: featureData.createdAt || new Date().toISOString(),
imagePaths: migratedImagePaths,
descriptionHistory: initialHistory,
};

View File

@@ -14,7 +14,8 @@
*/
import path from 'path';
import type { Feature, ParsedTask, PlanSpec } from '@automaker/types';
import type { Feature, FeatureStatusWithPipeline, ParsedTask, PlanSpec } from '@automaker/types';
import { isPipelineStatus } from '@automaker/types';
import {
atomicWriteJson,
readJsonWithRecovery,
@@ -28,9 +29,40 @@ import type { EventEmitter } from '../lib/events.js';
import type { AutoModeEventType } from './typed-event-bus.js';
import { getNotificationService } from './notification-service.js';
import { FeatureLoader } from './feature-loader.js';
import { pipelineService } from './pipeline-service.js';
const logger = createLogger('FeatureStateManager');
// Notification type constants
const NOTIFICATION_TYPE_WAITING_APPROVAL = 'feature_waiting_approval';
const NOTIFICATION_TYPE_VERIFIED = 'feature_verified';
const NOTIFICATION_TYPE_FEATURE_ERROR = 'feature_error';
const NOTIFICATION_TYPE_AUTO_MODE_ERROR = 'auto_mode_error';
// Notification title constants
const NOTIFICATION_TITLE_WAITING_APPROVAL = 'Feature Ready for Review';
const NOTIFICATION_TITLE_VERIFIED = 'Feature Verified';
const NOTIFICATION_TITLE_FEATURE_ERROR = 'Feature Failed';
const NOTIFICATION_TITLE_AUTO_MODE_ERROR = 'Auto Mode Error';
/**
* Auto-mode event payload structure
* This is the payload that comes with 'auto-mode:event' events
*/
interface AutoModeEventPayload {
type?: string;
featureId?: string;
featureName?: string;
passes?: boolean;
executionMode?: 'auto' | 'manual';
message?: string;
error?: string;
errorType?: string;
projectPath?: string;
/** Status field present when type === 'feature_status_changed' */
status?: string;
}
/**
* FeatureStateManager handles feature status updates with persistence guarantees.
*
@@ -43,10 +75,28 @@ const logger = createLogger('FeatureStateManager');
export class FeatureStateManager {
private events: EventEmitter;
private featureLoader: FeatureLoader;
private unsubscribe: (() => void) | null = null;
constructor(events: EventEmitter, featureLoader: FeatureLoader) {
this.events = events;
this.featureLoader = featureLoader;
// Subscribe to error events to create notifications
this.unsubscribe = events.subscribe((type, payload) => {
if (type === 'auto-mode:event') {
this.handleAutoModeEventError(payload as AutoModeEventPayload);
}
});
}
/**
* Cleanup subscriptions
*/
destroy(): void {
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
}
}
/**
@@ -104,77 +154,18 @@ export class FeatureStateManager {
feature.status = status;
feature.updatedAt = new Date().toISOString();
// Set justFinishedAt timestamp when moving to waiting_approval (agent just completed)
// Badge will show for 2 minutes after this timestamp
if (status === 'waiting_approval') {
// Handle justFinishedAt timestamp based on status
const shouldSetJustFinishedAt = status === 'waiting_approval';
const shouldClearJustFinishedAt = status !== 'waiting_approval';
if (shouldSetJustFinishedAt) {
feature.justFinishedAt = new Date().toISOString();
} else if (shouldClearJustFinishedAt) {
feature.justFinishedAt = undefined;
}
// Finalize task statuses when feature is done:
// - Mark any in_progress tasks as completed (agent finished but didn't explicitly complete them)
// - Do NOT mark pending tasks as completed (they were never started)
// - Clear currentTaskId since no task is actively running
// This prevents cards in "waiting for review" from appearing to still have running tasks
if (feature.planSpec?.tasks) {
let tasksFinalized = 0;
let tasksPending = 0;
for (const task of feature.planSpec.tasks) {
if (task.status === 'in_progress') {
task.status = 'completed';
tasksFinalized++;
} else if (task.status === 'pending') {
tasksPending++;
}
}
if (tasksFinalized > 0) {
logger.info(
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to waiting_approval`
);
}
if (tasksPending > 0) {
logger.warn(
`[updateFeatureStatus] Feature ${featureId} moving to waiting_approval with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
);
}
// Update tasksCompleted count to reflect actual completed tasks
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
(t) => t.status === 'completed'
).length;
feature.planSpec.currentTaskId = undefined;
}
} else if (status === 'verified') {
// Also finalize in_progress tasks when moving directly to verified (skipTests=false)
// Do NOT mark pending tasks as completed - they were never started
if (feature.planSpec?.tasks) {
let tasksFinalized = 0;
let tasksPending = 0;
for (const task of feature.planSpec.tasks) {
if (task.status === 'in_progress') {
task.status = 'completed';
tasksFinalized++;
} else if (task.status === 'pending') {
tasksPending++;
}
}
if (tasksFinalized > 0) {
logger.info(
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to verified`
);
}
if (tasksPending > 0) {
logger.warn(
`[updateFeatureStatus] Feature ${featureId} moving to verified with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
);
}
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
(t) => t.status === 'completed'
).length;
feature.planSpec.currentTaskId = undefined;
}
// Clear the timestamp when moving to other statuses
feature.justFinishedAt = undefined;
} else {
// Clear the timestamp when moving to other statuses
feature.justFinishedAt = undefined;
// Finalize in-progress tasks when reaching terminal states (waiting_approval or verified)
if (status === 'waiting_approval' || status === 'verified') {
this.finalizeInProgressTasks(feature, featureId, status);
}
// PERSIST BEFORE EMIT (Pitfall 2)
@@ -191,19 +182,21 @@ export class FeatureStateManager {
// Wrapped in try-catch so failures don't block syncFeatureToAppSpec below
try {
const notificationService = getNotificationService();
const displayName = this.getFeatureDisplayName(feature, featureId);
if (status === 'waiting_approval') {
await notificationService.createNotification({
type: 'feature_waiting_approval',
title: 'Feature Ready for Review',
message: `"${feature.name || featureId}" is ready for your review and approval.`,
type: NOTIFICATION_TYPE_WAITING_APPROVAL,
title: displayName,
message: NOTIFICATION_TITLE_WAITING_APPROVAL,
featureId,
projectPath,
});
} else if (status === 'verified') {
await notificationService.createNotification({
type: 'feature_verified',
title: 'Feature Verified',
message: `"${feature.name || featureId}" has been verified and is complete.`,
type: NOTIFICATION_TYPE_VERIFIED,
title: displayName,
message: NOTIFICATION_TITLE_VERIFIED,
featureId,
projectPath,
});
@@ -252,7 +245,7 @@ export class FeatureStateManager {
const currentStatus = feature?.status;
// Preserve pipeline_* statuses so resumePipelineFeature can resume from the correct step
if (currentStatus && currentStatus.startsWith('pipeline_')) {
if (isPipelineStatus(currentStatus)) {
logger.info(
`Feature ${featureId} was in ${currentStatus}; preserving pipeline status for resume`
);
@@ -270,7 +263,8 @@ export class FeatureStateManager {
/**
* Shared helper that scans features in a project directory and resets any stuck
* in transient states (in_progress, interrupted, pipeline_*) back to resting states.
* in transient states (in_progress, interrupted) back to resting states.
* Pipeline_* statuses are preserved so they can be resumed.
*
* Also resets:
* - generating planSpec status back to pending
@@ -324,10 +318,7 @@ export class FeatureStateManager {
// Reset features in active execution states back to a resting state
// After a server restart, no processes are actually running
const isActiveState =
originalStatus === 'in_progress' ||
originalStatus === 'interrupted' ||
(originalStatus != null && originalStatus.startsWith('pipeline_'));
const isActiveState = originalStatus === 'in_progress' || originalStatus === 'interrupted';
if (isActiveState) {
const hasApprovedPlan = feature.planSpec?.status === 'approved';
@@ -338,6 +329,17 @@ export class FeatureStateManager {
);
}
// Handle pipeline_* statuses separately: preserve them so they can be resumed
// but still count them as needing attention if they were stuck.
if (isPipelineStatus(originalStatus)) {
// We don't change the status, but we still want to reset planSpec/task states
// if they were stuck in transient generation/execution modes.
// No feature.status change here.
logger.debug(
`[${callerLabel}] Preserving pipeline status for feature ${feature.id}: ${originalStatus}`
);
}
// Reset generating planSpec status back to pending (spec generation was interrupted)
if (feature.planSpec?.status === 'generating') {
feature.planSpec.status = 'pending';
@@ -396,10 +398,12 @@ export class FeatureStateManager {
* Resets:
* - in_progress features back to ready (if has plan) or backlog (if no plan)
* - interrupted features back to ready (if has plan) or backlog (if no plan)
* - pipeline_* features back to ready (if has plan) or backlog (if no plan)
* - generating planSpec status back to pending
* - in_progress tasks back to pending
*
* Preserves:
* - pipeline_* statuses (so resumePipelineFeature can resume from correct step)
*
* @param projectPath - The project path to reset features for
*/
async resetStuckFeatures(projectPath: string): Promise<void> {
@@ -530,6 +534,10 @@ export class FeatureStateManager {
* This is called after agent execution completes to save a summary
* extracted from the agent's output using <summary> tags.
*
* For pipeline features (status starts with pipeline_), summaries are accumulated
* across steps with a header identifying each step. For non-pipeline features,
* the summary is replaced entirely.
*
* @param projectPath - The project path
* @param featureId - The feature ID
* @param summary - The summary text to save
@@ -537,6 +545,7 @@ export class FeatureStateManager {
async saveFeatureSummary(projectPath: string, featureId: string, summary: string): Promise<void> {
const featureDir = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDir, 'feature.json');
const normalizedSummary = summary.trim();
try {
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
@@ -552,7 +561,63 @@ export class FeatureStateManager {
return;
}
feature.summary = summary;
if (!normalizedSummary) {
logger.debug(
`[saveFeatureSummary] Skipping empty summary for feature ${featureId} (status="${feature.status}")`
);
return;
}
// For pipeline features, accumulate summaries across steps
if (isPipelineStatus(feature.status)) {
// If we already have a non-phase summary (typically the initial implementation
// summary from in_progress), normalize it into a named phase before appending
// pipeline step summaries. This keeps the format consistent for UI phase parsing.
const implementationHeader = '### Implementation';
if (feature.summary && !feature.summary.trimStart().startsWith('### ')) {
feature.summary = `${implementationHeader}\n\n${feature.summary.trim()}`;
}
const stepName = await this.getPipelineStepName(projectPath, feature.status);
const stepHeader = `### ${stepName}`;
const stepSection = `${stepHeader}\n\n${normalizedSummary}`;
if (feature.summary) {
// Check if this step already exists in the summary (e.g., if retried)
// Use section splitting to only match real section boundaries, not text in body content
const separator = '\n\n---\n\n';
const sections = feature.summary.split(separator);
let replaced = false;
const updatedSections = sections.map((section) => {
if (section.startsWith(`${stepHeader}\n\n`)) {
replaced = true;
return stepSection;
}
return section;
});
if (replaced) {
feature.summary = updatedSections.join(separator);
logger.info(
`[saveFeatureSummary] Updated existing pipeline step summary for feature ${featureId}: step="${stepName}"`
);
} else {
// Append as a new section
feature.summary = `${feature.summary}${separator}${stepSection}`;
logger.info(
`[saveFeatureSummary] Appended new pipeline step summary for feature ${featureId}: step="${stepName}"`
);
}
} else {
feature.summary = stepSection;
logger.info(
`[saveFeatureSummary] Initialized pipeline summary for feature ${featureId}: step="${stepName}"`
);
}
} else {
feature.summary = normalizedSummary;
}
feature.updatedAt = new Date().toISOString();
// PERSIST BEFORE EMIT
@@ -562,13 +627,42 @@ export class FeatureStateManager {
this.emitAutoModeEvent('auto_mode_summary', {
featureId,
projectPath,
summary,
summary: feature.summary,
});
} catch (error) {
logger.error(`Failed to save summary for ${featureId}:`, error);
}
}
/**
* Look up the pipeline step name from the current pipeline status.
*
* @param projectPath - The project path
* @param status - The current pipeline status (e.g., 'pipeline_abc123')
* @returns The step name, or a fallback based on the step ID
*/
private async getPipelineStepName(projectPath: string, status: string): Promise<string> {
try {
const stepId = pipelineService.getStepIdFromStatus(status as FeatureStatusWithPipeline);
if (stepId) {
const step = await pipelineService.getStep(projectPath, stepId);
if (step) return step.name;
}
} catch (error) {
logger.debug(
`[getPipelineStepName] Failed to look up step name for status "${status}", using fallback:`,
error
);
}
// Fallback: derive a human-readable name from the status suffix
// e.g., 'pipeline_code_review' → 'Code Review'
const suffix = status.replace('pipeline_', '');
return suffix
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Update the status of a specific task within planSpec.tasks
*
@@ -581,7 +675,8 @@ export class FeatureStateManager {
projectPath: string,
featureId: string,
taskId: string,
status: ParsedTask['status']
status: ParsedTask['status'],
summary?: string
): Promise<void> {
const featureDir = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDir, 'feature.json');
@@ -604,6 +699,9 @@ export class FeatureStateManager {
const task = feature.planSpec.tasks.find((t) => t.id === taskId);
if (task) {
task.status = status;
if (summary) {
task.summary = summary;
}
feature.updatedAt = new Date().toISOString();
// PERSIST BEFORE EMIT
@@ -615,6 +713,7 @@ export class FeatureStateManager {
projectPath,
taskId,
status,
summary,
tasks: feature.planSpec.tasks,
});
} else {
@@ -628,6 +727,137 @@ export class FeatureStateManager {
}
}
/**
* Get the display name for a feature, preferring title over feature ID.
* Empty string titles are treated as missing and fallback to featureId.
*
* @param feature - The feature to get the display name for
* @param featureId - The feature ID to use as fallback
* @returns The display name (title or feature ID)
*/
private getFeatureDisplayName(feature: Feature, featureId: string): string {
// Use title if it's a non-empty string, otherwise fallback to featureId
return feature.title && feature.title.trim() ? feature.title : featureId;
}
/**
* Handle auto-mode events to create error notifications.
* This listens for error events and creates notifications to alert users.
*/
private async handleAutoModeEventError(payload: AutoModeEventPayload): Promise<void> {
if (!payload.type) return;
// Only handle error events
if (payload.type !== 'auto_mode_error' && payload.type !== 'auto_mode_feature_complete') {
return;
}
// For auto_mode_feature_complete, only notify on failures (passes === false)
if (payload.type === 'auto_mode_feature_complete' && payload.passes !== false) {
return;
}
// Get project path - handle different event formats
const projectPath = payload.projectPath;
if (!projectPath) return;
try {
const notificationService = getNotificationService();
// Determine notification type and title based on event type
// Only auto_mode_feature_complete events should create feature_error notifications
const isFeatureError = payload.type === 'auto_mode_feature_complete';
const notificationType = isFeatureError
? NOTIFICATION_TYPE_FEATURE_ERROR
: NOTIFICATION_TYPE_AUTO_MODE_ERROR;
const notificationTitle = isFeatureError
? NOTIFICATION_TITLE_FEATURE_ERROR
: NOTIFICATION_TITLE_AUTO_MODE_ERROR;
// Build error message
let errorMessage = payload.message || 'An error occurred';
if (payload.error) {
errorMessage = payload.error;
}
// Use feature title as notification title when available, fall back to gesture name
let title = notificationTitle;
if (payload.featureId) {
const displayName = await this.getFeatureDisplayNameById(projectPath, payload.featureId);
if (displayName) {
title = displayName;
errorMessage = `${notificationTitle}: ${errorMessage}`;
}
}
await notificationService.createNotification({
type: notificationType,
title,
message: errorMessage,
featureId: payload.featureId,
projectPath,
});
} catch (notificationError) {
logger.warn(`Failed to create error notification:`, notificationError);
}
}
/**
* Get feature display name by loading the feature directly.
*/
private async getFeatureDisplayNameById(
projectPath: string,
featureId: string
): Promise<string | null> {
const feature = await this.loadFeature(projectPath, featureId);
if (!feature) return null;
return this.getFeatureDisplayName(feature, featureId);
}
/**
* Finalize in-progress tasks when a feature reaches a terminal state.
* Marks in_progress tasks as completed but leaves pending tasks untouched.
*
* @param feature - The feature whose tasks should be finalized
* @param featureId - The feature ID for logging
* @param targetStatus - The status the feature is transitioning to
*/
private finalizeInProgressTasks(feature: Feature, featureId: string, targetStatus: string): void {
if (!feature.planSpec?.tasks) {
return;
}
let tasksFinalized = 0;
let tasksPending = 0;
for (const task of feature.planSpec.tasks) {
if (task.status === 'in_progress') {
task.status = 'completed';
tasksFinalized++;
} else if (task.status === 'pending') {
tasksPending++;
}
}
// Update tasksCompleted count to reflect actual completed tasks
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
(t) => t.status === 'completed'
).length;
feature.planSpec.currentTaskId = undefined;
if (tasksFinalized > 0) {
logger.info(
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to ${targetStatus}`
);
}
if (tasksPending > 0) {
logger.warn(
`[updateFeatureStatus] Feature ${featureId} moving to ${targetStatus} with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
);
}
}
/**
* Emit an auto-mode event via the event emitter
*

View File

@@ -0,0 +1,282 @@
/**
* Ntfy Service - Sends push notifications via ntfy.sh
*
* Provides integration with ntfy.sh for push notifications.
* Supports custom servers, authentication, tags, emojis, and click actions.
*
* @see https://docs.ntfy.sh/publish/
*/
import { createLogger } from '@automaker/utils';
import type { NtfyEndpointConfig, EventHookContext } from '@automaker/types';
const logger = createLogger('Ntfy');
/** Default timeout for ntfy HTTP requests (10 seconds) */
const DEFAULT_NTFY_TIMEOUT = 10000;
// Re-export EventHookContext as NtfyContext for backward compatibility
export type NtfyContext = EventHookContext;
/**
* Ntfy Service
*
* Handles sending notifications to ntfy.sh endpoints.
*/
export class NtfyService {
/**
* Send a notification to a ntfy.sh endpoint
*
* @param endpoint The ntfy.sh endpoint configuration
* @param options Notification options (title, body, tags, etc.)
* @param context Context for variable substitution
*/
async sendNotification(
endpoint: NtfyEndpointConfig,
options: {
title?: string;
body?: string;
tags?: string;
emoji?: string;
clickUrl?: string;
priority?: 1 | 2 | 3 | 4 | 5;
},
context: NtfyContext
): Promise<{ success: boolean; error?: string }> {
if (!endpoint.enabled) {
logger.warn(`Ntfy endpoint "${endpoint.name}" is disabled, skipping notification`);
return { success: false, error: 'Endpoint is disabled' };
}
// Validate endpoint configuration
const validationError = this.validateEndpoint(endpoint);
if (validationError) {
logger.error(`Invalid ntfy endpoint configuration: ${validationError}`);
return { success: false, error: validationError };
}
// Build URL
const serverUrl = endpoint.serverUrl.replace(/\/$/, ''); // Remove trailing slash
const url = `${serverUrl}/${encodeURIComponent(endpoint.topic)}`;
// Build headers
const headers: Record<string, string> = {
'Content-Type': 'text/plain; charset=utf-8',
};
// Title (with variable substitution)
const title = this.substituteVariables(options.title || this.getDefaultTitle(context), context);
if (title) {
headers['Title'] = title;
}
// Priority
const priority = options.priority || 3;
headers['Priority'] = String(priority);
// Tags and emoji
const tags = this.buildTags(
options.tags || endpoint.defaultTags,
options.emoji || endpoint.defaultEmoji
);
if (tags) {
headers['Tags'] = tags;
}
// Click action URL
const clickUrl = this.substituteVariables(
options.clickUrl || endpoint.defaultClickUrl || '',
context
);
if (clickUrl) {
headers['Click'] = clickUrl;
}
// Authentication
this.addAuthHeaders(headers, endpoint);
// Message body (with variable substitution)
const body = this.substituteVariables(options.body || this.getDefaultBody(context), context);
logger.info(`Sending ntfy notification to ${endpoint.name}: ${title}`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_NTFY_TIMEOUT);
try {
const response = await fetch(url, {
method: 'POST',
headers,
body,
signal: controller.signal,
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
logger.error(`Ntfy notification failed with status ${response.status}: ${errorText}`);
return {
success: false,
error: `HTTP ${response.status}: ${errorText}`,
};
}
logger.info(`Ntfy notification sent successfully to ${endpoint.name}`);
return { success: true };
} catch (error) {
if ((error as Error).name === 'AbortError') {
logger.error(`Ntfy notification timed out after ${DEFAULT_NTFY_TIMEOUT}ms`);
return { success: false, error: 'Request timed out' };
}
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Ntfy notification failed: ${errorMessage}`);
return { success: false, error: errorMessage };
} finally {
clearTimeout(timeoutId);
}
}
/**
* Validate an ntfy endpoint configuration
*/
validateEndpoint(endpoint: NtfyEndpointConfig): string | null {
// Validate server URL
if (!endpoint.serverUrl) {
return 'Server URL is required';
}
try {
new URL(endpoint.serverUrl);
} catch {
return 'Invalid server URL format';
}
// Validate topic
if (!endpoint.topic) {
return 'Topic is required';
}
if (endpoint.topic.includes(' ') || endpoint.topic.includes('\t')) {
return 'Topic cannot contain spaces';
}
// Validate authentication
if (endpoint.authType === 'basic') {
if (!endpoint.username || !endpoint.password) {
return 'Username and password are required for basic authentication';
}
} else if (endpoint.authType === 'token') {
if (!endpoint.token) {
return 'Access token is required for token authentication';
}
}
return null;
}
/**
* Build tags string from tags and emoji
*/
private buildTags(tags?: string, emoji?: string): string {
const tagList: string[] = [];
if (tags) {
// Split by comma and trim whitespace
const parsedTags = tags
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0);
tagList.push(...parsedTags);
}
if (emoji) {
// Add emoji as first tag if it looks like a shortcode
if (emoji.startsWith(':') && emoji.endsWith(':')) {
tagList.unshift(emoji.slice(1, -1));
} else if (!emoji.includes(' ')) {
// If it's a single emoji or shortcode without colons, add as-is
tagList.unshift(emoji);
}
}
return tagList.join(',');
}
/**
* Add authentication headers based on auth type
*/
private addAuthHeaders(headers: Record<string, string>, endpoint: NtfyEndpointConfig): void {
if (endpoint.authType === 'basic' && endpoint.username && endpoint.password) {
const credentials = Buffer.from(`${endpoint.username}:${endpoint.password}`).toString(
'base64'
);
headers['Authorization'] = `Basic ${credentials}`;
} else if (endpoint.authType === 'token' && endpoint.token) {
headers['Authorization'] = `Bearer ${endpoint.token}`;
}
}
/**
* Get default title based on event context
*/
private getDefaultTitle(context: NtfyContext): string {
const eventName = this.formatEventName(context.eventType);
if (context.featureName) {
return `${eventName}: ${context.featureName}`;
}
return eventName;
}
/**
* Get default body based on event context
*/
private getDefaultBody(context: NtfyContext): string {
const lines: string[] = [];
if (context.featureName) {
lines.push(`Feature: ${context.featureName}`);
}
if (context.featureId) {
lines.push(`ID: ${context.featureId}`);
}
if (context.projectName) {
lines.push(`Project: ${context.projectName}`);
}
if (context.error) {
lines.push(`Error: ${context.error}`);
}
lines.push(`Time: ${context.timestamp}`);
return lines.join('\n');
}
/**
* Format event type to human-readable name
*/
private formatEventName(eventType: string): string {
const eventNames: Record<string, string> = {
feature_created: 'Feature Created',
feature_success: 'Feature Completed',
feature_error: 'Feature Failed',
auto_mode_complete: 'Auto Mode Complete',
auto_mode_error: 'Auto Mode Error',
};
return eventNames[eventType] || eventType;
}
/**
* Substitute {{variable}} placeholders in a string
*/
private substituteVariables(template: string, context: NtfyContext): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => {
const value = context[variable as keyof NtfyContext];
if (value === undefined || value === null) {
return '';
}
return String(value);
});
}
}
// Singleton instance
export const ntfyService = new NtfyService();

View File

@@ -115,6 +115,7 @@ export class PipelineOrchestrator {
projectPath,
});
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
const currentStatus = `pipeline_${step.id}`;
await this.runAgentFn(
workDir,
featureId,
@@ -133,6 +134,8 @@ export class PipelineOrchestrator {
useClaudeCodeSystemPrompt,
thinkingLevel: feature.thinkingLevel,
reasoningEffort: feature.reasoningEffort,
status: currentStatus,
providerId: feature.providerId,
}
);
try {
@@ -165,7 +168,18 @@ export class PipelineOrchestrator {
if (previousContext) prompt += `### Previous Work\n${previousContext}\n\n`;
return (
prompt +
`### Pipeline Step Instructions\n${step.instructions}\n\n### Task\nComplete the pipeline step instructions above.`
`### Pipeline Step Instructions\n${step.instructions}\n\n### Task\nComplete the pipeline step instructions above.\n\n` +
`**CRITICAL: After completing the instructions, you MUST output a summary using this EXACT format:**\n\n` +
`<summary>\n` +
`## Summary: ${step.name}\n\n` +
`### Changes Implemented\n` +
`- [List all changes made in this step]\n\n` +
`### Files Modified\n` +
`- [List all files modified in this step]\n\n` +
`### Outcome\n` +
`- [Describe the result of this step]\n` +
`</summary>\n\n` +
`The <summary> and </summary> tags MUST be on their own lines. This is REQUIRED.`
);
}
@@ -336,6 +350,7 @@ export class PipelineOrchestrator {
});
const abortController = runningEntry.abortController;
runningEntry.branchName = feature.branchName ?? null;
let pipelineCompleted = false;
try {
validateWorkingDirectory(projectPath);
@@ -389,6 +404,7 @@ export class PipelineOrchestrator {
};
await this.executePipeline(context);
pipelineCompleted = true;
// Re-fetch feature to check if executePipeline set a terminal status (e.g., merge_conflict)
const reloadedFeature = await this.featureStateManager.loadFeature(projectPath, featureId);
@@ -425,8 +441,21 @@ export class PipelineOrchestrator {
});
}
} else {
// If pipeline steps completed successfully, don't send the feature back to backlog.
// The pipeline work is done — set to waiting_approval so the user can review.
const fallbackStatus = pipelineCompleted ? 'waiting_approval' : 'backlog';
if (pipelineCompleted) {
logger.info(
`[resumeFromStep] Feature ${featureId} failed after pipeline completed. ` +
`Setting status to waiting_approval instead of backlog to preserve pipeline work.`
);
}
logger.error(`Pipeline resume failed for ${featureId}:`, error);
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog');
// Don't overwrite terminal states like 'merge_conflict' that were set during pipeline execution
const currentFeature = await this.featureStateManager.loadFeature(projectPath, featureId);
if (currentFeature?.status !== 'merge_conflict') {
await this.updateFeatureStatusFn(projectPath, featureId, fallbackStatus);
}
this.eventBus.emitAutoModeEvent('auto_mode_error', {
featureId,
featureName: feature.title,
@@ -490,7 +519,10 @@ export class PipelineOrchestrator {
requirePlanApproval: false,
useClaudeCodeSystemPrompt: context.useClaudeCodeSystemPrompt,
autoLoadClaudeMd: context.autoLoadClaudeMd,
thinkingLevel: context.feature.thinkingLevel,
reasoningEffort: context.feature.reasoningEffort,
status: context.feature.status,
providerId: context.feature.providerId,
}
);
}

View File

@@ -28,6 +28,8 @@ const logger = createLogger('PullService');
export interface PullOptions {
/** Remote name to pull from (defaults to 'origin') */
remote?: string;
/** Specific remote branch to pull (e.g. 'main'). When provided, overrides the tracking branch and fetches this branch from the remote. */
remoteBranch?: string;
/** When true, automatically stash local changes before pulling and reapply after */
stashIfNeeded?: boolean;
}
@@ -243,6 +245,7 @@ export async function performPull(
): Promise<PullResult> {
const targetRemote = options?.remote || 'origin';
const stashIfNeeded = options?.stashIfNeeded ?? false;
const targetRemoteBranch = options?.remoteBranch;
// 1. Get current branch name
let branchName: string;
@@ -313,24 +316,34 @@ export async function performPull(
}
// 7. Verify upstream tracking or remote branch exists
const upstreamStatus = await hasUpstreamOrRemoteBranch(worktreePath, branchName, targetRemote);
if (upstreamStatus === 'none') {
let stashRecoveryFailed = false;
if (didStash) {
const stashPopped = await tryPopStash(worktreePath);
stashRecoveryFailed = !stashPopped;
// Skip this check when a specific remote branch is provided - we always use
// explicit 'git pull <remote> <branch>' args in that case.
let upstreamStatus: UpstreamStatus = 'tracking';
if (!targetRemoteBranch) {
upstreamStatus = await hasUpstreamOrRemoteBranch(worktreePath, branchName, targetRemote);
if (upstreamStatus === 'none') {
let stashRecoveryFailed = false;
if (didStash) {
const stashPopped = await tryPopStash(worktreePath);
stashRecoveryFailed = !stashPopped;
}
return {
success: false,
error: `Branch '${branchName}' has no upstream branch on remote '${targetRemote}'. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`,
stashRecoveryFailed: stashRecoveryFailed ? stashRecoveryFailed : undefined,
};
}
return {
success: false,
error: `Branch '${branchName}' has no upstream branch on remote '${targetRemote}'. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`,
stashRecoveryFailed: stashRecoveryFailed ? stashRecoveryFailed : undefined,
};
}
// 8. Pull latest changes
// When a specific remote branch is requested, always use explicit remote + branch args.
// When the branch has a configured upstream tracking ref, let Git use it automatically.
// When only the remote branch exists (no tracking ref), explicitly specify remote and branch.
const pullArgs = upstreamStatus === 'tracking' ? ['pull'] : ['pull', targetRemote, branchName];
const pullArgs = targetRemoteBranch
? ['pull', targetRemote, targetRemoteBranch]
: upstreamStatus === 'tracking'
? ['pull']
: ['pull', targetRemote, branchName];
let pullConflict = false;
let pullConflictFiles: string[] = [];

View File

@@ -618,6 +618,36 @@ export class SettingsService {
ignoreEmptyArrayOverwrite('eventHooks');
}
// Guard ntfyEndpoints against accidental wipe
// (similar to eventHooks, these are user-configured and shouldn't be lost)
// Check for explicit permission to clear ntfyEndpoints (escape hatch for intentional clearing)
const allowEmptyNtfyEndpoints =
(sanitizedUpdates as Record<string, unknown>).__allowEmptyNtfyEndpoints === true;
// Remove the flag so it doesn't get persisted
delete (sanitizedUpdates as Record<string, unknown>).__allowEmptyNtfyEndpoints;
if (!allowEmptyNtfyEndpoints) {
const currentNtfyLen = Array.isArray(current.ntfyEndpoints)
? current.ntfyEndpoints.length
: 0;
const newNtfyLen = Array.isArray(sanitizedUpdates.ntfyEndpoints)
? sanitizedUpdates.ntfyEndpoints.length
: currentNtfyLen;
if (Array.isArray(sanitizedUpdates.ntfyEndpoints) && newNtfyLen === 0 && currentNtfyLen > 0) {
logger.warn(
'[WIPE_PROTECTION] Attempted to set ntfyEndpoints to empty array! Ignoring update.',
{
currentNtfyLen,
newNtfyLen,
}
);
delete sanitizedUpdates.ntfyEndpoints;
}
} else {
logger.info('[INTENTIONAL_CLEAR] Clearing ntfyEndpoints via escape hatch');
}
// Empty object overwrite guard
const ignoreEmptyObjectOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
const nextVal = sanitizedUpdates[key] as unknown;
@@ -1023,6 +1053,8 @@ export class SettingsService {
keyboardShortcuts:
(appState.keyboardShortcuts as KeyboardShortcuts) ||
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
eventHooks: (appState.eventHooks as GlobalSettings['eventHooks']) || [],
ntfyEndpoints: (appState.ntfyEndpoints as GlobalSettings['ntfyEndpoints']) || [],
projects: (appState.projects as ProjectRef[]) || [],
trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [],
projectHistory: (appState.projectHistory as string[]) || [],

View File

@@ -101,12 +101,32 @@ export function detectTaskStartMarker(text: string): string | null {
}
/**
* Detect [TASK_COMPLETE] marker in text and extract task ID
* Detect [TASK_COMPLETE] marker in text and extract task ID and summary
* Format: [TASK_COMPLETE] T###: Brief summary
*/
export function detectTaskCompleteMarker(text: string): string | null {
const match = text.match(/\[TASK_COMPLETE\]\s*(T\d{3})/);
return match ? match[1] : null;
export function detectTaskCompleteMarker(text: string): { id: string; summary?: string } | null {
// Use a regex that captures the summary until newline or next task marker
// Allow brackets in summary content (e.g., "supports array[index] access")
// Pattern breakdown:
// - \[TASK_COMPLETE\]\s* - Match the marker
// - (T\d{3}) - Capture task ID
// - (?::\s*([^\n\[]+))? - Optionally capture summary (stops at newline or bracket)
// - But we want to allow brackets in summary, so we use a different approach:
// - Match summary until newline, then trim any trailing markers in post-processing
const match = text.match(/\[TASK_COMPLETE\]\s*(T\d{3})(?::\s*(.+?))?(?=\n|$)/i);
if (!match) return null;
// Post-process: remove trailing task markers from summary if present
let summary = match[2]?.trim();
if (summary) {
// Remove trailing content that looks like another marker
summary = summary.replace(/\s*\[TASK_[A-Z_]+\].*$/i, '').trim();
}
return {
id: match[1],
summary: summary || undefined,
};
}
/**
@@ -194,10 +214,14 @@ export function extractSummary(text: string): string | null {
}
// Check for ## Summary section (use last match)
const sectionMatches = text.matchAll(/##\s*Summary\s*\n+([\s\S]*?)(?=\n##|\n\*\*|$)/gi);
// Stop at \n## [^#] (same-level headers like "## Changes") but preserve ### subsections
// (like "### Root Cause", "### Fix Applied") that belong to the summary content.
const sectionMatches = text.matchAll(/##\s*Summary\s*\n+([\s\S]*?)(?=\n## [^#]|$)/gi);
const sectionMatch = getLastMatch(sectionMatches);
if (sectionMatch) {
return truncate(sectionMatch[1].trim(), 500);
const content = sectionMatch[1].trim();
// Keep full content (including ### subsections) up to max length
return content.length > 500 ? `${content.substring(0, 500)}...` : content;
}
// Check for **Goal**: section (lite mode, use last match)

View File

@@ -39,6 +39,18 @@ export interface WorktreeInfo {
* 3. Listing all worktrees with normalized paths
*/
export class WorktreeResolver {
private normalizeBranchName(branchName: string | null | undefined): string | null {
if (!branchName) return null;
let normalized = branchName.trim();
if (!normalized) return null;
normalized = normalized.replace(/^refs\/heads\//, '');
normalized = normalized.replace(/^refs\/remotes\/[^/]+\//, '');
normalized = normalized.replace(/^(origin|upstream)\//, '');
return normalized || null;
}
/**
* Get the current branch name for a git repository
*
@@ -64,6 +76,9 @@ export class WorktreeResolver {
*/
async findWorktreeForBranch(projectPath: string, branchName: string): Promise<string | null> {
try {
const normalizedTargetBranch = this.normalizeBranchName(branchName);
if (!normalizedTargetBranch) return null;
const { stdout } = await execAsync('git worktree list --porcelain', {
cwd: projectPath,
});
@@ -76,10 +91,10 @@ export class WorktreeResolver {
if (line.startsWith('worktree ')) {
currentPath = line.slice(9);
} else if (line.startsWith('branch ')) {
currentBranch = line.slice(7).replace('refs/heads/', '');
currentBranch = this.normalizeBranchName(line.slice(7));
} else if (line === '' && currentPath && currentBranch) {
// End of a worktree entry
if (currentBranch === branchName) {
if (currentBranch === normalizedTargetBranch) {
// Resolve to absolute path - git may return relative paths
// On Windows, this is critical for cwd to work correctly
// On all platforms, absolute paths ensure consistent behavior
@@ -91,7 +106,7 @@ export class WorktreeResolver {
}
// Check the last entry (if file doesn't end with newline)
if (currentPath && currentBranch && currentBranch === branchName) {
if (currentPath && currentBranch && currentBranch === normalizedTargetBranch) {
return this.resolvePath(projectPath, currentPath);
}
@@ -123,7 +138,7 @@ export class WorktreeResolver {
if (line.startsWith('worktree ')) {
currentPath = line.slice(9);
} else if (line.startsWith('branch ')) {
currentBranch = line.slice(7).replace('refs/heads/', '');
currentBranch = this.normalizeBranchName(line.slice(7));
} else if (line.startsWith('detached')) {
// Detached HEAD - branch is null
currentBranch = null;

View File

@@ -0,0 +1,333 @@
import { describe, it, expect } from 'vitest';
import {
computeIsDirty,
updateTabWithContent as updateTabContent,
markTabAsSaved as markTabSaved,
} from '../../../../ui/src/components/views/file-editor-view/file-editor-dirty-utils.ts';
/**
* Unit tests for the file editor store logic, focusing on the unsaved indicator fix.
*
* The bug was: File unsaved indicators weren't working reliably - editing a file
* and saving it would sometimes leave the dirty indicator (dot) visible.
*
* Root causes:
* 1. Stale closure in handleSave - captured activeTab could have old content
* 2. Editor buffer not synced - CodeMirror might have buffered changes not yet in store
*
* Fix:
* - handleSave now gets fresh state from store using getState()
* - handleSave gets current content from editor via getValue()
* - Content is synced to store before saving if it differs
*
* Since we can't easily test the React/zustand store in node environment,
* we test the pure logic that the store uses for dirty state tracking.
*/
describe('File editor dirty state logic', () => {
describe('updateTabContent', () => {
it('should set isDirty to true when content differs from originalContent', () => {
const tab = {
content: 'original content',
originalContent: 'original content',
isDirty: false,
};
const updated = updateTabContent(tab, 'modified content');
expect(updated.isDirty).toBe(true);
expect(updated.content).toBe('modified content');
expect(updated.originalContent).toBe('original content');
});
it('should set isDirty to false when content matches originalContent', () => {
const tab = {
content: 'original content',
originalContent: 'original content',
isDirty: false,
};
// First modify it
let updated = updateTabContent(tab, 'modified content');
expect(updated.isDirty).toBe(true);
// Now update back to original
updated = updateTabContent(updated, 'original content');
expect(updated.isDirty).toBe(false);
});
it('should handle empty content correctly', () => {
const tab = {
content: '',
originalContent: '',
isDirty: false,
};
const updated = updateTabContent(tab, 'new content');
expect(updated.isDirty).toBe(true);
});
});
describe('markTabSaved', () => {
it('should set isDirty to false and update both content and originalContent', () => {
const tab = {
content: 'original content',
originalContent: 'original content',
isDirty: false,
};
// First modify
let updated = updateTabContent(tab, 'modified content');
expect(updated.isDirty).toBe(true);
// Then save
updated = markTabSaved(updated, 'modified content');
expect(updated.isDirty).toBe(false);
expect(updated.content).toBe('modified content');
expect(updated.originalContent).toBe('modified content');
});
it('should correctly clear dirty state when save is triggered after edit', () => {
// This test simulates the bug scenario:
// 1. User edits file -> isDirty = true
// 2. User saves -> markTabSaved should set isDirty = false
let tab = {
content: 'initial',
originalContent: 'initial',
isDirty: false,
};
// Simulate user editing
tab = updateTabContent(tab, 'initial\nnew line');
// Should be dirty
expect(tab.isDirty).toBe(true);
// Simulate save (with the content that was saved)
tab = markTabSaved(tab, 'initial\nnew line');
// Should NOT be dirty anymore
expect(tab.isDirty).toBe(false);
});
});
describe('race condition handling', () => {
it('should correctly handle updateTabContent after markTabSaved with same content', () => {
// This tests the scenario where:
// 1. CodeMirror has a pending onChange with content "B"
// 2. User presses save when editor shows "B"
// 3. markTabSaved is called with "B"
// 4. CodeMirror's pending onChange fires with "B" (same content)
// Result: isDirty should remain false
let tab = {
content: 'A',
originalContent: 'A',
isDirty: false,
};
// User edits to "B"
tab = updateTabContent(tab, 'B');
// Save with "B"
tab = markTabSaved(tab, 'B');
// Late onChange with same content "B"
tab = updateTabContent(tab, 'B');
expect(tab.isDirty).toBe(false);
expect(tab.content).toBe('B');
});
it('should correctly handle updateTabContent after markTabSaved with different content', () => {
// This tests the scenario where:
// 1. CodeMirror has a pending onChange with content "C"
// 2. User presses save when store has "B"
// 3. markTabSaved is called with "B"
// 4. CodeMirror's pending onChange fires with "C" (different content)
// Result: isDirty should be true (file changed after save)
let tab = {
content: 'A',
originalContent: 'A',
isDirty: false,
};
// User edits to "B"
tab = updateTabContent(tab, 'B');
// Save with "B"
tab = markTabSaved(tab, 'B');
// Late onChange with different content "C"
tab = updateTabContent(tab, 'C');
// File changed after save, so it should be dirty
expect(tab.isDirty).toBe(true);
expect(tab.content).toBe('C');
expect(tab.originalContent).toBe('B');
});
it('should handle rapid edit-save-edit cycle correctly', () => {
// Simulate rapid user actions
let tab = {
content: 'v1',
originalContent: 'v1',
isDirty: false,
};
// Edit 1
tab = updateTabContent(tab, 'v2');
expect(tab.isDirty).toBe(true);
// Save 1
tab = markTabSaved(tab, 'v2');
expect(tab.isDirty).toBe(false);
// Edit 2
tab = updateTabContent(tab, 'v3');
expect(tab.isDirty).toBe(true);
// Save 2
tab = markTabSaved(tab, 'v3');
expect(tab.isDirty).toBe(false);
// Edit 3 (back to v2)
tab = updateTabContent(tab, 'v2');
expect(tab.isDirty).toBe(true);
// Save 3
tab = markTabSaved(tab, 'v2');
expect(tab.isDirty).toBe(false);
});
});
describe('handleSave stale closure fix simulation', () => {
it('demonstrates the fix: using fresh content instead of closure content', () => {
// This test demonstrates why the fix was necessary.
// The old handleSave captured activeTab in closure, which could be stale.
// The fix gets fresh state from getState() and uses editor.getValue().
// Simulate store state
let storeState = {
tabs: [
{
id: 'tab-1',
content: 'A',
originalContent: 'A',
isDirty: false,
},
],
activeTabId: 'tab-1',
};
// Simulate a "stale closure" capturing the tab state
const staleClosureTab = storeState.tabs[0];
// User edits - store state updates
storeState = {
...storeState,
tabs: [
{
id: 'tab-1',
content: 'B',
originalContent: 'A',
isDirty: true,
},
],
};
// OLD BUG: Using stale closure tab would save "A" (old content)
const oldBugSavedContent = staleClosureTab!.content;
expect(oldBugSavedContent).toBe('A'); // Wrong! Should be "B"
// FIX: Using fresh state from getState() gets correct content
const freshTab = storeState.tabs[0];
const fixedSavedContent = freshTab!.content;
expect(fixedSavedContent).toBe('B'); // Correct!
});
it('demonstrates syncing editor content before save', () => {
// This test demonstrates why we need to get content from editor directly.
// The store might have stale content if onChange hasn't fired yet.
// Simulate store state (has old content because onChange hasn't fired)
let storeContent = 'A';
// Editor has newer content (not yet synced to store)
const editorContent = 'B';
// FIX: Use editor content if available, fall back to store content
const contentToSave = editorContent ?? storeContent;
expect(contentToSave).toBe('B'); // Correctly saves editor content
// Simulate syncing to store before save
if (editorContent !== null && editorContent !== storeContent) {
storeContent = editorContent;
}
// Now store is synced
expect(storeContent).toBe('B');
// After save, markTabSaved would set originalContent = savedContent
// and isDirty = false (if no more changes come in)
});
});
describe('edge cases', () => {
it('should handle whitespace-only changes as dirty', () => {
let tab = {
content: 'hello',
originalContent: 'hello',
isDirty: false,
};
tab = updateTabContent(tab, 'hello ');
expect(tab.isDirty).toBe(true);
});
it('should treat CRLF and LF line endings as equivalent (not dirty)', () => {
let tab = {
content: 'line1\nline2',
originalContent: 'line1\nline2',
isDirty: false,
};
// CodeMirror normalizes \r\n to \n internally, so content that only
// differs by line endings should NOT be considered dirty.
tab = updateTabContent(tab, 'line1\r\nline2');
expect(tab.isDirty).toBe(false);
});
it('should handle unicode content correctly', () => {
let tab = {
content: '你好世界',
originalContent: '你好世界',
isDirty: false,
};
tab = updateTabContent(tab, '你好宇宙');
expect(tab.isDirty).toBe(true);
tab = markTabSaved(tab, '你好宇宙');
expect(tab.isDirty).toBe(false);
});
it('should handle very large content efficiently', () => {
// Generate a large string (1MB)
const largeOriginal = 'x'.repeat(1024 * 1024);
const largeModified = largeOriginal + 'y';
let tab = {
content: largeOriginal,
originalContent: largeOriginal,
isDirty: false,
};
tab = updateTabContent(tab, largeModified);
expect(tab.isDirty).toBe(true);
});
});
});

View File

@@ -1,5 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getMCPServersFromSettings } from '@/lib/settings-helpers.js';
import {
getMCPServersFromSettings,
getProviderById,
getProviderByModelId,
resolveProviderContext,
getAllProviderModels,
} from '@/lib/settings-helpers.js';
import type { SettingsService } from '@/services/settings-service.js';
// Mock the logger
@@ -286,4 +292,691 @@ describe('settings-helpers.ts', () => {
});
});
});
describe('getProviderById', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return provider when found by ID', async () => {
const mockProvider = { id: 'zai-1', name: 'Zai', enabled: true };
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getProviderById('zai-1', mockSettingsService);
expect(result.provider).toEqual(mockProvider);
});
it('should return undefined when provider not found', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getProviderById('unknown', mockSettingsService);
expect(result.provider).toBeUndefined();
});
it('should return provider even if disabled (caller handles enabled state)', async () => {
const mockProvider = { id: 'disabled-1', name: 'Disabled', enabled: false };
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getProviderById('disabled-1', mockSettingsService);
expect(result.provider).toEqual(mockProvider);
});
});
describe('getProviderByModelId', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return provider and modelConfig when found by model ID', async () => {
const mockModel = { id: 'custom-model-1', name: 'Custom Model' };
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [mockModel],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getProviderByModelId('custom-model-1', mockSettingsService);
expect(result.provider).toEqual(mockProvider);
expect(result.modelConfig).toEqual(mockModel);
});
it('should resolve mapped Claude model when mapsToClaudeModel is present', async () => {
const mockModel = {
id: 'custom-model-1',
name: 'Custom Model',
mapsToClaudeModel: 'sonnet-3-5',
};
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [mockModel],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getProviderByModelId('custom-model-1', mockSettingsService);
expect(result.resolvedModel).toBeDefined();
// resolveModelString('sonnet-3-5') usually returns 'claude-3-5-sonnet-20240620' or similar
});
it('should ignore disabled providers', async () => {
const mockModel = { id: 'custom-model-1', name: 'Custom Model' };
const mockProvider = {
id: 'disabled-1',
name: 'Disabled Provider',
enabled: false,
models: [mockModel],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getProviderByModelId('custom-model-1', mockSettingsService);
expect(result.provider).toBeUndefined();
});
});
describe('resolveProviderContext', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should resolve provider by explicit providerId', async () => {
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [{ id: 'custom-model-1', name: 'Custom Model' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
} as unknown as SettingsService;
const result = await resolveProviderContext(
mockSettingsService,
'custom-model-1',
'provider-1'
);
expect(result.provider).toEqual(mockProvider);
expect(result.credentials).toEqual({ anthropicApiKey: 'test-key' });
});
it('should return undefined provider when explicit providerId not found', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(
mockSettingsService,
'some-model',
'unknown-provider'
);
expect(result.provider).toBeUndefined();
});
it('should fallback to model-based lookup when providerId not provided', async () => {
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [{ id: 'custom-model-1', name: 'Custom Model' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(mockSettingsService, 'custom-model-1');
expect(result.provider).toEqual(mockProvider);
expect(result.modelConfig?.id).toBe('custom-model-1');
});
it('should resolve mapsToClaudeModel to actual Claude model', async () => {
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [
{
id: 'custom-model-1',
name: 'Custom Model',
mapsToClaudeModel: 'sonnet',
},
],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(mockSettingsService, 'custom-model-1');
// resolveModelString('sonnet') should return a valid Claude model ID
expect(result.resolvedModel).toBeDefined();
expect(result.resolvedModel).toContain('claude');
});
it('should handle empty providers list', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(mockSettingsService, 'some-model');
expect(result.provider).toBeUndefined();
expect(result.resolvedModel).toBeUndefined();
expect(result.modelConfig).toBeUndefined();
});
it('should handle missing claudeCompatibleProviders field', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(mockSettingsService, 'some-model');
expect(result.provider).toBeUndefined();
});
it('should skip disabled providers during fallback lookup', async () => {
const disabledProvider = {
id: 'disabled-1',
name: 'Disabled Provider',
enabled: false,
models: [{ id: 'model-in-disabled', name: 'Model' }],
};
const enabledProvider = {
id: 'enabled-1',
name: 'Enabled Provider',
enabled: true,
models: [{ id: 'model-in-enabled', name: 'Model' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [disabledProvider, enabledProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
// Should skip the disabled provider and find the model in the enabled one
const result = await resolveProviderContext(mockSettingsService, 'model-in-enabled');
expect(result.provider?.id).toBe('enabled-1');
// Should not find model that only exists in disabled provider
const result2 = await resolveProviderContext(mockSettingsService, 'model-in-disabled');
expect(result2.provider).toBeUndefined();
});
it('should perform case-insensitive model ID matching', async () => {
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [{ id: 'Custom-Model-1', name: 'Custom Model' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(mockSettingsService, 'custom-model-1');
expect(result.provider).toEqual(mockProvider);
expect(result.modelConfig?.id).toBe('Custom-Model-1');
});
it('should return error result on exception', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(mockSettingsService, 'some-model');
expect(result.provider).toBeUndefined();
expect(result.credentials).toBeUndefined();
expect(result.resolvedModel).toBeUndefined();
expect(result.modelConfig).toBeUndefined();
});
it('should persist and load provider config from server settings', async () => {
// This test verifies the main bug fix: providers are loaded from server settings
const savedProvider = {
id: 'saved-provider-1',
name: 'Saved Provider',
enabled: true,
apiKeySource: 'credentials' as const,
models: [
{
id: 'saved-model-1',
name: 'Saved Model',
mapsToClaudeModel: 'sonnet',
},
],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [savedProvider],
}),
getCredentials: vi.fn().mockResolvedValue({
anthropicApiKey: 'saved-api-key',
}),
} as unknown as SettingsService;
// Simulate loading saved provider config
const result = await resolveProviderContext(
mockSettingsService,
'saved-model-1',
'saved-provider-1'
);
// Verify the provider is loaded from server settings
expect(result.provider).toEqual(savedProvider);
expect(result.provider?.id).toBe('saved-provider-1');
expect(result.provider?.models).toHaveLength(1);
expect(result.credentials?.anthropicApiKey).toBe('saved-api-key');
// Verify model mapping is resolved
expect(result.resolvedModel).toContain('claude');
});
it('should accept custom logPrefix parameter', async () => {
// Verify that the logPrefix parameter is accepted (used by facade.ts)
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [{ id: 'model-1', name: 'Model' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
// Call with custom logPrefix (as facade.ts does)
const result = await resolveProviderContext(
mockSettingsService,
'model-1',
undefined,
'[CustomPrefix]'
);
// Function should work the same with custom prefix
expect(result.provider).toEqual(mockProvider);
});
// Session restore scenarios - provider.enabled: undefined should be treated as enabled
describe('session restore scenarios (enabled: undefined)', () => {
it('should treat provider with enabled: undefined as enabled', async () => {
// This is the main bug fix: when providers are loaded from settings on session restore,
// enabled might be undefined (not explicitly set) and should be treated as enabled
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: undefined, // Not explicitly set - should be treated as enabled
models: [{ id: 'model-1', name: 'Model' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(mockSettingsService, 'model-1');
// Provider should be found and used even though enabled is undefined
expect(result.provider).toEqual(mockProvider);
expect(result.modelConfig?.id).toBe('model-1');
});
it('should use provider by ID when enabled is undefined', async () => {
// This tests the explicit providerId lookup with undefined enabled
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: undefined, // Not explicitly set - should be treated as enabled
models: [{ id: 'custom-model', name: 'Custom Model', mapsToClaudeModel: 'sonnet' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
} as unknown as SettingsService;
const result = await resolveProviderContext(
mockSettingsService,
'custom-model',
'provider-1'
);
// Provider should be found and used even though enabled is undefined
expect(result.provider).toEqual(mockProvider);
expect(result.credentials?.anthropicApiKey).toBe('test-key');
expect(result.resolvedModel).toContain('claude');
});
it('should find model via fallback in provider with enabled: undefined', async () => {
// Test fallback model lookup when provider has undefined enabled
const providerWithUndefinedEnabled = {
id: 'provider-1',
name: 'Provider 1',
// enabled is not set (undefined)
models: [{ id: 'model-1', name: 'Model' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [providerWithUndefinedEnabled],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(mockSettingsService, 'model-1');
expect(result.provider).toEqual(providerWithUndefinedEnabled);
expect(result.modelConfig?.id).toBe('model-1');
});
it('should still use provider for connection when model not found in its models array', async () => {
// This tests the fix: when providerId is explicitly set and provider is found,
// but the model isn't in that provider's models array, we still use that provider
// for connection settings (baseUrl, credentials)
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
baseUrl: 'https://custom-api.example.com',
models: [{ id: 'other-model', name: 'Other Model' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
} as unknown as SettingsService;
const result = await resolveProviderContext(
mockSettingsService,
'unknown-model', // Model not in provider's models array
'provider-1'
);
// Provider should still be returned for connection settings
expect(result.provider).toEqual(mockProvider);
// modelConfig should be undefined since the model wasn't found
expect(result.modelConfig).toBeUndefined();
// resolvedModel should be undefined since no mapping was found
expect(result.resolvedModel).toBeUndefined();
});
it('should fallback to find modelConfig in other providers when not in explicit providerId provider', async () => {
// When providerId is set and provider is found, but model isn't there,
// we should still search for modelConfig in other providers
const provider1 = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
baseUrl: 'https://provider1.example.com',
models: [{ id: 'provider1-model', name: 'Provider 1 Model' }],
};
const provider2 = {
id: 'provider-2',
name: 'Provider 2',
enabled: true,
baseUrl: 'https://provider2.example.com',
models: [
{
id: 'shared-model',
name: 'Shared Model',
mapsToClaudeModel: 'sonnet',
},
],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [provider1, provider2],
}),
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
} as unknown as SettingsService;
const result = await resolveProviderContext(
mockSettingsService,
'shared-model', // This model is in provider-2, not provider-1
'provider-1' // But we explicitly want to use provider-1
);
// Provider should still be provider-1 (for connection settings)
expect(result.provider).toEqual(provider1);
// But modelConfig should be found from provider-2
expect(result.modelConfig?.id).toBe('shared-model');
// And the model mapping should be resolved
expect(result.resolvedModel).toContain('claude');
});
it('should handle multiple providers with mixed enabled states', async () => {
// Test the full session restore scenario with multiple providers
const providers = [
{
id: 'provider-1',
name: 'First Provider',
enabled: undefined, // Undefined after restore
models: [{ id: 'model-a', name: 'Model A' }],
},
{
id: 'provider-2',
name: 'Second Provider',
// enabled field missing entirely
models: [{ id: 'model-b', name: 'Model B', mapsToClaudeModel: 'opus' }],
},
{
id: 'provider-3',
name: 'Disabled Provider',
enabled: false, // Explicitly disabled
models: [{ id: 'model-c', name: 'Model C' }],
},
];
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: providers,
}),
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
} as unknown as SettingsService;
// Provider 1 should work (enabled: undefined)
const result1 = await resolveProviderContext(mockSettingsService, 'model-a', 'provider-1');
expect(result1.provider?.id).toBe('provider-1');
expect(result1.modelConfig?.id).toBe('model-a');
// Provider 2 should work (enabled field missing)
const result2 = await resolveProviderContext(mockSettingsService, 'model-b', 'provider-2');
expect(result2.provider?.id).toBe('provider-2');
expect(result2.modelConfig?.id).toBe('model-b');
expect(result2.resolvedModel).toContain('claude');
// Provider 3 with explicit providerId IS returned even if disabled
// (caller handles enabled state check)
const result3 = await resolveProviderContext(mockSettingsService, 'model-c', 'provider-3');
// Provider is found but modelConfig won't be found since disabled providers
// skip model lookup in their models array
expect(result3.provider).toEqual(providers[2]);
expect(result3.modelConfig).toBeUndefined();
});
});
});
describe('getAllProviderModels', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return all models from enabled providers', async () => {
const mockProviders = [
{
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [
{ id: 'model-1', name: 'Model 1' },
{ id: 'model-2', name: 'Model 2' },
],
},
{
id: 'provider-2',
name: 'Provider 2',
enabled: true,
models: [{ id: 'model-3', name: 'Model 3' }],
},
];
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: mockProviders,
}),
} as unknown as SettingsService;
const result = await getAllProviderModels(mockSettingsService);
expect(result).toHaveLength(3);
expect(result[0].providerId).toBe('provider-1');
expect(result[0].model.id).toBe('model-1');
expect(result[2].providerId).toBe('provider-2');
});
it('should filter out disabled providers', async () => {
const mockProviders = [
{
id: 'enabled-1',
name: 'Enabled Provider',
enabled: true,
models: [{ id: 'model-1', name: 'Model 1' }],
},
{
id: 'disabled-1',
name: 'Disabled Provider',
enabled: false,
models: [{ id: 'model-2', name: 'Model 2' }],
},
];
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: mockProviders,
}),
} as unknown as SettingsService;
const result = await getAllProviderModels(mockSettingsService);
expect(result).toHaveLength(1);
expect(result[0].providerId).toBe('enabled-1');
});
it('should return empty array when no providers configured', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [],
}),
} as unknown as SettingsService;
const result = await getAllProviderModels(mockSettingsService);
expect(result).toEqual([]);
});
it('should handle missing claudeCompatibleProviders field', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getAllProviderModels(mockSettingsService);
expect(result).toEqual([]);
});
it('should handle provider with no models', async () => {
const mockProviders = [
{
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [],
},
{
id: 'provider-2',
name: 'Provider 2',
enabled: true,
// no models field
},
];
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: mockProviders,
}),
} as unknown as SettingsService;
const result = await getAllProviderModels(mockSettingsService);
expect(result).toEqual([]);
});
it('should return empty array on exception', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')),
} as unknown as SettingsService;
const result = await getAllProviderModels(mockSettingsService);
expect(result).toEqual([]);
});
});
});

View File

@@ -15,6 +15,7 @@ import {
calculateReasoningTimeout,
REASONING_TIMEOUT_MULTIPLIERS,
DEFAULT_TIMEOUT_MS,
validateBareModelId,
} from '@automaker/types';
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
@@ -455,4 +456,19 @@ describe('codex-provider.ts', () => {
expect(calculateReasoningTimeout('xhigh')).toBe(120000);
});
});
describe('validateBareModelId integration', () => {
it('should allow codex- prefixed models for Codex provider with expectedProvider="codex"', () => {
expect(() => validateBareModelId('codex-gpt-4', 'CodexProvider', 'codex')).not.toThrow();
expect(() =>
validateBareModelId('codex-gpt-5.1-codex-max', 'CodexProvider', 'codex')
).not.toThrow();
});
it('should reject other provider prefixes for Codex provider', () => {
expect(() => validateBareModelId('cursor-gpt-4', 'CodexProvider', 'codex')).toThrow();
expect(() => validateBareModelId('gemini-2.5-flash', 'CodexProvider', 'codex')).toThrow();
expect(() => validateBareModelId('copilot-gpt-4', 'CodexProvider', 'codex')).toThrow();
});
});
});

View File

@@ -331,13 +331,15 @@ describe('copilot-provider.ts', () => {
});
});
it('should normalize tool.execution_end event', () => {
it('should normalize tool.execution_complete event', () => {
const event = {
type: 'tool.execution_end',
type: 'tool.execution_complete',
data: {
toolName: 'read_file',
toolCallId: 'call-123',
result: 'file content',
success: true,
result: {
content: 'file content',
},
},
};
@@ -357,23 +359,85 @@ describe('copilot-provider.ts', () => {
});
});
it('should handle tool.execution_end with error', () => {
it('should handle tool.execution_complete with error', () => {
const event = {
type: 'tool.execution_end',
type: 'tool.execution_complete',
data: {
toolName: 'bash',
toolCallId: 'call-456',
error: 'Command failed',
success: false,
error: {
message: 'Command failed',
},
},
};
const result = provider.normalizeEvent(event);
expect(result?.message?.content?.[0]).toMatchObject({
type: 'tool_result',
tool_use_id: 'call-456',
content: '[ERROR] Command failed',
});
});
it('should handle tool.execution_complete with empty result', () => {
const event = {
type: 'tool.execution_complete',
data: {
toolCallId: 'call-789',
success: true,
result: {
content: '',
},
},
};
const result = provider.normalizeEvent(event);
expect(result?.message?.content?.[0]).toMatchObject({
type: 'tool_result',
tool_use_id: 'call-789',
content: '',
});
});
it('should handle tool.execution_complete with missing result', () => {
const event = {
type: 'tool.execution_complete',
data: {
toolCallId: 'call-999',
success: true,
// No result field
},
};
const result = provider.normalizeEvent(event);
expect(result?.message?.content?.[0]).toMatchObject({
type: 'tool_result',
tool_use_id: 'call-999',
content: '',
});
});
it('should handle tool.execution_complete with error code', () => {
const event = {
type: 'tool.execution_complete',
data: {
toolCallId: 'call-567',
success: false,
error: {
message: 'Permission denied',
code: 'EACCES',
},
},
};
const result = provider.normalizeEvent(event);
expect(result?.message?.content?.[0]).toMatchObject({
type: 'tool_result',
tool_use_id: 'call-567',
content: '[ERROR] Permission denied (EACCES)',
});
});
it('should normalize session.idle to success result', () => {
const event = { type: 'session.idle' };

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { CursorProvider } from '@/providers/cursor-provider.js';
import { validateBareModelId } from '@automaker/types';
describe('cursor-provider.ts', () => {
describe('buildCliArgs', () => {
@@ -154,4 +155,81 @@ describe('cursor-provider.ts', () => {
expect(msg!.subtype).toBe('success');
});
});
describe('Cursor Gemini models support', () => {
let provider: CursorProvider;
beforeEach(() => {
provider = Object.create(CursorProvider.prototype) as CursorProvider & {
cliPath?: string;
};
provider.cliPath = '/usr/local/bin/cursor-agent';
});
describe('buildCliArgs with Cursor Gemini models', () => {
it('should handle cursor-gemini-3-pro model', () => {
const args = provider.buildCliArgs({
prompt: 'Write a function',
model: 'gemini-3-pro', // Bare model ID after stripping cursor- prefix
cwd: '/tmp/project',
});
const modelIndex = args.indexOf('--model');
expect(modelIndex).toBeGreaterThan(-1);
expect(args[modelIndex + 1]).toBe('gemini-3-pro');
});
it('should handle cursor-gemini-3-flash model', () => {
const args = provider.buildCliArgs({
prompt: 'Quick task',
model: 'gemini-3-flash', // Bare model ID after stripping cursor- prefix
cwd: '/tmp/project',
});
const modelIndex = args.indexOf('--model');
expect(modelIndex).toBeGreaterThan(-1);
expect(args[modelIndex + 1]).toBe('gemini-3-flash');
});
it('should include --resume with Cursor Gemini models when sdkSessionId is provided', () => {
const args = provider.buildCliArgs({
prompt: 'Continue task',
model: 'gemini-3-pro',
cwd: '/tmp/project',
sdkSessionId: 'cursor-gemini-session-123',
});
const resumeIndex = args.indexOf('--resume');
expect(resumeIndex).toBeGreaterThan(-1);
expect(args[resumeIndex + 1]).toBe('cursor-gemini-session-123');
});
});
describe('validateBareModelId with Cursor Gemini models', () => {
it('should allow gemini- prefixed models for Cursor provider with expectedProvider="cursor"', () => {
// This is the key fix - Cursor Gemini models have bare IDs like "gemini-3-pro"
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', () => {
expect(() => validateBareModelId('codex-gpt-4', 'CursorProvider', 'cursor')).toThrow();
expect(() => validateBareModelId('copilot-gpt-4', 'CursorProvider', 'cursor')).toThrow();
expect(() => validateBareModelId('opencode-gpt-4', 'CursorProvider', 'cursor')).toThrow();
});
it('should accept cursor- prefixed models when expectedProvider is "cursor" (for double-prefix validation)', () => {
// Note: When expectedProvider="cursor", we skip the cursor- prefix check
// This is intentional because the validation happens AFTER prefix stripping
// So if cursor-gemini-3-pro reaches validateBareModelId with expectedProvider="cursor",
// it means the prefix was NOT properly stripped, but we skip it anyway
// since we're checking if the Cursor provider itself can receive cursor- prefixed models
expect(() =>
validateBareModelId('cursor-gemini-3-pro', 'CursorProvider', 'cursor')
).not.toThrow();
});
});
});
});

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { GeminiProvider } from '@/providers/gemini-provider.js';
import type { ProviderMessage } from '@automaker/types';
import { validateBareModelId } from '@automaker/types';
describe('gemini-provider.ts', () => {
let provider: GeminiProvider;
@@ -253,4 +254,19 @@ describe('gemini-provider.ts', () => {
expect(msg.subtype).toBe('success');
});
});
describe('validateBareModelId integration', () => {
it('should allow gemini- prefixed models for Gemini provider with expectedProvider="gemini"', () => {
expect(() =>
validateBareModelId('gemini-2.5-flash', 'GeminiProvider', 'gemini')
).not.toThrow();
expect(() => validateBareModelId('gemini-2.5-pro', 'GeminiProvider', 'gemini')).not.toThrow();
});
it('should reject other provider prefixes for Gemini provider', () => {
expect(() => validateBareModelId('cursor-gpt-4', 'GeminiProvider', 'gemini')).toThrow();
expect(() => validateBareModelId('codex-gpt-4', 'GeminiProvider', 'gemini')).toThrow();
expect(() => validateBareModelId('copilot-gpt-4', 'GeminiProvider', 'gemini')).toThrow();
});
});
});

View File

@@ -0,0 +1,270 @@
/**
* Tests for default fields applied to features created by parseAndCreateFeatures
*
* Verifies that auto-created features include planningMode: 'skip',
* requirePlanApproval: false, and dependencies: [].
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import path from 'path';
// Use vi.hoisted to create mock functions that can be referenced in vi.mock factories
const { mockMkdir, mockAtomicWriteJson, mockExtractJsonWithArray, mockCreateNotification } =
vi.hoisted(() => ({
mockMkdir: vi.fn().mockResolvedValue(undefined),
mockAtomicWriteJson: vi.fn().mockResolvedValue(undefined),
mockExtractJsonWithArray: vi.fn(),
mockCreateNotification: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('@/lib/secure-fs.js', () => ({
mkdir: mockMkdir,
}));
vi.mock('@automaker/utils', () => ({
createLogger: vi.fn().mockReturnValue({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
atomicWriteJson: mockAtomicWriteJson,
DEFAULT_BACKUP_COUNT: 3,
}));
vi.mock('@automaker/platform', () => ({
getFeaturesDir: vi.fn((projectPath: string) => path.join(projectPath, '.automaker', 'features')),
}));
vi.mock('@/lib/json-extractor.js', () => ({
extractJsonWithArray: mockExtractJsonWithArray,
}));
vi.mock('@/services/notification-service.js', () => ({
getNotificationService: vi.fn(() => ({
createNotification: mockCreateNotification,
})),
}));
// Import after mocks are set up
import { parseAndCreateFeatures } from '../../../../src/routes/app-spec/parse-and-create-features.js';
describe('parseAndCreateFeatures - default fields', () => {
const mockEvents = {
emit: vi.fn(),
} as any;
const projectPath = '/test/project';
beforeEach(() => {
vi.clearAllMocks();
});
it('should set planningMode to "skip" on created features', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-1',
title: 'Test Feature',
description: 'A test feature',
priority: 1,
complexity: 'simple',
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
expect(mockAtomicWriteJson).toHaveBeenCalledTimes(1);
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
expect(writtenData.planningMode).toBe('skip');
});
it('should set requirePlanApproval to false on created features', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-1',
title: 'Test Feature',
description: 'A test feature',
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
expect(writtenData.requirePlanApproval).toBe(false);
});
it('should set dependencies to empty array when not provided', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-1',
title: 'Test Feature',
description: 'A test feature',
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
expect(writtenData.dependencies).toEqual([]);
});
it('should preserve dependencies when provided by the parser', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-1',
title: 'Test Feature',
description: 'A test feature',
dependencies: ['feature-0'],
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
expect(writtenData.dependencies).toEqual(['feature-0']);
});
it('should apply all default fields consistently across multiple features', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-1',
title: 'Feature 1',
description: 'First feature',
},
{
id: 'feature-2',
title: 'Feature 2',
description: 'Second feature',
dependencies: ['feature-1'],
},
{
id: 'feature-3',
title: 'Feature 3',
description: 'Third feature',
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
expect(mockAtomicWriteJson).toHaveBeenCalledTimes(3);
for (let i = 0; i < 3; i++) {
const writtenData = mockAtomicWriteJson.mock.calls[i][1];
expect(writtenData.planningMode, `feature ${i + 1} planningMode`).toBe('skip');
expect(writtenData.requirePlanApproval, `feature ${i + 1} requirePlanApproval`).toBe(false);
expect(Array.isArray(writtenData.dependencies), `feature ${i + 1} dependencies`).toBe(true);
}
// Feature 2 should have its explicit dependency preserved
expect(mockAtomicWriteJson.mock.calls[1][1].dependencies).toEqual(['feature-1']);
// Features 1 and 3 should have empty arrays
expect(mockAtomicWriteJson.mock.calls[0][1].dependencies).toEqual([]);
expect(mockAtomicWriteJson.mock.calls[2][1].dependencies).toEqual([]);
});
it('should set status to "backlog" on all created features', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-1',
title: 'Test Feature',
description: 'A test feature',
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
expect(writtenData.status).toBe('backlog');
});
it('should include createdAt and updatedAt timestamps', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-1',
title: 'Test Feature',
description: 'A test feature',
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
expect(writtenData.createdAt).toBeDefined();
expect(writtenData.updatedAt).toBeDefined();
// Should be valid ISO date strings
expect(new Date(writtenData.createdAt).toISOString()).toBe(writtenData.createdAt);
expect(new Date(writtenData.updatedAt).toISOString()).toBe(writtenData.updatedAt);
});
it('should use default values for optional fields not provided', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-minimal',
title: 'Minimal Feature',
description: 'Only required fields',
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
expect(writtenData.category).toBe('Uncategorized');
expect(writtenData.priority).toBe(2);
expect(writtenData.complexity).toBe('moderate');
expect(writtenData.dependencies).toEqual([]);
expect(writtenData.planningMode).toBe('skip');
expect(writtenData.requirePlanApproval).toBe(false);
});
it('should emit success event after creating features', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-1',
title: 'Feature 1',
description: 'First',
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
expect(mockEvents.emit).toHaveBeenCalledWith(
'spec-regeneration:event',
expect.objectContaining({
type: 'spec_regeneration_complete',
projectPath,
})
);
});
it('should emit error event when no valid JSON is found', async () => {
mockExtractJsonWithArray.mockReturnValue(null);
await parseAndCreateFeatures(projectPath, 'invalid content', mockEvents);
expect(mockEvents.emit).toHaveBeenCalledWith(
'spec-regeneration:event',
expect.objectContaining({
type: 'spec_regeneration_error',
projectPath,
})
);
});
});

View File

@@ -0,0 +1,149 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { mockGetAll, mockCreate, mockUpdate, mockDelete, mockClearBacklogPlan } = vi.hoisted(() => ({
mockGetAll: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockDelete: vi.fn(),
mockClearBacklogPlan: vi.fn(),
}));
vi.mock('@/services/feature-loader.js', () => ({
FeatureLoader: class {
getAll = mockGetAll;
create = mockCreate;
update = mockUpdate;
delete = mockDelete;
},
}));
vi.mock('@/routes/backlog-plan/common.js', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
clearBacklogPlan: mockClearBacklogPlan,
getErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)),
logError: vi.fn(),
}));
import { createApplyHandler } from '@/routes/backlog-plan/routes/apply.js';
function createMockRes() {
const res: {
status: ReturnType<typeof vi.fn>;
json: ReturnType<typeof vi.fn>;
} = {
status: vi.fn(),
json: vi.fn(),
};
res.status.mockReturnValue(res);
return res;
}
describe('createApplyHandler', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetAll.mockResolvedValue([]);
mockCreate.mockResolvedValue({ id: 'feature-created' });
mockUpdate.mockResolvedValue({});
mockDelete.mockResolvedValue(true);
mockClearBacklogPlan.mockResolvedValue(undefined);
});
it('applies default feature model and planning settings when backlog plan additions omit them', async () => {
const settingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
defaultFeatureModel: { model: 'codex-gpt-5.2-codex', reasoningEffort: 'high' },
defaultPlanningMode: 'spec',
defaultRequirePlanApproval: true,
}),
getProjectSettings: vi.fn().mockResolvedValue({}),
} as any;
const req = {
body: {
projectPath: '/tmp/project',
plan: {
changes: [
{
type: 'add',
feature: {
id: 'feature-from-plan',
title: 'Created from plan',
description: 'desc',
},
},
],
},
},
} as any;
const res = createMockRes();
await createApplyHandler(settingsService)(req, res as any);
expect(mockCreate).toHaveBeenCalledWith(
'/tmp/project',
expect.objectContaining({
model: 'codex-gpt-5.2-codex',
reasoningEffort: 'high',
planningMode: 'spec',
requirePlanApproval: true,
})
);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: true,
})
);
});
it('uses project default feature model override and enforces no approval for skip mode', async () => {
const settingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
defaultFeatureModel: { model: 'claude-opus' },
defaultPlanningMode: 'skip',
defaultRequirePlanApproval: true,
}),
getProjectSettings: vi.fn().mockResolvedValue({
defaultFeatureModel: {
model: 'GLM-4.7',
providerId: 'provider-glm',
thinkingLevel: 'adaptive',
},
}),
} as any;
const req = {
body: {
projectPath: '/tmp/project',
plan: {
changes: [
{
type: 'add',
feature: {
id: 'feature-from-plan',
title: 'Created from plan',
},
},
],
},
},
} as any;
const res = createMockRes();
await createApplyHandler(settingsService)(req, res as any);
expect(mockCreate).toHaveBeenCalledWith(
'/tmp/project',
expect.objectContaining({
model: 'GLM-4.7',
providerId: 'provider-glm',
thinkingLevel: 'adaptive',
planningMode: 'skip',
requirePlanApproval: false,
})
);
});
});

View File

@@ -47,6 +47,8 @@ describe('running-agents routes', () => {
projectPath: '/home/user/project',
projectName: 'project',
isAutoMode: true,
model: 'claude-sonnet-4-20250514',
provider: 'claude',
title: 'Implement login feature',
description: 'Add user authentication with OAuth',
},
@@ -55,6 +57,8 @@ describe('running-agents routes', () => {
projectPath: '/home/user/other-project',
projectName: 'other-project',
isAutoMode: false,
model: 'codex-gpt-5.1',
provider: 'codex',
title: 'Fix navigation bug',
description: undefined,
},
@@ -82,6 +86,8 @@ describe('running-agents routes', () => {
projectPath: '/project',
projectName: 'project',
isAutoMode: true,
model: undefined,
provider: undefined,
title: undefined,
description: undefined,
},
@@ -141,6 +147,8 @@ describe('running-agents routes', () => {
projectPath: `/project-${i}`,
projectName: `project-${i}`,
isAutoMode: i % 2 === 0,
model: i % 3 === 0 ? 'claude-sonnet-4-20250514' : 'claude-haiku-4-5',
provider: 'claude',
title: `Feature ${i}`,
description: `Description ${i}`,
}));
@@ -167,6 +175,8 @@ describe('running-agents routes', () => {
projectPath: '/workspace/project-alpha',
projectName: 'project-alpha',
isAutoMode: true,
model: 'claude-sonnet-4-20250514',
provider: 'claude',
title: 'Feature A',
description: 'In project alpha',
},
@@ -175,6 +185,8 @@ describe('running-agents routes', () => {
projectPath: '/workspace/project-beta',
projectName: 'project-beta',
isAutoMode: false,
model: 'codex-gpt-5.1',
provider: 'codex',
title: 'Feature B',
description: 'In project beta',
},
@@ -191,5 +203,56 @@ describe('running-agents routes', () => {
expect(response.runningAgents[0].projectPath).toBe('/workspace/project-alpha');
expect(response.runningAgents[1].projectPath).toBe('/workspace/project-beta');
});
it('should include model and provider information for running agents', async () => {
// Arrange
const runningAgents = [
{
featureId: 'feature-claude',
projectPath: '/project',
projectName: 'project',
isAutoMode: true,
model: 'claude-sonnet-4-20250514',
provider: 'claude',
title: 'Claude Feature',
description: 'Using Claude model',
},
{
featureId: 'feature-codex',
projectPath: '/project',
projectName: 'project',
isAutoMode: false,
model: 'codex-gpt-5.1',
provider: 'codex',
title: 'Codex Feature',
description: 'Using Codex model',
},
{
featureId: 'feature-cursor',
projectPath: '/project',
projectName: 'project',
isAutoMode: false,
model: 'cursor-auto',
provider: 'cursor',
title: 'Cursor Feature',
description: 'Using Cursor model',
},
];
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents);
// Act
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
await handler(req, res);
// Assert
const response = vi.mocked(res.json).mock.calls[0][0];
expect(response.runningAgents[0].model).toBe('claude-sonnet-4-20250514');
expect(response.runningAgents[0].provider).toBe('claude');
expect(response.runningAgents[1].model).toBe('codex-gpt-5.1');
expect(response.runningAgents[1].provider).toBe('codex');
expect(response.runningAgents[2].model).toBe('cursor-auto');
expect(response.runningAgents[2].provider).toBe('cursor');
});
});
});

View File

@@ -0,0 +1,930 @@
/**
* Tests for worktree list endpoint handling of detached HEAD state.
*
* When a worktree is in detached HEAD state (e.g., during a rebase),
* `git worktree list --porcelain` outputs "detached" instead of
* "branch refs/heads/...". Previously, these worktrees were silently
* dropped from the response because the parser required both path AND branch.
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { createMockExpressContext } from '../../../utils/mocks.js';
// Mock all external dependencies before importing the module under test
vi.mock('child_process', () => ({
exec: vi.fn(),
}));
vi.mock('@/lib/git.js', () => ({
execGitCommand: vi.fn(),
}));
vi.mock('@automaker/git-utils', () => ({
isGitRepo: vi.fn(async () => true),
}));
vi.mock('@automaker/utils', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
vi.mock('@automaker/types', () => ({
validatePRState: vi.fn((state: string) => state),
}));
vi.mock('@/lib/secure-fs.js', () => ({
access: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn(),
readdir: vi.fn().mockResolvedValue([]),
stat: vi.fn(),
}));
vi.mock('@/lib/worktree-metadata.js', () => ({
readAllWorktreeMetadata: vi.fn(async () => new Map()),
updateWorktreePRInfo: vi.fn(async () => undefined),
}));
vi.mock('@/routes/worktree/common.js', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
getErrorMessage: vi.fn((e: Error) => e?.message || 'Unknown error'),
logError: vi.fn(),
normalizePath: vi.fn((p: string) => p),
execEnv: {},
isGhCliAvailable: vi.fn().mockResolvedValue(false),
};
});
vi.mock('@/routes/github/routes/check-github-remote.js', () => ({
checkGitHubRemote: vi.fn().mockResolvedValue({ hasGitHubRemote: false }),
}));
import { createListHandler } from '@/routes/worktree/routes/list.js';
import * as secureFs from '@/lib/secure-fs.js';
import { execGitCommand } from '@/lib/git.js';
import { readAllWorktreeMetadata, updateWorktreePRInfo } from '@/lib/worktree-metadata.js';
import { isGitRepo } from '@automaker/git-utils';
import { isGhCliAvailable, normalizePath, getErrorMessage } from '@/routes/worktree/common.js';
import { checkGitHubRemote } from '@/routes/github/routes/check-github-remote.js';
/**
* Set up execGitCommand mock (list handler uses this via lib/git.js, not child_process.exec).
*/
function setupExecGitCommandMock(options: {
porcelainOutput: string;
projectBranch?: string;
gitDirs?: Record<string, string>;
worktreeBranches?: Record<string, string>;
}) {
const { porcelainOutput, projectBranch = 'main', gitDirs = {}, worktreeBranches = {} } = options;
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
if (args[0] === 'worktree' && args[1] === 'list' && args[2] === '--porcelain') {
return porcelainOutput;
}
if (args[0] === 'branch' && args[1] === '--show-current') {
if (worktreeBranches[cwd] !== undefined) {
return worktreeBranches[cwd] + '\n';
}
return projectBranch + '\n';
}
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
if (cwd && gitDirs[cwd]) {
return gitDirs[cwd] + '\n';
}
throw new Error('not a git directory');
}
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref' && args[2] === 'HEAD') {
return 'HEAD\n';
}
if (args[0] === 'worktree' && args[1] === 'prune') {
return '';
}
if (args[0] === 'status' && args[1] === '--porcelain') {
return '';
}
if (args[0] === 'diff' && args[1] === '--name-only' && args[2] === '--diff-filter=U') {
return '';
}
return '';
});
}
describe('worktree list - detached HEAD handling', () => {
let req: Request;
let res: Response;
beforeEach(() => {
vi.clearAllMocks();
const context = createMockExpressContext();
req = context.req;
res = context.res;
// Re-establish mock implementations cleared by mockReset/clearAllMocks
vi.mocked(isGitRepo).mockResolvedValue(true);
vi.mocked(readAllWorktreeMetadata).mockResolvedValue(new Map());
vi.mocked(isGhCliAvailable).mockResolvedValue(false);
vi.mocked(checkGitHubRemote).mockResolvedValue({ hasGitHubRemote: false });
vi.mocked(normalizePath).mockImplementation((p: string) => p);
vi.mocked(getErrorMessage).mockImplementation(
(e: unknown) => (e as Error)?.message || 'Unknown error'
);
// Default: all paths exist
vi.mocked(secureFs.access).mockResolvedValue(undefined);
// Default: .worktrees directory doesn't exist (no scan via readdir)
vi.mocked(secureFs.readdir).mockRejectedValue(new Error('ENOENT'));
// Default: readFile fails
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
// Default execGitCommand so list handler gets valid porcelain/branch output (vitest clearMocks resets implementations)
setupExecGitCommandMock({
porcelainOutput: 'worktree /project\nbranch refs/heads/main\n\n',
projectBranch: 'main',
});
});
/**
* Helper: set up execGitCommand mock for the list handler.
* Worktree-specific behavior can be customized via the options parameter.
*/
function setupStandardExec(options: {
porcelainOutput: string;
projectBranch?: string;
/** Map of worktree path -> git-dir path */
gitDirs?: Record<string, string>;
/** Map of worktree cwd -> branch for `git branch --show-current` */
worktreeBranches?: Record<string, string>;
}) {
setupExecGitCommandMock(options);
}
/** Suppress .worktrees dir scan by making access throw for the .worktrees dir. */
function disableWorktreesScan() {
vi.mocked(secureFs.access).mockImplementation(async (p) => {
const pathStr = String(p);
// Block only the .worktrees dir access check in scanWorktreesDirectory
if (pathStr.endsWith('.worktrees') || pathStr.endsWith('.worktrees/')) {
throw new Error('ENOENT');
}
// All other paths exist
return undefined;
});
}
describe('porcelain parser', () => {
it('should include normal worktrees with branch lines', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/feature-a',
'branch refs/heads/feature-a',
'',
].join('\n'),
});
disableWorktreesScan();
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
success: boolean;
worktrees: Array<{ branch: string; path: string; isMain: boolean; hasWorktree: boolean }>;
};
expect(response.success).toBe(true);
expect(response.worktrees).toHaveLength(2);
expect(response.worktrees[0]).toEqual(
expect.objectContaining({
path: '/project',
branch: 'main',
isMain: true,
hasWorktree: true,
})
);
expect(response.worktrees[1]).toEqual(
expect.objectContaining({
path: '/project/.worktrees/feature-a',
branch: 'feature-a',
isMain: false,
hasWorktree: true,
})
);
});
it('should include worktrees with detached HEAD and recover branch from rebase-merge state', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/rebasing-wt',
'detached',
'',
].join('\n'),
gitDirs: {
'/project/.worktrees/rebasing-wt': '/project/.worktrees/rebasing-wt/.git',
},
});
disableWorktreesScan();
// rebase-merge/head-name returns the branch being rebased
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('rebase-merge/head-name')) {
return 'refs/heads/feature/my-rebasing-branch\n' as any;
}
throw new Error('ENOENT');
});
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string; isCurrent: boolean }>;
};
expect(response.worktrees).toHaveLength(2);
expect(response.worktrees[1]).toEqual(
expect.objectContaining({
path: '/project/.worktrees/rebasing-wt',
branch: 'feature/my-rebasing-branch',
isMain: false,
isCurrent: false,
hasWorktree: true,
})
);
});
it('should include worktrees with detached HEAD and recover branch from rebase-apply state', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/apply-wt',
'detached',
'',
].join('\n'),
gitDirs: {
'/project/.worktrees/apply-wt': '/project/.worktrees/apply-wt/.git',
},
});
disableWorktreesScan();
// rebase-merge doesn't exist, but rebase-apply does
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('rebase-apply/head-name')) {
return 'refs/heads/feature/apply-branch\n' as any;
}
throw new Error('ENOENT');
});
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string }>;
};
const detachedWt = response.worktrees.find((w) => w.path === '/project/.worktrees/apply-wt');
expect(detachedWt).toBeDefined();
expect(detachedWt!.branch).toBe('feature/apply-branch');
});
it('should show merge conflict worktrees normally since merge does not detach HEAD', async () => {
// During a merge conflict, HEAD stays on the branch, so `git worktree list --porcelain`
// still outputs `branch refs/heads/...`. This test verifies merge conflicts don't
// trigger the detached HEAD recovery path.
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/merge-wt',
'branch refs/heads/feature/merge-branch',
'',
].join('\n'),
});
disableWorktreesScan();
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string }>;
};
const mergeWt = response.worktrees.find((w) => w.path === '/project/.worktrees/merge-wt');
expect(mergeWt).toBeDefined();
expect(mergeWt!.branch).toBe('feature/merge-branch');
});
it('should fall back to (detached) when all branch recovery methods fail', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/unknown-wt',
'detached',
'',
].join('\n'),
worktreeBranches: {
'/project/.worktrees/unknown-wt': '', // empty = no branch
},
});
disableWorktreesScan();
// All readFile calls fail (no gitDirs so rev-parse --git-dir will throw)
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string }>;
};
const detachedWt = response.worktrees.find(
(w) => w.path === '/project/.worktrees/unknown-wt'
);
expect(detachedWt).toBeDefined();
expect(detachedWt!.branch).toBe('(detached)');
});
it('should not include detached worktree when directory does not exist on disk', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/deleted-wt',
'detached',
'',
].join('\n'),
});
// The deleted worktree doesn't exist on disk
vi.mocked(secureFs.access).mockImplementation(async (p) => {
const pathStr = String(p);
if (pathStr.includes('deleted-wt')) {
throw new Error('ENOENT');
}
if (pathStr.endsWith('.worktrees') || pathStr.endsWith('.worktrees/')) {
throw new Error('ENOENT');
}
return undefined;
});
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string }>;
};
// Only the main worktree should be present
expect(response.worktrees).toHaveLength(1);
expect(response.worktrees[0].path).toBe('/project');
});
it('should set isCurrent to false for detached worktrees even if recovered branch matches current branch', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/rebasing-wt',
'detached',
'',
].join('\n'),
// currentBranch for project is 'feature/my-branch'
projectBranch: 'feature/my-branch',
gitDirs: {
'/project/.worktrees/rebasing-wt': '/project/.worktrees/rebasing-wt/.git',
},
});
disableWorktreesScan();
// Recovery returns the same branch as currentBranch
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('rebase-merge/head-name')) {
return 'refs/heads/feature/my-branch\n' as any;
}
throw new Error('ENOENT');
});
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; isCurrent: boolean; path: string }>;
};
const detachedWt = response.worktrees.find(
(w) => w.path === '/project/.worktrees/rebasing-wt'
);
expect(detachedWt).toBeDefined();
// Detached worktrees should always have isCurrent=false
expect(detachedWt!.isCurrent).toBe(false);
});
it('should handle mixed normal and detached worktrees', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/normal-wt',
'branch refs/heads/feature-normal',
'',
'worktree /project/.worktrees/rebasing-wt',
'detached',
'',
'worktree /project/.worktrees/another-normal',
'branch refs/heads/feature-other',
'',
].join('\n'),
gitDirs: {
'/project/.worktrees/rebasing-wt': '/project/.worktrees/rebasing-wt/.git',
},
});
disableWorktreesScan();
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('rebase-merge/head-name')) {
return 'refs/heads/feature/rebasing\n' as any;
}
throw new Error('ENOENT');
});
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string; isMain: boolean }>;
};
expect(response.worktrees).toHaveLength(4);
expect(response.worktrees[0]).toEqual(
expect.objectContaining({ path: '/project', branch: 'main', isMain: true })
);
expect(response.worktrees[1]).toEqual(
expect.objectContaining({
path: '/project/.worktrees/normal-wt',
branch: 'feature-normal',
isMain: false,
})
);
expect(response.worktrees[2]).toEqual(
expect.objectContaining({
path: '/project/.worktrees/rebasing-wt',
branch: 'feature/rebasing',
isMain: false,
})
);
expect(response.worktrees[3]).toEqual(
expect.objectContaining({
path: '/project/.worktrees/another-normal',
branch: 'feature-other',
isMain: false,
})
);
});
it('should correctly advance isFirst flag past detached worktrees', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/detached-wt',
'detached',
'',
'worktree /project/.worktrees/normal-wt',
'branch refs/heads/feature-x',
'',
].join('\n'),
});
disableWorktreesScan();
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; isMain: boolean }>;
};
expect(response.worktrees).toHaveLength(3);
expect(response.worktrees[0].isMain).toBe(true); // main
expect(response.worktrees[1].isMain).toBe(false); // detached
expect(response.worktrees[2].isMain).toBe(false); // normal
});
it('should not add removed detached worktrees to removedWorktrees list', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/gone-wt',
'detached',
'',
].join('\n'),
});
// The detached worktree doesn't exist on disk
vi.mocked(secureFs.access).mockImplementation(async (p) => {
const pathStr = String(p);
if (pathStr.includes('gone-wt')) {
throw new Error('ENOENT');
}
if (pathStr.endsWith('.worktrees') || pathStr.endsWith('.worktrees/')) {
throw new Error('ENOENT');
}
return undefined;
});
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string }>;
removedWorktrees?: Array<{ path: string; branch: string }>;
};
// Should not be in removed list since we don't know the branch
expect(response.removedWorktrees).toBeUndefined();
});
it('should strip refs/heads/ prefix from recovered branch name', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/wt1',
'detached',
'',
].join('\n'),
gitDirs: {
'/project/.worktrees/wt1': '/project/.worktrees/wt1/.git',
},
});
disableWorktreesScan();
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('rebase-merge/head-name')) {
return 'refs/heads/my-branch\n' as any;
}
throw new Error('ENOENT');
});
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string }>;
};
const wt = response.worktrees.find((w) => w.path === '/project/.worktrees/wt1');
expect(wt).toBeDefined();
// Should be 'my-branch', not 'refs/heads/my-branch'
expect(wt!.branch).toBe('my-branch');
});
});
describe('scanWorktreesDirectory with detached HEAD recovery', () => {
it('should recover branch for discovered worktrees with detached HEAD', async () => {
req.body = { projectPath: '/project' };
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
if (args[0] === 'worktree' && args[1] === 'list') {
return 'worktree /project\nbranch refs/heads/main\n\n';
}
if (args[0] === 'branch' && args[1] === '--show-current') {
return cwd === '/project' ? 'main\n' : '\n';
}
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') {
return 'HEAD\n';
}
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
return '/project/.worktrees/orphan-wt/.git\n';
}
return '';
});
// .worktrees directory exists and has an orphan worktree
vi.mocked(secureFs.access).mockResolvedValue(undefined);
vi.mocked(secureFs.readdir).mockResolvedValue([
{ name: 'orphan-wt', isDirectory: () => true, isFile: () => false } as any,
]);
vi.mocked(secureFs.stat).mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
} as any);
// readFile returns branch from rebase-merge/head-name
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('rebase-merge/head-name')) {
return 'refs/heads/feature/orphan-branch\n' as any;
}
throw new Error('ENOENT');
});
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string }>;
};
const orphanWt = response.worktrees.find((w) => w.path === '/project/.worktrees/orphan-wt');
expect(orphanWt).toBeDefined();
expect(orphanWt!.branch).toBe('feature/orphan-branch');
});
it('should skip discovered worktrees when all branch detection fails', async () => {
req.body = { projectPath: '/project' };
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
if (args[0] === 'worktree' && args[1] === 'list') {
return 'worktree /project\nbranch refs/heads/main\n\n';
}
if (args[0] === 'branch' && args[1] === '--show-current') {
return cwd === '/project' ? 'main\n' : '\n';
}
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') {
return 'HEAD\n';
}
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
throw new Error('not a git dir');
}
return '';
});
vi.mocked(secureFs.access).mockResolvedValue(undefined);
vi.mocked(secureFs.readdir).mockResolvedValue([
{ name: 'broken-wt', isDirectory: () => true, isFile: () => false } as any,
]);
vi.mocked(secureFs.stat).mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
} as any);
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string }>;
};
// Only main worktree should be present
expect(response.worktrees).toHaveLength(1);
expect(response.worktrees[0].branch).toBe('main');
});
});
describe('PR tracking precedence', () => {
it('should keep manually tracked PR from metadata when branch PR differs', async () => {
req.body = { projectPath: '/project', includeDetails: true };
vi.mocked(readAllWorktreeMetadata).mockResolvedValue(
new Map([
[
'feature-a',
{
branch: 'feature-a',
createdAt: '2026-01-01T00:00:00.000Z',
pr: {
number: 99,
url: 'https://github.com/org/repo/pull/99',
title: 'Manual override PR',
state: 'OPEN',
createdAt: '2026-01-01T00:00:00.000Z',
},
},
],
])
);
vi.mocked(isGhCliAvailable).mockResolvedValue(true);
vi.mocked(checkGitHubRemote).mockResolvedValue({
hasGitHubRemote: true,
owner: 'org',
repo: 'repo',
});
vi.mocked(secureFs.access).mockImplementation(async (p) => {
const pathStr = String(p);
if (
pathStr.includes('MERGE_HEAD') ||
pathStr.includes('rebase-merge') ||
pathStr.includes('rebase-apply') ||
pathStr.includes('CHERRY_PICK_HEAD')
) {
throw new Error('ENOENT');
}
return undefined;
});
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
throw new Error('no git dir');
}
if (args[0] === 'worktree' && args[1] === 'list') {
return [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/feature-a',
'branch refs/heads/feature-a',
'',
].join('\n');
}
if (args[0] === 'branch' && args[1] === '--show-current') {
return cwd === '/project' ? 'main\n' : 'feature-a\n';
}
if (args[0] === 'status' && args[1] === '--porcelain') {
return '';
}
return '';
});
(exec as unknown as Mock).mockImplementation(
(
cmd: string,
_opts: unknown,
callback?: (err: Error | null, out: { stdout: string; stderr: string }) => void
) => {
const cb = typeof _opts === 'function' ? _opts : callback!;
if (cmd.includes('gh pr list')) {
cb(null, {
stdout: JSON.stringify([
{
number: 42,
title: 'Branch PR',
url: 'https://github.com/org/repo/pull/42',
state: 'OPEN',
headRefName: 'feature-a',
createdAt: '2026-01-02T00:00:00.000Z',
},
]),
stderr: '',
});
} else {
cb(null, { stdout: '', stderr: '' });
}
}
);
disableWorktreesScan();
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; pr?: { number: number; title: string } }>;
};
const featureWorktree = response.worktrees.find((w) => w.branch === 'feature-a');
expect(featureWorktree?.pr?.number).toBe(99);
expect(featureWorktree?.pr?.title).toBe('Manual override PR');
});
it('should prefer GitHub PR when it matches metadata number and sync updated fields', async () => {
req.body = { projectPath: '/project-2', includeDetails: true };
vi.mocked(readAllWorktreeMetadata).mockResolvedValue(
new Map([
[
'feature-a',
{
branch: 'feature-a',
createdAt: '2026-01-01T00:00:00.000Z',
pr: {
number: 42,
url: 'https://github.com/org/repo/pull/42',
title: 'Old title',
state: 'OPEN',
createdAt: '2026-01-01T00:00:00.000Z',
},
},
],
])
);
vi.mocked(isGhCliAvailable).mockResolvedValue(true);
vi.mocked(checkGitHubRemote).mockResolvedValue({
hasGitHubRemote: true,
owner: 'org',
repo: 'repo',
});
vi.mocked(secureFs.access).mockImplementation(async (p) => {
const pathStr = String(p);
if (
pathStr.includes('MERGE_HEAD') ||
pathStr.includes('rebase-merge') ||
pathStr.includes('rebase-apply') ||
pathStr.includes('CHERRY_PICK_HEAD')
) {
throw new Error('ENOENT');
}
return undefined;
});
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
throw new Error('no git dir');
}
if (args[0] === 'worktree' && args[1] === 'list') {
return [
'worktree /project-2',
'branch refs/heads/main',
'',
'worktree /project-2/.worktrees/feature-a',
'branch refs/heads/feature-a',
'',
].join('\n');
}
if (args[0] === 'branch' && args[1] === '--show-current') {
return cwd === '/project-2' ? 'main\n' : 'feature-a\n';
}
if (args[0] === 'status' && args[1] === '--porcelain') {
return '';
}
return '';
});
(exec as unknown as Mock).mockImplementation(
(
cmd: string,
_opts: unknown,
callback?: (err: Error | null, out: { stdout: string; stderr: string }) => void
) => {
const cb = typeof _opts === 'function' ? _opts : callback!;
if (cmd.includes('gh pr list')) {
cb(null, {
stdout: JSON.stringify([
{
number: 42,
title: 'New title from GitHub',
url: 'https://github.com/org/repo/pull/42',
state: 'MERGED',
headRefName: 'feature-a',
createdAt: '2026-01-02T00:00:00.000Z',
},
]),
stderr: '',
});
} else {
cb(null, { stdout: '', stderr: '' });
}
}
);
disableWorktreesScan();
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; pr?: { number: number; title: string; state: string } }>;
};
const featureWorktree = response.worktrees.find((w) => w.branch === 'feature-a');
expect(featureWorktree?.pr?.number).toBe(42);
expect(featureWorktree?.pr?.title).toBe('New title from GitHub');
expect(featureWorktree?.pr?.state).toBe('MERGED');
expect(vi.mocked(updateWorktreePRInfo)).toHaveBeenCalledWith(
'/project-2',
'feature-a',
expect.objectContaining({
number: 42,
title: 'New title from GitHub',
state: 'MERGED',
})
);
});
});
});

View File

@@ -0,0 +1,446 @@
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { AgentExecutor } from '../../../src/services/agent-executor.js';
import type { TypedEventBus } from '../../../src/services/typed-event-bus.js';
import type { FeatureStateManager } from '../../../src/services/feature-state-manager.js';
import type { PlanApprovalService } from '../../../src/services/plan-approval-service.js';
import type { BaseProvider } from '../../../src/providers/base-provider.js';
import * as secureFs from '../../../src/lib/secure-fs.js';
import { getFeatureDir } from '@automaker/platform';
import { buildPromptWithImages } from '@automaker/utils';
vi.mock('../../../src/lib/secure-fs.js', () => ({
mkdir: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockResolvedValue(undefined),
appendFile: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockResolvedValue(''),
}));
vi.mock('@automaker/platform', () => ({
getFeatureDir: vi.fn(),
}));
vi.mock('@automaker/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@automaker/utils')>();
return {
...actual,
buildPromptWithImages: vi.fn(),
createLogger: vi.fn().mockReturnValue({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
};
});
describe('AgentExecutor Summary Extraction', () => {
let mockEventBus: TypedEventBus;
let mockFeatureStateManager: FeatureStateManager;
let mockPlanApprovalService: PlanApprovalService;
beforeEach(() => {
vi.clearAllMocks();
mockEventBus = {
emitAutoModeEvent: vi.fn(),
} as unknown as TypedEventBus;
mockFeatureStateManager = {
updateTaskStatus: vi.fn().mockResolvedValue(undefined),
updateFeaturePlanSpec: vi.fn().mockResolvedValue(undefined),
saveFeatureSummary: vi.fn().mockResolvedValue(undefined),
} as unknown as FeatureStateManager;
mockPlanApprovalService = {
waitForApproval: vi.fn(),
} as unknown as PlanApprovalService;
(getFeatureDir as Mock).mockReturnValue('/mock/feature/dir');
(buildPromptWithImages as Mock).mockResolvedValue({ content: 'mocked prompt' });
});
it('should extract summary from new session content only', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
null
);
const previousContent = `Some previous work.
<summary>Old summary</summary>`;
const newWork = `New implementation work.
<summary>New summary</summary>`;
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: newWork }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const options = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet',
planningMode: 'skip' as const,
previousContent,
};
const callbacks = {
waitForApproval: vi.fn(),
saveFeatureSummary: vi.fn(),
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn(),
};
await executor.execute(options, callbacks);
// Verify it called saveFeatureSummary with the NEW summary
expect(callbacks.saveFeatureSummary).toHaveBeenCalledWith(
'/project',
'test-feature',
'New summary'
);
// Ensure it didn't call it with Old summary
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalledWith(
'/project',
'test-feature',
'Old summary'
);
});
it('should not save summary if no summary in NEW session content', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
null
);
const previousContent = `Some previous work.
<summary>Old summary</summary>`;
const newWork = `New implementation work without a summary tag.`;
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: newWork }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const options = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet',
planningMode: 'skip' as const,
previousContent,
};
const callbacks = {
waitForApproval: vi.fn(),
saveFeatureSummary: vi.fn(),
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn(),
};
await executor.execute(options, callbacks);
// Verify it NEVER called saveFeatureSummary because there was no NEW summary
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalled();
});
it('should extract task summary and update task status during streaming', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
null
);
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: 'Working... ' }],
},
};
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: '[TASK_COMPLETE] T001: Task finished successfully' }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
// We trigger executeTasksLoop by providing persistedTasks
const options = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet',
planningMode: 'skip' as const,
existingApprovedPlanContent: 'Some plan',
persistedTasks: [{ id: 'T001', description: 'Task 1', status: 'pending' as const }],
};
const callbacks = {
waitForApproval: vi.fn(),
saveFeatureSummary: vi.fn(),
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
};
await executor.execute(options, callbacks);
// Verify it updated task status with summary
expect(mockFeatureStateManager.updateTaskStatus).toHaveBeenCalledWith(
'/project',
'test-feature',
'T001',
'completed',
'Task finished successfully'
);
});
describe('Pipeline step summary fallback', () => {
it('should save fallback summary when extraction fails for pipeline step', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
null
);
// Content without a summary tag (extraction will fail)
const newWork = 'Implementation completed without summary tag.';
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: newWork }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const options = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet',
planningMode: 'skip' as const,
status: 'pipeline_step1' as const, // Pipeline status triggers fallback
};
const callbacks = {
waitForApproval: vi.fn(),
saveFeatureSummary: vi.fn(),
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn(),
};
await executor.execute(options, callbacks);
// Verify fallback summary was saved with trimmed content
expect(callbacks.saveFeatureSummary).toHaveBeenCalledWith(
'/project',
'test-feature',
'Implementation completed without summary tag.'
);
});
it('should not save fallback for non-pipeline status when extraction fails', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
null
);
// Content without a summary tag
const newWork = 'Implementation completed without summary tag.';
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: newWork }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const options = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet',
planningMode: 'skip' as const,
status: 'in_progress' as const, // Non-pipeline status
};
const callbacks = {
waitForApproval: vi.fn(),
saveFeatureSummary: vi.fn(),
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn(),
};
await executor.execute(options, callbacks);
// Verify no fallback was saved for non-pipeline status
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalled();
});
it('should not save empty fallback for pipeline step', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
null
);
// Empty/whitespace-only content
const newWork = ' \n\t ';
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: newWork }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const options = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet',
planningMode: 'skip' as const,
status: 'pipeline_step1' as const,
};
const callbacks = {
waitForApproval: vi.fn(),
saveFeatureSummary: vi.fn(),
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn(),
};
await executor.execute(options, callbacks);
// Verify no fallback was saved since content was empty/whitespace
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalled();
});
it('should prefer extracted summary over fallback for pipeline step', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
null
);
// Content WITH a summary tag
const newWork = `Implementation details here.
<summary>Proper summary from extraction</summary>`;
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: newWork }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const options = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet',
planningMode: 'skip' as const,
status: 'pipeline_step1' as const,
};
const callbacks = {
waitForApproval: vi.fn(),
saveFeatureSummary: vi.fn(),
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn(),
};
await executor.execute(options, callbacks);
// Verify extracted summary was saved, not the full content
expect(callbacks.saveFeatureSummary).toHaveBeenCalledWith(
'/project',
'test-feature',
'Proper summary from extraction'
);
// Ensure it didn't save the full content as fallback
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalledWith(
'/project',
'test-feature',
expect.stringContaining('Implementation details here')
);
});
});
});

View File

@@ -1181,6 +1181,50 @@ describe('AgentExecutor', () => {
);
});
it('should pass claudeCompatibleProvider to executeQuery options', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
mockSettingsService
);
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const mockClaudeProvider = { id: 'zai-1', name: 'Zai' } as any;
const options: AgentExecutionOptions = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-6',
claudeCompatibleProvider: mockClaudeProvider,
};
const callbacks = {
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
saveFeatureSummary: vi.fn(),
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
};
await executor.execute(options, callbacks);
expect(mockProvider.executeQuery).toHaveBeenCalledWith(
expect.objectContaining({
claudeCompatibleProvider: mockClaudeProvider,
})
);
});
it('should return correct result structure', async () => {
const executor = new AgentExecutor(
mockEventBus,
@@ -1235,4 +1279,471 @@ describe('AgentExecutor', () => {
expect(typeof result.aborted).toBe('boolean');
});
});
describe('pipeline summary fallback with scaffold stripping', () => {
it('should strip follow-up scaffold from fallback summary when extraction fails', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
mockSettingsService
);
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: 'Some agent output without summary markers' }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
const options: AgentExecutionOptions = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
previousContent: 'Previous session content',
status: 'pipeline_step1', // Pipeline status to trigger fallback
};
const callbacks = {
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
saveFeatureSummary,
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
};
await executor.execute(options, callbacks);
// The fallback summary should be called without the scaffold header
expect(saveFeatureSummary).toHaveBeenCalled();
const savedSummary = saveFeatureSummary.mock.calls[0][2];
// Should not contain the scaffold header
expect(savedSummary).not.toContain('---');
expect(savedSummary).not.toContain('Follow-up Session');
// Should contain the actual content
expect(savedSummary).toContain('Some agent output without summary markers');
});
it('should not save fallback when scaffold is the only content after stripping', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
mockSettingsService
);
// Provider yields no content - only scaffold will be present
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
// Empty stream - no actual content
}),
} as unknown as BaseProvider;
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
const options: AgentExecutionOptions = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
previousContent: 'Previous session content',
status: 'pipeline_step1', // Pipeline status
};
const callbacks = {
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
saveFeatureSummary,
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
};
await executor.execute(options, callbacks);
// Should not save an empty fallback (after scaffold is stripped)
expect(saveFeatureSummary).not.toHaveBeenCalled();
});
it('should save extracted summary when available, not fallback', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
mockSettingsService
);
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [
{
type: 'text',
text: 'Some content\n\n<summary>Extracted summary here</summary>\n\nMore content',
},
],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
const options: AgentExecutionOptions = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
previousContent: 'Previous session content',
status: 'pipeline_step1', // Pipeline status
};
const callbacks = {
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
saveFeatureSummary,
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
};
await executor.execute(options, callbacks);
// Should save the extracted summary, not the full content
expect(saveFeatureSummary).toHaveBeenCalledTimes(1);
expect(saveFeatureSummary).toHaveBeenCalledWith(
'/project',
'test-feature',
'Extracted summary here'
);
});
it('should handle scaffold with various whitespace patterns', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
mockSettingsService
);
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: 'Agent response here' }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
const options: AgentExecutionOptions = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
previousContent: 'Previous session content',
status: 'pipeline_step1',
};
const callbacks = {
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
saveFeatureSummary,
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
};
await executor.execute(options, callbacks);
// Should strip scaffold and save actual content
expect(saveFeatureSummary).toHaveBeenCalled();
const savedSummary = saveFeatureSummary.mock.calls[0][2];
expect(savedSummary.trim()).toBe('Agent response here');
});
it('should handle scaffold with extra newlines between markers', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
mockSettingsService
);
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: 'Actual content after scaffold' }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
// Set up with previous content to trigger scaffold insertion
const options: AgentExecutionOptions = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
previousContent: 'Previous session content',
status: 'pipeline_step1',
};
const callbacks = {
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
saveFeatureSummary,
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
};
await executor.execute(options, callbacks);
expect(saveFeatureSummary).toHaveBeenCalled();
const savedSummary = saveFeatureSummary.mock.calls[0][2];
// Verify the scaffold is stripped
expect(savedSummary).not.toMatch(/---\s*##\s*Follow-up Session/);
});
it('should handle content without any scaffold (first session)', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
mockSettingsService
);
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: 'First session output without summary' }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
// No previousContent means no scaffold
const options: AgentExecutionOptions = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
previousContent: undefined, // No previous content
status: 'pipeline_step1',
};
const callbacks = {
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
saveFeatureSummary,
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
};
await executor.execute(options, callbacks);
expect(saveFeatureSummary).toHaveBeenCalled();
const savedSummary = saveFeatureSummary.mock.calls[0][2];
expect(savedSummary).toBe('First session output without summary');
});
it('should handle non-pipeline status without saving fallback', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
mockSettingsService
);
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: 'Output without summary' }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
const options: AgentExecutionOptions = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
previousContent: 'Previous content',
status: 'implementing', // Non-pipeline status
};
const callbacks = {
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
saveFeatureSummary,
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
};
await executor.execute(options, callbacks);
// Should NOT save fallback for non-pipeline status
expect(saveFeatureSummary).not.toHaveBeenCalled();
});
it('should correctly handle content that starts with dashes but is not scaffold', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
mockSettingsService
);
// Content that looks like it might have dashes but is actual content
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: '---This is a code comment or separator---' }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
const options: AgentExecutionOptions = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
previousContent: undefined,
status: 'pipeline_step1',
};
const callbacks = {
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
saveFeatureSummary,
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
};
await executor.execute(options, callbacks);
expect(saveFeatureSummary).toHaveBeenCalled();
const savedSummary = saveFeatureSummary.mock.calls[0][2];
// Content should be preserved since it's not the scaffold pattern
expect(savedSummary).toContain('---This is a code comment or separator---');
});
it('should handle scaffold at different positions in content', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
mockSettingsService
);
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: 'Content after scaffold marker' }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
// With previousContent, scaffold will be at the start of sessionContent
const options: AgentExecutionOptions = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
previousContent: 'Previous content',
status: 'pipeline_step1',
};
const callbacks = {
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
saveFeatureSummary,
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
};
await executor.execute(options, callbacks);
expect(saveFeatureSummary).toHaveBeenCalled();
const savedSummary = saveFeatureSummary.mock.calls[0][2];
// Scaffold should be stripped, only actual content remains
expect(savedSummary).toBe('Content after scaffold marker');
});
});
});

View File

@@ -1050,4 +1050,383 @@ describe('auto-loop-coordinator.ts', () => {
);
});
});
describe('auto_mode_idle emission timing (idle check fix)', () => {
it('emits auto_mode_idle when no features in any state (empty project)', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
vi.mocked(mockLoadAllFeatures).mockResolvedValue([]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration and idle event
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', null);
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath: '/test/project',
branchName: null,
});
});
it('does NOT emit auto_mode_idle when features are in in_progress status', async () => {
// No pending features (backlog/ready)
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
// But there are features in in_progress status
const inProgressFeature: Feature = {
...testFeature,
id: 'feature-1',
status: 'in_progress',
title: 'In Progress Feature',
};
vi.mocked(mockLoadAllFeatures).mockResolvedValue([inProgressFeature]);
// No running features in concurrency manager (they were released during status update)
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should NOT emit auto_mode_idle because there's an in_progress feature
expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath: '/test/project',
branchName: null,
});
});
it('emits auto_mode_idle after in_progress feature completes', async () => {
const completedFeature: Feature = {
...testFeature,
id: 'feature-1',
status: 'completed',
title: 'Completed Feature',
};
// Initially has in_progress feature
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
vi.mocked(mockLoadAllFeatures).mockResolvedValue([completedFeature]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should emit auto_mode_idle because all features are completed
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath: '/test/project',
branchName: null,
});
});
it('does NOT emit auto_mode_idle for in_progress features in main worktree (no branchName)', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
// Feature in main worktree has no branchName
const mainWorktreeFeature: Feature = {
...testFeature,
id: 'feature-main',
status: 'in_progress',
title: 'Main Worktree Feature',
branchName: undefined, // Main worktree feature
};
// Feature in branch worktree has branchName
const branchFeature: Feature = {
...testFeature,
id: 'feature-branch',
status: 'in_progress',
title: 'Branch Feature',
branchName: 'feature/some-branch',
};
vi.mocked(mockLoadAllFeatures).mockResolvedValue([mainWorktreeFeature, branchFeature]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
// Start auto mode for main worktree
await coordinator.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should NOT emit auto_mode_idle because there's an in_progress feature in main worktree
expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith(
'auto_mode_idle',
expect.objectContaining({
projectPath: '/test/project',
branchName: null,
})
);
});
it('does NOT emit auto_mode_idle for in_progress features with matching branchName', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
// Feature in matching branch
const matchingBranchFeature: Feature = {
...testFeature,
id: 'feature-matching',
status: 'in_progress',
title: 'Matching Branch Feature',
branchName: 'feature/test-branch',
};
// Feature in different branch
const differentBranchFeature: Feature = {
...testFeature,
id: 'feature-different',
status: 'in_progress',
title: 'Different Branch Feature',
branchName: 'feature/other-branch',
};
vi.mocked(mockLoadAllFeatures).mockResolvedValue([
matchingBranchFeature,
differentBranchFeature,
]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
// Start auto mode for feature/test-branch
await coordinator.startAutoLoopForProject('/test/project', 'feature/test-branch', 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', 'feature/test-branch');
// Should NOT emit auto_mode_idle because there's an in_progress feature with matching branch
expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith(
'auto_mode_idle',
expect.objectContaining({
projectPath: '/test/project',
branchName: 'feature/test-branch',
})
);
});
it('emits auto_mode_idle when in_progress feature has different branchName', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
// Only feature is in a different branch
const differentBranchFeature: Feature = {
...testFeature,
id: 'feature-different',
status: 'in_progress',
title: 'Different Branch Feature',
branchName: 'feature/other-branch',
};
vi.mocked(mockLoadAllFeatures).mockResolvedValue([differentBranchFeature]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
// Start auto mode for feature/test-branch
await coordinator.startAutoLoopForProject('/test/project', 'feature/test-branch', 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', 'feature/test-branch');
// Should emit auto_mode_idle because the in_progress feature is in a different branch
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath: '/test/project',
branchName: 'feature/test-branch',
});
});
it('emits auto_mode_idle when only backlog/ready features exist and no running/in_progress features', async () => {
// backlog/ready features should be in loadPendingFeatures, not loadAllFeatures for idle check
// But this test verifies the idle check doesn't incorrectly block on backlog/ready
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); // No pending (for current iteration check)
const backlogFeature: Feature = {
...testFeature,
id: 'feature-1',
status: 'backlog',
title: 'Backlog Feature',
};
const readyFeature: Feature = {
...testFeature,
id: 'feature-2',
status: 'ready',
title: 'Ready Feature',
};
vi.mocked(mockLoadAllFeatures).mockResolvedValue([backlogFeature, readyFeature]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should NOT emit auto_mode_idle because there are backlog/ready features
// (even though they're not in_progress, the idle check only looks at in_progress status)
// Actually, backlog/ready would be caught by loadPendingFeatures on next iteration,
// so this should emit idle since runningCount=0 and no in_progress features
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath: '/test/project',
branchName: null,
});
});
it('handles loadAllFeaturesFn error gracefully (falls back to emitting idle)', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
vi.mocked(mockLoadAllFeatures).mockRejectedValue(new Error('Failed to load features'));
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should still emit auto_mode_idle when loadAllFeatures fails (defensive behavior)
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath: '/test/project',
branchName: null,
});
});
it('handles missing loadAllFeaturesFn gracefully (falls back to emitting idle)', async () => {
// Create coordinator without loadAllFeaturesFn
const coordWithoutLoadAll = new AutoLoopCoordinator(
mockEventBus,
mockConcurrencyManager,
mockSettingsService,
mockExecuteFeature,
mockLoadPendingFeatures,
mockSaveExecutionState,
mockClearExecutionState,
mockResetStuckFeatures,
mockIsFeatureFinished,
mockIsFeatureRunning
// loadAllFeaturesFn omitted
);
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordWithoutLoadAll.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordWithoutLoadAll.stopAutoLoopForProject('/test/project', null);
// Should emit auto_mode_idle when loadAllFeaturesFn is missing (defensive behavior)
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath: '/test/project',
branchName: null,
});
});
it('only emits auto_mode_idle once per idle period (hasEmittedIdleEvent flag)', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
vi.mocked(mockLoadAllFeatures).mockResolvedValue([]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time multiple times to trigger multiple loop iterations
await vi.advanceTimersByTimeAsync(11000); // First idle check
await vi.advanceTimersByTimeAsync(11000); // Second idle check
await vi.advanceTimersByTimeAsync(11000); // Third idle check
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should only emit auto_mode_idle once despite multiple iterations
const idleCalls = vi
.mocked(mockEventBus.emitAutoModeEvent)
.mock.calls.filter((call) => call[0] === 'auto_mode_idle');
expect(idleCalls.length).toBe(1);
});
it('premature auto_mode_idle bug scenario: runningCount=0 but feature still in_progress', async () => {
// This test reproduces the exact bug scenario described in the feature:
// When a feature completes, there's a brief window where:
// 1. The feature has been released from runningFeatures (so runningCount = 0)
// 2. The feature's status is still 'in_progress' during the status update transition
// 3. pendingFeatures returns empty (only checks 'backlog'/'ready' statuses)
// The fix ensures auto_mode_idle is NOT emitted in this window
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); // No backlog/ready features
// Feature is still in in_progress status (during status update transition)
const transitioningFeature: Feature = {
...testFeature,
id: 'feature-1',
status: 'in_progress',
title: 'Transitioning Feature',
};
vi.mocked(mockLoadAllFeatures).mockResolvedValue([transitioningFeature]);
// Feature has been released from concurrency manager (runningCount = 0)
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', null);
// The fix prevents auto_mode_idle from being emitted in this scenario
expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath: '/test/project',
branchName: null,
});
});
});
});

View File

@@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest';
import { AutoModeServiceFacade } from '@/services/auto-mode/facade.js';
import type { Feature } from '@automaker/types';
describe('AutoModeServiceFacade', () => {
describe('isFeatureEligibleForAutoMode', () => {
it('should include features with pipeline_* status', () => {
const features: Partial<Feature>[] = [
{ id: '1', status: 'ready', branchName: 'main' },
{ id: '2', status: 'pipeline_testing', branchName: 'main' },
{ id: '3', status: 'in_progress', branchName: 'main' },
{ id: '4', status: 'interrupted', branchName: 'main' },
{ id: '5', status: 'backlog', branchName: 'main' },
];
const branchName = 'main';
const primaryBranch = 'main';
const filtered = features.filter((f) =>
AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, branchName, primaryBranch)
);
expect(filtered.map((f) => f.id)).toContain('1'); // ready
expect(filtered.map((f) => f.id)).toContain('2'); // pipeline_testing
expect(filtered.map((f) => f.id)).toContain('4'); // interrupted
expect(filtered.map((f) => f.id)).toContain('5'); // backlog
expect(filtered.map((f) => f.id)).not.toContain('3'); // in_progress
});
it('should correctly handle main worktree alignment', () => {
const features: Partial<Feature>[] = [
{ id: '1', status: 'ready', branchName: undefined },
{ id: '2', status: 'ready', branchName: 'main' },
{ id: '3', status: 'ready', branchName: 'other' },
];
const branchName = null; // main worktree
const primaryBranch = 'main';
const filtered = features.filter((f) =>
AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, branchName, primaryBranch)
);
expect(filtered.map((f) => f.id)).toContain('1'); // no branch
expect(filtered.map((f) => f.id)).toContain('2'); // matching primary branch
expect(filtered.map((f) => f.id)).not.toContain('3'); // mismatching branch
});
it('should exclude completed, verified, and waiting_approval statuses', () => {
const features: Partial<Feature>[] = [
{ id: '1', status: 'completed', branchName: 'main' },
{ id: '2', status: 'verified', branchName: 'main' },
{ id: '3', status: 'waiting_approval', branchName: 'main' },
];
const filtered = features.filter((f) =>
AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, 'main', 'main')
);
expect(filtered).toHaveLength(0);
});
it('should include pipeline_complete as eligible (still a pipeline status)', () => {
const feature: Partial<Feature> = {
id: '1',
status: 'pipeline_complete',
branchName: 'main',
};
const result = AutoModeServiceFacade.isFeatureEligibleForAutoMode(
feature as Feature,
'main',
'main'
);
expect(result).toBe(true);
});
it('should filter pipeline features by branch in named worktrees', () => {
const features: Partial<Feature>[] = [
{ id: '1', status: 'pipeline_testing', branchName: 'feature-branch' },
{ id: '2', status: 'pipeline_review', branchName: 'other-branch' },
{ id: '3', status: 'pipeline_deploy', branchName: undefined },
];
const filtered = features.filter((f) =>
AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, 'feature-branch', null)
);
expect(filtered.map((f) => f.id)).toEqual(['1']);
});
it('should handle null primaryBranch for main worktree', () => {
const features: Partial<Feature>[] = [
{ id: '1', status: 'ready', branchName: undefined },
{ id: '2', status: 'ready', branchName: 'main' },
];
const filtered = features.filter((f) =>
AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, null, null)
);
// When primaryBranch is null, only features with no branchName are included
expect(filtered.map((f) => f.id)).toEqual(['1']);
});
it('should include various pipeline_* step IDs as eligible', () => {
const statuses = [
'pipeline_step_abc_123',
'pipeline_code_review',
'pipeline_step1',
'pipeline_testing',
'pipeline_deploy',
];
for (const status of statuses) {
const feature: Partial<Feature> = { id: '1', status, branchName: 'main' };
const result = AutoModeServiceFacade.isFeatureEligibleForAutoMode(
feature as Feature,
'main',
'main'
);
expect(result).toBe(true);
}
});
});
});

View File

@@ -0,0 +1,207 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock dependencies (hoisted)
vi.mock('../../../../src/services/agent-executor.js');
vi.mock('../../../../src/lib/settings-helpers.js');
vi.mock('../../../../src/providers/provider-factory.js');
vi.mock('../../../../src/lib/sdk-options.js');
vi.mock('@automaker/model-resolver', () => ({
resolveModelString: vi.fn((model, fallback) => model || fallback),
DEFAULT_MODELS: { claude: 'claude-3-5-sonnet' },
}));
import { AutoModeServiceFacade } from '../../../../src/services/auto-mode/facade.js';
import { AgentExecutor } from '../../../../src/services/agent-executor.js';
import * as settingsHelpers from '../../../../src/lib/settings-helpers.js';
import { ProviderFactory } from '../../../../src/providers/provider-factory.js';
import * as sdkOptions from '../../../../src/lib/sdk-options.js';
describe('AutoModeServiceFacade Agent Runner', () => {
let mockAgentExecutor: MockAgentExecutor;
let mockSettingsService: MockSettingsService;
let facade: AutoModeServiceFacade;
// Type definitions for mocks
interface MockAgentExecutor {
execute: ReturnType<typeof vi.fn>;
}
interface MockSettingsService {
getGlobalSettings: ReturnType<typeof vi.fn>;
getCredentials: ReturnType<typeof vi.fn>;
getProjectSettings: ReturnType<typeof vi.fn>;
}
beforeEach(() => {
vi.clearAllMocks();
// Set up the mock for createAutoModeOptions
// Note: Using 'as any' because Options type from SDK is complex and we only need
// the specific fields that are verified in tests (maxTurns, allowedTools, etc.)
vi.mocked(sdkOptions.createAutoModeOptions).mockReturnValue({
maxTurns: 123,
allowedTools: ['tool1'],
systemPrompt: 'system-prompt',
} as any);
mockAgentExecutor = {
execute: vi.fn().mockResolvedValue(undefined),
};
(AgentExecutor as any).mockImplementation(function (this: MockAgentExecutor) {
return mockAgentExecutor;
});
mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({}),
getCredentials: vi.fn().mockResolvedValue({}),
getProjectSettings: vi.fn().mockResolvedValue({}),
};
// Helper to access the private createRunAgentFn via factory creation
facade = AutoModeServiceFacade.create('/project', {
events: { on: vi.fn(), emit: vi.fn(), subscribe: vi.fn().mockReturnValue(vi.fn()) } as any,
settingsService: mockSettingsService,
sharedServices: {
eventBus: { emitAutoModeEvent: vi.fn() } as any,
worktreeResolver: { getCurrentBranch: vi.fn().mockResolvedValue('main') } as any,
concurrencyManager: {
isRunning: vi.fn().mockReturnValue(false),
getRunningFeature: vi.fn().mockReturnValue(null),
} as any,
} as any,
});
});
it('should resolve provider by providerId and pass to AgentExecutor', async () => {
// 1. Setup mocks
const mockProvider = { getName: () => 'mock-provider' };
(ProviderFactory.getProviderForModel as any).mockReturnValue(mockProvider);
const mockClaudeProvider = { id: 'zai-1', name: 'Zai' };
const mockCredentials = { apiKey: 'test-key' };
(settingsHelpers.resolveProviderContext as any).mockResolvedValue({
provider: mockClaudeProvider,
credentials: mockCredentials,
resolvedModel: undefined,
});
const runAgentFn = (facade as any).executionService.runAgentFn;
// 2. Execute
await runAgentFn(
'/workdir',
'feature-1',
'prompt',
new AbortController(),
'/project',
[],
'model-1',
{
providerId: 'zai-1',
}
);
// 3. Verify
expect(settingsHelpers.resolveProviderContext).toHaveBeenCalledWith(
mockSettingsService,
'model-1',
'zai-1',
'[AutoModeFacade]'
);
expect(mockAgentExecutor.execute).toHaveBeenCalledWith(
expect.objectContaining({
claudeCompatibleProvider: mockClaudeProvider,
credentials: mockCredentials,
model: 'model-1', // Original model ID
}),
expect.any(Object)
);
});
it('should fallback to model-based lookup if providerId is not provided', async () => {
const mockProvider = { getName: () => 'mock-provider' };
(ProviderFactory.getProviderForModel as any).mockReturnValue(mockProvider);
const mockClaudeProvider = { id: 'zai-model', name: 'Zai Model' };
(settingsHelpers.resolveProviderContext as any).mockResolvedValue({
provider: mockClaudeProvider,
credentials: { apiKey: 'model-key' },
resolvedModel: 'resolved-model-1',
});
const runAgentFn = (facade as any).executionService.runAgentFn;
await runAgentFn(
'/workdir',
'feature-1',
'prompt',
new AbortController(),
'/project',
[],
'model-1',
{
// no providerId
}
);
expect(settingsHelpers.resolveProviderContext).toHaveBeenCalledWith(
mockSettingsService,
'model-1',
undefined,
'[AutoModeFacade]'
);
expect(mockAgentExecutor.execute).toHaveBeenCalledWith(
expect.objectContaining({
claudeCompatibleProvider: mockClaudeProvider,
}),
expect.any(Object)
);
});
it('should use resolvedModel from provider config for createAutoModeOptions if it maps to a Claude model', async () => {
const mockProvider = { getName: () => 'mock-provider' };
(ProviderFactory.getProviderForModel as any).mockReturnValue(mockProvider);
const mockClaudeProvider = {
id: 'zai-1',
name: 'Zai',
models: [{ id: 'custom-model-1', mapsToClaudeModel: 'claude-3-opus' }],
};
(settingsHelpers.resolveProviderContext as any).mockResolvedValue({
provider: mockClaudeProvider,
credentials: { apiKey: 'test-key' },
resolvedModel: 'claude-3-5-opus',
});
const runAgentFn = (facade as any).executionService.runAgentFn;
await runAgentFn(
'/workdir',
'feature-1',
'prompt',
new AbortController(),
'/project',
[],
'custom-model-1',
{
providerId: 'zai-1',
}
);
// Verify createAutoModeOptions was called with the mapped model
expect(sdkOptions.createAutoModeOptions).toHaveBeenCalledWith(
expect.objectContaining({
model: 'claude-3-5-opus',
})
);
// Verify AgentExecutor.execute still gets the original custom model ID
expect(mockAgentExecutor.execute).toHaveBeenCalledWith(
expect.objectContaining({
model: 'custom-model-1',
}),
expect.any(Object)
);
});
});

View File

@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import path from 'path';
import os from 'os';
import fs from 'fs/promises';
import { spawn } from 'child_process';
// Mock child_process
vi.mock('child_process', () => ({
spawn: vi.fn(),
execSync: vi.fn(),
execFile: vi.fn(),
}));
// Mock secure-fs
vi.mock('@/lib/secure-fs.js', () => ({
access: vi.fn(),
}));
// Mock net
vi.mock('net', () => ({
default: {
createServer: vi.fn(),
},
createServer: vi.fn(),
}));
import * as secureFs from '@/lib/secure-fs.js';
import net from 'net';
describe('DevServerService Event Types', () => {
let testDataDir: string;
let worktreeDir: string;
let mockEmitter: EventEmitter;
beforeEach(async () => {
vi.clearAllMocks();
vi.resetModules();
testDataDir = path.join(os.tmpdir(), `dev-server-events-test-${Date.now()}`);
worktreeDir = path.join(os.tmpdir(), `dev-server-worktree-events-test-${Date.now()}`);
await fs.mkdir(testDataDir, { recursive: true });
await fs.mkdir(worktreeDir, { recursive: true });
mockEmitter = new EventEmitter();
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockServer = new EventEmitter() as any;
mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => {
process.nextTick(() => mockServer.emit('listening'));
});
mockServer.close = vi.fn();
vi.mocked(net.createServer).mockReturnValue(mockServer);
});
afterEach(async () => {
try {
await fs.rm(testDataDir, { recursive: true, force: true });
await fs.rm(worktreeDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
it('should emit all required event types during dev server lifecycle', async () => {
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.initialize(testDataDir, mockEmitter as any);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const emittedEvents: Record<string, any[]> = {
'dev-server:starting': [],
'dev-server:started': [],
'dev-server:url-detected': [],
'dev-server:output': [],
'dev-server:stopped': [],
};
Object.keys(emittedEvents).forEach((type) => {
mockEmitter.on(type, (payload) => emittedEvents[type].push(payload));
});
// 1. Starting & Started
await service.startDevServer(worktreeDir, worktreeDir);
expect(emittedEvents['dev-server:starting'].length).toBe(1);
expect(emittedEvents['dev-server:started'].length).toBe(1);
// 2. Output & URL Detected
mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5173/\n'));
// Throttled output needs a bit of time (OUTPUT_THROTTLE_MS is 100ms)
await new Promise((resolve) => setTimeout(resolve, 250));
expect(emittedEvents['dev-server:output'].length).toBeGreaterThanOrEqual(1);
expect(emittedEvents['dev-server:url-detected'].length).toBe(1);
expect(emittedEvents['dev-server:url-detected'][0].url).toBe('http://localhost:5173/');
// 3. Stopped
await service.stopDevServer(worktreeDir);
expect(emittedEvents['dev-server:stopped'].length).toBe(1);
});
});
// Helper to create a mock child process
function createMockProcess() {
const mockProcess = new EventEmitter() as any;
mockProcess.stdout = new EventEmitter();
mockProcess.stderr = new EventEmitter();
mockProcess.kill = vi.fn();
mockProcess.killed = false;
mockProcess.pid = 12345;
mockProcess.unref = vi.fn();
return mockProcess;
}

View File

@@ -0,0 +1,240 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import path from 'path';
import os from 'os';
import fs from 'fs/promises';
import { spawn, execSync } from 'child_process';
// Mock child_process
vi.mock('child_process', () => ({
spawn: vi.fn(),
execSync: vi.fn(),
execFile: vi.fn(),
}));
// Mock secure-fs
vi.mock('@/lib/secure-fs.js', () => ({
access: vi.fn(),
}));
// Mock net
vi.mock('net', () => ({
default: {
createServer: vi.fn(),
},
createServer: vi.fn(),
}));
import * as secureFs from '@/lib/secure-fs.js';
import net from 'net';
describe('DevServerService Persistence & Sync', () => {
let testDataDir: string;
let worktreeDir: string;
let mockEmitter: EventEmitter;
beforeEach(async () => {
vi.clearAllMocks();
vi.resetModules();
testDataDir = path.join(os.tmpdir(), `dev-server-persistence-test-${Date.now()}`);
worktreeDir = path.join(os.tmpdir(), `dev-server-worktree-test-${Date.now()}`);
await fs.mkdir(testDataDir, { recursive: true });
await fs.mkdir(worktreeDir, { recursive: true });
mockEmitter = new EventEmitter();
// Default mock for secureFs.access - return resolved (file exists)
vi.mocked(secureFs.access).mockResolvedValue(undefined);
// Default mock for net.createServer - port available
const mockServer = new EventEmitter() as any;
mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => {
process.nextTick(() => mockServer.emit('listening'));
});
mockServer.close = vi.fn();
vi.mocked(net.createServer).mockReturnValue(mockServer);
// Default mock for execSync - no process on port
vi.mocked(execSync).mockImplementation(() => {
throw new Error('No process found');
});
});
afterEach(async () => {
try {
await fs.rm(testDataDir, { recursive: true, force: true });
await fs.rm(worktreeDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
it('should emit dev-server:starting when startDevServer is called', async () => {
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.initialize(testDataDir, mockEmitter as any);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const events: any[] = [];
mockEmitter.on('dev-server:starting', (payload) => events.push(payload));
await service.startDevServer(worktreeDir, worktreeDir);
expect(events.length).toBe(1);
expect(events[0].worktreePath).toBe(worktreeDir);
});
it('should prevent concurrent starts for the same worktree', async () => {
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.initialize(testDataDir, mockEmitter as any);
// Delay spawn to simulate long starting time
vi.mocked(spawn).mockImplementation(() => {
const p = createMockProcess();
// Don't return immediately, simulate some work
return p as any;
});
// Start first one (don't await yet if we want to test concurrency)
const promise1 = service.startDevServer(worktreeDir, worktreeDir);
// Try to start second one immediately
const result2 = await service.startDevServer(worktreeDir, worktreeDir);
expect(result2.success).toBe(false);
expect(result2.error).toContain('already starting');
await promise1;
});
it('should persist state to dev-servers.json when started', async () => {
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.initialize(testDataDir, mockEmitter as any);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
await service.startDevServer(worktreeDir, worktreeDir);
const statePath = path.join(testDataDir, 'dev-servers.json');
const exists = await fs
.access(statePath)
.then(() => true)
.catch(() => false);
expect(exists).toBe(true);
const content = await fs.readFile(statePath, 'utf-8');
const state = JSON.parse(content);
expect(state.length).toBe(1);
expect(state[0].worktreePath).toBe(worktreeDir);
});
it('should load state from dev-servers.json on initialize', async () => {
// 1. Create a fake state file
const persistedInfo = [
{
worktreePath: worktreeDir,
allocatedPort: 3005,
port: 3005,
url: 'http://localhost:3005',
startedAt: new Date().toISOString(),
urlDetected: true,
customCommand: 'npm run dev',
},
];
await fs.writeFile(path.join(testDataDir, 'dev-servers.json'), JSON.stringify(persistedInfo));
// 2. Mock port as IN USE (so it re-attaches)
const mockServer = new EventEmitter() as any;
mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => {
// Fail to listen = port in use
process.nextTick(() => mockServer.emit('error', new Error('EADDRINUSE')));
});
vi.mocked(net.createServer).mockReturnValue(mockServer);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.initialize(testDataDir, mockEmitter as any);
expect(service.isRunning(worktreeDir)).toBe(true);
const info = service.getServerInfo(worktreeDir);
expect(info?.port).toBe(3005);
});
it('should prune stale servers from state on initialize if port is available', async () => {
// 1. Create a fake state file
const persistedInfo = [
{
worktreePath: worktreeDir,
allocatedPort: 3005,
port: 3005,
url: 'http://localhost:3005',
startedAt: new Date().toISOString(),
urlDetected: true,
},
];
await fs.writeFile(path.join(testDataDir, 'dev-servers.json'), JSON.stringify(persistedInfo));
// 2. Mock port as AVAILABLE (so it prunes)
const mockServer = new EventEmitter() as any;
mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => {
process.nextTick(() => mockServer.emit('listening'));
});
mockServer.close = vi.fn();
vi.mocked(net.createServer).mockReturnValue(mockServer);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.initialize(testDataDir, mockEmitter as any);
expect(service.isRunning(worktreeDir)).toBe(false);
// Give it a moment to complete the pruning saveState
await new Promise((resolve) => setTimeout(resolve, 100));
// Check if file was updated
const content = await fs.readFile(path.join(testDataDir, 'dev-servers.json'), 'utf-8');
const state = JSON.parse(content);
expect(state.length).toBe(0);
});
it('should update persisted state when URL is detected', async () => {
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.initialize(testDataDir, mockEmitter as any);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
await service.startDevServer(worktreeDir, worktreeDir);
// Simulate output with URL
mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5555/\n'));
// Give it a moment to process and save (needs to wait for saveQueue)
await new Promise((resolve) => setTimeout(resolve, 300));
const content = await fs.readFile(path.join(testDataDir, 'dev-servers.json'), 'utf-8');
const state = JSON.parse(content);
expect(state[0].url).toBe('http://localhost:5555/');
expect(state[0].port).toBe(5555);
expect(state[0].urlDetected).toBe(true);
});
});
// Helper to create a mock child process
function createMockProcess() {
const mockProcess = new EventEmitter() as any;
mockProcess.stdout = new EventEmitter();
mockProcess.stderr = new EventEmitter();
mockProcess.kill = vi.fn();
mockProcess.killed = false;
mockProcess.pid = 12345;
mockProcess.unref = vi.fn();
return mockProcess;
}

View File

@@ -5,6 +5,9 @@ import type { SettingsService } from '../../../src/services/settings-service.js'
import type { EventHistoryService } from '../../../src/services/event-history-service.js';
import type { FeatureLoader } from '../../../src/services/feature-loader.js';
// Mock global fetch for ntfy tests
const originalFetch = global.fetch;
/**
* Create a mock EventEmitter for testing
*/
@@ -38,9 +41,15 @@ function createMockEventEmitter(): EventEmitter & {
/**
* Create a mock SettingsService
*/
function createMockSettingsService(hooks: unknown[] = []): SettingsService {
function createMockSettingsService(
hooks: unknown[] = [],
ntfyEndpoints: unknown[] = []
): SettingsService {
return {
getGlobalSettings: vi.fn().mockResolvedValue({ eventHooks: hooks }),
getGlobalSettings: vi.fn().mockResolvedValue({
eventHooks: hooks,
ntfyEndpoints: ntfyEndpoints,
}),
} as unknown as SettingsService;
}
@@ -70,6 +79,7 @@ describe('EventHookService', () => {
let mockSettingsService: ReturnType<typeof createMockSettingsService>;
let mockEventHistoryService: ReturnType<typeof createMockEventHistoryService>;
let mockFeatureLoader: ReturnType<typeof createMockFeatureLoader>;
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
service = new EventHookService();
@@ -77,10 +87,14 @@ describe('EventHookService', () => {
mockSettingsService = createMockSettingsService();
mockEventHistoryService = createMockEventHistoryService();
mockFeatureLoader = createMockFeatureLoader();
// Set up mock fetch for ntfy tests
mockFetch = vi.fn();
global.fetch = mockFetch;
});
afterEach(() => {
service.destroy();
global.fetch = originalFetch;
});
describe('initialize', () => {
@@ -832,4 +846,631 @@ describe('EventHookService', () => {
expect(storeCall.error).toBe('Feature stopped by user');
});
});
describe('ntfy hook execution', () => {
const mockNtfyEndpoint = {
id: 'endpoint-1',
name: 'Test Endpoint',
serverUrl: 'https://ntfy.sh',
topic: 'test-topic',
authType: 'none' as const,
enabled: true,
};
it('should execute ntfy hook when endpoint is configured', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Success Hook',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
title: 'Feature {{featureName}} completed!',
priority: 3,
},
},
];
mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Test Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('https://ntfy.sh/test-topic');
expect(options.method).toBe('POST');
expect(options.headers['Title']).toBe('Feature Test Feature completed!');
});
it('should NOT execute ntfy hook when endpoint is not found', async () => {
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Hook with Missing Endpoint',
action: {
type: 'ntfy',
endpointId: 'non-existent-endpoint',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Test Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
});
// Fetch should NOT have been called since endpoint doesn't exist
expect(mockFetch).not.toHaveBeenCalled();
});
it('should use ntfy endpoint default values when hook does not override', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpointWithDefaults = {
...mockNtfyEndpoint,
defaultTags: 'default-tag',
defaultEmoji: 'tada',
defaultClickUrl: 'https://default.example.com',
};
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_error',
name: 'Ntfy Error Hook',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
// No title, tags, or emoji - should use endpoint defaults
},
},
];
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaults]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Failed Feature',
passes: false,
message: 'Something went wrong',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const options = mockFetch.mock.calls[0][1];
// Should use default tags and emoji from endpoint
expect(options.headers['Tags']).toBe('tada,default-tag');
// Click URL gets deep-link query param when feature context is available
expect(options.headers['Click']).toContain('https://default.example.com/board');
expect(options.headers['Click']).toContain('featureId=feat-1');
});
it('should send ntfy notification with authentication', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpointWithAuth = {
...mockNtfyEndpoint,
authType: 'token' as const,
token: 'tk_test_token',
};
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Authenticated Ntfy Hook',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [endpointWithAuth]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Test Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Authorization']).toBe('Bearer tk_test_token');
});
it('should handle ntfy notification failure gracefully', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Hook That Will Fail',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Test Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
// Should not throw - error should be caught gracefully
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
// Event should still be stored even if ntfy hook fails
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
});
it('should substitute variables in ntfy title and body', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Hook with Variables',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
title: '[{{projectName}}] {{featureName}}',
body: 'Feature {{featureId}} completed at {{timestamp}}',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-123',
featureName: 'Cool Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/my-project',
projectName: 'my-project',
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('[my-project] Cool Feature');
expect(options.body).toContain('feat-123');
});
it('should NOT execute ntfy hook when endpoint is disabled', async () => {
const disabledEndpoint = {
...mockNtfyEndpoint,
enabled: false,
};
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Hook with Disabled Endpoint',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [disabledEndpoint]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Test Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
});
// Fetch should not be called because endpoint is disabled
expect(mockFetch).not.toHaveBeenCalled();
});
it('should use hook-specific values over endpoint defaults', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpointWithDefaults = {
...mockNtfyEndpoint,
defaultTags: 'default-tag',
defaultEmoji: 'default-emoji',
defaultClickUrl: 'https://default.example.com',
};
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Hook with Overrides',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
tags: 'override-tag',
emoji: 'override-emoji',
clickUrl: 'https://override.example.com',
priority: 5,
},
},
];
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaults]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Test Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const options = mockFetch.mock.calls[0][1];
// Hook values should override endpoint defaults
expect(options.headers['Tags']).toBe('override-emoji,override-tag');
// Click URL uses hook-specific base URL with deep link params applied
expect(options.headers['Click']).toContain('https://override.example.com/board');
expect(options.headers['Click']).toContain('featureId=feat-1');
expect(options.headers['Priority']).toBe('5');
});
describe('click URL deep linking', () => {
it('should generate board URL with featureId query param when feature context is available', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpointWithDefaultClickUrl = {
...mockNtfyEndpoint,
defaultClickUrl: 'https://app.example.com',
};
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Hook',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'test-feature-123',
featureName: 'Test Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const options = mockFetch.mock.calls[0][1];
const clickUrl = options.headers['Click'];
// Should use /board path with featureId query param
expect(clickUrl).toContain('/board');
expect(clickUrl).toContain('featureId=test-feature-123');
// Should NOT use the old path-based format
expect(clickUrl).not.toContain('/feature/');
});
it('should generate board URL without featureId when no feature context', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpointWithDefaultClickUrl = {
...mockNtfyEndpoint,
defaultClickUrl: 'https://app.example.com',
};
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'auto_mode_complete',
name: 'Auto Mode Complete Hook',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
// Event without featureId but with projectPath (auto_mode_idle triggers auto_mode_complete)
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_idle',
executionMode: 'auto',
projectPath: '/test/project',
totalFeatures: 5,
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const options = mockFetch.mock.calls[0][1];
const clickUrl = options.headers['Click'];
// Should navigate to board without featureId
expect(clickUrl).toContain('/board');
expect(clickUrl).not.toContain('featureId=');
});
it('should apply deep link params to hook-specific click URL', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpointWithDefaultClickUrl = {
...mockNtfyEndpoint,
defaultClickUrl: 'https://default.example.com',
};
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Hook with Custom Click URL',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
clickUrl: 'https://custom.example.com/custom-page',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-789',
featureName: 'Custom URL Test',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const options = mockFetch.mock.calls[0][1];
const clickUrl = options.headers['Click'];
// Should use the hook-specific click URL with deep link params applied
expect(clickUrl).toContain('https://custom.example.com/board');
expect(clickUrl).toContain('featureId=feat-789');
});
it('should preserve existing query params when adding featureId', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpointWithDefaultClickUrl = {
...mockNtfyEndpoint,
defaultClickUrl: 'https://app.example.com/board?view=list',
};
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Hook',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-456',
featureName: 'Test Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const options = mockFetch.mock.calls[0][1];
const clickUrl = options.headers['Click'];
// Should preserve existing query params and add featureId
expect(clickUrl).toContain('view=list');
expect(clickUrl).toContain('featureId=feat-456');
// Should be properly formatted URL
expect(clickUrl).toMatch(/^https:\/\/app\.example\.com\/board\?.+$/);
});
});
});
});

View File

@@ -451,13 +451,28 @@ describe('execution-service.ts', () => {
const callArgs = mockRunAgentFn.mock.calls[0];
expect(callArgs[0]).toMatch(/test.*project/); // workDir contains project
expect(callArgs[1]).toBe('feature-1');
expect(callArgs[2]).toContain('Feature Implementation Task');
expect(callArgs[2]).toContain('Feature Task');
expect(callArgs[3]).toBeInstanceOf(AbortController);
expect(callArgs[4]).toBe('/test/project');
// Model (index 6) should be resolved
expect(callArgs[6]).toBe('claude-sonnet-4');
});
it('passes providerId to runAgentFn when present on feature', async () => {
const featureWithProvider: Feature = {
...testFeature,
providerId: 'zai-provider-1',
};
vi.mocked(mockLoadFeatureFn).mockResolvedValue(featureWithProvider);
await service.executeFeature('/test/project', 'feature-1');
expect(mockRunAgentFn).toHaveBeenCalled();
const callArgs = mockRunAgentFn.mock.calls[0];
const options = callArgs[7];
expect(options.providerId).toBe('zai-provider-1');
});
it('executes pipeline after agent completes', async () => {
const pipelineSteps = [{ id: 'step-1', name: 'Step 1', order: 1, instructions: 'Do step 1' }];
vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({
@@ -1316,16 +1331,19 @@ describe('execution-service.ts', () => {
);
});
it('falls back to project path when worktree not found', async () => {
it('emits error and does not execute agent when worktree is not found in worktree mode', async () => {
vi.mocked(mockWorktreeResolver.findWorktreeForBranch).mockResolvedValue(null);
await service.executeFeature('/test/project', 'feature-1', true);
// Should still run agent, just with project path
expect(mockRunAgentFn).toHaveBeenCalled();
const callArgs = mockRunAgentFn.mock.calls[0];
// First argument is workDir - should be normalized path to /test/project
expect(callArgs[0]).toBe(normalizePath('/test/project'));
expect(mockRunAgentFn).not.toHaveBeenCalled();
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
'auto_mode_error',
expect.objectContaining({
featureId: 'feature-1',
error: 'Worktree enabled but no worktree found for feature branch "feature/test-1".',
})
);
});
it('skips worktree resolution when useWorktrees is false', async () => {
@@ -1439,6 +1457,114 @@ describe('execution-service.ts', () => {
expect.objectContaining({ passes: true })
);
});
// Helper to create ExecutionService with a custom loadFeatureFn that returns
// different features on first load (initial) vs subsequent loads (after completion)
const createServiceWithCustomLoad = (completedFeature: Feature): ExecutionService => {
let loadCallCount = 0;
mockLoadFeatureFn = vi.fn().mockImplementation(() => {
loadCallCount++;
return loadCallCount === 1 ? testFeature : completedFeature;
});
return new ExecutionService(
mockEventBus,
mockConcurrencyManager,
mockWorktreeResolver,
mockSettingsService,
mockRunAgentFn,
mockExecutePipelineFn,
mockUpdateFeatureStatusFn,
mockLoadFeatureFn,
mockGetPlanningPromptPrefixFn,
mockSaveFeatureSummaryFn,
mockRecordLearningsFn,
mockContextExistsFn,
mockResumeFeatureFn,
mockTrackFailureFn,
mockSignalPauseFn,
mockRecordSuccessFn,
mockSaveExecutionStateFn,
mockLoadContextFilesFn
);
};
it('does not overwrite accumulated summary when feature already has one', async () => {
const featureWithAccumulatedSummary: Feature = {
...testFeature,
summary:
'### Implementation\n\nFirst step output\n\n---\n\n### Code Review\n\nReview findings',
};
const svc = createServiceWithCustomLoad(featureWithAccumulatedSummary);
await svc.executeFeature('/test/project', 'feature-1');
// saveFeatureSummaryFn should NOT be called because feature already has a summary
// This prevents overwriting accumulated pipeline summaries with just the last step's output
expect(mockSaveFeatureSummaryFn).not.toHaveBeenCalled();
});
it('saves summary when feature has no existing summary', async () => {
const featureWithoutSummary: Feature = {
...testFeature,
summary: undefined,
};
vi.mocked(secureFs.readFile).mockResolvedValue(
'🔧 Tool: Edit\nInput: {"file_path": "/src/index.ts"}\n\n<summary>New summary</summary>'
);
const svc = createServiceWithCustomLoad(featureWithoutSummary);
await svc.executeFeature('/test/project', 'feature-1');
// Should save the extracted summary since feature has none
expect(mockSaveFeatureSummaryFn).toHaveBeenCalledWith(
'/test/project',
'feature-1',
'Test summary'
);
});
it('does not overwrite summary when feature has empty string summary (treats as no summary)', async () => {
// Empty string is falsy, so it should be treated as "no summary" and a new one should be saved
const featureWithEmptySummary: Feature = {
...testFeature,
summary: '',
};
vi.mocked(secureFs.readFile).mockResolvedValue(
'🔧 Tool: Edit\nInput: {"file_path": "/src/index.ts"}\n\n<summary>New summary</summary>'
);
const svc = createServiceWithCustomLoad(featureWithEmptySummary);
await svc.executeFeature('/test/project', 'feature-1');
// Empty string is falsy, so it should save a new summary
expect(mockSaveFeatureSummaryFn).toHaveBeenCalledWith(
'/test/project',
'feature-1',
'Test summary'
);
});
it('preserves accumulated summary when feature is transitioned from pipeline to verified', async () => {
// This is the key scenario: feature went through pipeline steps, accumulated a summary,
// then status changed to 'verified' - we should NOT overwrite the accumulated summary
const featureWithAccumulatedSummary: Feature = {
...testFeature,
status: 'verified',
summary:
'### Implementation\n\nCreated auth module\n\n---\n\n### Code Review\n\nApproved\n\n---\n\n### Testing\n\nAll tests pass',
};
vi.mocked(secureFs.readFile).mockResolvedValue('Agent output with summary');
const svc = createServiceWithCustomLoad(featureWithAccumulatedSummary);
await svc.executeFeature('/test/project', 'feature-1');
// The accumulated summary should be preserved
expect(mockSaveFeatureSummaryFn).not.toHaveBeenCalled();
});
});
describe('executeFeature - agent output validation', () => {
@@ -1874,5 +2000,60 @@ describe('execution-service.ts', () => {
// The only non-in_progress status call should be absent since merge_conflict returns early
expect(statusCalls.length).toBe(0);
});
it('sets waiting_approval instead of backlog when error occurs after pipeline completes', async () => {
// Set up pipeline with steps
vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({
version: 1,
steps: [{ id: 'step-1', name: 'Step 1', order: 1, instructions: 'Do step 1' }] as any,
});
// Pipeline succeeds, but reading agent output throws after pipeline completes
mockExecutePipelineFn = vi.fn().mockResolvedValue(undefined);
// Simulate an error after pipeline completes by making loadFeature throw
// on the post-pipeline refresh call
let loadCallCount = 0;
mockLoadFeatureFn = vi.fn().mockImplementation(() => {
loadCallCount++;
if (loadCallCount === 1) return testFeature; // initial load
// Second call is the task-retry check, third is the pipeline refresh
if (loadCallCount <= 2) return testFeature;
throw new Error('Unexpected post-pipeline error');
});
const svc = createServiceWithMocks();
await svc.executeFeature('/test/project', 'feature-1');
// Should set to waiting_approval, NOT backlog, since pipeline completed
const backlogCalls = vi
.mocked(mockUpdateFeatureStatusFn)
.mock.calls.filter((call) => call[2] === 'backlog');
expect(backlogCalls.length).toBe(0);
const waitingCalls = vi
.mocked(mockUpdateFeatureStatusFn)
.mock.calls.filter((call) => call[2] === 'waiting_approval');
expect(waitingCalls.length).toBeGreaterThan(0);
});
it('still sets backlog when error occurs before pipeline completes', async () => {
// Set up pipeline with steps
vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({
version: 1,
steps: [{ id: 'step-1', name: 'Step 1', order: 1, instructions: 'Do step 1' }] as any,
});
// Pipeline itself throws (e.g., agent error during pipeline step)
mockExecutePipelineFn = vi.fn().mockRejectedValue(new Error('Agent execution failed'));
const svc = createServiceWithMocks();
await svc.executeFeature('/test/project', 'feature-1');
// Should still set to backlog since pipeline did NOT complete
const backlogCalls = vi
.mocked(mockUpdateFeatureStatusFn)
.mock.calls.filter((call) => call[2] === 'backlog');
expect(backlogCalls.length).toBe(1);
});
});
});

View File

@@ -2,12 +2,17 @@ import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import path from 'path';
import { FeatureStateManager } from '@/services/feature-state-manager.js';
import type { Feature } from '@automaker/types';
import { isPipelineStatus } from '@automaker/types';
const PIPELINE_SUMMARY_SEPARATOR = '\n\n---\n\n';
const PIPELINE_SUMMARY_HEADER_PREFIX = '### ';
import type { EventEmitter } from '@/lib/events.js';
import type { FeatureLoader } from '@/services/feature-loader.js';
import * as secureFs from '@/lib/secure-fs.js';
import { atomicWriteJson, readJsonWithRecovery } from '@automaker/utils';
import { getFeatureDir, getFeaturesDir } from '@automaker/platform';
import { getNotificationService } from '@/services/notification-service.js';
import { pipelineService } from '@/services/pipeline-service.js';
/**
* Helper to normalize paths for cross-platform test compatibility.
@@ -42,6 +47,16 @@ vi.mock('@/services/notification-service.js', () => ({
})),
}));
vi.mock('@/services/pipeline-service.js', () => ({
pipelineService: {
getStepIdFromStatus: vi.fn((status: string) => {
if (status.startsWith('pipeline_')) return status.replace('pipeline_', '');
return null;
}),
getStep: vi.fn(),
},
}));
describe('FeatureStateManager', () => {
let manager: FeatureStateManager;
let mockEvents: EventEmitter;
@@ -264,6 +279,81 @@ describe('FeatureStateManager', () => {
);
});
it('should use feature.title as notification title for waiting_approval status', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
const featureWithTitle: Feature = {
...mockFeature,
title: 'My Awesome Feature Title',
name: 'old-name-property', // name property exists but should not be used
};
(readJsonWithRecovery as Mock).mockResolvedValue({
data: featureWithTitle,
recovered: false,
source: 'main',
});
await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval');
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'feature_waiting_approval',
title: 'My Awesome Feature Title',
message: 'Feature Ready for Review',
})
);
});
it('should fallback to featureId as notification title when feature.title is undefined in waiting_approval notification', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
const featureWithoutTitle: Feature = {
...mockFeature,
title: undefined,
name: 'old-name-property',
};
(readJsonWithRecovery as Mock).mockResolvedValue({
data: featureWithoutTitle,
recovered: false,
source: 'main',
});
await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval');
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'feature_waiting_approval',
title: 'feature-123',
message: 'Feature Ready for Review',
})
);
});
it('should handle empty string title by using featureId as notification title in waiting_approval notification', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
const featureWithEmptyTitle: Feature = {
...mockFeature,
title: '',
name: 'old-name-property',
};
(readJsonWithRecovery as Mock).mockResolvedValue({
data: featureWithEmptyTitle,
recovered: false,
source: 'main',
});
await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval');
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'feature_waiting_approval',
title: 'feature-123',
message: 'Feature Ready for Review',
})
);
});
it('should create notification for verified status', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
@@ -283,6 +373,81 @@ describe('FeatureStateManager', () => {
);
});
it('should use feature.title as notification title for verified status', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
const featureWithTitle: Feature = {
...mockFeature,
title: 'My Awesome Feature Title',
name: 'old-name-property', // name property exists but should not be used
};
(readJsonWithRecovery as Mock).mockResolvedValue({
data: featureWithTitle,
recovered: false,
source: 'main',
});
await manager.updateFeatureStatus('/project', 'feature-123', 'verified');
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'feature_verified',
title: 'My Awesome Feature Title',
message: 'Feature Verified',
})
);
});
it('should fallback to featureId as notification title when feature.title is undefined in verified notification', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
const featureWithoutTitle: Feature = {
...mockFeature,
title: undefined,
name: 'old-name-property',
};
(readJsonWithRecovery as Mock).mockResolvedValue({
data: featureWithoutTitle,
recovered: false,
source: 'main',
});
await manager.updateFeatureStatus('/project', 'feature-123', 'verified');
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'feature_verified',
title: 'feature-123',
message: 'Feature Verified',
})
);
});
it('should handle empty string title by using featureId as notification title in verified notification', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
const featureWithEmptyTitle: Feature = {
...mockFeature,
title: '',
name: 'old-name-property',
};
(readJsonWithRecovery as Mock).mockResolvedValue({
data: featureWithEmptyTitle,
recovered: false,
source: 'main',
});
await manager.updateFeatureStatus('/project', 'feature-123', 'verified');
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'feature_verified',
title: 'feature-123',
message: 'Feature Verified',
})
);
});
it('should sync to app_spec for completed status', async () => {
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature },
@@ -341,9 +506,6 @@ describe('FeatureStateManager', () => {
describe('markFeatureInterrupted', () => {
it('should mark feature as interrupted', async () => {
(secureFs.readFile as Mock).mockResolvedValue(
JSON.stringify({ ...mockFeature, status: 'in_progress' })
);
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'in_progress' },
recovered: false,
@@ -358,20 +520,25 @@ describe('FeatureStateManager', () => {
});
it('should preserve pipeline_* statuses', async () => {
(secureFs.readFile as Mock).mockResolvedValue(
JSON.stringify({ ...mockFeature, status: 'pipeline_step_1' })
);
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step_1' },
recovered: false,
source: 'main',
});
await manager.markFeatureInterrupted('/project', 'feature-123', 'server shutdown');
// Should NOT call atomicWriteJson because pipeline status is preserved
expect(atomicWriteJson).not.toHaveBeenCalled();
expect(isPipelineStatus('pipeline_step_1')).toBe(true);
});
it('should preserve pipeline_complete status', async () => {
(secureFs.readFile as Mock).mockResolvedValue(
JSON.stringify({ ...mockFeature, status: 'pipeline_complete' })
);
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_complete' },
recovered: false,
source: 'main',
});
await manager.markFeatureInterrupted('/project', 'feature-123');
@@ -379,7 +546,6 @@ describe('FeatureStateManager', () => {
});
it('should handle feature not found', async () => {
(secureFs.readFile as Mock).mockRejectedValue(new Error('ENOENT'));
(readJsonWithRecovery as Mock).mockResolvedValue({
data: null,
recovered: true,
@@ -439,6 +605,29 @@ describe('FeatureStateManager', () => {
expect(savedFeature.status).toBe('backlog');
});
it('should preserve pipeline_* statuses during reset', async () => {
const pipelineFeature: Feature = {
...mockFeature,
status: 'pipeline_testing',
planSpec: { status: 'approved', version: 1, reviewedByUser: true },
};
(secureFs.readdir as Mock).mockResolvedValue([
{ name: 'feature-123', isDirectory: () => true },
]);
(readJsonWithRecovery as Mock).mockResolvedValue({
data: pipelineFeature,
recovered: false,
source: 'main',
});
await manager.resetStuckFeatures('/project');
// Status should NOT be changed, but needsUpdate might be true if other things reset
// In this case, nothing else should be reset, so atomicWriteJson shouldn't be called
expect(atomicWriteJson).not.toHaveBeenCalled();
});
it('should reset generating planSpec status to pending', async () => {
const stuckFeature: Feature = {
...mockFeature,
@@ -628,6 +817,379 @@ describe('FeatureStateManager', () => {
expect(atomicWriteJson).not.toHaveBeenCalled();
expect(mockEvents.emit).not.toHaveBeenCalled();
});
it('should accumulate summary with step header for pipeline features', async () => {
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'First step output');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nFirst step output`
);
});
it('should append subsequent pipeline step summaries with separator', async () => {
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nFirst step output`;
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Second step output');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nFirst step output${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nSecond step output`
);
});
it('should normalize existing non-phase summary before appending pipeline step summary', async () => {
const existingSummary = 'Implemented authentication and settings management.';
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Reviewed and approved changes');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nImplemented authentication and settings management.${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nReviewed and approved changes`
);
});
it('should use fallback step name when pipeline step not found', async () => {
(pipelineService.getStep as Mock).mockResolvedValue(null);
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_unknown_step', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Step output');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Unknown Step\n\nStep output`
);
});
it('should overwrite summary for non-pipeline features', async () => {
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'in_progress', summary: 'Old summary' },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'New summary');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe('New summary');
});
it('should emit full accumulated summary for pipeline features', async () => {
const existingSummary = '### Code Review\n\nFirst step output';
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Refinement', id: 'step2' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Refinement output');
const expectedSummary =
'### Code Review\n\nFirst step output\n\n---\n\n### Refinement\n\nRefinement output';
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
type: 'auto_mode_summary',
featureId: 'feature-123',
projectPath: '/project',
summary: expectedSummary,
});
});
it('should skip accumulation for pipeline features when summary is empty', async () => {
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step1', summary: '' },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Test output');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
// Empty string is falsy, so should start fresh
expect(savedFeature.summary).toBe('### Testing\n\nTest output');
});
it('should skip persistence when incoming summary is only whitespace', async () => {
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step1', summary: '### Existing\n\nValue' },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', ' \n\t ');
expect(atomicWriteJson).not.toHaveBeenCalled();
expect(mockEvents.emit).not.toHaveBeenCalled();
});
it('should accumulate three pipeline steps in chronological order', async () => {
// Step 1: Code Review
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Review findings');
const afterStep1 = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(afterStep1.summary).toBe('### Code Review\n\nReview findings');
// Step 2: Testing (summary from step 1 exists)
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/feature-123');
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step2', summary: afterStep1.summary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'All tests pass');
const afterStep2 = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
// Step 3: Refinement (summaries from steps 1+2 exist)
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/feature-123');
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Refinement', id: 'step3' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step3', summary: afterStep2.summary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Code polished');
const afterStep3 = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
// Verify the full accumulated summary has all three steps in order
expect(afterStep3.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nReview findings${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nAll tests pass${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Refinement\n\nCode polished`
);
});
it('should replace existing step summary if called again for the same step', async () => {
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nInitial code${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nFirst review attempt`;
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary(
'/project',
'feature-123',
'Second review attempt (success)'
);
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
// Should REPLACE "First review attempt" with "Second review attempt (success)"
// and NOT append it as a new section
expect(savedFeature.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nInitial code${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nSecond review attempt (success)`
);
// Ensure it didn't duplicate the separator or header
expect(
savedFeature.summary.match(new RegExp(PIPELINE_SUMMARY_HEADER_PREFIX + 'Code Review', 'g'))
?.length
).toBe(1);
expect(
savedFeature.summary.match(new RegExp(PIPELINE_SUMMARY_SEPARATOR.trim(), 'g'))?.length
).toBe(1);
});
it('should replace last step summary without trailing separator', async () => {
// Test case: replacing the last step which has no separator after it
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nInitial code${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nFirst test run`;
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'All tests pass');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nInitial code${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nAll tests pass`
);
});
it('should replace first step summary with separator after it', async () => {
// Test case: replacing the first step which has a separator after it
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nFirst attempt${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nAll tests pass`;
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Second attempt');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nSecond attempt${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nAll tests pass`
);
});
it('should not match step header appearing in body text, only at section boundaries', async () => {
// Test case: body text contains "### Testing" which should NOT be matched
// Only headers at actual section boundaries should be replaced
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nThis step covers the Testing module.\n\n### Testing\n\nThe above is just markdown in body, not a section header.${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nReal test section`;
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Updated test results');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
// The section replacement should only replace the actual Testing section at the boundary
// NOT the "### Testing" that appears in the body text
expect(savedFeature.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nThis step covers the Testing module.\n\n### Testing\n\nThe above is just markdown in body, not a section header.${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nUpdated test results`
);
});
it('should handle step name with special regex characters safely', async () => {
// Test case: step name contains characters that would break regex
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Code (Review)\n\nFirst attempt`;
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code (Review)', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Second attempt');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Code (Review)\n\nSecond attempt`
);
});
it('should handle step name with brackets safely', async () => {
// Test case: step name contains array-like syntax [0]
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Step [0]\n\nFirst attempt`;
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Step [0]', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Second attempt');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Step [0]\n\nSecond attempt`
);
});
it('should handle pipelineService.getStepIdFromStatus throwing an error gracefully', async () => {
(pipelineService.getStepIdFromStatus as Mock).mockImplementation(() => {
throw new Error('Config not found');
});
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_my_step', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Step output');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
// Should use fallback: capitalize each word in the status suffix
expect(savedFeature.summary).toBe(`${PIPELINE_SUMMARY_HEADER_PREFIX}My Step\n\nStep output`);
});
it('should handle pipelineService.getStep throwing an error gracefully', async () => {
(pipelineService.getStep as Mock).mockRejectedValue(new Error('Disk read error'));
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_code_review', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Step output');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
// Should use fallback: capitalize each word in the status suffix
expect(savedFeature.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nStep output`
);
});
it('should handle summary content with markdown formatting', async () => {
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
const markdownSummary =
'## Changes Made\n- Fixed **bug** in `parser.ts`\n- Added `validateInput()` function\n\n```typescript\nconst x = 1;\n```';
await manager.saveFeatureSummary('/project', 'feature-123', markdownSummary);
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\n${markdownSummary}`
);
});
it('should persist before emitting event for pipeline summary accumulation', async () => {
const callOrder: string[] = [];
const existingSummary = '### Code Review\n\nFirst step output';
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary },
recovered: false,
source: 'main',
});
(atomicWriteJson as Mock).mockImplementation(async () => {
callOrder.push('persist');
});
(mockEvents.emit as Mock).mockImplementation(() => {
callOrder.push('emit');
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Test results');
expect(callOrder).toEqual(['persist', 'emit']);
});
});
describe('updateTaskStatus', () => {
@@ -668,6 +1230,48 @@ describe('FeatureStateManager', () => {
});
});
it('should update task status and summary and emit event', async () => {
const featureWithTasks: Feature = {
...mockFeature,
planSpec: {
status: 'approved',
version: 1,
reviewedByUser: true,
tasks: [{ id: 'task-1', title: 'Task 1', status: 'pending', description: '' }],
},
};
(readJsonWithRecovery as Mock).mockResolvedValue({
data: featureWithTasks,
recovered: false,
source: 'main',
});
await manager.updateTaskStatus(
'/project',
'feature-123',
'task-1',
'completed',
'Task finished successfully'
);
// Verify persisted
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.planSpec?.tasks?.[0].status).toBe('completed');
expect(savedFeature.planSpec?.tasks?.[0].summary).toBe('Task finished successfully');
// Verify event emitted
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
type: 'auto_mode_task_status',
featureId: 'feature-123',
projectPath: '/project',
taskId: 'task-1',
status: 'completed',
summary: 'Task finished successfully',
tasks: expect.any(Array),
});
});
it('should handle task not found', async () => {
const featureWithTasks: Feature = {
...mockFeature,
@@ -757,4 +1361,179 @@ describe('FeatureStateManager', () => {
expect(callOrder).toEqual(['persist', 'emit']);
});
});
describe('handleAutoModeEventError', () => {
let subscribeCallback: (type: string, payload: unknown) => void;
beforeEach(() => {
// Get the subscribe callback from the mock - the callback passed TO subscribe is at index [0]
// subscribe is called like: events.subscribe(callback), so callback is at mock.calls[0][0]
const mockCalls = (mockEvents.subscribe as Mock).mock.calls;
if (mockCalls.length > 0 && mockCalls[0].length > 0) {
subscribeCallback = mockCalls[0][0] as typeof subscribeCallback;
}
});
it('should ignore events with no type', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
await subscribeCallback('auto-mode:event', {});
expect(mockNotificationService.createNotification).not.toHaveBeenCalled();
});
it('should ignore non-error events', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
await subscribeCallback('auto-mode:event', {
type: 'auto_mode_feature_complete',
passes: true,
projectPath: '/project',
});
expect(mockNotificationService.createNotification).not.toHaveBeenCalled();
});
it('should create auto_mode_error notification with gesture name as title when no featureId', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
await subscribeCallback('auto-mode:event', {
type: 'auto_mode_error',
message: 'Something went wrong',
projectPath: '/project',
});
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'auto_mode_error',
title: 'Auto Mode Error',
message: 'Something went wrong',
projectPath: '/project',
})
);
});
it('should use error field instead of message when available', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
await subscribeCallback('auto-mode:event', {
type: 'auto_mode_error',
message: 'Some message',
error: 'The actual error',
projectPath: '/project',
});
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'auto_mode_error',
message: 'The actual error',
})
);
});
it('should use feature title as notification title for feature error with featureId', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, title: 'Login Page Feature' },
recovered: false,
source: 'main',
});
subscribeCallback('auto-mode:event', {
type: 'auto_mode_feature_complete',
passes: false,
featureId: 'feature-123',
error: 'Build failed',
projectPath: '/project',
});
// Wait for async handleAutoModeEventError to complete
await vi.waitFor(() => {
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'feature_error',
title: 'Login Page Feature',
message: 'Feature Failed: Build failed',
featureId: 'feature-123',
})
);
});
});
it('should ignore auto_mode_feature_complete without passes=false', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
await subscribeCallback('auto-mode:event', {
type: 'auto_mode_feature_complete',
passes: true,
projectPath: '/project',
});
expect(mockNotificationService.createNotification).not.toHaveBeenCalled();
});
it('should handle missing projectPath gracefully', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
await subscribeCallback('auto-mode:event', {
type: 'auto_mode_error',
message: 'Error occurred',
});
expect(mockNotificationService.createNotification).not.toHaveBeenCalled();
});
it('should handle notification service failures gracefully', async () => {
(getNotificationService as Mock).mockImplementation(() => {
throw new Error('Service unavailable');
});
// Should not throw - the callback returns void so we just call it and wait for async work
subscribeCallback('auto-mode:event', {
type: 'auto_mode_error',
message: 'Error',
projectPath: '/project',
});
// Give async handleAutoModeEventError time to complete
await new Promise((resolve) => setTimeout(resolve, 0));
});
});
describe('destroy', () => {
it('should unsubscribe from event subscription', () => {
const unsubscribeFn = vi.fn();
(mockEvents.subscribe as Mock).mockReturnValue(unsubscribeFn);
// Create a new manager to get a fresh subscription
const newManager = new FeatureStateManager(mockEvents, mockFeatureLoader);
// Call destroy
newManager.destroy();
// Verify unsubscribe was called
expect(unsubscribeFn).toHaveBeenCalled();
});
it('should handle destroy being called multiple times', () => {
const unsubscribeFn = vi.fn();
(mockEvents.subscribe as Mock).mockReturnValue(unsubscribeFn);
const newManager = new FeatureStateManager(mockEvents, mockFeatureLoader);
// Call destroy multiple times
newManager.destroy();
newManager.destroy();
// Should only unsubscribe once
expect(unsubscribeFn).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,642 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { NtfyService } from '../../../src/services/ntfy-service.js';
import type { NtfyEndpointConfig } from '@automaker/types';
// Mock global fetch
const originalFetch = global.fetch;
describe('NtfyService', () => {
let service: NtfyService;
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
service = new NtfyService();
mockFetch = vi.fn();
global.fetch = mockFetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
/**
* Create a valid endpoint config for testing
*/
function createEndpoint(overrides: Partial<NtfyEndpointConfig> = {}): NtfyEndpointConfig {
return {
id: 'test-endpoint-id',
name: 'Test Endpoint',
serverUrl: 'https://ntfy.sh',
topic: 'test-topic',
authType: 'none',
enabled: true,
...overrides,
};
}
/**
* Create a basic context for testing
*/
function createContext() {
return {
featureId: 'feat-123',
featureName: 'Test Feature',
projectPath: '/test/project',
projectName: 'test-project',
timestamp: '2024-01-15T10:30:00.000Z',
eventType: 'feature_success',
};
}
describe('validateEndpoint', () => {
it('should return null for valid endpoint with no auth', () => {
const endpoint = createEndpoint();
const result = service.validateEndpoint(endpoint);
expect(result).toBeNull();
});
it('should return null for valid endpoint with basic auth', () => {
const endpoint = createEndpoint({
authType: 'basic',
username: 'user',
password: 'pass',
});
const result = service.validateEndpoint(endpoint);
expect(result).toBeNull();
});
it('should return null for valid endpoint with token auth', () => {
const endpoint = createEndpoint({
authType: 'token',
token: 'tk_123456',
});
const result = service.validateEndpoint(endpoint);
expect(result).toBeNull();
});
it('should return error when serverUrl is missing', () => {
const endpoint = createEndpoint({ serverUrl: '' });
const result = service.validateEndpoint(endpoint);
expect(result).toBe('Server URL is required');
});
it('should return error when serverUrl is invalid', () => {
const endpoint = createEndpoint({ serverUrl: 'not-a-valid-url' });
const result = service.validateEndpoint(endpoint);
expect(result).toBe('Invalid server URL format');
});
it('should return error when topic is missing', () => {
const endpoint = createEndpoint({ topic: '' });
const result = service.validateEndpoint(endpoint);
expect(result).toBe('Topic is required');
});
it('should return error when topic contains spaces', () => {
const endpoint = createEndpoint({ topic: 'invalid topic' });
const result = service.validateEndpoint(endpoint);
expect(result).toBe('Topic cannot contain spaces');
});
it('should return error when topic contains tabs', () => {
const endpoint = createEndpoint({ topic: 'invalid\ttopic' });
const result = service.validateEndpoint(endpoint);
expect(result).toBe('Topic cannot contain spaces');
});
it('should return error when basic auth is missing username', () => {
const endpoint = createEndpoint({
authType: 'basic',
username: '',
password: 'pass',
});
const result = service.validateEndpoint(endpoint);
expect(result).toBe('Username and password are required for basic authentication');
});
it('should return error when basic auth is missing password', () => {
const endpoint = createEndpoint({
authType: 'basic',
username: 'user',
password: '',
});
const result = service.validateEndpoint(endpoint);
expect(result).toBe('Username and password are required for basic authentication');
});
it('should return error when token auth is missing token', () => {
const endpoint = createEndpoint({
authType: 'token',
token: '',
});
const result = service.validateEndpoint(endpoint);
expect(result).toBe('Access token is required for token authentication');
});
});
describe('sendNotification', () => {
it('should return error when endpoint is disabled', async () => {
const endpoint = createEndpoint({ enabled: false });
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(false);
expect(result.error).toBe('Endpoint is disabled');
expect(mockFetch).not.toHaveBeenCalled();
});
it('should return error when endpoint validation fails', async () => {
const endpoint = createEndpoint({ serverUrl: '' });
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(false);
expect(result.error).toBe('Server URL is required');
expect(mockFetch).not.toHaveBeenCalled();
});
it('should send notification with default values', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint();
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('https://ntfy.sh/test-topic');
expect(options.method).toBe('POST');
expect(options.headers['Content-Type']).toBe('text/plain; charset=utf-8');
expect(options.headers['Title']).toContain('Feature Completed');
expect(options.headers['Priority']).toBe('3');
});
it('should send notification with custom title and body', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint();
const result = await service.sendNotification(
endpoint,
{
title: 'Custom Title',
body: 'Custom body message',
},
createContext()
);
expect(result.success).toBe(true);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Custom Title');
expect(options.body).toBe('Custom body message');
});
it('should send notification with tags and emoji', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint();
const result = await service.sendNotification(
endpoint,
{
tags: 'warning,skull',
emoji: 'tada',
},
createContext()
);
expect(result.success).toBe(true);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Tags']).toBe('tada,warning,skull');
});
it('should send notification with priority', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint();
const result = await service.sendNotification(endpoint, { priority: 5 }, createContext());
expect(result.success).toBe(true);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Priority']).toBe('5');
});
it('should send notification with click URL', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint();
const result = await service.sendNotification(
endpoint,
{ clickUrl: 'https://example.com/feature/123' },
createContext()
);
expect(result.success).toBe(true);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Click']).toBe('https://example.com/feature/123');
});
it('should use endpoint default tags and emoji when not specified', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint({
defaultTags: 'default-tag',
defaultEmoji: 'rocket',
});
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(true);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Tags']).toBe('rocket,default-tag');
});
it('should use endpoint default click URL when not specified', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint({
defaultClickUrl: 'https://default.example.com',
});
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(true);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Click']).toBe('https://default.example.com');
});
it('should send notification with basic authentication', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint({
authType: 'basic',
username: 'testuser',
password: 'testpass',
});
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(true);
const options = mockFetch.mock.calls[0][1];
// Basic auth should be base64 encoded
const expectedAuth = Buffer.from('testuser:testpass').toString('base64');
expect(options.headers['Authorization']).toBe(`Basic ${expectedAuth}`);
});
it('should send notification with token authentication', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint({
authType: 'token',
token: 'tk_test_token_123',
});
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(true);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Authorization']).toBe('Bearer tk_test_token_123');
});
it('should return error on HTTP error response', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 403,
text: () => Promise.resolve('Forbidden - invalid token'),
});
const endpoint = createEndpoint();
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(false);
expect(result.error).toContain('403');
expect(result.error).toContain('Forbidden');
});
it('should return error on timeout', async () => {
mockFetch.mockImplementationOnce(() => {
const error = new Error('Aborted');
error.name = 'AbortError';
throw error;
});
const endpoint = createEndpoint();
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(false);
expect(result.error).toBe('Request timed out');
});
it('should return error on network error', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
const endpoint = createEndpoint();
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(false);
expect(result.error).toBe('Network error');
});
it('should handle server URL with trailing slash', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint({ serverUrl: 'https://ntfy.sh/' });
await service.sendNotification(endpoint, {}, createContext());
const url = mockFetch.mock.calls[0][0];
expect(url).toBe('https://ntfy.sh/test-topic');
});
it('should URL encode the topic', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint({ topic: 'test/topic#special' });
await service.sendNotification(endpoint, {}, createContext());
const url = mockFetch.mock.calls[0][0];
expect(url).toContain('test%2Ftopic%23special');
});
});
describe('variable substitution', () => {
it('should substitute {{featureId}} in title', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(
endpoint,
{ title: 'Feature {{featureId}} completed' },
createContext()
);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Feature feat-123 completed');
});
it('should substitute {{featureName}} in body', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(
endpoint,
{ body: 'The feature "{{featureName}}" is done!' },
createContext()
);
const options = mockFetch.mock.calls[0][1];
expect(options.body).toBe('The feature "Test Feature" is done!');
});
it('should substitute {{projectName}} in title', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(
endpoint,
{ title: '[{{projectName}}] Event: {{eventType}}' },
createContext()
);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('[test-project] Event: feature_success');
});
it('should substitute {{timestamp}} in body', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(
endpoint,
{ body: 'Completed at: {{timestamp}}' },
createContext()
);
const options = mockFetch.mock.calls[0][1];
expect(options.body).toBe('Completed at: 2024-01-15T10:30:00.000Z');
});
it('should substitute {{error}} in body for error events', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
const context = {
...createContext(),
eventType: 'feature_error',
error: 'Something went wrong',
};
await service.sendNotification(endpoint, { title: 'Error: {{error}}' }, context);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Error: Something went wrong');
});
it('should substitute multiple variables', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(
endpoint,
{
title: '[{{projectName}}] {{featureName}}',
body: 'Feature {{featureId}} completed at {{timestamp}}',
},
createContext()
);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('[test-project] Test Feature');
expect(options.body).toBe('Feature feat-123 completed at 2024-01-15T10:30:00.000Z');
});
it('should replace unknown variables with empty string', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(
endpoint,
{ title: 'Value: {{unknownVariable}}' },
createContext()
);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Value: ');
});
});
describe('default title generation', () => {
it('should generate title with feature name for feature_success', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(endpoint, {}, createContext());
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Feature Completed: Test Feature');
});
it('should generate title without feature name when missing', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
const context = { ...createContext(), featureName: undefined };
await service.sendNotification(endpoint, {}, context);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Feature Completed');
});
it('should generate correct title for feature_created', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
const context = { ...createContext(), eventType: 'feature_created' };
await service.sendNotification(endpoint, {}, context);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Feature Created: Test Feature');
});
it('should generate correct title for feature_error', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
const context = { ...createContext(), eventType: 'feature_error' };
await service.sendNotification(endpoint, {}, context);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Feature Failed: Test Feature');
});
it('should generate correct title for auto_mode_complete', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
const context = {
...createContext(),
eventType: 'auto_mode_complete',
featureName: undefined,
};
await service.sendNotification(endpoint, {}, context);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Auto Mode Complete');
});
it('should generate correct title for auto_mode_error', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
const context = { ...createContext(), eventType: 'auto_mode_error', featureName: undefined };
await service.sendNotification(endpoint, {}, context);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Auto Mode Error');
});
});
describe('default body generation', () => {
it('should generate body with feature info', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(endpoint, {}, createContext());
const options = mockFetch.mock.calls[0][1];
expect(options.body).toContain('Feature: Test Feature');
expect(options.body).toContain('ID: feat-123');
expect(options.body).toContain('Project: test-project');
expect(options.body).toContain('Time: 2024-01-15T10:30:00.000Z');
});
it('should include error in body for error events', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
const context = {
...createContext(),
eventType: 'feature_error',
error: 'Build failed',
};
await service.sendNotification(endpoint, {}, context);
const options = mockFetch.mock.calls[0][1];
expect(options.body).toContain('Error: Build failed');
});
});
describe('emoji and tags handling', () => {
it('should handle emoji shortcode with colons', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(endpoint, { emoji: ':tada:' }, createContext());
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Tags']).toBe('tada');
});
it('should handle emoji without colons', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(endpoint, { emoji: 'warning' }, createContext());
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Tags']).toBe('warning');
});
it('should combine emoji and tags correctly', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(
endpoint,
{ emoji: 'rotating_light', tags: 'urgent,alert' },
createContext()
);
const options = mockFetch.mock.calls[0][1];
// Emoji comes first, then tags
expect(options.headers['Tags']).toBe('rotating_light,urgent,alert');
});
it('should ignore emoji with spaces', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(
endpoint,
{ emoji: 'multi word emoji', tags: 'test' },
createContext()
);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Tags']).toBe('test');
});
});
});

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, vi } from 'vitest';
import { PipelineOrchestrator } from '../../../src/services/pipeline-orchestrator.js';
import type { Feature } from '@automaker/types';
describe('PipelineOrchestrator Prompts', () => {
const mockFeature: Feature = {
id: 'feature-123',
title: 'Test Feature',
description: 'A test feature',
status: 'in_progress',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
tasks: [],
};
const mockBuildFeaturePrompt = (feature: Feature) => `Feature: ${feature.title}`;
it('should include mandatory summary requirement in pipeline step prompt', () => {
const orchestrator = new PipelineOrchestrator(
null as any, // eventBus
null as any, // featureStateManager
null as any, // agentExecutor
null as any, // testRunnerService
null as any, // worktreeResolver
null as any, // concurrencyManager
null as any, // settingsService
null as any, // updateFeatureStatusFn
null as any, // loadContextFilesFn
mockBuildFeaturePrompt,
null as any, // executeFeatureFn
null as any // runAgentFn
);
const step = {
id: 'step1',
name: 'Code Review',
instructions: 'Review the code for quality.',
};
const prompt = orchestrator.buildPipelineStepPrompt(
step as any,
mockFeature,
'Previous work context',
{ implementationInstructions: '', playwrightVerificationInstructions: '' }
);
expect(prompt).toContain('## Pipeline Step: Code Review');
expect(prompt).toContain('Review the code for quality.');
expect(prompt).toContain(
'**CRITICAL: After completing the instructions, you MUST output a summary using this EXACT format:**'
);
expect(prompt).toContain('<summary>');
expect(prompt).toContain('## Summary: Code Review');
expect(prompt).toContain('</summary>');
expect(prompt).toContain('The <summary> and </summary> tags MUST be on their own lines.');
});
});

View File

@@ -0,0 +1,356 @@
/**
* Tests for providerId passthrough in PipelineOrchestrator
* Verifies that feature.providerId is forwarded to runAgentFn in both
* executePipeline (step execution) and executeTestStep (test fix) contexts.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Feature, PipelineStep } from '@automaker/types';
import {
PipelineOrchestrator,
type PipelineContext,
type UpdateFeatureStatusFn,
type BuildFeaturePromptFn,
type ExecuteFeatureFn,
type RunAgentFn,
} from '../../../src/services/pipeline-orchestrator.js';
import type { TypedEventBus } from '../../../src/services/typed-event-bus.js';
import type { FeatureStateManager } from '../../../src/services/feature-state-manager.js';
import type { AgentExecutor } from '../../../src/services/agent-executor.js';
import type { WorktreeResolver } from '../../../src/services/worktree-resolver.js';
import type { SettingsService } from '../../../src/services/settings-service.js';
import type { ConcurrencyManager } from '../../../src/services/concurrency-manager.js';
import type { TestRunnerService } from '../../../src/services/test-runner-service.js';
import * as secureFs from '../../../src/lib/secure-fs.js';
import { getFeatureDir } from '@automaker/platform';
import {
getPromptCustomization,
getAutoLoadClaudeMdSetting,
filterClaudeMdFromContext,
} from '../../../src/lib/settings-helpers.js';
// Mock pipelineService
vi.mock('../../../src/services/pipeline-service.js', () => ({
pipelineService: {
isPipelineStatus: vi.fn(),
getStepIdFromStatus: vi.fn(),
getPipelineConfig: vi.fn(),
getNextStatus: vi.fn(),
},
}));
// Mock merge-service
vi.mock('../../../src/services/merge-service.js', () => ({
performMerge: vi.fn().mockResolvedValue({ success: true }),
}));
// Mock secureFs
vi.mock('../../../src/lib/secure-fs.js', () => ({
readFile: vi.fn(),
access: vi.fn(),
}));
// Mock settings helpers
vi.mock('../../../src/lib/settings-helpers.js', () => ({
getPromptCustomization: vi.fn().mockResolvedValue({
taskExecution: {
implementationInstructions: 'test instructions',
playwrightVerificationInstructions: 'test playwright',
},
}),
getAutoLoadClaudeMdSetting: vi.fn().mockResolvedValue(true),
getUseClaudeCodeSystemPromptSetting: vi.fn().mockResolvedValue(true),
filterClaudeMdFromContext: vi.fn().mockReturnValue('context prompt'),
}));
// Mock validateWorkingDirectory
vi.mock('../../../src/lib/sdk-options.js', () => ({
validateWorkingDirectory: vi.fn(),
}));
// Mock platform
vi.mock('@automaker/platform', () => ({
getFeatureDir: vi
.fn()
.mockImplementation(
(projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}`
),
}));
// Mock model-resolver
vi.mock('@automaker/model-resolver', () => ({
resolveModelString: vi.fn().mockReturnValue('claude-sonnet-4'),
DEFAULT_MODELS: { claude: 'claude-sonnet-4' },
}));
describe('PipelineOrchestrator - providerId passthrough', () => {
let mockEventBus: TypedEventBus;
let mockFeatureStateManager: FeatureStateManager;
let mockAgentExecutor: AgentExecutor;
let mockTestRunnerService: TestRunnerService;
let mockWorktreeResolver: WorktreeResolver;
let mockConcurrencyManager: ConcurrencyManager;
let mockUpdateFeatureStatusFn: UpdateFeatureStatusFn;
let mockLoadContextFilesFn: vi.Mock;
let mockBuildFeaturePromptFn: BuildFeaturePromptFn;
let mockExecuteFeatureFn: ExecuteFeatureFn;
let mockRunAgentFn: RunAgentFn;
let orchestrator: PipelineOrchestrator;
const testSteps: PipelineStep[] = [
{
id: 'step-1',
name: 'Step 1',
order: 1,
instructions: 'Do step 1',
colorClass: 'blue',
createdAt: '',
updatedAt: '',
},
];
const createFeatureWithProvider = (providerId?: string): Feature => ({
id: 'feature-1',
title: 'Test Feature',
category: 'test',
description: 'Test description',
status: 'pipeline_step-1',
branchName: 'feature/test-1',
providerId,
});
beforeEach(() => {
vi.clearAllMocks();
mockEventBus = {
emitAutoModeEvent: vi.fn(),
getUnderlyingEmitter: vi.fn().mockReturnValue({}),
} as unknown as TypedEventBus;
mockFeatureStateManager = {
updateFeatureStatus: vi.fn().mockResolvedValue(undefined),
loadFeature: vi.fn().mockResolvedValue(createFeatureWithProvider()),
} as unknown as FeatureStateManager;
mockAgentExecutor = {
execute: vi.fn().mockResolvedValue({ success: true }),
} as unknown as AgentExecutor;
mockTestRunnerService = {
startTests: vi
.fn()
.mockResolvedValue({ success: true, result: { sessionId: 'test-session-1' } }),
getSession: vi.fn().mockReturnValue({
status: 'passed',
exitCode: 0,
startedAt: new Date(),
finishedAt: new Date(),
}),
getSessionOutput: vi
.fn()
.mockReturnValue({ success: true, result: { output: 'All tests passed' } }),
} as unknown as TestRunnerService;
mockWorktreeResolver = {
findWorktreeForBranch: vi.fn().mockResolvedValue('/test/worktree'),
getCurrentBranch: vi.fn().mockResolvedValue('main'),
} as unknown as WorktreeResolver;
mockConcurrencyManager = {
acquire: vi.fn().mockImplementation(({ featureId, isAutoMode }) => ({
featureId,
projectPath: '/test/project',
abortController: new AbortController(),
branchName: null,
worktreePath: null,
isAutoMode: isAutoMode ?? false,
})),
release: vi.fn(),
getRunningFeature: vi.fn().mockReturnValue(undefined),
} as unknown as ConcurrencyManager;
mockUpdateFeatureStatusFn = vi.fn().mockResolvedValue(undefined);
mockLoadContextFilesFn = vi.fn().mockResolvedValue({ contextPrompt: 'test context' });
mockBuildFeaturePromptFn = vi.fn().mockReturnValue('Feature prompt content');
mockExecuteFeatureFn = vi.fn().mockResolvedValue(undefined);
mockRunAgentFn = vi.fn().mockResolvedValue(undefined);
vi.mocked(secureFs.readFile).mockResolvedValue('Previous context');
vi.mocked(secureFs.access).mockResolvedValue(undefined);
vi.mocked(getFeatureDir).mockImplementation(
(projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}`
);
vi.mocked(getPromptCustomization).mockResolvedValue({
taskExecution: {
implementationInstructions: 'test instructions',
playwrightVerificationInstructions: 'test playwright',
},
} as any);
vi.mocked(getAutoLoadClaudeMdSetting).mockResolvedValue(true);
vi.mocked(filterClaudeMdFromContext).mockReturnValue('context prompt');
orchestrator = new PipelineOrchestrator(
mockEventBus,
mockFeatureStateManager,
mockAgentExecutor,
mockTestRunnerService,
mockWorktreeResolver,
mockConcurrencyManager,
null,
mockUpdateFeatureStatusFn,
mockLoadContextFilesFn,
mockBuildFeaturePromptFn,
mockExecuteFeatureFn,
mockRunAgentFn
);
});
describe('executePipeline', () => {
it('should pass providerId to runAgentFn options when feature has providerId', async () => {
const feature = createFeatureWithProvider('moonshot-ai');
const context: PipelineContext = {
projectPath: '/test/project',
featureId: 'feature-1',
feature,
steps: testSteps,
workDir: '/test/project',
worktreePath: null,
branchName: 'feature/test-1',
abortController: new AbortController(),
autoLoadClaudeMd: true,
testAttempts: 0,
maxTestAttempts: 5,
};
await orchestrator.executePipeline(context);
expect(mockRunAgentFn).toHaveBeenCalledTimes(1);
const options = mockRunAgentFn.mock.calls[0][7];
expect(options).toHaveProperty('providerId', 'moonshot-ai');
});
it('should pass undefined providerId when feature has no providerId', async () => {
const feature = createFeatureWithProvider(undefined);
const context: PipelineContext = {
projectPath: '/test/project',
featureId: 'feature-1',
feature,
steps: testSteps,
workDir: '/test/project',
worktreePath: null,
branchName: 'feature/test-1',
abortController: new AbortController(),
autoLoadClaudeMd: true,
testAttempts: 0,
maxTestAttempts: 5,
};
await orchestrator.executePipeline(context);
expect(mockRunAgentFn).toHaveBeenCalledTimes(1);
const options = mockRunAgentFn.mock.calls[0][7];
expect(options).toHaveProperty('providerId', undefined);
});
it('should pass status alongside providerId in options', async () => {
const feature = createFeatureWithProvider('zhipu');
const context: PipelineContext = {
projectPath: '/test/project',
featureId: 'feature-1',
feature,
steps: testSteps,
workDir: '/test/project',
worktreePath: null,
branchName: 'feature/test-1',
abortController: new AbortController(),
autoLoadClaudeMd: true,
testAttempts: 0,
maxTestAttempts: 5,
};
await orchestrator.executePipeline(context);
const options = mockRunAgentFn.mock.calls[0][7];
expect(options).toHaveProperty('providerId', 'zhipu');
expect(options).toHaveProperty('status');
});
});
describe('executeTestStep', () => {
it('should pass providerId in test fix agent options when tests fail', async () => {
vi.mocked(mockTestRunnerService.getSession)
.mockReturnValueOnce({
status: 'failed',
exitCode: 1,
startedAt: new Date(),
finishedAt: new Date(),
} as never)
.mockReturnValueOnce({
status: 'passed',
exitCode: 0,
startedAt: new Date(),
finishedAt: new Date(),
} as never);
const feature = createFeatureWithProvider('custom-provider');
const context: PipelineContext = {
projectPath: '/test/project',
featureId: 'feature-1',
feature,
steps: testSteps,
workDir: '/test/project',
worktreePath: null,
branchName: 'feature/test-1',
abortController: new AbortController(),
autoLoadClaudeMd: true,
testAttempts: 0,
maxTestAttempts: 5,
};
await orchestrator.executeTestStep(context, 'npm test');
// The fix agent should receive providerId
expect(mockRunAgentFn).toHaveBeenCalledTimes(1);
const options = mockRunAgentFn.mock.calls[0][7];
expect(options).toHaveProperty('providerId', 'custom-provider');
}, 15000);
it('should pass thinkingLevel in test fix agent options', async () => {
vi.mocked(mockTestRunnerService.getSession)
.mockReturnValueOnce({
status: 'failed',
exitCode: 1,
startedAt: new Date(),
finishedAt: new Date(),
} as never)
.mockReturnValueOnce({
status: 'passed',
exitCode: 0,
startedAt: new Date(),
finishedAt: new Date(),
} as never);
const feature = createFeatureWithProvider('moonshot-ai');
feature.thinkingLevel = 'high';
const context: PipelineContext = {
projectPath: '/test/project',
featureId: 'feature-1',
feature,
steps: testSteps,
workDir: '/test/project',
worktreePath: null,
branchName: 'feature/test-1',
abortController: new AbortController(),
autoLoadClaudeMd: true,
testAttempts: 0,
maxTestAttempts: 5,
};
await orchestrator.executeTestStep(context, 'npm test');
const options = mockRunAgentFn.mock.calls[0][7];
expect(options).toHaveProperty('thinkingLevel', 'high');
expect(options).toHaveProperty('providerId', 'moonshot-ai');
}, 15000);
});
});

View File

@@ -0,0 +1,302 @@
/**
* Tests for status + providerId coexistence in PipelineOrchestrator options.
*
* During rebase onto upstream/v1.0.0rc, a merge conflict arose where
* upstream added `status: currentStatus` and the incoming branch added
* `providerId: feature.providerId`. The conflict resolution kept BOTH fields.
*
* This test validates that both fields coexist correctly in the options
* object passed to runAgentFn in both executePipeline and executeTestStep.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Feature, PipelineStep } from '@automaker/types';
import {
PipelineOrchestrator,
type PipelineContext,
type UpdateFeatureStatusFn,
type BuildFeaturePromptFn,
type ExecuteFeatureFn,
type RunAgentFn,
} from '../../../src/services/pipeline-orchestrator.js';
import type { TypedEventBus } from '../../../src/services/typed-event-bus.js';
import type { FeatureStateManager } from '../../../src/services/feature-state-manager.js';
import type { AgentExecutor } from '../../../src/services/agent-executor.js';
import type { WorktreeResolver } from '../../../src/services/worktree-resolver.js';
import type { ConcurrencyManager } from '../../../src/services/concurrency-manager.js';
import type { TestRunnerService } from '../../../src/services/test-runner-service.js';
import * as secureFs from '../../../src/lib/secure-fs.js';
import { getFeatureDir } from '@automaker/platform';
import {
getPromptCustomization,
getAutoLoadClaudeMdSetting,
filterClaudeMdFromContext,
} from '../../../src/lib/settings-helpers.js';
vi.mock('../../../src/services/pipeline-service.js', () => ({
pipelineService: {
isPipelineStatus: vi.fn(),
getStepIdFromStatus: vi.fn(),
getPipelineConfig: vi.fn(),
getNextStatus: vi.fn(),
},
}));
vi.mock('../../../src/services/merge-service.js', () => ({
performMerge: vi.fn().mockResolvedValue({ success: true }),
}));
vi.mock('../../../src/lib/secure-fs.js', () => ({
readFile: vi.fn(),
access: vi.fn(),
}));
vi.mock('../../../src/lib/settings-helpers.js', () => ({
getPromptCustomization: vi.fn().mockResolvedValue({
taskExecution: {
implementationInstructions: 'test instructions',
playwrightVerificationInstructions: 'test playwright',
},
}),
getAutoLoadClaudeMdSetting: vi.fn().mockResolvedValue(true),
getUseClaudeCodeSystemPromptSetting: vi.fn().mockResolvedValue(true),
filterClaudeMdFromContext: vi.fn().mockReturnValue('context prompt'),
}));
vi.mock('../../../src/lib/sdk-options.js', () => ({
validateWorkingDirectory: vi.fn(),
}));
vi.mock('@automaker/platform', () => ({
getFeatureDir: vi
.fn()
.mockImplementation(
(projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}`
),
}));
vi.mock('@automaker/model-resolver', () => ({
resolveModelString: vi.fn().mockReturnValue('claude-sonnet-4'),
DEFAULT_MODELS: { claude: 'claude-sonnet-4' },
}));
describe('PipelineOrchestrator - status and providerId coexistence', () => {
let mockRunAgentFn: RunAgentFn;
let orchestrator: PipelineOrchestrator;
const testSteps: PipelineStep[] = [
{
id: 'implement',
name: 'Implement Feature',
order: 1,
instructions: 'Implement the feature',
colorClass: 'blue',
createdAt: '',
updatedAt: '',
},
];
const createFeature = (overrides: Partial<Feature> = {}): Feature => ({
id: 'feature-1',
title: 'Test Feature',
category: 'test',
description: 'Test description',
status: 'pipeline_implement',
branchName: 'feature/test-1',
providerId: 'moonshot-ai',
thinkingLevel: 'medium',
reasoningEffort: 'high',
...overrides,
});
const createContext = (feature: Feature): PipelineContext => ({
projectPath: '/test/project',
featureId: feature.id,
feature,
steps: testSteps,
workDir: '/test/project',
worktreePath: null,
branchName: feature.branchName ?? 'main',
abortController: new AbortController(),
autoLoadClaudeMd: true,
testAttempts: 0,
maxTestAttempts: 5,
});
beforeEach(() => {
vi.clearAllMocks();
mockRunAgentFn = vi.fn().mockResolvedValue(undefined);
vi.mocked(secureFs.readFile).mockResolvedValue('Previous context');
vi.mocked(secureFs.access).mockResolvedValue(undefined);
vi.mocked(getFeatureDir).mockImplementation(
(projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}`
);
vi.mocked(getPromptCustomization).mockResolvedValue({
taskExecution: {
implementationInstructions: 'test instructions',
playwrightVerificationInstructions: 'test playwright',
},
} as any);
vi.mocked(getAutoLoadClaudeMdSetting).mockResolvedValue(true);
vi.mocked(filterClaudeMdFromContext).mockReturnValue('context prompt');
const mockEventBus = {
emitAutoModeEvent: vi.fn(),
getUnderlyingEmitter: vi.fn().mockReturnValue({}),
} as unknown as TypedEventBus;
const mockFeatureStateManager = {
updateFeatureStatus: vi.fn().mockResolvedValue(undefined),
loadFeature: vi.fn().mockResolvedValue(createFeature()),
} as unknown as FeatureStateManager;
const mockTestRunnerService = {
startTests: vi
.fn()
.mockResolvedValue({ success: true, result: { sessionId: 'test-session-1' } }),
getSession: vi.fn().mockReturnValue({
status: 'passed',
exitCode: 0,
startedAt: new Date(),
finishedAt: new Date(),
}),
getSessionOutput: vi
.fn()
.mockReturnValue({ success: true, result: { output: 'All tests passed' } }),
} as unknown as TestRunnerService;
orchestrator = new PipelineOrchestrator(
mockEventBus,
mockFeatureStateManager,
{} as AgentExecutor,
mockTestRunnerService,
{
findWorktreeForBranch: vi.fn().mockResolvedValue('/test/worktree'),
getCurrentBranch: vi.fn().mockResolvedValue('main'),
} as unknown as WorktreeResolver,
{
acquire: vi.fn().mockImplementation(({ featureId }) => ({
featureId,
projectPath: '/test/project',
abortController: new AbortController(),
branchName: null,
worktreePath: null,
isAutoMode: false,
})),
release: vi.fn(),
getRunningFeature: vi.fn().mockReturnValue(undefined),
} as unknown as ConcurrencyManager,
null,
vi.fn().mockResolvedValue(undefined),
vi.fn().mockResolvedValue({ contextPrompt: 'test context' }),
vi.fn().mockReturnValue('Feature prompt content'),
vi.fn().mockResolvedValue(undefined),
mockRunAgentFn
);
});
describe('executePipeline - options object', () => {
it('should pass both status and providerId in options', async () => {
const feature = createFeature({ providerId: 'moonshot-ai' });
const context = createContext(feature);
await orchestrator.executePipeline(context);
expect(mockRunAgentFn).toHaveBeenCalledTimes(1);
const options = mockRunAgentFn.mock.calls[0][7];
expect(options).toHaveProperty('status', 'pipeline_implement');
expect(options).toHaveProperty('providerId', 'moonshot-ai');
});
it('should pass status even when providerId is undefined', async () => {
const feature = createFeature({ providerId: undefined });
const context = createContext(feature);
await orchestrator.executePipeline(context);
const options = mockRunAgentFn.mock.calls[0][7];
expect(options).toHaveProperty('status', 'pipeline_implement');
expect(options).toHaveProperty('providerId', undefined);
});
it('should pass thinkingLevel and reasoningEffort alongside status and providerId', async () => {
const feature = createFeature({
providerId: 'zhipu',
thinkingLevel: 'high',
reasoningEffort: 'medium',
});
const context = createContext(feature);
await orchestrator.executePipeline(context);
const options = mockRunAgentFn.mock.calls[0][7];
expect(options).toHaveProperty('status', 'pipeline_implement');
expect(options).toHaveProperty('providerId', 'zhipu');
expect(options).toHaveProperty('thinkingLevel', 'high');
expect(options).toHaveProperty('reasoningEffort', 'medium');
});
});
describe('executeTestStep - options object', () => {
it('should pass both status and providerId in test fix agent options', async () => {
const feature = createFeature({
status: 'running',
providerId: 'custom-provider',
});
const context = createContext(feature);
const mockTestRunner = orchestrator['testRunnerService'] as any;
vi.mocked(mockTestRunner.getSession)
.mockReturnValueOnce({
status: 'failed',
exitCode: 1,
startedAt: new Date(),
finishedAt: new Date(),
})
.mockReturnValueOnce({
status: 'passed',
exitCode: 0,
startedAt: new Date(),
finishedAt: new Date(),
});
await orchestrator.executeTestStep(context, 'npm test');
expect(mockRunAgentFn).toHaveBeenCalledTimes(1);
const options = mockRunAgentFn.mock.calls[0][7];
expect(options).toHaveProperty('status', 'running');
expect(options).toHaveProperty('providerId', 'custom-provider');
}, 15000);
it('should pass feature.status (not currentStatus) in test fix context', async () => {
const feature = createFeature({
status: 'pipeline_test',
providerId: 'moonshot-ai',
});
const context = createContext(feature);
const mockTestRunner = orchestrator['testRunnerService'] as any;
vi.mocked(mockTestRunner.getSession)
.mockReturnValueOnce({
status: 'failed',
exitCode: 1,
startedAt: new Date(),
finishedAt: new Date(),
})
.mockReturnValueOnce({
status: 'passed',
exitCode: 0,
startedAt: new Date(),
finishedAt: new Date(),
});
await orchestrator.executeTestStep(context, 'npm test');
const options = mockRunAgentFn.mock.calls[0][7];
// In test fix context, status should come from context.feature.status
expect(options).toHaveProperty('status', 'pipeline_test');
expect(options).toHaveProperty('providerId', 'moonshot-ai');
}, 15000);
});
});

View File

@@ -0,0 +1,598 @@
/**
* Integration tests for pipeline summary accumulation across multiple steps.
*
* These tests verify the end-to-end behavior where:
* 1. Each pipeline step produces a summary via agent-executor → callbacks.saveFeatureSummary()
* 2. FeatureStateManager.saveFeatureSummary() accumulates summaries with step headers
* 3. The emitted auto_mode_summary event contains the full accumulated summary
* 4. The UI can use feature.summary (accumulated) instead of extractSummary() (last-only)
*/
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import { FeatureStateManager } from '@/services/feature-state-manager.js';
import type { Feature } from '@automaker/types';
import type { EventEmitter } from '@/lib/events.js';
import type { FeatureLoader } from '@/services/feature-loader.js';
import { atomicWriteJson, readJsonWithRecovery } from '@automaker/utils';
import { getFeatureDir } from '@automaker/platform';
import { pipelineService } from '@/services/pipeline-service.js';
// Mock dependencies
vi.mock('@/lib/secure-fs.js', () => ({
readFile: vi.fn(),
readdir: vi.fn(),
}));
vi.mock('@automaker/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@automaker/utils')>();
return {
...actual,
atomicWriteJson: vi.fn(),
readJsonWithRecovery: vi.fn(),
logRecoveryWarning: vi.fn(),
};
});
vi.mock('@automaker/platform', () => ({
getFeatureDir: vi.fn(),
getFeaturesDir: vi.fn(),
}));
vi.mock('@/services/notification-service.js', () => ({
getNotificationService: vi.fn(() => ({
createNotification: vi.fn(),
})),
}));
vi.mock('@/services/pipeline-service.js', () => ({
pipelineService: {
getStepIdFromStatus: vi.fn((status: string) => {
if (status.startsWith('pipeline_')) return status.replace('pipeline_', '');
return null;
}),
getStep: vi.fn(),
},
}));
describe('Pipeline Summary Accumulation (Integration)', () => {
let manager: FeatureStateManager;
let mockEvents: EventEmitter;
const baseFeature: Feature = {
id: 'pipeline-feature-1',
name: 'Pipeline Feature',
title: 'Pipeline Feature Title',
description: 'A feature going through pipeline steps',
status: 'pipeline_step1',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
};
beforeEach(() => {
vi.clearAllMocks();
mockEvents = {
emit: vi.fn(),
subscribe: vi.fn(() => vi.fn()),
};
const mockFeatureLoader = {
syncFeatureToAppSpec: vi.fn(),
} as unknown as FeatureLoader;
manager = new FeatureStateManager(mockEvents, mockFeatureLoader);
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1');
});
describe('multi-step pipeline summary accumulation', () => {
it('should accumulate summaries across three pipeline steps in chronological order', async () => {
// --- Step 1: Implementation ---
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary(
'/project',
'pipeline-feature-1',
'## Changes\n- Added auth module\n- Created user service'
);
const step1Feature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(step1Feature.summary).toBe(
'### Implementation\n\n## Changes\n- Added auth module\n- Created user service'
);
// --- Step 2: Code Review ---
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1');
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step2' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step2', summary: step1Feature.summary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary(
'/project',
'pipeline-feature-1',
'## Review Findings\n- Style issues fixed\n- Added error handling'
);
const step2Feature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
// --- Step 3: Testing ---
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1');
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step3' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step3', summary: step2Feature.summary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary(
'/project',
'pipeline-feature-1',
'## Test Results\n- 42 tests pass\n- 98% coverage'
);
const finalFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
// Verify the full accumulated summary has all three steps separated by ---
const expectedSummary = [
'### Implementation',
'',
'## Changes',
'- Added auth module',
'- Created user service',
'',
'---',
'',
'### Code Review',
'',
'## Review Findings',
'- Style issues fixed',
'- Added error handling',
'',
'---',
'',
'### Testing',
'',
'## Test Results',
'- 42 tests pass',
'- 98% coverage',
].join('\n');
expect(finalFeature.summary).toBe(expectedSummary);
});
it('should emit the full accumulated summary in auto_mode_summary event', async () => {
// Step 1
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Step 1 output');
// Verify the event was emitted with correct data
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
type: 'auto_mode_summary',
featureId: 'pipeline-feature-1',
projectPath: '/project',
summary: '### Implementation\n\nStep 1 output',
});
// Step 2 (with accumulated summary from step 1)
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1');
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: {
...baseFeature,
status: 'pipeline_step2',
summary: '### Implementation\n\nStep 1 output',
},
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Step 2 output');
// The event should contain the FULL accumulated summary, not just step 2
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
type: 'auto_mode_summary',
featureId: 'pipeline-feature-1',
projectPath: '/project',
summary: '### Implementation\n\nStep 1 output\n\n---\n\n### Testing\n\nStep 2 output',
});
});
});
describe('edge cases in pipeline accumulation', () => {
it('should normalize a legacy implementation summary before appending pipeline output', async () => {
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step2' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: {
...baseFeature,
status: 'pipeline_step2',
summary: 'Implemented authentication and settings updates.',
},
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Reviewed and approved');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe(
'### Implementation\n\nImplemented authentication and settings updates.\n\n---\n\n### Code Review\n\nReviewed and approved'
);
});
it('should skip persistence when a pipeline step summary is empty', async () => {
const existingSummary = '### Step 1\n\nFirst step output';
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Step 2', id: 'step2' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step2', summary: existingSummary },
recovered: false,
source: 'main',
});
// Empty summary should be ignored to avoid persisting blank sections.
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', '');
expect(atomicWriteJson).not.toHaveBeenCalled();
expect(mockEvents.emit).not.toHaveBeenCalled();
});
it('should handle pipeline step name lookup failure with fallback', async () => {
(pipelineService.getStepIdFromStatus as Mock).mockImplementation(() => {
throw new Error('Pipeline config not loaded');
});
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_code_review', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Review output');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
// Fallback: capitalize words from status suffix
expect(savedFeature.summary).toBe('### Code Review\n\nReview output');
});
it('should handle summary with special markdown characters in pipeline mode', async () => {
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
const markdownSummary = [
'## Changes Made',
'- Fixed **critical bug** in `parser.ts`',
'- Added `validateInput()` function',
'',
'```typescript',
'const x = 1;',
'```',
'',
'| Column | Value |',
'|--------|-------|',
'| Tests | Pass |',
].join('\n');
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', markdownSummary);
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe(`### Implementation\n\n${markdownSummary}`);
// Verify markdown is preserved
expect(savedFeature.summary).toContain('```typescript');
expect(savedFeature.summary).toContain('| Column | Value |');
});
it('should correctly handle rapid sequential pipeline steps without data loss', async () => {
// Simulate 5 rapid pipeline steps
const stepConfigs = [
{ name: 'Planning', status: 'pipeline_step1', content: 'Plan created' },
{ name: 'Implementation', status: 'pipeline_step2', content: 'Code written' },
{ name: 'Code Review', status: 'pipeline_step3', content: 'Review complete' },
{ name: 'Testing', status: 'pipeline_step4', content: 'All tests pass' },
{ name: 'Refinement', status: 'pipeline_step5', content: 'Code polished' },
];
let currentSummary: string | undefined = undefined;
for (const step of stepConfigs) {
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1');
(pipelineService.getStep as Mock).mockResolvedValue({
name: step.name,
id: step.status.replace('pipeline_', ''),
});
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: step.status, summary: currentSummary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', step.content);
currentSummary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary;
}
// Final summary should contain all 5 steps
expect(currentSummary).toContain('### Planning');
expect(currentSummary).toContain('Plan created');
expect(currentSummary).toContain('### Implementation');
expect(currentSummary).toContain('Code written');
expect(currentSummary).toContain('### Code Review');
expect(currentSummary).toContain('Review complete');
expect(currentSummary).toContain('### Testing');
expect(currentSummary).toContain('All tests pass');
expect(currentSummary).toContain('### Refinement');
expect(currentSummary).toContain('Code polished');
// Verify there are exactly 4 separators (between 5 steps)
const separatorCount = (currentSummary!.match(/\n\n---\n\n/g) || []).length;
expect(separatorCount).toBe(4);
});
});
describe('UI summary display logic', () => {
it('should emit accumulated summary that UI can display directly (no extractSummary needed)', async () => {
// This test verifies the UI can use feature.summary directly
// without needing to call extractSummary() which only returns the last entry
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'First step');
const step1Summary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary;
// Step 2
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1');
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step2', summary: step1Summary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Second step');
const emittedEvent = (mockEvents.emit as Mock).mock.calls[0][1];
const accumulatedSummary = emittedEvent.summary;
// The accumulated summary should contain BOTH steps
expect(accumulatedSummary).toContain('### Implementation');
expect(accumulatedSummary).toContain('First step');
expect(accumulatedSummary).toContain('### Testing');
expect(accumulatedSummary).toContain('Second step');
});
it('should handle single-step pipeline (no accumulation needed)', async () => {
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Single step output');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe('### Implementation\n\nSingle step output');
// No separator should be present for single step
expect(savedFeature.summary).not.toContain('---');
});
it('should preserve chronological order of summaries', async () => {
// Step 1
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Alpha', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'First');
const step1Summary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary;
// Step 2
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1');
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Beta', id: 'step2' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step2', summary: step1Summary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Second');
const finalSummary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary;
// Verify order: Alpha should come before Beta
const alphaIndex = finalSummary!.indexOf('### Alpha');
const betaIndex = finalSummary!.indexOf('### Beta');
expect(alphaIndex).toBeLessThan(betaIndex);
});
});
describe('non-pipeline features', () => {
it('should overwrite summary for non-pipeline features', async () => {
(readJsonWithRecovery as Mock).mockResolvedValue({
data: {
...baseFeature,
status: 'in_progress', // Non-pipeline status
summary: 'Old summary',
},
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'New summary');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe('New summary');
});
it('should not add step headers for non-pipeline features', async () => {
(readJsonWithRecovery as Mock).mockResolvedValue({
data: {
...baseFeature,
status: 'in_progress', // Non-pipeline status
summary: undefined,
},
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Simple summary');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe('Simple summary');
expect(savedFeature.summary).not.toContain('###');
});
});
describe('summary content edge cases', () => {
it('should handle summary with unicode characters', async () => {
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
const unicodeSummary = 'Test results: ✅ 42 passed, ❌ 0 failed, 🎉 100% coverage';
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', unicodeSummary);
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toContain('✅');
expect(savedFeature.summary).toContain('❌');
expect(savedFeature.summary).toContain('🎉');
});
it('should handle very long summary content', async () => {
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
// Generate a very long summary (10KB+)
const longContent = 'This is a line of content.\n'.repeat(500);
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', longContent);
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary!.length).toBeGreaterThan(10000);
});
it('should handle summary with markdown tables', async () => {
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
const tableSummary = `
## Test Results
| Test Suite | Passed | Failed | Skipped |
|------------|--------|--------|---------|
| Unit | 42 | 0 | 2 |
| Integration| 15 | 0 | 0 |
| E2E | 8 | 1 | 0 |
`;
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', tableSummary);
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toContain('| Test Suite |');
expect(savedFeature.summary).toContain('| Unit | 42 |');
});
it('should handle summary with nested markdown headers', async () => {
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
const nestedSummary = `
## Main Changes
### Backend
- Added API endpoints
### Frontend
- Created components
#### Deep nesting
- Minor fix
`;
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', nestedSummary);
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toContain('### Backend');
expect(savedFeature.summary).toContain('### Frontend');
expect(savedFeature.summary).toContain('#### Deep nesting');
});
});
describe('persistence and event ordering', () => {
it('should persist summary BEFORE emitting event', async () => {
const callOrder: string[] = [];
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
(atomicWriteJson as Mock).mockImplementation(async () => {
callOrder.push('persist');
});
(mockEvents.emit as Mock).mockImplementation(() => {
callOrder.push('emit');
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Summary');
expect(callOrder).toEqual(['persist', 'emit']);
});
it('should not emit event if persistence fails (error is caught silently)', async () => {
// Note: saveFeatureSummary catches errors internally and logs them
// It does NOT re-throw, so the method completes successfully even on error
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
(atomicWriteJson as Mock).mockRejectedValue(new Error('Disk full'));
// Method completes without throwing (error is logged internally)
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Summary');
// Event should NOT be emitted since persistence failed
expect(mockEvents.emit).not.toHaveBeenCalled();
});
});
});

View File

@@ -14,12 +14,28 @@ import {
type Credentials,
type ProjectSettings,
} from '@/types/settings.js';
import type { NtfyEndpointConfig } from '@automaker/types';
describe('settings-service.ts', () => {
let testDataDir: string;
let testProjectDir: string;
let settingsService: SettingsService;
/**
* Helper to create a test ntfy endpoint with sensible defaults
*/
function createTestNtfyEndpoint(overrides: Partial<NtfyEndpointConfig> = {}): NtfyEndpointConfig {
return {
id: `endpoint-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
name: 'Test Endpoint',
serverUrl: 'https://ntfy.sh',
topic: 'test-topic',
authType: 'none',
enabled: true,
...overrides,
};
}
beforeEach(async () => {
testDataDir = path.join(os.tmpdir(), `settings-test-${Date.now()}`);
testProjectDir = path.join(os.tmpdir(), `project-test-${Date.now()}`);
@@ -171,6 +187,150 @@ describe('settings-service.ts', () => {
expect(updated.theme).toBe('solarized');
});
it('should not overwrite non-empty ntfyEndpoints with an empty array (data loss guard)', async () => {
const endpoint1 = createTestNtfyEndpoint({
id: 'endpoint-1',
name: 'My Ntfy',
topic: 'my-topic',
});
const initial: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
ntfyEndpoints: [endpoint1] as any,
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
const updated = await settingsService.updateGlobalSettings({
ntfyEndpoints: [],
} as any);
// The empty array should be ignored - existing endpoints should be preserved
expect(updated.ntfyEndpoints?.length).toBe(1);
expect((updated.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
});
it('should allow adding new ntfyEndpoints to existing list', async () => {
const endpoint1 = createTestNtfyEndpoint({
id: 'endpoint-1',
name: 'First Endpoint',
topic: 'first-topic',
});
const endpoint2 = createTestNtfyEndpoint({
id: 'endpoint-2',
name: 'Second Endpoint',
serverUrl: 'https://ntfy.example.com',
topic: 'second-topic',
authType: 'token',
token: 'test-token',
});
const initial: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
ntfyEndpoints: [endpoint1] as any,
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
const updated = await settingsService.updateGlobalSettings({
ntfyEndpoints: [endpoint1, endpoint2] as any,
});
// Both endpoints should be present
expect(updated.ntfyEndpoints?.length).toBe(2);
expect((updated.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
expect((updated.ntfyEndpoints as any)?.[1]?.id).toBe('endpoint-2');
});
it('should allow updating ntfyEndpoints with non-empty array', async () => {
const originalEndpoint = createTestNtfyEndpoint({
id: 'endpoint-1',
name: 'Original Name',
topic: 'original-topic',
});
const updatedEndpoint = createTestNtfyEndpoint({
id: 'endpoint-1',
name: 'Updated Name',
topic: 'updated-topic',
enabled: false,
});
const initial: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
ntfyEndpoints: [originalEndpoint] as any,
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
const updated = await settingsService.updateGlobalSettings({
ntfyEndpoints: [updatedEndpoint] as any,
});
// The update should go through with the new values
expect(updated.ntfyEndpoints?.length).toBe(1);
expect((updated.ntfyEndpoints as any)?.[0]?.name).toBe('Updated Name');
expect((updated.ntfyEndpoints as any)?.[0]?.topic).toBe('updated-topic');
expect((updated.ntfyEndpoints as any)?.[0]?.enabled).toBe(false);
});
it('should allow empty ntfyEndpoints when no existing endpoints exist', async () => {
// Start with no endpoints (default state)
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(DEFAULT_GLOBAL_SETTINGS, null, 2));
// Trying to set empty array should be fine when there are no existing endpoints
const updated = await settingsService.updateGlobalSettings({
ntfyEndpoints: [],
} as any);
// Empty array should be set (no data loss because there was nothing to lose)
expect(updated.ntfyEndpoints?.length ?? 0).toBe(0);
});
it('should preserve ntfyEndpoints while updating other settings', async () => {
const endpoint = createTestNtfyEndpoint({
id: 'endpoint-1',
name: 'My Endpoint',
topic: 'my-topic',
});
const initial: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
theme: 'dark',
ntfyEndpoints: [endpoint] as any,
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
// Update theme without sending ntfyEndpoints
const updated = await settingsService.updateGlobalSettings({
theme: 'light',
});
// Theme should be updated
expect(updated.theme).toBe('light');
// ntfyEndpoints should be preserved from existing settings
expect(updated.ntfyEndpoints?.length).toBe(1);
expect((updated.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
});
it('should allow clearing ntfyEndpoints with escape hatch flag', async () => {
const endpoint = createTestNtfyEndpoint({ id: 'endpoint-1' });
const initial: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
ntfyEndpoints: [endpoint] as any,
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
// Use escape hatch to intentionally clear ntfyEndpoints
const updated = await settingsService.updateGlobalSettings({
ntfyEndpoints: [],
__allowEmptyNtfyEndpoints: true,
} as any);
// The empty array should be applied because escape hatch was used
expect(updated.ntfyEndpoints?.length ?? 0).toBe(0);
});
it('should create data directory if it does not exist', async () => {
const newDataDir = path.join(os.tmpdir(), `new-data-dir-${Date.now()}`);
const newService = new SettingsService(newDataDir);
@@ -562,6 +722,73 @@ describe('settings-service.ts', () => {
expect(projectSettings.boardBackground?.imagePath).toBe('/path/to/image.jpg');
});
it('should migrate ntfyEndpoints from localStorage data', async () => {
const localStorageData = {
'automaker-storage': JSON.stringify({
state: {
ntfyEndpoints: [
{
id: 'endpoint-1',
name: 'My Ntfy Server',
serverUrl: 'https://ntfy.sh',
topic: 'my-topic',
authType: 'none',
enabled: true,
},
],
},
}),
};
const result = await settingsService.migrateFromLocalStorage(localStorageData);
expect(result.success).toBe(true);
expect(result.migratedGlobalSettings).toBe(true);
const settings = await settingsService.getGlobalSettings();
expect(settings.ntfyEndpoints?.length).toBe(1);
expect((settings.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
expect((settings.ntfyEndpoints as any)?.[0]?.name).toBe('My Ntfy Server');
expect((settings.ntfyEndpoints as any)?.[0]?.topic).toBe('my-topic');
});
it('should migrate eventHooks and ntfyEndpoints together from localStorage data', async () => {
const localStorageData = {
'automaker-storage': JSON.stringify({
state: {
eventHooks: [
{
id: 'hook-1',
name: 'Test Hook',
eventType: 'feature:started',
enabled: true,
actions: [],
},
],
ntfyEndpoints: [
{
id: 'endpoint-1',
name: 'My Endpoint',
serverUrl: 'https://ntfy.sh',
topic: 'test-topic',
authType: 'none',
enabled: true,
},
],
},
}),
};
const result = await settingsService.migrateFromLocalStorage(localStorageData);
expect(result.success).toBe(true);
const settings = await settingsService.getGlobalSettings();
expect(settings.eventHooks?.length).toBe(1);
expect(settings.ntfyEndpoints?.length).toBe(1);
expect((settings.eventHooks as any)?.[0]?.id).toBe('hook-1');
expect((settings.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
});
it('should handle direct localStorage values', async () => {
const localStorageData = {
'automaker:lastProjectDir': '/path/to/project',

View File

@@ -207,12 +207,21 @@ Let me begin by...
describe('detectTaskCompleteMarker', () => {
it('should detect task complete marker and return task ID', () => {
expect(detectTaskCompleteMarker('[TASK_COMPLETE] T001')).toBe('T001');
expect(detectTaskCompleteMarker('[TASK_COMPLETE] T042')).toBe('T042');
expect(detectTaskCompleteMarker('[TASK_COMPLETE] T001')).toEqual({
id: 'T001',
summary: undefined,
});
expect(detectTaskCompleteMarker('[TASK_COMPLETE] T042')).toEqual({
id: 'T042',
summary: undefined,
});
});
it('should handle marker with summary', () => {
expect(detectTaskCompleteMarker('[TASK_COMPLETE] T001: User model created')).toBe('T001');
expect(detectTaskCompleteMarker('[TASK_COMPLETE] T001: User model created')).toEqual({
id: 'T001',
summary: 'User model created',
});
});
it('should return null when no marker present', () => {
@@ -229,7 +238,28 @@ Done with the implementation:
Moving on to...
`;
expect(detectTaskCompleteMarker(accumulated)).toBe('T003');
expect(detectTaskCompleteMarker(accumulated)).toEqual({
id: 'T003',
summary: 'Database setup complete',
});
});
it('should find marker in the middle of a stream with trailing text', () => {
const streamText =
'The implementation is complete! [TASK_COMPLETE] T001: Added user model and tests. Now let me check the next task...';
expect(detectTaskCompleteMarker(streamText)).toEqual({
id: 'T001',
summary: 'Added user model and tests. Now let me check the next task...',
});
});
it('should find marker in the middle of a stream with multiple tasks and return the FIRST match', () => {
const streamText =
'[TASK_COMPLETE] T001: Task one done. Continuing... [TASK_COMPLETE] T002: Task two done. Moving on...';
expect(detectTaskCompleteMarker(streamText)).toEqual({
id: 'T001',
summary: 'Task one done. Continuing...',
});
});
it('should not confuse with TASK_START marker', () => {
@@ -240,6 +270,44 @@ Moving on to...
expect(detectTaskCompleteMarker('[TASK_COMPLETE] TASK1')).toBeNull();
expect(detectTaskCompleteMarker('[TASK_COMPLETE] T1')).toBeNull();
});
it('should allow brackets in summary text', () => {
// Regression test: summaries containing array[index] syntax should not be truncated
expect(
detectTaskCompleteMarker('[TASK_COMPLETE] T001: Supports array[index] access syntax')
).toEqual({
id: 'T001',
summary: 'Supports array[index] access syntax',
});
});
it('should handle summary with multiple brackets', () => {
expect(
detectTaskCompleteMarker('[TASK_COMPLETE] T042: Fixed bug in data[0].items[key] mapping')
).toEqual({
id: 'T042',
summary: 'Fixed bug in data[0].items[key] mapping',
});
});
it('should stop at newline in summary', () => {
const result = detectTaskCompleteMarker(
'[TASK_COMPLETE] T001: First line\nSecond line without marker'
);
expect(result).toEqual({
id: 'T001',
summary: 'First line',
});
});
it('should stop at next TASK_START marker', () => {
expect(
detectTaskCompleteMarker('[TASK_COMPLETE] T001: Summary text[TASK_START] T002')
).toEqual({
id: 'T001',
summary: 'Summary text',
});
});
});
describe('detectPhaseCompleteMarker', () => {
@@ -505,6 +573,55 @@ Implementation details.
`;
expect(extractSummary(text)).toBe('Summary content here.');
});
it('should include ### subsections within the summary (not cut off at ### Root Cause)', () => {
const text = `
## Summary
Overview of changes.
### Root Cause
The bug was caused by X.
### Fix Applied
Changed Y to Z.
## Other Section
More content.
`;
const result = extractSummary(text);
expect(result).not.toBeNull();
expect(result).toContain('Overview of changes.');
expect(result).toContain('### Root Cause');
expect(result).toContain('The bug was caused by X.');
expect(result).toContain('### Fix Applied');
expect(result).toContain('Changed Y to Z.');
expect(result).not.toContain('## Other Section');
});
it('should include ### subsections and stop at next ## header', () => {
const text = `
## Summary
Brief intro.
### Changes
- File A modified
- File B added
### Notes
Important context.
## Implementation
Details here.
`;
const result = extractSummary(text);
expect(result).not.toBeNull();
expect(result).toContain('Brief intro.');
expect(result).toContain('### Changes');
expect(result).toContain('### Notes');
expect(result).not.toContain('## Implementation');
});
});
describe('**Goal**: section (lite planning mode)', () => {
@@ -624,7 +741,7 @@ Summary section content.
expect(extractSummary('Random text without any summary patterns')).toBeNull();
});
it('should handle multiple paragraph summaries (return first paragraph)', () => {
it('should include all paragraphs in ## Summary section', () => {
const text = `
## Summary
@@ -634,7 +751,89 @@ Second paragraph of summary.
## Other
`;
expect(extractSummary(text)).toBe('First paragraph of summary.');
const result = extractSummary(text);
expect(result).toContain('First paragraph of summary.');
expect(result).toContain('Second paragraph of summary.');
});
});
describe('pipeline accumulated output (multiple <summary> tags)', () => {
it('should return only the LAST summary tag from accumulated pipeline output', () => {
// Documents WHY the UI needs server-side feature.summary:
// When pipeline steps accumulate raw output in agent-output.md, each step
// writes its own <summary> tag. extractSummary takes only the LAST match,
// losing all previous steps' summaries.
const accumulatedOutput = `
## Step 1: Code Review
Some review output...
<summary>
## Code Review Summary
- Found 3 issues
- Suggested 2 improvements
</summary>
---
## Follow-up Session
## Step 2: Testing
Running tests...
<summary>
## Testing Summary
- All 15 tests pass
- Coverage at 92%
</summary>
`;
const result = extractSummary(accumulatedOutput);
// Only the LAST summary tag is returned - the Code Review summary is lost
expect(result).toBe('## Testing Summary\n- All 15 tests pass\n- Coverage at 92%');
expect(result).not.toContain('Code Review');
});
it('should return only the LAST summary from three pipeline steps', () => {
const accumulatedOutput = `
<summary>Step 1: Implementation complete</summary>
---
## Follow-up Session
<summary>Step 2: Code review findings</summary>
---
## Follow-up Session
<summary>Step 3: All tests passing</summary>
`;
const result = extractSummary(accumulatedOutput);
expect(result).toBe('Step 3: All tests passing');
expect(result).not.toContain('Step 1');
expect(result).not.toContain('Step 2');
});
it('should handle accumulated output where only one step has a summary tag', () => {
const accumulatedOutput = `
## Step 1: Implementation
Some raw output without summary tags...
---
## Follow-up Session
## Step 2: Testing
<summary>
## Test Results
- All tests pass
</summary>
`;
const result = extractSummary(accumulatedOutput);
expect(result).toBe('## Test Results\n- All tests pass');
});
});
});

View File

@@ -107,6 +107,25 @@ branch refs/heads/feature-y
expect(result).toBe(normalizePath('/Users/dev/project/.worktrees/feature-x'));
});
it('should normalize refs/heads and trim when resolving target branch', async () => {
mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' }));
const result = await resolver.findWorktreeForBranch(
'/Users/dev/project',
' refs/heads/feature-x '
);
expect(result).toBe(normalizePath('/Users/dev/project/.worktrees/feature-x'));
});
it('should normalize remote-style target branch names', async () => {
mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' }));
const result = await resolver.findWorktreeForBranch('/Users/dev/project', 'origin/feature-x');
expect(result).toBe(normalizePath('/Users/dev/project/.worktrees/feature-x'));
});
it('should return null when branch not found', async () => {
mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' }));

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { isPipelineStatus } from '@automaker/types';
describe('isPipelineStatus', () => {
it('should return true for valid pipeline statuses', () => {
expect(isPipelineStatus('pipeline_step1')).toBe(true);
expect(isPipelineStatus('pipeline_testing')).toBe(true);
expect(isPipelineStatus('pipeline_code_review')).toBe(true);
expect(isPipelineStatus('pipeline_complete')).toBe(true);
});
it('should return true for pipeline_ prefix with any non-empty suffix', () => {
expect(isPipelineStatus('pipeline_')).toBe(false); // Empty suffix is invalid
expect(isPipelineStatus('pipeline_123')).toBe(true);
expect(isPipelineStatus('pipeline_step_abc_123')).toBe(true);
});
it('should return false for non-pipeline statuses', () => {
expect(isPipelineStatus('in_progress')).toBe(false);
expect(isPipelineStatus('backlog')).toBe(false);
expect(isPipelineStatus('ready')).toBe(false);
expect(isPipelineStatus('interrupted')).toBe(false);
expect(isPipelineStatus('waiting_approval')).toBe(false);
expect(isPipelineStatus('verified')).toBe(false);
expect(isPipelineStatus('completed')).toBe(false);
});
it('should return false for null and undefined', () => {
expect(isPipelineStatus(null)).toBe(false);
expect(isPipelineStatus(undefined)).toBe(false);
});
it('should return false for empty string', () => {
expect(isPipelineStatus('')).toBe(false);
});
it('should return false for partial matches', () => {
expect(isPipelineStatus('pipeline')).toBe(false);
expect(isPipelineStatus('pipelin_step1')).toBe(false);
expect(isPipelineStatus('Pipeline_step1')).toBe(false);
expect(isPipelineStatus('PIPELINE_step1')).toBe(false);
});
it('should return false for pipeline prefix embedded in longer string', () => {
expect(isPipelineStatus('not_pipeline_step1')).toBe(false);
expect(isPipelineStatus('my_pipeline_step')).toBe(false);
});
});

View File

@@ -0,0 +1,563 @@
/**
* End-to-end integration tests for agent output summary display flow.
*
* These tests validate the complete flow from:
* 1. Server-side summary accumulation (FeatureStateManager.saveFeatureSummary)
* 2. Event emission with accumulated summary (auto_mode_summary event)
* 3. UI-side summary retrieval (feature.summary via API)
* 4. UI-side summary parsing and display (parsePhaseSummaries, extractSummary)
*
* The tests simulate what happens when:
* - A feature goes through multiple pipeline steps
* - Each step produces a summary
* - The server accumulates all summaries
* - The UI displays the accumulated summary
*/
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import { FeatureStateManager } from '@/services/feature-state-manager.js';
import type { Feature } from '@automaker/types';
import type { EventEmitter } from '@/lib/events.js';
import type { FeatureLoader } from '@/services/feature-loader.js';
import { atomicWriteJson, readJsonWithRecovery } from '@automaker/utils';
import { getFeatureDir } from '@automaker/platform';
import { pipelineService } from '@/services/pipeline-service.js';
// Mock dependencies
vi.mock('@/lib/secure-fs.js', () => ({
readFile: vi.fn(),
readdir: vi.fn(),
}));
vi.mock('@automaker/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@automaker/utils')>();
return {
...actual,
atomicWriteJson: vi.fn(),
readJsonWithRecovery: vi.fn(),
logRecoveryWarning: vi.fn(),
};
});
vi.mock('@automaker/platform', () => ({
getFeatureDir: vi.fn(),
getFeaturesDir: vi.fn(),
}));
vi.mock('@/services/notification-service.js', () => ({
getNotificationService: vi.fn(() => ({
createNotification: vi.fn(),
})),
}));
vi.mock('@/services/pipeline-service.js', () => ({
pipelineService: {
getStepIdFromStatus: vi.fn((status: string) => {
if (status.startsWith('pipeline_')) return status.replace('pipeline_', '');
return null;
}),
getStep: vi.fn(),
},
}));
// ============================================================================
// UI-side parsing functions (mirrored from apps/ui/src/lib/log-parser.ts)
// ============================================================================
function parsePhaseSummaries(summary: string | undefined): Map<string, string> {
const phaseSummaries = new Map<string, string>();
if (!summary || !summary.trim()) return phaseSummaries;
const sections = summary.split(/\n\n---\n\n/);
for (const section of sections) {
const headerMatch = section.match(/^###\s+(.+?)(?:\n|$)/);
if (headerMatch) {
const phaseName = headerMatch[1].trim().toLowerCase();
const content = section.substring(headerMatch[0].length).trim();
phaseSummaries.set(phaseName, content);
}
}
return phaseSummaries;
}
function extractSummary(rawOutput: string): string | null {
if (!rawOutput || !rawOutput.trim()) return null;
const regexesToTry: Array<{
regex: RegExp;
processor: (m: RegExpMatchArray) => string;
}> = [
{ regex: /<summary>([\s\S]*?)<\/summary>/gi, processor: (m) => m[1] },
{ regex: /^##\s+Summary[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm, processor: (m) => m[1] },
];
for (const { regex, processor } of regexesToTry) {
const matches = [...rawOutput.matchAll(regex)];
if (matches.length > 0) {
const lastMatch = matches[matches.length - 1];
return processor(lastMatch).trim();
}
}
return null;
}
function isAccumulatedSummary(summary: string | undefined): boolean {
if (!summary || !summary.trim()) return false;
return summary.includes('\n\n---\n\n') && (summary.match(/###\s+.+/g)?.length ?? 0) > 0;
}
/**
* Returns the first summary candidate that contains non-whitespace content.
* Mirrors getFirstNonEmptySummary from apps/ui/src/lib/summary-selection.ts
*/
function getFirstNonEmptySummary(...candidates: (string | null | undefined)[]): string | null {
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim().length > 0) {
return candidate;
}
}
return null;
}
// ============================================================================
// Unit tests for helper functions
// ============================================================================
describe('getFirstNonEmptySummary', () => {
it('should return the first non-empty string', () => {
expect(getFirstNonEmptySummary(null, undefined, 'first', 'second')).toBe('first');
});
it('should skip null and undefined candidates', () => {
expect(getFirstNonEmptySummary(null, undefined, 'valid')).toBe('valid');
});
it('should skip whitespace-only strings', () => {
expect(getFirstNonEmptySummary(' ', '\n\t', 'actual content')).toBe('actual content');
});
it('should return null when all candidates are empty', () => {
expect(getFirstNonEmptySummary(null, undefined, '', ' ')).toBeNull();
});
it('should return null when no candidates provided', () => {
expect(getFirstNonEmptySummary()).toBeNull();
});
it('should handle empty string as invalid', () => {
expect(getFirstNonEmptySummary('', 'valid')).toBe('valid');
});
it('should prefer first valid candidate', () => {
expect(getFirstNonEmptySummary('first', 'second', 'third')).toBe('first');
});
it('should handle strings with only spaces as invalid', () => {
expect(getFirstNonEmptySummary(' ', ' \n ', 'valid')).toBe('valid');
});
it('should accept strings with content surrounded by whitespace', () => {
expect(getFirstNonEmptySummary(' content with spaces ')).toBe(' content with spaces ');
});
});
describe('Agent Output Summary E2E Flow', () => {
let manager: FeatureStateManager;
let mockEvents: EventEmitter;
const baseFeature: Feature = {
id: 'e2e-feature-1',
name: 'E2E Feature',
title: 'E2E Feature Title',
description: 'A feature going through complete pipeline',
status: 'pipeline_implementation',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
};
beforeEach(() => {
vi.clearAllMocks();
mockEvents = {
emit: vi.fn(),
subscribe: vi.fn(() => vi.fn()),
};
const mockFeatureLoader = {
syncFeatureToAppSpec: vi.fn(),
} as unknown as FeatureLoader;
manager = new FeatureStateManager(mockEvents, mockFeatureLoader);
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
});
describe('complete pipeline flow: server accumulation → UI display', () => {
it('should maintain complete summary across all pipeline steps', async () => {
// ===== STEP 1: Implementation =====
(pipelineService.getStep as Mock).mockResolvedValue({
name: 'Implementation',
id: 'implementation',
});
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary(
'/project',
'e2e-feature-1',
'## Changes\n- Created auth module\n- Added user service'
);
const step1Feature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
const step1Summary = step1Feature.summary;
// Verify server-side accumulation format
expect(step1Summary).toBe(
'### Implementation\n\n## Changes\n- Created auth module\n- Added user service'
);
// Verify UI can parse this summary
const phases1 = parsePhaseSummaries(step1Summary);
expect(phases1.size).toBe(1);
expect(phases1.get('implementation')).toContain('Created auth module');
// ===== STEP 2: Code Review =====
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
(pipelineService.getStep as Mock).mockResolvedValue({
name: 'Code Review',
id: 'code_review',
});
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_code_review', summary: step1Summary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary(
'/project',
'e2e-feature-1',
'## Review Results\n- Approved with minor suggestions'
);
const step2Feature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
const step2Summary = step2Feature.summary;
// Verify accumulation now has both steps
expect(step2Summary).toContain('### Implementation');
expect(step2Summary).toContain('Created auth module');
expect(step2Summary).toContain('### Code Review');
expect(step2Summary).toContain('Approved with minor suggestions');
expect(step2Summary).toContain('\n\n---\n\n'); // Separator
// Verify UI can parse accumulated summary
expect(isAccumulatedSummary(step2Summary)).toBe(true);
const phases2 = parsePhaseSummaries(step2Summary);
expect(phases2.size).toBe(2);
expect(phases2.get('implementation')).toContain('Created auth module');
expect(phases2.get('code review')).toContain('Approved with minor suggestions');
// ===== STEP 3: Testing =====
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'testing' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_testing', summary: step2Summary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary(
'/project',
'e2e-feature-1',
'## Test Results\n- 42 tests pass\n- 98% coverage'
);
const finalFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
const finalSummary = finalFeature.summary;
// Verify final accumulation has all three steps
expect(finalSummary).toContain('### Implementation');
expect(finalSummary).toContain('Created auth module');
expect(finalSummary).toContain('### Code Review');
expect(finalSummary).toContain('Approved with minor suggestions');
expect(finalSummary).toContain('### Testing');
expect(finalSummary).toContain('42 tests pass');
// Verify UI-side parsing of complete pipeline
expect(isAccumulatedSummary(finalSummary)).toBe(true);
const finalPhases = parsePhaseSummaries(finalSummary);
expect(finalPhases.size).toBe(3);
// Verify chronological order (implementation before testing)
const summaryLines = finalSummary!.split('\n');
const implIndex = summaryLines.findIndex((l) => l.includes('### Implementation'));
const reviewIndex = summaryLines.findIndex((l) => l.includes('### Code Review'));
const testIndex = summaryLines.findIndex((l) => l.includes('### Testing'));
expect(implIndex).toBeLessThan(reviewIndex);
expect(reviewIndex).toBeLessThan(testIndex);
});
it('should emit events with accumulated summaries for real-time UI updates', async () => {
// Step 1
(pipelineService.getStep as Mock).mockResolvedValue({
name: 'Implementation',
id: 'implementation',
});
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Step 1 output');
// Verify event emission
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
type: 'auto_mode_summary',
featureId: 'e2e-feature-1',
projectPath: '/project',
summary: '### Implementation\n\nStep 1 output',
});
// Step 2
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'testing' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: {
...baseFeature,
status: 'pipeline_testing',
summary: '### Implementation\n\nStep 1 output',
},
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Step 2 output');
// Event should contain FULL accumulated summary
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
type: 'auto_mode_summary',
featureId: 'e2e-feature-1',
projectPath: '/project',
summary: '### Implementation\n\nStep 1 output\n\n---\n\n### Testing\n\nStep 2 output',
});
});
});
describe('UI display logic: feature.summary vs extractSummary()', () => {
it('should prefer feature.summary (server-accumulated) over extractSummary() (last only)', () => {
// Simulate what the server has accumulated
const featureSummary = [
'### Implementation',
'',
'## Changes',
'- Created feature',
'',
'---',
'',
'### Testing',
'',
'## Results',
'- All tests pass',
].join('\n');
// Simulate raw agent output (only contains last summary)
const rawOutput = `
Working on tests...
<summary>
## Results
- All tests pass
</summary>
`;
// UI logic: getFirstNonEmptySummary(feature?.summary, extractSummary(output))
const displaySummary = getFirstNonEmptySummary(featureSummary, extractSummary(rawOutput));
// Should use server-accumulated summary
expect(displaySummary).toBe(featureSummary);
expect(displaySummary).toContain('### Implementation');
expect(displaySummary).toContain('### Testing');
// If server summary was missing, only last summary would be shown
const fallbackSummary = extractSummary(rawOutput);
expect(fallbackSummary).not.toContain('Implementation');
expect(fallbackSummary).toContain('All tests pass');
});
it('should handle legacy features without server accumulation', () => {
// Legacy features have no feature.summary
const featureSummary = undefined;
// Raw output contains the summary
const rawOutput = `
<summary>
## Implementation Complete
- Created the feature
- All tests pass
</summary>
`;
// UI logic: getFirstNonEmptySummary(feature?.summary, extractSummary(output))
const displaySummary = getFirstNonEmptySummary(featureSummary, extractSummary(rawOutput));
// Should fall back to client-side extraction
expect(displaySummary).toContain('Implementation Complete');
expect(displaySummary).toContain('All tests pass');
});
});
describe('error recovery and edge cases', () => {
it('should gracefully handle pipeline interruption', async () => {
// Step 1 completes
(pipelineService.getStep as Mock).mockResolvedValue({
name: 'Implementation',
id: 'implementation',
});
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Implementation done');
const step1Summary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary;
// Pipeline gets interrupted (status changes but summary is preserved)
// When user views the feature later, the summary should still be available
expect(step1Summary).toBe('### Implementation\n\nImplementation done');
// UI can still parse the partial pipeline
const phases = parsePhaseSummaries(step1Summary);
expect(phases.size).toBe(1);
expect(phases.get('implementation')).toBe('Implementation done');
});
it('should handle very large accumulated summaries', async () => {
// Generate large content for each step
const generateLargeContent = (stepNum: number) => {
const lines = [`## Step ${stepNum} Changes`];
for (let i = 0; i < 100; i++) {
lines.push(
`- Change ${i}: This is a detailed description of the change made during step ${stepNum}`
);
}
return lines.join('\n');
};
// Simulate 5 pipeline steps with large content
let currentSummary: string | undefined = undefined;
const stepNames = ['Planning', 'Implementation', 'Code Review', 'Testing', 'Refinement'];
for (let i = 0; i < 5; i++) {
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
(pipelineService.getStep as Mock).mockResolvedValue({
name: stepNames[i],
id: stepNames[i].toLowerCase().replace(' ', '_'),
});
(readJsonWithRecovery as Mock).mockResolvedValue({
data: {
...baseFeature,
status: `pipeline_${stepNames[i].toLowerCase().replace(' ', '_')}`,
summary: currentSummary,
},
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'e2e-feature-1', generateLargeContent(i + 1));
currentSummary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary;
}
// Final summary should be large but still parseable
expect(currentSummary!.length).toBeGreaterThan(5000);
expect(isAccumulatedSummary(currentSummary)).toBe(true);
const phases = parsePhaseSummaries(currentSummary);
expect(phases.size).toBe(5);
// Verify all steps are present
for (const stepName of stepNames) {
expect(phases.has(stepName.toLowerCase())).toBe(true);
}
});
});
describe('query invalidation simulation', () => {
it('should trigger UI refetch on auto_mode_summary event', async () => {
// This test documents the expected behavior:
// When saveFeatureSummary is called, it emits auto_mode_summary event
// The UI's use-query-invalidation.ts invalidates the feature query
// This causes a refetch of the feature, getting the updated summary
(pipelineService.getStep as Mock).mockResolvedValue({
name: 'Implementation',
id: 'implementation',
});
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Summary content');
// Verify event was emitted (triggers React Query invalidation)
expect(mockEvents.emit).toHaveBeenCalledWith(
'auto-mode:event',
expect.objectContaining({
type: 'auto_mode_summary',
featureId: 'e2e-feature-1',
summary: expect.any(String),
})
);
// The UI would then:
// 1. Receive the event via WebSocket
// 2. Invalidate the feature query
// 3. Refetch the feature (GET /api/features/:id)
// 4. Display the updated feature.summary
});
});
});
/**
* KEY E2E FLOW SUMMARY:
*
* 1. PIPELINE EXECUTION:
* - Feature starts with status='pipeline_implementation'
* - Agent runs and produces summary
* - FeatureStateManager.saveFeatureSummary() accumulates with step header
* - Status advances to 'pipeline_testing'
* - Process repeats for each step
*
* 2. SERVER-SIDE ACCUMULATION:
* - First step: `### Implementation\n\n<content>`
* - Second step: `### Implementation\n\n<content>\n\n---\n\n### Testing\n\n<content>`
* - Pattern continues with each step
*
* 3. EVENT EMISSION:
* - auto_mode_summary event contains FULL accumulated summary
* - UI receives event via WebSocket
* - React Query invalidates feature query
* - Feature is refetched with updated summary
*
* 4. UI DISPLAY:
* - AgentOutputModal uses: getFirstNonEmptySummary(feature?.summary, extractSummary(output))
* - feature.summary is preferred (contains all steps)
* - extractSummary() is fallback (last summary only)
* - parsePhaseSummaries() can split into individual phases for UI
*
* 5. FALLBACK FOR LEGACY:
* - Old features may not have feature.summary
* - UI falls back to extracting from raw output
* - Only last summary is available in this case
*/

View File

@@ -0,0 +1,403 @@
/**
* Unit tests for the agent output summary priority logic.
*
* These tests verify the summary display logic used in AgentOutputModal
* where the UI must choose between server-accumulated summaries and
* client-side extracted summaries.
*
* Priority order (from agent-output-modal.tsx):
* 1. feature.summary (server-accumulated, contains all pipeline steps)
* 2. extractSummary(output) (client-side fallback, last summary only)
*
* This priority is crucial for pipeline features where the server-side
* accumulation provides the complete history of all step summaries.
*/
import { describe, it, expect } from 'vitest';
// Import the actual extractSummary function to ensure test behavior matches production
import { extractSummary } from '../../../../ui/src/lib/log-parser.ts';
import { getFirstNonEmptySummary } from '../../../../ui/src/lib/summary-selection.ts';
/**
* Simulates the summary priority logic from AgentOutputModal.
*
* Priority:
* 1. feature?.summary (server-accumulated)
* 2. extractSummary(output) (client-side fallback)
*/
function getDisplaySummary(
featureSummary: string | undefined | null,
rawOutput: string
): string | null {
return getFirstNonEmptySummary(featureSummary, extractSummary(rawOutput));
}
describe('Agent Output Summary Priority Logic', () => {
describe('priority order: feature.summary over extractSummary', () => {
it('should use feature.summary when available (server-accumulated wins)', () => {
const featureSummary = '### Step 1\n\nFirst step\n\n---\n\n### Step 2\n\nSecond step';
const rawOutput = `
<summary>
Only the last summary is extracted client-side
</summary>
`;
const result = getDisplaySummary(featureSummary, rawOutput);
// Server-accumulated summary should be used, not client-side extraction
expect(result).toBe(featureSummary);
expect(result).toContain('### Step 1');
expect(result).toContain('### Step 2');
expect(result).not.toContain('Only the last summary');
});
it('should use client-side extractSummary when feature.summary is undefined', () => {
const rawOutput = `
<summary>
This is the only summary
</summary>
`;
const result = getDisplaySummary(undefined, rawOutput);
expect(result).toBe('This is the only summary');
});
it('should use client-side extractSummary when feature.summary is null', () => {
const rawOutput = `
<summary>
Client-side extracted summary
</summary>
`;
const result = getDisplaySummary(null, rawOutput);
expect(result).toBe('Client-side extracted summary');
});
it('should use client-side extractSummary when feature.summary is empty string', () => {
const rawOutput = `
<summary>
Fallback content
</summary>
`;
const result = getDisplaySummary('', rawOutput);
// Empty string is falsy, so fallback is used
expect(result).toBe('Fallback content');
});
it('should use client-side extractSummary when feature.summary is whitespace only', () => {
const rawOutput = `
<summary>
Fallback for whitespace summary
</summary>
`;
const result = getDisplaySummary(' \n ', rawOutput);
expect(result).toBe('Fallback for whitespace summary');
});
it('should preserve original server summary formatting when non-empty after trim', () => {
const featureSummary = '\n### Implementation\n\n- Added API route\n';
const result = getDisplaySummary(featureSummary, '');
expect(result).toBe(featureSummary);
expect(result).toContain('### Implementation');
});
});
describe('pipeline step accumulation scenarios', () => {
it('should display all pipeline steps when using server-accumulated summary', () => {
// This simulates a feature that went through 3 pipeline steps
const featureSummary = [
'### Implementation',
'',
'## Changes',
'- Created new module',
'- Added tests',
'',
'---',
'',
'### Code Review',
'',
'## Review Results',
'- Approved with minor suggestions',
'',
'---',
'',
'### Testing',
'',
'## Test Results',
'- All 42 tests pass',
'- Coverage: 98%',
].join('\n');
const rawOutput = `
<summary>
Only testing step visible in raw output
</summary>
`;
const result = getDisplaySummary(featureSummary, rawOutput);
// All pipeline steps should be visible
expect(result).toContain('### Implementation');
expect(result).toContain('### Code Review');
expect(result).toContain('### Testing');
expect(result).toContain('All 42 tests pass');
});
it('should display only last summary when server-side accumulation not available', () => {
// When feature.summary is not available, only the last summary is shown
const rawOutput = `
<summary>
Step 1: Implementation complete
</summary>
---
<summary>
Step 2: Code review complete
</summary>
---
<summary>
Step 3: Testing complete
</summary>
`;
const result = getDisplaySummary(undefined, rawOutput);
// Only the LAST summary should be shown (client-side fallback behavior)
expect(result).toBe('Step 3: Testing complete');
expect(result).not.toContain('Step 1');
expect(result).not.toContain('Step 2');
});
it('should handle single-step pipeline (no accumulation needed)', () => {
const featureSummary = '### Implementation\n\nCreated the feature';
const rawOutput = '';
const result = getDisplaySummary(featureSummary, rawOutput);
expect(result).toBe(featureSummary);
expect(result).not.toContain('---'); // No separator for single step
});
});
describe('edge cases', () => {
it('should return null when both feature.summary and extractSummary are unavailable', () => {
const rawOutput = 'No summary tags here, just regular output.';
const result = getDisplaySummary(undefined, rawOutput);
expect(result).toBeNull();
});
it('should return null when rawOutput is empty and no feature summary', () => {
const result = getDisplaySummary(undefined, '');
expect(result).toBeNull();
});
it('should return null when rawOutput is whitespace only', () => {
const result = getDisplaySummary(undefined, ' \n\n ');
expect(result).toBeNull();
});
it('should use client-side fallback when feature.summary is empty string (falsy)', () => {
// Empty string is falsy in JavaScript, so fallback is correctly used.
// This is the expected behavior - an empty summary has no value to display.
const rawOutput = `
<summary>
Fallback content when server summary is empty
</summary>
`;
// Empty string is falsy, so fallback is used
const result = getDisplaySummary('', rawOutput);
expect(result).toBe('Fallback content when server summary is empty');
});
it('should behave identically when feature is null vs feature.summary is undefined', () => {
// This test verifies that the behavior is consistent whether:
// - The feature object itself is null/undefined
// - The feature object exists but summary property is undefined
const rawOutput = `
<summary>
Client-side extracted summary
</summary>
`;
// Both scenarios should use client-side fallback
const resultWithUndefined = getDisplaySummary(undefined, rawOutput);
const resultWithNull = getDisplaySummary(null, rawOutput);
expect(resultWithUndefined).toBe('Client-side extracted summary');
expect(resultWithNull).toBe('Client-side extracted summary');
expect(resultWithUndefined).toBe(resultWithNull);
});
});
describe('markdown content preservation', () => {
it('should preserve markdown formatting in server-accumulated summary', () => {
const featureSummary = `### Code Review
## Changes Made
- Fixed **critical bug** in \`parser.ts\`
- Added \`validateInput()\` function
\`\`\`typescript
const x = 1;
\`\`\`
| Test | Result |
|------|--------|
| Unit | Pass |`;
const result = getDisplaySummary(featureSummary, '');
expect(result).toContain('**critical bug**');
expect(result).toContain('`parser.ts`');
expect(result).toContain('```typescript');
expect(result).toContain('| Test | Result |');
});
it('should preserve unicode in server-accumulated summary', () => {
const featureSummary = '### Testing\n\n✅ 42 passed\n❌ 0 failed\n🎉 100% coverage';
const result = getDisplaySummary(featureSummary, '');
expect(result).toContain('✅');
expect(result).toContain('❌');
expect(result).toContain('🎉');
});
});
describe('real-world scenarios', () => {
it('should handle typical pipeline feature with server accumulation', () => {
// Simulates a real pipeline feature that went through Implementation → Testing
const featureSummary = `### Implementation
## Changes Made
- Created UserProfile component
- Added authentication middleware
- Updated API endpoints
---
### Testing
## Test Results
- Unit tests: 15 passed
- Integration tests: 8 passed
- E2E tests: 3 passed`;
const rawOutput = `
Working on the feature...
<summary>
## Test Results
- Unit tests: 15 passed
- Integration tests: 8 passed
- E2E tests: 3 passed
</summary>
`;
const result = getDisplaySummary(featureSummary, rawOutput);
// Both steps should be visible
expect(result).toContain('### Implementation');
expect(result).toContain('### Testing');
expect(result).toContain('UserProfile component');
expect(result).toContain('15 passed');
});
it('should handle non-pipeline feature (single summary)', () => {
// Non-pipeline features have a single summary, no accumulation
const featureSummary = '## Implementation Complete\n- Created the feature\n- All tests pass';
const rawOutput = '';
const result = getDisplaySummary(featureSummary, rawOutput);
expect(result).toBe(featureSummary);
expect(result).not.toContain('###'); // No step headers for non-pipeline
});
it('should handle legacy feature without server summary (fallback)', () => {
// Legacy features may not have feature.summary set
const rawOutput = `
<summary>
Legacy implementation from before server-side accumulation
</summary>
`;
const result = getDisplaySummary(undefined, rawOutput);
expect(result).toBe('Legacy implementation from before server-side accumulation');
});
});
describe('view mode determination logic', () => {
/**
* Simulates the effectiveViewMode logic from agent-output-modal.tsx line 86
* Default to 'summary' if summary is available, otherwise 'parsed'
*/
function getEffectiveViewMode(
viewMode: string | null,
summary: string | null
): 'summary' | 'parsed' {
return (viewMode ?? (summary ? 'summary' : 'parsed')) as 'summary' | 'parsed';
}
it('should default to summary view when server summary is available', () => {
const summary = '### Implementation\n\nContent';
const result = getEffectiveViewMode(null, summary);
expect(result).toBe('summary');
});
it('should default to summary view when client-side extraction succeeds', () => {
const summary = 'Extracted from raw output';
const result = getEffectiveViewMode(null, summary);
expect(result).toBe('summary');
});
it('should default to parsed view when no summary is available', () => {
const result = getEffectiveViewMode(null, null);
expect(result).toBe('parsed');
});
it('should respect explicit view mode selection over default', () => {
const summary = 'Summary is available';
expect(getEffectiveViewMode('raw', summary)).toBe('raw');
expect(getEffectiveViewMode('parsed', summary)).toBe('parsed');
expect(getEffectiveViewMode('changes', summary)).toBe('changes');
});
});
});
/**
* KEY ARCHITECTURE INSIGHT:
*
* The priority order (feature.summary > extractSummary(output)) is essential for
* pipeline features because:
*
* 1. Server-side accumulation (FeatureStateManager.saveFeatureSummary) collects
* ALL step summaries with headers and separators in chronological order.
*
* 2. Client-side extractSummary() only returns the LAST summary tag from raw output,
* losing all previous step summaries.
*
* 3. The UI must prefer feature.summary to display the complete history of all
* pipeline steps to the user.
*
* For non-pipeline features (single execution), both sources contain the same
* summary, so the priority doesn't matter. But for pipeline features, using the
* wrong source would result in incomplete information display.
*/

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import {
parseAllPhaseSummaries,
parsePhaseSummaries,
extractPhaseSummary,
extractImplementationSummary,
isAccumulatedSummary,
} from '../../../../ui/src/lib/log-parser.ts';
describe('log-parser mixed summary format compatibility', () => {
const mixedSummary = [
'Implemented core auth flow and API wiring.',
'',
'---',
'',
'### Code Review',
'',
'Addressed lint warnings and improved error handling.',
'',
'---',
'',
'### Testing',
'',
'All tests passing.',
].join('\n');
it('treats leading headerless section as Implementation phase', () => {
const phases = parsePhaseSummaries(mixedSummary);
expect(phases.get('implementation')).toBe('Implemented core auth flow and API wiring.');
expect(phases.get('code review')).toBe('Addressed lint warnings and improved error handling.');
expect(phases.get('testing')).toBe('All tests passing.');
});
it('returns implementation summary from mixed format', () => {
expect(extractImplementationSummary(mixedSummary)).toBe(
'Implemented core auth flow and API wiring.'
);
});
it('includes Implementation as the first parsed phase entry', () => {
const entries = parseAllPhaseSummaries(mixedSummary);
expect(entries[0]).toMatchObject({
phaseName: 'Implementation',
content: 'Implemented core auth flow and API wiring.',
});
expect(entries.map((entry) => entry.phaseName)).toEqual([
'Implementation',
'Code Review',
'Testing',
]);
});
it('extracts specific phase summaries from mixed format', () => {
expect(extractPhaseSummary(mixedSummary, 'Implementation')).toBe(
'Implemented core auth flow and API wiring.'
);
expect(extractPhaseSummary(mixedSummary, 'Code Review')).toBe(
'Addressed lint warnings and improved error handling.'
);
expect(extractPhaseSummary(mixedSummary, 'Testing')).toBe('All tests passing.');
});
it('treats mixed format as accumulated summary', () => {
expect(isAccumulatedSummary(mixedSummary)).toBe(true);
});
});

View File

@@ -0,0 +1,973 @@
/**
* Unit tests for log-parser phase summary parsing functions.
*
* These functions are used to parse accumulated summaries that contain multiple
* pipeline step summaries separated by `---` and identified by `### StepName` headers.
*
* Functions tested:
* - parsePhaseSummaries: Parses the entire accumulated summary into a Map
* - extractPhaseSummary: Extracts a specific phase's content
* - extractImplementationSummary: Extracts implementation phase content (convenience)
* - isAccumulatedSummary: Checks if a summary is in accumulated format
*/
import { describe, it, expect } from 'vitest';
// Mirror the functions from apps/ui/src/lib/log-parser.ts
// (We can't import directly because it's a UI file)
/**
* Parses an accumulated summary string into individual phase summaries.
*/
function parsePhaseSummaries(summary: string | undefined): Map<string, string> {
const phaseSummaries = new Map<string, string>();
if (!summary || !summary.trim()) {
return phaseSummaries;
}
// Split by the horizontal rule separator
const sections = summary.split(/\n\n---\n\n/);
for (const section of sections) {
// Match the phase header pattern: ### Phase Name
const headerMatch = section.match(/^###\s+(.+?)(?:\n|$)/);
if (headerMatch) {
const phaseName = headerMatch[1].trim().toLowerCase();
// Extract content after the header (skip the header line and leading newlines)
const content = section.substring(headerMatch[0].length).trim();
phaseSummaries.set(phaseName, content);
}
}
return phaseSummaries;
}
/**
* Extracts a specific phase summary from an accumulated summary string.
*/
function extractPhaseSummary(summary: string | undefined, phaseName: string): string | null {
const phaseSummaries = parsePhaseSummaries(summary);
const normalizedPhaseName = phaseName.toLowerCase();
return phaseSummaries.get(normalizedPhaseName) || null;
}
/**
* Extracts the implementation phase summary from an accumulated summary string.
*/
function extractImplementationSummary(summary: string | undefined): string | null {
if (!summary || !summary.trim()) {
return null;
}
const phaseSummaries = parsePhaseSummaries(summary);
// Try exact match first
const implementationContent = phaseSummaries.get('implementation');
if (implementationContent) {
return implementationContent;
}
// Fallback: find any phase containing "implement"
for (const [phaseName, content] of phaseSummaries) {
if (phaseName.includes('implement')) {
return content;
}
}
// If no phase summaries found, the summary might not be in accumulated format
// (legacy or non-pipeline feature). In this case, return the whole summary
// if it looks like a single summary (no phase headers).
if (!summary.includes('### ') && !summary.includes('\n---\n')) {
return summary;
}
return null;
}
/**
* Checks if a summary string is in the accumulated multi-phase format.
*/
function isAccumulatedSummary(summary: string | undefined): boolean {
if (!summary || !summary.trim()) {
return false;
}
// Check for the presence of phase headers with separator
const hasMultiplePhases =
summary.includes('\n\n---\n\n') && summary.match(/###\s+.+/g)?.length > 0;
return hasMultiplePhases;
}
/**
* Represents a single phase entry in an accumulated summary.
*/
interface PhaseSummaryEntry {
/** The phase name (e.g., "Implementation", "Testing", "Code Review") */
phaseName: string;
/** The content of this phase's summary */
content: string;
/** The original header line (e.g., "### Implementation") */
header: string;
}
/** Default phase name used for non-accumulated summaries */
const DEFAULT_PHASE_NAME = 'Summary';
/**
* Parses an accumulated summary into individual phase entries.
* Returns phases in the order they appear in the summary.
*/
function parseAllPhaseSummaries(summary: string | undefined): PhaseSummaryEntry[] {
const entries: PhaseSummaryEntry[] = [];
if (!summary || !summary.trim()) {
return entries;
}
// Check if this is an accumulated summary (has phase headers)
if (!summary.includes('### ')) {
// Not an accumulated summary - return as single entry with generic name
return [
{ phaseName: DEFAULT_PHASE_NAME, content: summary, header: `### ${DEFAULT_PHASE_NAME}` },
];
}
// Split by the horizontal rule separator
const sections = summary.split(/\n\n---\n\n/);
for (const section of sections) {
// Match the phase header pattern: ### Phase Name
const headerMatch = section.match(/^(###\s+)(.+?)(?:\n|$)/);
if (headerMatch) {
const header = headerMatch[0].trim();
const phaseName = headerMatch[2].trim();
// Extract content after the header (skip the header line and leading newlines)
const content = section.substring(headerMatch[0].length).trim();
entries.push({ phaseName, content, header });
}
}
return entries;
}
describe('parsePhaseSummaries', () => {
describe('basic parsing', () => {
it('should parse single phase summary', () => {
const summary = `### Implementation
## Changes Made
- Created new module
- Added unit tests`;
const result = parsePhaseSummaries(summary);
expect(result.size).toBe(1);
expect(result.get('implementation')).toBe(
'## Changes Made\n- Created new module\n- Added unit tests'
);
});
it('should parse multiple phase summaries', () => {
const summary = `### Implementation
## Changes Made
- Created new module
---
### Testing
## Test Results
- All tests pass`;
const result = parsePhaseSummaries(summary);
expect(result.size).toBe(2);
expect(result.get('implementation')).toBe('## Changes Made\n- Created new module');
expect(result.get('testing')).toBe('## Test Results\n- All tests pass');
});
it('should handle three or more phases', () => {
const summary = `### Planning
Plan created
---
### Implementation
Code written
---
### Testing
Tests pass
---
### Refinement
Code polished`;
const result = parsePhaseSummaries(summary);
expect(result.size).toBe(4);
expect(result.get('planning')).toBe('Plan created');
expect(result.get('implementation')).toBe('Code written');
expect(result.get('testing')).toBe('Tests pass');
expect(result.get('refinement')).toBe('Code polished');
});
});
describe('edge cases', () => {
it('should return empty map for undefined summary', () => {
const result = parsePhaseSummaries(undefined);
expect(result.size).toBe(0);
});
it('should return empty map for null summary', () => {
const result = parsePhaseSummaries(null as unknown as string);
expect(result.size).toBe(0);
});
it('should return empty map for empty string', () => {
const result = parsePhaseSummaries('');
expect(result.size).toBe(0);
});
it('should return empty map for whitespace-only string', () => {
const result = parsePhaseSummaries(' \n\n ');
expect(result.size).toBe(0);
});
it('should handle summary without phase headers', () => {
const summary = 'Just some regular content without headers';
const result = parsePhaseSummaries(summary);
expect(result.size).toBe(0);
});
it('should handle section without header after separator', () => {
const summary = `### Implementation
Content here
---
This section has no header`;
const result = parsePhaseSummaries(summary);
expect(result.size).toBe(1);
expect(result.get('implementation')).toBe('Content here');
});
});
describe('phase name normalization', () => {
it('should normalize phase names to lowercase', () => {
const summary = `### IMPLEMENTATION
Content`;
const result = parsePhaseSummaries(summary);
expect(result.has('implementation')).toBe(true);
expect(result.has('IMPLEMENTATION')).toBe(false);
});
it('should handle mixed case phase names', () => {
const summary = `### Code Review
Content`;
const result = parsePhaseSummaries(summary);
expect(result.has('code review')).toBe(true);
});
it('should preserve spaces in multi-word phase names', () => {
const summary = `### Code Review
Content`;
const result = parsePhaseSummaries(summary);
expect(result.get('code review')).toBe('Content');
});
});
describe('content preservation', () => {
it('should preserve markdown formatting in content', () => {
const summary = `### Implementation
## Heading
- **Bold text**
- \`code\`
\`\`\`typescript
const x = 1;
\`\`\``;
const result = parsePhaseSummaries(summary);
const content = result.get('implementation');
expect(content).toContain('**Bold text**');
expect(content).toContain('`code`');
expect(content).toContain('```typescript');
});
it('should preserve unicode in content', () => {
const summary = `### Testing
Results: ✅ 42 passed, ❌ 0 failed`;
const result = parsePhaseSummaries(summary);
expect(result.get('testing')).toContain('✅');
expect(result.get('testing')).toContain('❌');
});
it('should preserve tables in content', () => {
const summary = `### Testing
| Test | Result |
|------|--------|
| Unit | Pass |`;
const result = parsePhaseSummaries(summary);
expect(result.get('testing')).toContain('| Test | Result |');
});
it('should handle empty phase content', () => {
const summary = `### Implementation
---
### Testing
Content`;
const result = parsePhaseSummaries(summary);
expect(result.get('implementation')).toBe('');
expect(result.get('testing')).toBe('Content');
});
});
});
describe('extractPhaseSummary', () => {
describe('extraction by phase name', () => {
it('should extract specified phase content', () => {
const summary = `### Implementation
Implementation content
---
### Testing
Testing content`;
expect(extractPhaseSummary(summary, 'Implementation')).toBe('Implementation content');
expect(extractPhaseSummary(summary, 'Testing')).toBe('Testing content');
});
it('should be case-insensitive for phase name', () => {
const summary = `### Implementation
Content`;
expect(extractPhaseSummary(summary, 'implementation')).toBe('Content');
expect(extractPhaseSummary(summary, 'IMPLEMENTATION')).toBe('Content');
expect(extractPhaseSummary(summary, 'ImPlEmEnTaTiOn')).toBe('Content');
});
it('should return null for non-existent phase', () => {
const summary = `### Implementation
Content`;
expect(extractPhaseSummary(summary, 'NonExistent')).toBeNull();
});
});
describe('edge cases', () => {
it('should return null for undefined summary', () => {
expect(extractPhaseSummary(undefined, 'Implementation')).toBeNull();
});
it('should return null for empty summary', () => {
expect(extractPhaseSummary('', 'Implementation')).toBeNull();
});
it('should handle whitespace in phase name', () => {
const summary = `### Code Review
Content`;
expect(extractPhaseSummary(summary, 'Code Review')).toBe('Content');
expect(extractPhaseSummary(summary, 'code review')).toBe('Content');
});
});
});
describe('extractImplementationSummary', () => {
describe('exact match', () => {
it('should extract implementation phase by exact name', () => {
const summary = `### Implementation
## Changes Made
- Created feature
- Added tests
---
### Testing
Tests pass`;
const result = extractImplementationSummary(summary);
expect(result).toBe('## Changes Made\n- Created feature\n- Added tests');
});
it('should be case-insensitive', () => {
const summary = `### IMPLEMENTATION
Content`;
expect(extractImplementationSummary(summary)).toBe('Content');
});
});
describe('partial match fallback', () => {
it('should find phase containing "implement"', () => {
const summary = `### Feature Implementation
Content here`;
const result = extractImplementationSummary(summary);
expect(result).toBe('Content here');
});
it('should find phase containing "implementation"', () => {
const summary = `### Implementation Phase
Content here`;
const result = extractImplementationSummary(summary);
expect(result).toBe('Content here');
});
});
describe('legacy/non-accumulated summary handling', () => {
it('should return full summary if no phase headers present', () => {
const summary = `## Changes Made
- Created feature
- Added tests`;
const result = extractImplementationSummary(summary);
expect(result).toBe(summary);
});
it('should return null if summary has phase headers but no implementation', () => {
const summary = `### Testing
Tests pass
---
### Review
Review complete`;
const result = extractImplementationSummary(summary);
expect(result).toBeNull();
});
it('should not return full summary if it contains phase headers', () => {
const summary = `### Testing
Tests pass`;
const result = extractImplementationSummary(summary);
expect(result).toBeNull();
});
});
describe('edge cases', () => {
it('should return null for undefined summary', () => {
expect(extractImplementationSummary(undefined)).toBeNull();
});
it('should return null for empty string', () => {
expect(extractImplementationSummary('')).toBeNull();
});
it('should return null for whitespace-only string', () => {
expect(extractImplementationSummary(' \n\n ')).toBeNull();
});
});
});
describe('isAccumulatedSummary', () => {
describe('accumulated format detection', () => {
it('should return true for accumulated summary with separator and headers', () => {
const summary = `### Implementation
Content
---
### Testing
Content`;
expect(isAccumulatedSummary(summary)).toBe(true);
});
it('should return true for accumulated summary with multiple phases', () => {
const summary = `### Phase 1
Content 1
---
### Phase 2
Content 2
---
### Phase 3
Content 3`;
expect(isAccumulatedSummary(summary)).toBe(true);
});
it('should return true for accumulated summary with just one phase and separator', () => {
// Even a single phase with a separator suggests it's in accumulated format
const summary = `### Implementation
Content
---
### Testing
More content`;
expect(isAccumulatedSummary(summary)).toBe(true);
});
});
describe('non-accumulated format detection', () => {
it('should return false for summary without separator', () => {
const summary = `### Implementation
Just content`;
expect(isAccumulatedSummary(summary)).toBe(false);
});
it('should return false for summary with separator but no headers', () => {
const summary = `Content
---
More content`;
expect(isAccumulatedSummary(summary)).toBe(false);
});
it('should return false for simple text summary', () => {
const summary = 'Just a simple summary without any special formatting';
expect(isAccumulatedSummary(summary)).toBe(false);
});
it('should return false for markdown summary without phase headers', () => {
const summary = `## Changes Made
- Created feature
- Added tests`;
expect(isAccumulatedSummary(summary)).toBe(false);
});
});
describe('edge cases', () => {
it('should return false for undefined summary', () => {
expect(isAccumulatedSummary(undefined)).toBe(false);
});
it('should return false for null summary', () => {
expect(isAccumulatedSummary(null as unknown as string)).toBe(false);
});
it('should return false for empty string', () => {
expect(isAccumulatedSummary('')).toBe(false);
});
it('should return false for whitespace-only string', () => {
expect(isAccumulatedSummary(' \n\n ')).toBe(false);
});
});
});
describe('Integration: Full parsing workflow', () => {
it('should correctly parse typical server-accumulated pipeline summary', () => {
// This simulates what FeatureStateManager.saveFeatureSummary() produces
const summary = [
'### Implementation',
'',
'## Changes',
'- Added auth module',
'- Created user service',
'',
'---',
'',
'### Code Review',
'',
'## Review Results',
'- Style issues fixed',
'- Added error handling',
'',
'---',
'',
'### Testing',
'',
'## Test Results',
'- 42 tests pass',
'- 98% coverage',
].join('\n');
// Verify isAccumulatedSummary
expect(isAccumulatedSummary(summary)).toBe(true);
// Verify parsePhaseSummaries
const phases = parsePhaseSummaries(summary);
expect(phases.size).toBe(3);
expect(phases.get('implementation')).toContain('Added auth module');
expect(phases.get('code review')).toContain('Style issues fixed');
expect(phases.get('testing')).toContain('42 tests pass');
// Verify extractPhaseSummary
expect(extractPhaseSummary(summary, 'Implementation')).toContain('Added auth module');
expect(extractPhaseSummary(summary, 'Code Review')).toContain('Style issues fixed');
expect(extractPhaseSummary(summary, 'Testing')).toContain('42 tests pass');
// Verify extractImplementationSummary
expect(extractImplementationSummary(summary)).toContain('Added auth module');
});
it('should handle legacy non-pipeline summary correctly', () => {
// Legacy features have simple summaries without accumulation
const summary = `## Implementation Complete
- Created the feature
- All tests pass`;
// Should NOT be detected as accumulated
expect(isAccumulatedSummary(summary)).toBe(false);
// parsePhaseSummaries should return empty
const phases = parsePhaseSummaries(summary);
expect(phases.size).toBe(0);
// extractPhaseSummary should return null
expect(extractPhaseSummary(summary, 'Implementation')).toBeNull();
// extractImplementationSummary should return the full summary (legacy handling)
expect(extractImplementationSummary(summary)).toBe(summary);
});
it('should handle single-step pipeline summary', () => {
// A single pipeline step still gets the header but no separator
const summary = `### Implementation
## Changes
- Created the feature`;
// Should NOT be detected as accumulated (no separator)
expect(isAccumulatedSummary(summary)).toBe(false);
// parsePhaseSummaries should still extract the single phase
const phases = parsePhaseSummaries(summary);
expect(phases.size).toBe(1);
expect(phases.get('implementation')).toContain('Created the feature');
});
});
/**
* KEY ARCHITECTURE NOTES:
*
* 1. The accumulated summary format uses:
* - `### PhaseName` for step headers
* - `\n\n---\n\n` as separator between steps
*
* 2. Phase names are normalized to lowercase in the Map for case-insensitive lookup.
*
* 3. Legacy summaries (non-pipeline features) don't have phase headers and should
* be returned as-is by extractImplementationSummary.
*
* 4. isAccumulatedSummary() checks for BOTH separator AND phase headers to be
* confident that the summary is in the accumulated format.
*
* 5. The server-side FeatureStateManager.saveFeatureSummary() is responsible for
* creating summaries in this accumulated format.
*/
describe('parseAllPhaseSummaries', () => {
describe('basic parsing', () => {
it('should parse single phase summary into array with one entry', () => {
const summary = `### Implementation
## Changes Made
- Created new module
- Added unit tests`;
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(1);
expect(result[0].phaseName).toBe('Implementation');
expect(result[0].content).toBe('## Changes Made\n- Created new module\n- Added unit tests');
expect(result[0].header).toBe('### Implementation');
});
it('should parse multiple phase summaries in order', () => {
const summary = `### Implementation
## Changes Made
- Created new module
---
### Testing
## Test Results
- All tests pass`;
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(2);
// Verify order is preserved
expect(result[0].phaseName).toBe('Implementation');
expect(result[0].content).toBe('## Changes Made\n- Created new module');
expect(result[1].phaseName).toBe('Testing');
expect(result[1].content).toBe('## Test Results\n- All tests pass');
});
it('should parse three or more phases in correct order', () => {
const summary = `### Planning
Plan created
---
### Implementation
Code written
---
### Testing
Tests pass
---
### Refinement
Code polished`;
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(4);
expect(result[0].phaseName).toBe('Planning');
expect(result[1].phaseName).toBe('Implementation');
expect(result[2].phaseName).toBe('Testing');
expect(result[3].phaseName).toBe('Refinement');
});
});
describe('non-accumulated summary handling', () => {
it('should return single entry for summary without phase headers', () => {
const summary = `## Changes Made
- Created feature
- Added tests`;
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(1);
expect(result[0].phaseName).toBe('Summary');
expect(result[0].content).toBe(summary);
});
it('should return single entry for simple text summary', () => {
const summary = 'Just a simple summary without any special formatting';
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(1);
expect(result[0].phaseName).toBe('Summary');
expect(result[0].content).toBe(summary);
});
});
describe('edge cases', () => {
it('should return empty array for undefined summary', () => {
const result = parseAllPhaseSummaries(undefined);
expect(result.length).toBe(0);
});
it('should return empty array for empty string', () => {
const result = parseAllPhaseSummaries('');
expect(result.length).toBe(0);
});
it('should return empty array for whitespace-only string', () => {
const result = parseAllPhaseSummaries(' \n\n ');
expect(result.length).toBe(0);
});
it('should handle section without header after separator', () => {
const summary = `### Implementation
Content here
---
This section has no header`;
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(1);
expect(result[0].phaseName).toBe('Implementation');
});
});
describe('content preservation', () => {
it('should preserve markdown formatting in content', () => {
const summary = `### Implementation
## Heading
- **Bold text**
- \`code\`
\`\`\`typescript
const x = 1;
\`\`\``;
const result = parseAllPhaseSummaries(summary);
const content = result[0].content;
expect(content).toContain('**Bold text**');
expect(content).toContain('`code`');
expect(content).toContain('```typescript');
});
it('should preserve unicode in content', () => {
const summary = `### Testing
Results: ✅ 42 passed, ❌ 0 failed`;
const result = parseAllPhaseSummaries(summary);
expect(result[0].content).toContain('✅');
expect(result[0].content).toContain('❌');
});
it('should preserve tables in content', () => {
const summary = `### Testing
| Test | Result |
|------|--------|
| Unit | Pass |`;
const result = parseAllPhaseSummaries(summary);
expect(result[0].content).toContain('| Test | Result |');
});
it('should handle empty phase content', () => {
const summary = `### Implementation
---
### Testing
Content`;
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(2);
expect(result[0].content).toBe('');
expect(result[1].content).toBe('Content');
});
});
describe('header preservation', () => {
it('should preserve original header text', () => {
const summary = `### Code Review
Content`;
const result = parseAllPhaseSummaries(summary);
expect(result[0].header).toBe('### Code Review');
});
it('should preserve phase name with original casing', () => {
const summary = `### CODE REVIEW
Content`;
const result = parseAllPhaseSummaries(summary);
expect(result[0].phaseName).toBe('CODE REVIEW');
});
});
describe('chronological order preservation', () => {
it('should maintain order: Alpha before Beta before Gamma', () => {
const summary = `### Alpha
First
---
### Beta
Second
---
### Gamma
Third`;
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(3);
const names = result.map((e) => e.phaseName);
expect(names).toEqual(['Alpha', 'Beta', 'Gamma']);
});
it('should preserve typical pipeline order', () => {
const summary = [
'### Implementation',
'',
'## Changes',
'- Added auth module',
'',
'---',
'',
'### Code Review',
'',
'## Review Results',
'- Style issues fixed',
'',
'---',
'',
'### Testing',
'',
'## Test Results',
'- 42 tests pass',
].join('\n');
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(3);
expect(result[0].phaseName).toBe('Implementation');
expect(result[1].phaseName).toBe('Code Review');
expect(result[2].phaseName).toBe('Testing');
});
});
});

View File

@@ -0,0 +1,453 @@
/**
* Unit tests for the UI's log-parser extractSummary() function.
*
* These tests document the behavior of extractSummary() which is used as a
* CLIENT-SIDE FALLBACK when feature.summary (server-accumulated) is not available.
*
* IMPORTANT: extractSummary() returns only the LAST <summary> tag from raw output.
* For pipeline features with multiple steps, the server-side FeatureStateManager
* accumulates all step summaries into feature.summary, which the UI prefers.
*
* The tests below verify that extractSummary() correctly:
* - Returns the LAST summary when multiple exist (mimicking pipeline accumulation)
* - Handles various summary formats (<summary> tags, markdown headers)
* - Returns null when no summary is found
* - Handles edge cases like empty input and malformed tags
*/
import { describe, it, expect } from 'vitest';
// Recreate the extractSummary logic from apps/ui/src/lib/log-parser.ts
// We can't import directly because it's a UI file, so we mirror the logic here
/**
* Cleans up fragmented streaming text by removing spurious newlines
*/
function cleanFragmentedText(content: string): string {
let cleaned = content.replace(/([a-zA-Z])\n+([a-zA-Z])/g, '$1$2');
cleaned = cleaned.replace(/<([a-zA-Z]+)\n*([a-zA-Z]*)\n*>/g, '<$1$2>');
cleaned = cleaned.replace(/<\/([a-zA-Z]+)\n*([a-zA-Z]*)\n*>/g, '</$1$2>');
return cleaned;
}
/**
* Extracts summary content from raw log output
* Returns the LAST summary text if found, or null if no summary exists
*/
function extractSummary(rawOutput: string): string | null {
if (!rawOutput || !rawOutput.trim()) {
return null;
}
const cleanedOutput = cleanFragmentedText(rawOutput);
const regexesToTry: Array<{
regex: RegExp;
processor: (m: RegExpMatchArray) => string;
}> = [
{ regex: /<summary>([\s\S]*?)<\/summary>/gi, processor: (m) => m[1] },
{ regex: /^##\s+Summary[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm, processor: (m) => m[1] },
{
regex: /^##\s+(Feature|Changes|Implementation)[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm,
processor: (m) => `## ${m[1]}\n${m[2]}`,
},
{
regex: /(^|\n)(All tasks completed[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/g,
processor: (m) => m[2],
},
{
regex:
/(^|\n)((I've|I have) (successfully |now )?(completed|finished|implemented)[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/g,
processor: (m) => m[2],
},
];
for (const { regex, processor } of regexesToTry) {
const matches = [...cleanedOutput.matchAll(regex)];
if (matches.length > 0) {
const lastMatch = matches[matches.length - 1];
return cleanFragmentedText(processor(lastMatch)).trim();
}
}
return null;
}
describe('log-parser extractSummary (UI fallback)', () => {
describe('basic summary extraction', () => {
it('should extract summary from <summary> tags', () => {
const output = `
Some agent output...
<summary>
## Changes Made
- Fixed the bug in parser.ts
- Added error handling
</summary>
More output...
`;
const result = extractSummary(output);
expect(result).toBe('## Changes Made\n- Fixed the bug in parser.ts\n- Added error handling');
});
it('should prefer <summary> tags over markdown headers', () => {
const output = `
## Summary
Markdown summary here.
<summary>
XML summary here.
</summary>
`;
const result = extractSummary(output);
expect(result).toBe('XML summary here.');
});
});
describe('multiple summaries (pipeline accumulation scenario)', () => {
it('should return ONLY the LAST summary tag when multiple exist', () => {
// This is the key behavior for pipeline features:
// extractSummary returns only the LAST, which is why server-side
// accumulation is needed for multi-step pipelines
const output = `
## Step 1: Code Review
<summary>
- Found 3 issues
- Approved with changes
</summary>
---
## Step 2: Testing
<summary>
- All tests pass
- Coverage 95%
</summary>
`;
const result = extractSummary(output);
expect(result).toBe('- All tests pass\n- Coverage 95%');
expect(result).not.toContain('Code Review');
expect(result).not.toContain('Found 3 issues');
});
it('should return ONLY the LAST summary from three pipeline steps', () => {
const output = `
<summary>Step 1 complete</summary>
---
<summary>Step 2 complete</summary>
---
<summary>Step 3 complete - all done!</summary>
`;
const result = extractSummary(output);
expect(result).toBe('Step 3 complete - all done!');
expect(result).not.toContain('Step 1');
expect(result).not.toContain('Step 2');
});
it('should handle mixed summary formats across pipeline steps', () => {
const output = `
## Step 1
<summary>
Implementation done
</summary>
---
## Step 2
## Summary
Review complete
---
## Step 3
<summary>
All tests passing
</summary>
`;
const result = extractSummary(output);
// The <summary> tag format takes priority, and returns the LAST match
expect(result).toBe('All tests passing');
});
});
describe('priority order of summary patterns', () => {
it('should try patterns in priority order: <summary> first, then markdown headers', () => {
// When both <summary> tags and markdown headers exist,
// <summary> tags should take priority
const output = `
## Summary
This markdown summary should be ignored.
<summary>
This XML summary should be used.
</summary>
`;
const result = extractSummary(output);
expect(result).toBe('This XML summary should be used.');
expect(result).not.toContain('ignored');
});
it('should fall back to Feature/Changes/Implementation headers when no <summary> tag', () => {
// Note: The regex for these headers requires content before the header
// (^ at start or preceded by newline). Adding some content before.
const output = `
Agent output here...
## Feature
New authentication system with OAuth support.
## Next
`;
const result = extractSummary(output);
// Should find the Feature header and include it in result
// Note: Due to regex behavior, it captures content until next ##
expect(result).toContain('## Feature');
});
it('should fall back to completion phrases when no structured summary found', () => {
const output = `
Working on the feature...
Making progress...
All tasks completed successfully. The feature is ready.
🔧 Tool: Bash
`;
const result = extractSummary(output);
expect(result).toContain('All tasks completed');
});
});
describe('edge cases', () => {
it('should return null for empty string', () => {
expect(extractSummary('')).toBeNull();
});
it('should return null for whitespace-only string', () => {
expect(extractSummary(' \n\n ')).toBeNull();
});
it('should return null when no summary pattern found', () => {
expect(extractSummary('Random agent output without any summary patterns')).toBeNull();
});
it('should handle malformed <summary> tags gracefully', () => {
const output = `
<summary>
This summary is never closed...
`;
// Without closing tag, the regex won't match
expect(extractSummary(output)).toBeNull();
});
it('should handle empty <summary> tags', () => {
const output = `
<summary></summary>
`;
const result = extractSummary(output);
expect(result).toBe(''); // Empty string is valid
});
it('should handle <summary> tags with only whitespace', () => {
const output = `
<summary>
</summary>
`;
const result = extractSummary(output);
expect(result).toBe(''); // Trimmed to empty string
});
it('should handle summary with markdown code blocks', () => {
const output = `
<summary>
## Changes
\`\`\`typescript
const x = 1;
\`\`\`
Done!
</summary>
`;
const result = extractSummary(output);
expect(result).toContain('```typescript');
expect(result).toContain('const x = 1;');
});
it('should handle summary with special characters', () => {
const output = `
<summary>
Fixed bug in parser.ts: "quotes" and 'apostrophes'
Special chars: <>&$@#%^*
</summary>
`;
const result = extractSummary(output);
expect(result).toContain('"quotes"');
expect(result).toContain('<>&$@#%^*');
});
});
describe('fragmented streaming text handling', () => {
it('should handle fragmented <summary> tags from streaming', () => {
// Sometimes streaming providers split text like "<sum\n\nmary>"
const output = `
<sum
mary>
Fixed the issue
</sum
mary>
`;
const result = extractSummary(output);
// The cleanFragmentedText function should normalize this
expect(result).toBe('Fixed the issue');
});
it('should handle fragmented text within summary content', () => {
const output = `
<summary>
Fixed the bug in par
ser.ts
</summary>
`;
const result = extractSummary(output);
// cleanFragmentedText should join "par\n\nser" into "parser"
expect(result).toBe('Fixed the bug in parser.ts');
});
});
describe('completion phrase detection', () => {
it('should extract "All tasks completed" summaries', () => {
const output = `
Some output...
All tasks completed successfully. The feature is ready for review.
🔧 Tool: Bash
`;
const result = extractSummary(output);
expect(result).toContain('All tasks completed');
});
it("should extract I've completed summaries", () => {
const output = `
Working on feature...
I've successfully implemented the feature with all requirements met.
🔧 Tool: Read
`;
const result = extractSummary(output);
expect(result).toContain("I've successfully implemented");
});
it('should extract "I have finished" summaries', () => {
const output = `
Implementation phase...
I have finished the implementation.
📋 Planning
`;
const result = extractSummary(output);
expect(result).toContain('I have finished');
});
});
describe('real-world pipeline scenarios', () => {
it('should handle typical multi-step pipeline output (returns last only)', () => {
// This test documents WHY server-side accumulation is essential:
// extractSummary only returns the last step's summary
const output = `
📋 Planning Mode: Full
🔧 Tool: Read
Input: {"file_path": "src/parser.ts"}
<summary>
## Code Review
- Analyzed parser.ts
- Found potential improvements
</summary>
---
## Follow-up Session
🔧 Tool: Edit
Input: {"file_path": "src/parser.ts"}
<summary>
## Implementation
- Applied suggested improvements
- Updated tests
</summary>
---
## Follow-up Session
🔧 Tool: Bash
Input: {"command": "npm test"}
<summary>
## Testing
- All 42 tests pass
- No regressions detected
</summary>
`;
const result = extractSummary(output);
// Only the LAST summary is returned
expect(result).toBe('## Testing\n- All 42 tests pass\n- No regressions detected');
// Earlier summaries are lost
expect(result).not.toContain('Code Review');
expect(result).not.toContain('Implementation');
});
it('should handle single-step non-pipeline output', () => {
// For non-pipeline features, extractSummary works correctly
const output = `
Working on feature...
<summary>
## Implementation Complete
- Created new component
- Added unit tests
- Updated documentation
</summary>
`;
const result = extractSummary(output);
expect(result).toContain('Implementation Complete');
expect(result).toContain('Created new component');
});
});
});
/**
* These tests verify the UI fallback behavior for summary extraction.
*
* KEY INSIGHT: The extractSummary() function returns only the LAST summary,
* which is why the server-side FeatureStateManager.saveFeatureSummary() method
* accumulates all step summaries into feature.summary.
*
* The UI's AgentOutputModal component uses this priority:
* 1. feature.summary (server-accumulated, contains all steps)
* 2. extractSummary(output) (client-side fallback, last summary only)
*
* For pipeline features, this ensures all step summaries are displayed.
*/

View File

@@ -0,0 +1,533 @@
/**
* Unit tests for the UI's log-parser phase summary parsing functions.
*
* These tests verify the behavior of:
* - parsePhaseSummaries(): Parses accumulated summary into individual phases
* - extractPhaseSummary(): Extracts a specific phase's summary
* - extractImplementationSummary(): Extracts only the implementation phase
* - isAccumulatedSummary(): Checks if summary is in accumulated format
*
* The accumulated summary format uses markdown headers with `###` for phase names
* and `---` as separators between phases.
*
* TODO: These test helper functions are mirrored from apps/ui/src/lib/log-parser.ts
* because server-side tests cannot import from the UI module. If the production
* implementation changes, these tests may pass while production fails.
* Consider adding an integration test that validates the actual UI parsing behavior.
*/
import { describe, it, expect } from 'vitest';
// ============================================================================
// MIRRORED FUNCTIONS from apps/ui/src/lib/log-parser.ts
// ============================================================================
// NOTE: These functions are mirrored from the UI implementation because
// server-side tests cannot import from apps/ui/. Keep these in sync with the
// production implementation. The UI implementation includes additional
// handling for getPhaseSections/leadingImplementationSection for backward
// compatibility with mixed formats.
/**
* Parses an accumulated summary string into individual phase summaries.
*/
function parsePhaseSummaries(summary: string | undefined): Map<string, string> {
const phaseSummaries = new Map<string, string>();
if (!summary || !summary.trim()) {
return phaseSummaries;
}
// Split by the horizontal rule separator
const sections = summary.split(/\n\n---\n\n/);
for (const section of sections) {
// Match the phase header pattern: ### Phase Name
const headerMatch = section.match(/^###\s+(.+?)(?:\n|$)/);
if (headerMatch) {
const phaseName = headerMatch[1].trim().toLowerCase();
// Extract content after the header (skip the header line and leading newlines)
const content = section.substring(headerMatch[0].length).trim();
phaseSummaries.set(phaseName, content);
}
}
return phaseSummaries;
}
/**
* Extracts a specific phase summary from an accumulated summary string.
*/
function extractPhaseSummary(summary: string | undefined, phaseName: string): string | null {
const phaseSummaries = parsePhaseSummaries(summary);
const normalizedPhaseName = phaseName.toLowerCase();
return phaseSummaries.get(normalizedPhaseName) || null;
}
/**
* Gets the implementation phase summary from an accumulated summary string.
*/
function extractImplementationSummary(summary: string | undefined): string | null {
if (!summary || !summary.trim()) {
return null;
}
const phaseSummaries = parsePhaseSummaries(summary);
// Try exact match first
const implementationContent = phaseSummaries.get('implementation');
if (implementationContent) {
return implementationContent;
}
// Fallback: find any phase containing "implement"
for (const [phaseName, content] of phaseSummaries) {
if (phaseName.includes('implement')) {
return content;
}
}
// If no phase summaries found, the summary might not be in accumulated format
// (legacy or non-pipeline feature). In this case, return the whole summary
// if it looks like a single summary (no phase headers).
if (!summary.includes('### ') && !summary.includes('\n---\n')) {
return summary;
}
return null;
}
/**
* Checks if a summary string is in the accumulated multi-phase format.
*/
function isAccumulatedSummary(summary: string | undefined): boolean {
if (!summary || !summary.trim()) {
return false;
}
// Check for the presence of phase headers with separator
const hasMultiplePhases =
summary.includes('\n\n---\n\n') && summary.match(/###\s+.+/g)?.length > 0;
return hasMultiplePhases;
}
describe('phase summary parser', () => {
describe('parsePhaseSummaries', () => {
it('should parse single phase summary', () => {
const summary = `### Implementation
Created auth module with login functionality.`;
const result = parsePhaseSummaries(summary);
expect(result.size).toBe(1);
expect(result.get('implementation')).toBe('Created auth module with login functionality.');
});
it('should parse multiple phase summaries', () => {
const summary = `### Implementation
Created auth module.
---
### Testing
All tests pass.
---
### Code Review
Approved with minor suggestions.`;
const result = parsePhaseSummaries(summary);
expect(result.size).toBe(3);
expect(result.get('implementation')).toBe('Created auth module.');
expect(result.get('testing')).toBe('All tests pass.');
expect(result.get('code review')).toBe('Approved with minor suggestions.');
});
it('should handle empty input', () => {
expect(parsePhaseSummaries('').size).toBe(0);
expect(parsePhaseSummaries(undefined).size).toBe(0);
expect(parsePhaseSummaries(' \n\n ').size).toBe(0);
});
it('should handle phase names with spaces', () => {
const summary = `### Code Review
Review findings here.`;
const result = parsePhaseSummaries(summary);
expect(result.get('code review')).toBe('Review findings here.');
});
it('should normalize phase names to lowercase', () => {
const summary = `### IMPLEMENTATION
Content here.`;
const result = parsePhaseSummaries(summary);
expect(result.get('implementation')).toBe('Content here.');
expect(result.get('IMPLEMENTATION')).toBeUndefined();
});
it('should handle content with markdown', () => {
const summary = `### Implementation
## Changes Made
- Fixed bug in parser.ts
- Added error handling
\`\`\`typescript
const x = 1;
\`\`\``;
const result = parsePhaseSummaries(summary);
expect(result.get('implementation')).toContain('## Changes Made');
expect(result.get('implementation')).toContain('```typescript');
});
it('should return empty map for non-accumulated format', () => {
// Legacy format without phase headers
const summary = `## Summary
This is a simple summary without phase headers.`;
const result = parsePhaseSummaries(summary);
expect(result.size).toBe(0);
});
});
describe('extractPhaseSummary', () => {
it('should extract specific phase by name (case-insensitive)', () => {
const summary = `### Implementation
Implementation content.
---
### Testing
Testing content.`;
expect(extractPhaseSummary(summary, 'implementation')).toBe('Implementation content.');
expect(extractPhaseSummary(summary, 'IMPLEMENTATION')).toBe('Implementation content.');
expect(extractPhaseSummary(summary, 'Implementation')).toBe('Implementation content.');
expect(extractPhaseSummary(summary, 'testing')).toBe('Testing content.');
});
it('should return null for non-existent phase', () => {
const summary = `### Implementation
Content here.`;
expect(extractPhaseSummary(summary, 'code review')).toBeNull();
});
it('should return null for empty input', () => {
expect(extractPhaseSummary('', 'implementation')).toBeNull();
expect(extractPhaseSummary(undefined, 'implementation')).toBeNull();
});
});
describe('extractImplementationSummary', () => {
it('should extract implementation phase from accumulated summary', () => {
const summary = `### Implementation
Created auth module.
---
### Testing
All tests pass.
---
### Code Review
Approved.`;
const result = extractImplementationSummary(summary);
expect(result).toBe('Created auth module.');
expect(result).not.toContain('Testing');
expect(result).not.toContain('Code Review');
});
it('should return implementation phase even when not first', () => {
const summary = `### Planning
Plan created.
---
### Implementation
Implemented the feature.
---
### Review
Reviewed.`;
const result = extractImplementationSummary(summary);
expect(result).toBe('Implemented the feature.');
});
it('should handle phase with "implementation" in name', () => {
const summary = `### Feature Implementation
Built the feature.`;
const result = extractImplementationSummary(summary);
expect(result).toBe('Built the feature.');
});
it('should return full summary for non-accumulated format (legacy)', () => {
// Non-pipeline features store summary without phase headers
const summary = `## Changes
- Fixed bug
- Added tests`;
const result = extractImplementationSummary(summary);
expect(result).toBe(summary);
});
it('should return null for empty input', () => {
expect(extractImplementationSummary('')).toBeNull();
expect(extractImplementationSummary(undefined)).toBeNull();
expect(extractImplementationSummary(' \n\n ')).toBeNull();
});
it('should return null when no implementation phase in accumulated summary', () => {
const summary = `### Testing
Tests written.
---
### Code Review
Approved.`;
const result = extractImplementationSummary(summary);
expect(result).toBeNull();
});
});
describe('isAccumulatedSummary', () => {
it('should return true for accumulated multi-phase summary', () => {
const summary = `### Implementation
Content.
---
### Testing
Content.`;
expect(isAccumulatedSummary(summary)).toBe(true);
});
it('should return false for single phase summary (no separator)', () => {
const summary = `### Implementation
Content.`;
expect(isAccumulatedSummary(summary)).toBe(false);
});
it('should return false for legacy non-accumulated format', () => {
const summary = `## Summary
This is a simple summary.`;
expect(isAccumulatedSummary(summary)).toBe(false);
});
it('should return false for empty input', () => {
expect(isAccumulatedSummary('')).toBe(false);
expect(isAccumulatedSummary(undefined)).toBe(false);
expect(isAccumulatedSummary(' \n\n ')).toBe(false);
});
it('should return true even for two phases', () => {
const summary = `### Implementation
Content A.
---
### Code Review
Content B.`;
expect(isAccumulatedSummary(summary)).toBe(true);
});
});
describe('acceptance criteria scenarios', () => {
it('AC1: Implementation summary preserved when Testing completes', () => {
// Given a task card completes the Implementation phase,
// when the Testing phase subsequently completes,
// then the Implementation phase summary must remain stored independently
const summary = `### Implementation
- Created auth module
- Added user service
---
### Testing
- 42 tests pass
- 98% coverage`;
const impl = extractImplementationSummary(summary);
const testing = extractPhaseSummary(summary, 'testing');
expect(impl).toBe('- Created auth module\n- Added user service');
expect(testing).toBe('- 42 tests pass\n- 98% coverage');
expect(impl).not.toContain('Testing');
expect(testing).not.toContain('auth module');
});
it('AC4: Implementation Summary tab shows only implementation phase', () => {
// Given a task card has completed the Implementation phase
// (regardless of how many subsequent phases have run),
// when the user opens the "Implementation Summary" tab,
// then it must display only the summary produced by the Implementation phase
const summary = `### Implementation
Implementation phase output here.
---
### Testing
Testing phase output here.
---
### Code Review
Code review output here.`;
const impl = extractImplementationSummary(summary);
expect(impl).toBe('Implementation phase output here.');
expect(impl).not.toContain('Testing');
expect(impl).not.toContain('Code Review');
});
it('AC5: Empty state when implementation not started', () => {
// Given a task card has not yet started the Implementation phase
const summary = `### Planning
Planning phase complete.`;
const impl = extractImplementationSummary(summary);
// Should return null (UI shows "No implementation summary available")
expect(impl).toBeNull();
});
it('AC6: Single phase summary displayed correctly', () => {
// Given a task card where Implementation was the only completed phase
const summary = `### Implementation
Only implementation was done.`;
const impl = extractImplementationSummary(summary);
expect(impl).toBe('Only implementation was done.');
});
it('AC9: Mid-progress shows only completed phases', () => {
// Given a task card is mid-progress
// (e.g., Implementation and Testing complete, Code Review pending)
const summary = `### Implementation
Implementation done.
---
### Testing
Testing done.`;
const phases = parsePhaseSummaries(summary);
expect(phases.size).toBe(2);
expect(phases.has('implementation')).toBe(true);
expect(phases.has('testing')).toBe(true);
expect(phases.has('code review')).toBe(false);
});
it('AC10: All phases in chronological order', () => {
// Given all phases of a task card are complete
const summary = `### Implementation
First phase content.
---
### Testing
Second phase content.
---
### Code Review
Third phase content.`;
// ParsePhaseSummaries should preserve order
const phases = parsePhaseSummaries(summary);
const phaseNames = [...phases.keys()];
expect(phaseNames).toEqual(['implementation', 'testing', 'code review']);
});
it('AC17: Retried phase shows only latest', () => {
// Given a phase was retried, when viewing the Summary tab,
// only one entry for the retried phase must appear (the latest retry's summary)
//
// Note: The server-side FeatureStateManager overwrites the phase summary
// when the same phase runs again, so we only have one entry per phase name.
// This test verifies that the parser correctly handles this.
const summary = `### Implementation
First attempt content.
---
### Testing
First test run.
---
### Implementation
Retry content - fixed issues.
---
### Testing
Retry - all tests now pass.`;
const phases = parsePhaseSummaries(summary);
// The parser will have both entries, but Map keeps last value for same key
expect(phases.get('implementation')).toBe('Retry content - fixed issues.');
expect(phases.get('testing')).toBe('Retry - all tests now pass.');
});
});
});

View File

@@ -0,0 +1,238 @@
/**
* Unit tests for the summary auto-scroll detection logic.
*
* These tests verify the behavior of the scroll detection function used in
* AgentOutputModal to determine if auto-scroll should be enabled.
*
* The logic mirrors the handleSummaryScroll function in:
* apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx
*
* Auto-scroll behavior:
* - When user is at or near the bottom (< 50px from bottom), auto-scroll is enabled
* - When user scrolls up to view older content, auto-scroll is disabled
* - Scrolling back to bottom re-enables auto-scroll
*/
import { describe, it, expect } from 'vitest';
/**
* Determines if the scroll position is at the bottom of the container.
* This is the core logic from handleSummaryScroll in AgentOutputModal.
*
* @param scrollTop - Current scroll position from top
* @param scrollHeight - Total scrollable height
* @param clientHeight - Visible height of the container
* @param threshold - Distance from bottom to consider "at bottom" (default: 50px)
* @returns true if at bottom, false otherwise
*/
function isScrollAtBottom(
scrollTop: number,
scrollHeight: number,
clientHeight: number,
threshold = 50
): boolean {
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
return distanceFromBottom < threshold;
}
describe('Summary Auto-Scroll Detection Logic', () => {
describe('basic scroll position detection', () => {
it('should return true when scrolled to exact bottom', () => {
// Container: 500px tall, content: 1000px tall
// ScrollTop: 500 (scrolled to bottom)
const result = isScrollAtBottom(500, 1000, 500);
expect(result).toBe(true);
});
it('should return true when near bottom (within threshold)', () => {
// 49px from bottom - within 50px threshold
const result = isScrollAtBottom(451, 1000, 500);
expect(result).toBe(true);
});
it('should return true when exactly at threshold boundary (49px)', () => {
// 49px from bottom
const result = isScrollAtBottom(451, 1000, 500);
expect(result).toBe(true);
});
it('should return false when just outside threshold (51px)', () => {
// 51px from bottom - outside 50px threshold
const result = isScrollAtBottom(449, 1000, 500);
expect(result).toBe(false);
});
it('should return false when scrolled to top', () => {
const result = isScrollAtBottom(0, 1000, 500);
expect(result).toBe(false);
});
it('should return false when scrolled to middle', () => {
const result = isScrollAtBottom(250, 1000, 500);
expect(result).toBe(false);
});
});
describe('edge cases with small content', () => {
it('should return true when content fits in viewport (no scroll needed)', () => {
// Content is smaller than container - no scrolling possible
const result = isScrollAtBottom(0, 300, 500);
expect(result).toBe(true);
});
it('should return true when content exactly fits viewport', () => {
const result = isScrollAtBottom(0, 500, 500);
expect(result).toBe(true);
});
it('should return true when content slightly exceeds viewport (within threshold)', () => {
// Content: 540px, Viewport: 500px, can scroll 40px
// At scroll 0, we're 40px from bottom - within threshold
const result = isScrollAtBottom(0, 540, 500);
expect(result).toBe(true);
});
});
describe('large content scenarios', () => {
it('should correctly detect bottom in very long content', () => {
// Simulate accumulated summary from many pipeline steps
// Content: 10000px, Viewport: 500px
const result = isScrollAtBottom(9500, 10000, 500);
expect(result).toBe(true);
});
it('should correctly detect non-bottom in very long content', () => {
// User scrolled up to read earlier summaries
const result = isScrollAtBottom(5000, 10000, 500);
expect(result).toBe(false);
});
it('should detect when user scrolls up from bottom', () => {
// Started at bottom (scroll: 9500), then scrolled up 100px
const result = isScrollAtBottom(9400, 10000, 500);
expect(result).toBe(false);
});
});
describe('custom threshold values', () => {
it('should work with larger threshold (100px)', () => {
// 75px from bottom - within 100px threshold
const result = isScrollAtBottom(425, 1000, 500, 100);
expect(result).toBe(true);
});
it('should work with smaller threshold (10px)', () => {
// 15px from bottom - outside 10px threshold
const result = isScrollAtBottom(485, 1000, 500, 10);
expect(result).toBe(false);
});
it('should work with zero threshold (exact match only)', () => {
// At exact bottom - distanceFromBottom = 0, which is NOT < 0 with strict comparison
// This is an edge case: the implementation uses < not <=
const result = isScrollAtBottom(500, 1000, 500, 0);
expect(result).toBe(false); // 0 < 0 is false
// 1px from bottom - also fails
const result2 = isScrollAtBottom(499, 1000, 500, 0);
expect(result2).toBe(false);
// For exact match with 0 threshold, we need negative distanceFromBottom
// which happens when scrollTop > scrollHeight - clientHeight (overscroll)
const result3 = isScrollAtBottom(501, 1000, 500, 0);
expect(result3).toBe(true); // -1 < 0 is true
});
});
describe('pipeline summary scrolling scenarios', () => {
it('should enable auto-scroll when new content arrives while at bottom', () => {
// User is at bottom viewing step 2 summary
// Step 3 summary is added, increasing scrollHeight from 1000 to 1500
// ScrollTop stays at 950 (was at bottom), but now user needs to scroll
// Before new content: isScrollAtBottom(950, 1000, 500) = true
// After new content: auto-scroll should kick in to scroll to new bottom
// Simulating the auto-scroll effect setting scrollTop to new bottom
const newScrollTop = 1500 - 500; // scrollHeight - clientHeight
const result = isScrollAtBottom(newScrollTop, 1500, 500);
expect(result).toBe(true);
});
it('should not auto-scroll when user is reading earlier summaries', () => {
// User scrolled up to read step 1 summary while step 3 is added
// scrollHeight increases, but scrollTop stays same
// User is now further from bottom
// User was at scroll position 200 (reading early content)
// New content increases scrollHeight from 1000 to 1500
// Distance from bottom goes from 300 to 800
const result = isScrollAtBottom(200, 1500, 500);
expect(result).toBe(false);
});
it('should re-enable auto-scroll when user scrolls back to bottom', () => {
// User was reading step 1 (scrollTop: 200)
// User scrolls back to bottom to see latest content
const result = isScrollAtBottom(1450, 1500, 500);
expect(result).toBe(true);
});
});
describe('decimal scroll values', () => {
it('should handle fractional scroll positions', () => {
// Browsers can report fractional scroll values
const result = isScrollAtBottom(499.5, 1000, 500);
expect(result).toBe(true);
});
it('should handle fractional scroll heights', () => {
const result = isScrollAtBottom(450.7, 1000.3, 500);
expect(result).toBe(true);
});
});
describe('negative and invalid inputs', () => {
it('should handle negative scrollTop (bounce scroll)', () => {
// iOS can report negative scrollTop during bounce
const result = isScrollAtBottom(-10, 1000, 500);
expect(result).toBe(false);
});
it('should handle zero scrollHeight', () => {
// Empty content
const result = isScrollAtBottom(0, 0, 500);
expect(result).toBe(true);
});
it('should handle zero clientHeight', () => {
// Hidden container - distanceFromBottom = 1000 - 0 - 0 = 1000
// This is not < threshold, so returns false
// This edge case represents a broken/invisible container
const result = isScrollAtBottom(0, 1000, 0);
expect(result).toBe(false);
});
});
describe('real-world accumulated summary dimensions', () => {
it('should handle typical 3-step pipeline summary dimensions', () => {
// Approximate: 3 steps x ~800px each = ~2400px
// Viewport: 400px (modal height)
const result = isScrollAtBottom(2000, 2400, 400);
expect(result).toBe(true);
});
it('should handle large 10-step pipeline summary dimensions', () => {
// Approximate: 10 steps x ~800px each = ~8000px
// Viewport: 400px
const result = isScrollAtBottom(7600, 8000, 400);
expect(result).toBe(true);
});
it('should detect scroll to top of large summary', () => {
// User at top of 10-step summary
const result = isScrollAtBottom(0, 8000, 400);
expect(result).toBe(false);
});
});
});

View File

@@ -0,0 +1,128 @@
/**
* Unit tests for summary normalization between UI components and parser functions.
*
* These tests verify that:
* - getFirstNonEmptySummary returns string | null
* - parseAllPhaseSummaries and isAccumulatedSummary expect string | undefined
* - The normalization (summary ?? undefined) correctly converts null to undefined
*
* This ensures the UI components properly bridge the type gap between:
* - getFirstNonEmptySummary (returns string | null)
* - parseAllPhaseSummaries (expects string | undefined)
* - isAccumulatedSummary (expects string | undefined)
*/
import { describe, it, expect } from 'vitest';
import { parseAllPhaseSummaries, isAccumulatedSummary } from '../../../../ui/src/lib/log-parser.ts';
import { getFirstNonEmptySummary } from '../../../../ui/src/lib/summary-selection.ts';
describe('Summary Normalization', () => {
describe('getFirstNonEmptySummary', () => {
it('should return the first non-empty string', () => {
const result = getFirstNonEmptySummary(null, undefined, 'valid summary', 'another');
expect(result).toBe('valid summary');
});
it('should return null when all candidates are empty', () => {
const result = getFirstNonEmptySummary(null, undefined, '', ' ');
expect(result).toBeNull();
});
it('should return null when no candidates provided', () => {
const result = getFirstNonEmptySummary();
expect(result).toBeNull();
});
it('should return null for all null/undefined candidates', () => {
const result = getFirstNonEmptySummary(null, undefined, null);
expect(result).toBeNull();
});
it('should preserve original string formatting (not trim)', () => {
const result = getFirstNonEmptySummary(' summary with spaces ');
expect(result).toBe(' summary with spaces ');
});
});
describe('parseAllPhaseSummaries with normalized input', () => {
it('should handle null converted to undefined via ?? operator', () => {
const summary = getFirstNonEmptySummary(null, undefined);
// This is the normalization: summary ?? undefined
const normalizedSummary = summary ?? undefined;
// TypeScript should accept this without error
const result = parseAllPhaseSummaries(normalizedSummary);
expect(result).toEqual([]);
});
it('should parse accumulated summary when non-null is normalized', () => {
const rawSummary =
'### Implementation\n\nDid some work\n\n---\n\n### Testing\n\nAll tests pass';
const summary = getFirstNonEmptySummary(null, rawSummary);
const normalizedSummary = summary ?? undefined;
const result = parseAllPhaseSummaries(normalizedSummary);
expect(result).toHaveLength(2);
expect(result[0].phaseName).toBe('Implementation');
expect(result[1].phaseName).toBe('Testing');
});
});
describe('isAccumulatedSummary with normalized input', () => {
it('should return false for null converted to undefined', () => {
const summary = getFirstNonEmptySummary(null, undefined);
const normalizedSummary = summary ?? undefined;
const result = isAccumulatedSummary(normalizedSummary);
expect(result).toBe(false);
});
it('should return true for valid accumulated summary after normalization', () => {
const rawSummary =
'### Implementation\n\nDid some work\n\n---\n\n### Testing\n\nAll tests pass';
const summary = getFirstNonEmptySummary(rawSummary);
const normalizedSummary = summary ?? undefined;
const result = isAccumulatedSummary(normalizedSummary);
expect(result).toBe(true);
});
it('should return false for single-phase summary after normalization', () => {
const rawSummary = '### Implementation\n\nDid some work';
const summary = getFirstNonEmptySummary(rawSummary);
const normalizedSummary = summary ?? undefined;
const result = isAccumulatedSummary(normalizedSummary);
expect(result).toBe(false);
});
});
describe('Type safety verification', () => {
it('should demonstrate that null must be normalized to undefined', () => {
// This test documents the type mismatch that requires normalization
const summary: string | null = getFirstNonEmptySummary(null);
const normalizedSummary: string | undefined = summary ?? undefined;
// parseAllPhaseSummaries expects string | undefined, not string | null
// The normalization converts null -> undefined, which is compatible
const result = parseAllPhaseSummaries(normalizedSummary);
expect(result).toEqual([]);
});
it('should work with the actual usage pattern from components', () => {
// Simulates the actual pattern used in summary-dialog.tsx and agent-output-modal.tsx
const featureSummary: string | null | undefined = null;
const extractedSummary: string | null | undefined = undefined;
const rawSummary = getFirstNonEmptySummary(featureSummary, extractedSummary);
const normalizedSummary = rawSummary ?? undefined;
// Both parser functions should work with the normalized value
const phases = parseAllPhaseSummaries(normalizedSummary);
const hasMultiple = isAccumulatedSummary(normalizedSummary);
expect(phases).toEqual([]);
expect(hasMultiple).toBe(false);
});
});
});

View File

@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest';
import { parseAllPhaseSummaries, isAccumulatedSummary } from '../../../../ui/src/lib/log-parser.ts';
import { getFirstNonEmptySummary } from '../../../../ui/src/lib/summary-selection.ts';
/**
* Mirrors summary source priority in agent-info-panel.tsx:
* freshFeature.summary > feature.summary > summaryProp > agentInfo.summary
*/
function getCardEffectiveSummary(params: {
freshFeatureSummary?: string | null;
featureSummary?: string | null;
summaryProp?: string | null;
agentInfoSummary?: string | null;
}): string | undefined | null {
return getFirstNonEmptySummary(
params.freshFeatureSummary,
params.featureSummary,
params.summaryProp,
params.agentInfoSummary
);
}
/**
* Mirrors SummaryDialog raw summary selection in summary-dialog.tsx:
* summaryProp > feature.summary > agentInfo.summary
*/
function getDialogRawSummary(params: {
summaryProp?: string | null;
featureSummary?: string | null;
agentInfoSummary?: string | null;
}): string | undefined | null {
return getFirstNonEmptySummary(
params.summaryProp,
params.featureSummary,
params.agentInfoSummary
);
}
describe('Summary Source Flow Integration', () => {
it('uses fresh per-feature summary in card and preserves it through summary dialog', () => {
const staleListSummary = '## Old summary from stale list cache';
const freshAccumulatedSummary = `### Implementation
Implemented auth + profile flow.
---
### Testing
- Unit tests: 18 passed
- Integration tests: 6 passed`;
const parsedAgentInfoSummary = 'Fallback summary from parsed agent output';
const cardEffectiveSummary = getCardEffectiveSummary({
freshFeatureSummary: freshAccumulatedSummary,
featureSummary: staleListSummary,
summaryProp: undefined,
agentInfoSummary: parsedAgentInfoSummary,
});
expect(cardEffectiveSummary).toBe(freshAccumulatedSummary);
const dialogRawSummary = getDialogRawSummary({
summaryProp: cardEffectiveSummary,
featureSummary: staleListSummary,
agentInfoSummary: parsedAgentInfoSummary,
});
expect(dialogRawSummary).toBe(freshAccumulatedSummary);
expect(isAccumulatedSummary(dialogRawSummary ?? undefined)).toBe(true);
const phases = parseAllPhaseSummaries(dialogRawSummary ?? undefined);
expect(phases).toHaveLength(2);
expect(phases[0]?.phaseName).toBe('Implementation');
expect(phases[1]?.phaseName).toBe('Testing');
});
it('falls back in order when fresher sources are absent', () => {
const cardEffectiveSummary = getCardEffectiveSummary({
freshFeatureSummary: undefined,
featureSummary: '',
summaryProp: undefined,
agentInfoSummary: 'Agent parsed fallback',
});
expect(cardEffectiveSummary).toBe('Agent parsed fallback');
const dialogRawSummary = getDialogRawSummary({
summaryProp: undefined,
featureSummary: undefined,
agentInfoSummary: cardEffectiveSummary,
});
expect(dialogRawSummary).toBe('Agent parsed fallback');
expect(isAccumulatedSummary(dialogRawSummary ?? undefined)).toBe(false);
});
it('treats whitespace-only summaries as empty during fallback selection', () => {
const cardEffectiveSummary = getCardEffectiveSummary({
freshFeatureSummary: ' \n',
featureSummary: '\t',
summaryProp: ' ',
agentInfoSummary: 'Agent parsed fallback',
});
expect(cardEffectiveSummary).toBe('Agent parsed fallback');
});
});

1
apps/ui/.gitignore vendored
View File

@@ -38,6 +38,7 @@ yarn-error.log*
/playwright-report/
/blob-report/
/playwright/.cache/
/tests/.auth/
# Electron
/release/

View File

@@ -1,6 +1,6 @@
{
"name": "@automaker/ui",
"version": "0.15.0",
"version": "1.0.0",
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
"homepage": "https://github.com/AutoMaker-Org/automaker",
"repository": {
@@ -9,6 +9,7 @@
},
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
"desktopName": "automaker.desktop",
"private": true,
"engines": {
"node": ">=22.0.0 <23.0.0"
@@ -144,6 +145,9 @@
"@playwright/test": "1.57.0",
"@tailwindcss/vite": "4.1.18",
"@tanstack/router-plugin": "1.141.7",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/dagre": "0.7.53",
"@types/node": "22.19.3",
"@types/react": "19.2.7",
@@ -156,6 +160,7 @@
"electron-builder": "26.0.12",
"eslint": "9.39.2",
"eslint-plugin-react-hooks": "^7.0.1",
"jsdom": "^28.1.0",
"tailwindcss": "4.1.18",
"tw-animate-css": "1.4.0",
"typescript": "5.9.3",
@@ -202,6 +207,10 @@
"filter": [
"**/*"
]
},
{
"from": "public/logo_larger.png",
"to": "logo_larger.png"
}
],
"mac": {
@@ -261,7 +270,12 @@
"maintainer": "webdevcody@gmail.com",
"executableName": "automaker",
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
"synopsis": "AI-powered autonomous development studio"
"synopsis": "AI-powered autonomous development studio",
"desktop": {
"entry": {
"Icon": "/opt/Automaker/resources/logo_larger.png"
}
}
},
"rpm": {
"depends": [
@@ -275,7 +289,8 @@
"libuuid"
],
"compression": "xz",
"vendor": "AutoMaker Team"
"vendor": "AutoMaker Team",
"afterInstall": "scripts/rpm-after-install.sh"
},
"nsis": {
"oneClick": false,

View File

@@ -1,28 +1,60 @@
import { defineConfig, devices } from '@playwright/test';
import path from 'path';
const port = process.env.TEST_PORT || 3107;
// PATH that includes common git locations so the E2E server can run git (worktree list, etc.)
const pathSeparator = process.platform === 'win32' ? ';' : ':';
const extraPath =
process.platform === 'win32'
? [
process.env.LOCALAPPDATA && `${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`,
process.env.PROGRAMFILES && `${process.env.PROGRAMFILES}\\Git\\cmd`,
].filter(Boolean)
: [
'/opt/homebrew/bin',
'/usr/local/bin',
'/usr/bin',
'/home/linuxbrew/.linuxbrew/bin',
process.env.HOME && `${process.env.HOME}/.local/bin`,
].filter(Boolean);
const e2eServerPath = [process.env.PATH, ...extraPath].filter(Boolean).join(pathSeparator);
const serverPort = process.env.TEST_SERVER_PORT || 3108;
// When true, no webServer is started; you must run UI (port 3107) and server (3108) yourself.
const reuseServer = process.env.TEST_REUSE_SERVER === 'true';
const useExternalBackend = !!process.env.VITE_SERVER_URL;
// Only skip backend startup when explicitly requested for E2E runs.
// VITE_SERVER_URL may be set in user shells for local dev and should not affect tests.
const useExternalBackend = process.env.TEST_USE_EXTERNAL_BACKEND === 'true';
// Always use mock agent for tests (disables rate limiting, uses mock Claude responses)
const mockAgent = true;
// Auth state file written by global setup, reused by all tests to skip per-test login
const AUTH_STATE_PATH = path.join(__dirname, 'tests/.auth/storage-state.json');
export default defineConfig({
testDir: './tests',
// Keep Playwright scoped to E2E specs so Vitest unit files are not executed here.
testMatch: '**/*.spec.ts',
testIgnore: ['**/unit/**'],
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: 0,
workers: 1, // Run sequentially to avoid auth conflicts with shared server
reporter: 'html',
retries: process.env.CI ? 2 : 0,
// Use multiple workers for parallelism. CI gets 2 workers (constrained resources),
// local runs use 8 workers for faster test execution.
workers: process.env.CI ? 2 : 8,
reporter: process.env.CI ? 'github' : 'html',
timeout: 30000,
use: {
baseURL: `http://localhost:${port}`,
baseURL: `http://127.0.0.1:${port}`,
trace: 'on-failure',
screenshot: 'only-on-failure',
serviceWorkers: 'block',
// Reuse auth state from global setup - avoids per-test login overhead
storageState: AUTH_STATE_PATH,
},
// Global setup - authenticate before each test
// Global setup - authenticate once and save state for all workers
globalSetup: require.resolve('./tests/global-setup.ts'),
globalTeardown: require.resolve('./tests/global-teardown.ts'),
projects: [
{
name: 'chromium',
@@ -40,13 +72,15 @@ export default defineConfig({
: [
{
command: `cd ../server && npm run dev:test`,
url: `http://localhost:${serverPort}/api/health`,
url: `http://127.0.0.1:${serverPort}/api/health`,
// Don't reuse existing server to ensure we use the test API key
reuseExistingServer: false,
timeout: 60000,
env: {
...process.env,
PORT: String(serverPort),
// Ensure server can find git in CI/minimal env (worktree list, etc.)
PATH: e2eServerPath,
// Enable mock agent in CI to avoid real API calls
AUTOMAKER_MOCK_AGENT: mockAgent ? 'true' : 'false',
// Set a test API key for web mode authentication
@@ -59,13 +93,17 @@ export default defineConfig({
ALLOWED_ROOT_DIRECTORY: '',
// Simulate containerized environment to skip sandbox confirmation dialogs
IS_CONTAINERIZED: 'true',
// Increase Node.js memory limit to prevent OOM during tests
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--max-old-space-size=4096']
.filter(Boolean)
.join(' '),
},
},
]),
// Frontend Vite dev server
{
command: `npm run dev`,
url: `http://localhost:${port}`,
url: `http://127.0.0.1:${port}`,
reuseExistingServer: false,
timeout: 120000,
env: {
@@ -77,6 +115,11 @@ export default defineConfig({
VITE_SKIP_SETUP: 'true',
// Always skip electron plugin during tests - prevents duplicate server spawning
VITE_SKIP_ELECTRON: 'true',
// Clear VITE_SERVER_URL to force the frontend to use the Vite proxy (/api)
// instead of calling the backend directly. Direct calls bypass the proxy and
// cause cookie domain mismatches (cookies are bound to 127.0.0.1 but
// VITE_SERVER_URL typically uses localhost).
VITE_SERVER_URL: '',
},
},
],

View File

@@ -10,7 +10,9 @@ const execAsync = promisify(exec);
const SERVER_PORT = process.env.TEST_SERVER_PORT || 3108;
const UI_PORT = process.env.TEST_PORT || 3107;
const USE_EXTERNAL_SERVER = !!process.env.VITE_SERVER_URL;
// Match Playwright config semantics: only explicit opt-in should skip backend startup/cleanup.
// VITE_SERVER_URL may exist in local shells and should not implicitly affect test behavior.
const USE_EXTERNAL_SERVER = process.env.TEST_USE_EXTERNAL_BACKEND === 'true';
console.log(`[KillTestServers] SERVER_PORT ${SERVER_PORT}`);
console.log(`[KillTestServers] UI_PORT ${UI_PORT}`);
async function killProcessOnPort(port) {

View File

@@ -0,0 +1,14 @@
#!/bin/bash
# Set the setuid bit on chrome-sandbox so Electron's sandbox works on systems
# where unprivileged user namespaces are restricted (e.g. hardened kernels).
# On Fedora/RHEL with standard kernel settings this is a safe no-op.
chmod 4755 /opt/Automaker/chrome-sandbox 2>/dev/null || true
# Refresh the GTK icon cache so GNOME/KDE picks up the newly installed icon
# immediately without requiring a logout. The -f flag forces a rebuild even
# if the cache is up-to-date; -t suppresses the mtime check warning.
gtk-update-icon-cache -f -t /usr/share/icons/hicolor 2>/dev/null || true
# Rebuild the desktop entry database so the app appears in the app launcher
# straight after install.
update-desktop-database /usr/share/applications 2>/dev/null || true

View File

@@ -18,6 +18,8 @@ const __dirname = path.dirname(__filename);
const WORKSPACE_ROOT = path.resolve(__dirname, '../../..');
const FIXTURE_PATH = path.join(WORKSPACE_ROOT, 'test/fixtures/projectA');
const SPEC_FILE_PATH = path.join(FIXTURE_PATH, '.automaker/app_spec.txt');
const CONTEXT_DIR = path.join(FIXTURE_PATH, '.automaker/context');
const CONTEXT_METADATA_PATH = path.join(CONTEXT_DIR, 'context-metadata.json');
const SERVER_SETTINGS_PATH = path.join(WORKSPACE_ROOT, 'apps/server/data/settings.json');
// Create a shared test workspace directory that will be used as default for project creation
const TEST_WORKSPACE_DIR = path.join(os.tmpdir(), 'automaker-e2e-workspace');
@@ -145,6 +147,14 @@ function setupFixtures() {
fs.writeFileSync(SPEC_FILE_PATH, SPEC_CONTENT);
console.log(`Created fixture file: ${SPEC_FILE_PATH}`);
// Create .automaker/context and context-metadata.json (expected by context view / FS read)
if (!fs.existsSync(CONTEXT_DIR)) {
fs.mkdirSync(CONTEXT_DIR, { recursive: true });
console.log(`Created directory: ${CONTEXT_DIR}`);
}
fs.writeFileSync(CONTEXT_METADATA_PATH, JSON.stringify({ files: {} }, null, 2));
console.log(`Created fixture file: ${CONTEXT_METADATA_PATH}`);
// Reset server settings.json to a clean state for E2E tests
const settingsDir = path.dirname(SERVER_SETTINGS_PATH);
if (!fs.existsSync(settingsDir)) {

View File

@@ -312,7 +312,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-md overflow-y-auto">
<SheetHeader className="px-6 pt-6">
<SheetHeader
className="px-6"
style={{
paddingTop: 'max(1.5rem, calc(env(safe-area-inset-top, 0px) + 1rem))',
}}
>
<SheetTitle className="flex items-center gap-2">
<ImageIcon className="w-5 h-5 text-brand-500" />
Board Background Settings

View File

@@ -177,7 +177,7 @@ export function FileBrowserDialog({
onSelect(currentPath);
onOpenChange(false);
}
}, [currentPath, onSelect, onOpenChange]);
}, [currentPath, onSelect, onOpenChange, addRecentFolder]);
// Handle Command/Ctrl+Enter keyboard shortcut to select current folder
useEffect(() => {

View File

@@ -37,7 +37,7 @@ import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { Markdown } from '@/components/ui/markdown';
import { cn, modelSupportsThinking, generateUUID } from '@/lib/utils';
import { cn, generateUUID, normalizeModelEntry } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { useGitHubPRReviewComments } from '@/hooks/queries';
import { useCreateFeature, useResolveReviewThread } from '@/hooks/mutations';
@@ -45,7 +45,7 @@ import { toast } from 'sonner';
import type { PRReviewComment } from '@/lib/electron';
import type { Feature } from '@/store/app-store';
import type { PhaseModelEntry } from '@automaker/types';
import { supportsReasoningEffort, normalizeThinkingLevelForModel } from '@automaker/types';
import { normalizeThinkingLevelForModel } from '@automaker/types';
import { resolveModelString } from '@automaker/model-resolver';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults';
@@ -62,6 +62,8 @@ export interface PRCommentResolutionPRInfo {
title: string;
/** The branch name (headRefName) associated with this PR, used to assign features to the correct worktree */
headRefName?: string;
/** The URL of the PR, used to set prUrl on created features */
url?: string;
}
interface PRCommentResolutionDialogProps {
@@ -730,14 +732,9 @@ export function PRCommentResolutionDialog({
const selectedComments = comments.filter((c) => selectedIds.has(c.id));
// Resolve model settings from the current model entry
const selectedModel = resolveModelString(modelEntry.model);
const normalizedThinking = modelSupportsThinking(selectedModel)
? modelEntry.thinkingLevel || 'none'
: 'none';
const normalizedReasoning = supportsReasoningEffort(selectedModel)
? modelEntry.reasoningEffort || 'none'
: 'none';
// Resolve and normalize model settings
const normalizedEntry = normalizeModelEntry(modelEntry);
const selectedModel = resolveModelString(normalizedEntry.model);
setIsCreating(true);
setCreationErrors([]);
@@ -753,8 +750,13 @@ export function PRCommentResolutionDialog({
steps: [],
status: 'backlog',
model: selectedModel,
thinkingLevel: normalizedThinking,
reasoningEffort: normalizedReasoning,
thinkingLevel: normalizedEntry.thinkingLevel,
reasoningEffort: normalizedEntry.reasoningEffort,
providerId: normalizedEntry.providerId,
planningMode: 'skip',
requirePlanApproval: false,
dependencies: [],
...(pr.url ? { prUrl: pr.url } : {}),
// Associate feature with the PR's branch so it appears on the correct worktree
...(pr.headRefName ? { branchName: pr.headRefName } : {}),
};
@@ -779,8 +781,13 @@ export function PRCommentResolutionDialog({
steps: [],
status: 'backlog',
model: selectedModel,
thinkingLevel: normalizedThinking,
reasoningEffort: normalizedReasoning,
thinkingLevel: normalizedEntry.thinkingLevel,
reasoningEffort: normalizedEntry.reasoningEffort,
providerId: normalizedEntry.providerId,
planningMode: 'skip',
requirePlanApproval: false,
dependencies: [],
...(pr.url ? { prUrl: pr.url } : {}),
// Associate feature with the PR's branch so it appears on the correct worktree
...(pr.headRefName ? { branchName: pr.headRefName } : {}),
};

Some files were not shown because too many files have changed in this diff Show More