diff --git a/apps/server/src/lib/sdk-options.ts b/apps/server/src/lib/sdk-options.ts index 39409e9e..d9dc409f 100644 --- a/apps/server/src/lib/sdk-options.ts +++ b/apps/server/src/lib/sdk-options.ts @@ -19,7 +19,13 @@ import type { Options } from '@anthropic-ai/claude-agent-sdk'; import os from 'os'; import path from 'path'; import { resolveModelString } from '@automaker/model-resolver'; -import { DEFAULT_MODELS, CLAUDE_MODEL_MAP, type McpServerConfig } from '@automaker/types'; +import { + DEFAULT_MODELS, + CLAUDE_MODEL_MAP, + type McpServerConfig, + type ThinkingLevel, + getThinkingTokenBudget, +} from '@automaker/types'; import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform'; /** @@ -317,6 +323,21 @@ function buildMcpOptions(config: CreateSdkOptionsConfig): McpPermissionOptions { }; } +/** + * Build thinking options for SDK configuration. + * Converts ThinkingLevel to maxThinkingTokens for the Claude SDK. + * + * @param thinkingLevel - The thinking level to convert + * @returns Object with maxThinkingTokens if thinking is enabled + */ +function buildThinkingOptions(thinkingLevel?: ThinkingLevel): Partial { + const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel); + console.log( + `[SDK-Options] buildThinkingOptions: thinkingLevel="${thinkingLevel}" -> maxThinkingTokens=${maxThinkingTokens}` + ); + return maxThinkingTokens ? { maxThinkingTokens } : {}; +} + /** * Build system prompt configuration based on autoLoadClaudeMd setting. * When autoLoadClaudeMd is true: @@ -409,6 +430,9 @@ export interface CreateSdkOptionsConfig { /** Allow unrestricted tools when MCP servers are enabled */ mcpUnrestrictedTools?: boolean; + + /** Extended thinking level for Claude models */ + thinkingLevel?: ThinkingLevel; } // Re-export MCP types from @automaker/types for convenience @@ -435,6 +459,9 @@ export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Opt // Build CLAUDE.md auto-loading options if enabled const claudeMdOptions = buildClaudeMdOptions(config); + // Build thinking options + const thinkingOptions = buildThinkingOptions(config.thinkingLevel); + return { ...getBaseOptions(), // Override permissionMode - spec generation only needs read-only tools @@ -446,6 +473,7 @@ export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Opt cwd: config.cwd, allowedTools: [...TOOL_PRESETS.specGeneration], ...claudeMdOptions, + ...thinkingOptions, ...(config.abortController && { abortController: config.abortController }), ...(config.outputFormat && { outputFormat: config.outputFormat }), }; @@ -467,6 +495,9 @@ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig): // Build CLAUDE.md auto-loading options if enabled const claudeMdOptions = buildClaudeMdOptions(config); + // Build thinking options + const thinkingOptions = buildThinkingOptions(config.thinkingLevel); + return { ...getBaseOptions(), // Override permissionMode - feature generation only needs read-only tools @@ -476,6 +507,7 @@ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig): cwd: config.cwd, allowedTools: [...TOOL_PRESETS.readOnly], ...claudeMdOptions, + ...thinkingOptions, ...(config.abortController && { abortController: config.abortController }), }; } @@ -496,6 +528,9 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option // Build CLAUDE.md auto-loading options if enabled const claudeMdOptions = buildClaudeMdOptions(config); + // Build thinking options + const thinkingOptions = buildThinkingOptions(config.thinkingLevel); + return { ...getBaseOptions(), model: getModelForUseCase('suggestions', config.model), @@ -503,6 +538,7 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option cwd: config.cwd, allowedTools: [...TOOL_PRESETS.readOnly], ...claudeMdOptions, + ...thinkingOptions, ...(config.abortController && { abortController: config.abortController }), ...(config.outputFormat && { outputFormat: config.outputFormat }), }; @@ -531,6 +567,9 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options { // Build MCP-related options const mcpOptions = buildMcpOptions(config); + // Build thinking options + const thinkingOptions = buildThinkingOptions(config.thinkingLevel); + // Check sandbox compatibility (auto-disables for cloud storage paths) const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode); @@ -550,6 +589,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options { }, }), ...claudeMdOptions, + ...thinkingOptions, ...(config.abortController && { abortController: config.abortController }), ...mcpOptions.mcpServerOptions, }; @@ -575,6 +615,9 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { // Build MCP-related options const mcpOptions = buildMcpOptions(config); + // Build thinking options + const thinkingOptions = buildThinkingOptions(config.thinkingLevel); + // Check sandbox compatibility (auto-disables for cloud storage paths) const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode); @@ -594,6 +637,7 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { }, }), ...claudeMdOptions, + ...thinkingOptions, ...(config.abortController && { abortController: config.abortController }), ...mcpOptions.mcpServerOptions, }; @@ -621,6 +665,9 @@ export function createCustomOptions( // Build MCP-related options const mcpOptions = buildMcpOptions(config); + // Build thinking options + const thinkingOptions = buildThinkingOptions(config.thinkingLevel); + // For custom options: use explicit allowedTools if provided, otherwise use preset based on MCP settings const effectiveAllowedTools = config.allowedTools ? [...config.allowedTools] @@ -638,6 +685,7 @@ export function createCustomOptions( // Apply MCP bypass options if configured ...mcpOptions.bypassOptions, ...claudeMdOptions, + ...thinkingOptions, ...(config.abortController && { abortController: config.abortController }), ...mcpOptions.mcpServerOptions, }; diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 33494535..a51cc3b1 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -8,6 +8,7 @@ import { query, type Options } from '@anthropic-ai/claude-agent-sdk'; import { BaseProvider } from './base-provider.js'; import { classifyError, getUserFriendlyErrorMessage } from '@automaker/utils'; +import { getThinkingTokenBudget } from '@automaker/types'; import type { ExecuteOptions, ProviderMessage, @@ -60,8 +61,12 @@ export class ClaudeProvider extends BaseProvider { abortController, conversationHistory, sdkSessionId, + thinkingLevel, } = options; + // Convert thinking level to token budget + const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel); + // Build Claude SDK options // MCP permission logic - determines how to handle tool permissions when MCP servers are configured. // This logic mirrors buildMcpOptions() in sdk-options.ts but is applied here since @@ -103,6 +108,8 @@ export class ClaudeProvider extends BaseProvider { ...(options.sandbox && { sandbox: options.sandbox }), // Forward MCP servers configuration ...(options.mcpServers && { mcpServers: options.mcpServers }), + // Extended thinking configuration + ...(maxThinkingTokens && { maxThinkingTokens }), }; // Build prompt payload diff --git a/apps/server/src/routes/app-spec/generate-features-from-spec.ts b/apps/server/src/routes/app-spec/generate-features-from-spec.ts index 2171f2d2..a621c908 100644 --- a/apps/server/src/routes/app-spec/generate-features-from-spec.ts +++ b/apps/server/src/routes/app-spec/generate-features-from-spec.ts @@ -10,7 +10,7 @@ import * as secureFs from '../../lib/secure-fs.js'; import type { EventEmitter } from '../../lib/events.js'; import { createLogger } from '@automaker/utils'; import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types'; -import { resolveModelString } from '@automaker/model-resolver'; +import { resolvePhaseModel } from '@automaker/model-resolver'; import { createFeatureGenerationOptions } from '../../lib/sdk-options.js'; import { ProviderFactory } from '../../providers/provider-factory.js'; import { logAuthStatus } from './common.js'; @@ -109,9 +109,9 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge // Get model from phase settings const settings = await settingsService?.getGlobalSettings(); - const featureGenerationModel = + const phaseModelEntry = settings?.phaseModels?.featureGenerationModel || DEFAULT_PHASE_MODELS.featureGenerationModel; - const model = resolveModelString(featureGenerationModel); + const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); logger.info('Using model:', model); @@ -172,6 +172,7 @@ CRITICAL INSTRUCTIONS: abortController, autoLoadClaudeMd, model, + thinkingLevel, // Pass thinking level for extended thinking }); logger.debug('SDK Options:', JSON.stringify(options, null, 2)); diff --git a/apps/server/src/routes/app-spec/generate-spec.ts b/apps/server/src/routes/app-spec/generate-spec.ts index 64f12836..a0a11514 100644 --- a/apps/server/src/routes/app-spec/generate-spec.ts +++ b/apps/server/src/routes/app-spec/generate-spec.ts @@ -17,7 +17,7 @@ import { } from '../../lib/app-spec-format.js'; import { createLogger } from '@automaker/utils'; import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types'; -import { resolveModelString } from '@automaker/model-resolver'; +import { resolvePhaseModel } from '@automaker/model-resolver'; import { createSpecGenerationOptions } from '../../lib/sdk-options.js'; import { extractJson } from '../../lib/json-extractor.js'; import { ProviderFactory } from '../../providers/provider-factory.js'; @@ -102,9 +102,9 @@ ${getStructuredSpecPromptInstruction()}`; // Get model from phase settings const settings = await settingsService?.getGlobalSettings(); - const specGenerationModel = + const phaseModelEntry = settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel; - const model = resolveModelString(specGenerationModel); + const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); logger.info('Using model:', model); @@ -185,6 +185,7 @@ Your entire response should be valid JSON starting with { and ending with }. No abortController, autoLoadClaudeMd, model, + thinkingLevel, // Pass thinking level for extended thinking outputFormat: { type: 'json_schema', schema: specOutputSchema, diff --git a/apps/server/src/routes/backlog-plan/generate-plan.ts b/apps/server/src/routes/backlog-plan/generate-plan.ts index c0c61668..eb7110eb 100644 --- a/apps/server/src/routes/backlog-plan/generate-plan.ts +++ b/apps/server/src/routes/backlog-plan/generate-plan.ts @@ -7,7 +7,8 @@ import type { EventEmitter } from '../../lib/events.js'; import type { Feature, BacklogPlanResult, BacklogChange, DependencyUpdate } from '@automaker/types'; -import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types'; +import { DEFAULT_PHASE_MODELS, isCursorModel, type ThinkingLevel } from '@automaker/types'; +import { resolvePhaseModel } from '@automaker/model-resolver'; import { FeatureLoader } from '../../services/feature-loader.js'; import { ProviderFactory } from '../../providers/provider-factory.js'; import { extractJsonWithArray } from '../../lib/json-extractor.js'; @@ -107,10 +108,14 @@ export async function generateBacklogPlan( // Get the model to use from settings or provided override let effectiveModel = model; + let thinkingLevel: ThinkingLevel | undefined; if (!effectiveModel) { const settings = await settingsService?.getGlobalSettings(); - effectiveModel = + const phaseModelEntry = settings?.phaseModels?.backlogPlanningModel || DEFAULT_PHASE_MODELS.backlogPlanningModel; + const resolved = resolvePhaseModel(phaseModelEntry); + effectiveModel = resolved.model; + thinkingLevel = resolved.thinkingLevel; } logger.info('[BacklogPlan] Using model:', effectiveModel); @@ -154,6 +159,7 @@ ${userPrompt}`; abortController, settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined, readOnly: true, // Plan generation only generates text, doesn't write files + thinkingLevel, // Pass thinking level for extended thinking }); let responseText = ''; diff --git a/apps/server/src/routes/context/routes/describe-file.ts b/apps/server/src/routes/context/routes/describe-file.ts index 08da54ca..8891bdd7 100644 --- a/apps/server/src/routes/context/routes/describe-file.ts +++ b/apps/server/src/routes/context/routes/describe-file.ts @@ -15,7 +15,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk'; import { createLogger } from '@automaker/utils'; import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types'; import { PathNotAllowedError } from '@automaker/platform'; -import { resolveModelString } from '@automaker/model-resolver'; +import { resolvePhaseModel } from '@automaker/model-resolver'; import { createCustomOptions } from '../../../lib/sdk-options.js'; import { ProviderFactory } from '../../../providers/provider-factory.js'; import * as secureFs from '../../../lib/secure-fs.js'; @@ -182,11 +182,16 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`; // Get model from phase settings const settings = await settingsService?.getGlobalSettings(); - const fileDescriptionModel = + logger.info( + `[DescribeFile] Raw phaseModels from settings:`, + JSON.stringify(settings?.phaseModels, null, 2) + ); + const phaseModelEntry = settings?.phaseModels?.fileDescriptionModel || DEFAULT_PHASE_MODELS.fileDescriptionModel; - const model = resolveModelString(fileDescriptionModel); + logger.info(`[DescribeFile] fileDescriptionModel entry:`, JSON.stringify(phaseModelEntry)); + const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); - logger.debug(`[DescribeFile] Using model: ${model}`); + logger.info(`[DescribeFile] Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`); let description: string; @@ -231,6 +236,7 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`; allowedTools: [], autoLoadClaudeMd, sandbox: { enabled: true, autoAllowBashIfSandboxed: true }, + thinkingLevel, // Pass thinking level for extended thinking }); const promptGenerator = (async function* () { diff --git a/apps/server/src/routes/context/routes/describe-image.ts b/apps/server/src/routes/context/routes/describe-image.ts index baa24d9b..4b4c281d 100644 --- a/apps/server/src/routes/context/routes/describe-image.ts +++ b/apps/server/src/routes/context/routes/describe-image.ts @@ -15,7 +15,7 @@ import type { Request, Response } from 'express'; import { query } from '@anthropic-ai/claude-agent-sdk'; import { createLogger, readImageAsBase64 } from '@automaker/utils'; import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types'; -import { resolveModelString } from '@automaker/model-resolver'; +import { resolvePhaseModel } from '@automaker/model-resolver'; import { createCustomOptions } from '../../../lib/sdk-options.js'; import { ProviderFactory } from '../../../providers/provider-factory.js'; import * as secureFs from '../../../lib/secure-fs.js'; @@ -342,9 +342,9 @@ export function createDescribeImageHandler( // Get model from phase settings const settings = await settingsService?.getGlobalSettings(); - const imageDescriptionModel = + const phaseModelEntry = settings?.phaseModels?.imageDescriptionModel || DEFAULT_PHASE_MODELS.imageDescriptionModel; - const model = resolveModelString(imageDescriptionModel); + const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); logger.info(`[${requestId}] Using model: ${model}`); @@ -395,6 +395,7 @@ export function createDescribeImageHandler( allowedTools: [], autoLoadClaudeMd, sandbox: { enabled: true, autoAllowBashIfSandboxed: true }, + thinkingLevel, // Pass thinking level for extended thinking }); logger.info( diff --git a/apps/server/src/routes/github/routes/validate-issue.ts b/apps/server/src/routes/github/routes/validate-issue.ts index 9cc29637..18dd6917 100644 --- a/apps/server/src/routes/github/routes/validate-issue.ts +++ b/apps/server/src/routes/github/routes/validate-issue.ts @@ -17,7 +17,8 @@ import type { GitHubComment, LinkedPRInfo, } from '@automaker/types'; -import { isCursorModel } from '@automaker/types'; +import { isCursorModel, DEFAULT_PHASE_MODELS } from '@automaker/types'; +import { resolvePhaseModel } from '@automaker/model-resolver'; import { createSuggestionsOptions } from '../../../lib/sdk-options.js'; import { extractJson } from '../../../lib/json-extractor.js'; import { writeValidation } from '../../../lib/validation-storage.js'; @@ -174,6 +175,12 @@ ${prompt}`; '[ValidateIssue]' ); + // Get thinkingLevel from phase model settings (the model comes from request, but thinkingLevel from settings) + const settings = await settingsService?.getGlobalSettings(); + const phaseModelEntry = + settings?.phaseModels?.validationModel || DEFAULT_PHASE_MODELS.validationModel; + const { thinkingLevel } = resolvePhaseModel(phaseModelEntry); + // Create SDK options with structured output and abort controller const options = createSuggestionsOptions({ cwd: projectPath, @@ -181,6 +188,7 @@ ${prompt}`; systemPrompt: ISSUE_VALIDATION_SYSTEM_PROMPT, abortController, autoLoadClaudeMd, + thinkingLevel, outputFormat: { type: 'json_schema', schema: issueValidationSchema as Record, diff --git a/apps/server/src/routes/suggestions/generate-suggestions.ts b/apps/server/src/routes/suggestions/generate-suggestions.ts index fb4371ba..f06488e6 100644 --- a/apps/server/src/routes/suggestions/generate-suggestions.ts +++ b/apps/server/src/routes/suggestions/generate-suggestions.ts @@ -9,7 +9,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk'; import type { EventEmitter } from '../../lib/events.js'; import { createLogger } from '@automaker/utils'; import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types'; -import { resolveModelString } from '@automaker/model-resolver'; +import { resolvePhaseModel } from '@automaker/model-resolver'; import { createSuggestionsOptions } from '../../lib/sdk-options.js'; import { extractJsonWithArray } from '../../lib/json-extractor.js'; import { ProviderFactory } from '../../providers/provider-factory.js'; @@ -173,9 +173,9 @@ The response will be automatically formatted as structured JSON.`; // Get model from phase settings (Feature Enhancement = enhancementModel) const settings = await settingsService?.getGlobalSettings(); - const enhancementModel = + const phaseModelEntry = settings?.phaseModels?.enhancementModel || DEFAULT_PHASE_MODELS.enhancementModel; - const model = resolveModelString(enhancementModel); + const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); logger.info('[Suggestions] Using model:', model); @@ -247,6 +247,7 @@ Your entire response should be valid JSON starting with { and ending with }. No abortController, autoLoadClaudeMd, model, // Pass the model from settings + thinkingLevel, // Pass thinking level for extended thinking outputFormat: { type: 'json_schema', schema: suggestionsSchema, diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index fbfde6fa..23e74dc1 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -16,6 +16,8 @@ import type { ModelProvider, PipelineConfig, PipelineStep, + ThinkingLevel, + PlanningMode, } from '@automaker/types'; import { DEFAULT_PHASE_MODELS } from '@automaker/types'; import { @@ -24,7 +26,7 @@ import { classifyError, loadContextFiles, } from '@automaker/utils'; -import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver'; +import { resolveModelString, resolvePhaseModel, DEFAULT_MODELS } from '@automaker/model-resolver'; import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver'; import { getFeatureDir, getAutomakerDir, getFeaturesDir } from '@automaker/platform'; import { exec } from 'child_process'; @@ -51,8 +53,7 @@ import { const execAsync = promisify(exec); -// Planning mode types for spec-driven development -type PlanningMode = 'skip' | 'lite' | 'spec' | 'full'; +// PlanningMode type is imported from @automaker/types interface ParsedTask { id: string; // e.g., "T001" @@ -576,6 +577,7 @@ export class AutoModeService { requirePlanApproval: feature.requirePlanApproval, systemPrompt: contextFilesPrompt || undefined, autoLoadClaudeMd, + thinkingLevel: feature.thinkingLevel, } ); @@ -734,6 +736,7 @@ export class AutoModeService { previousContent: previousContext, systemPrompt: contextFilesPrompt || undefined, autoLoadClaudeMd, + thinkingLevel: feature.thinkingLevel, } ); @@ -1049,6 +1052,7 @@ Address the follow-up instructions above. Review the previous work and make the previousContent: previousContext || undefined, systemPrompt: contextFilesPrompt || undefined, autoLoadClaudeMd, + thinkingLevel: feature?.thinkingLevel, } ); @@ -1278,9 +1282,10 @@ Format your response as a structured markdown document.`; try { // Get model from phase settings const settings = await this.settingsService?.getGlobalSettings(); - const projectAnalysisModel = + const phaseModelEntry = settings?.phaseModels?.projectAnalysisModel || DEFAULT_PHASE_MODELS.projectAnalysisModel; - const analysisModel = resolveModelString(projectAnalysisModel, DEFAULT_MODELS.claude); + const { model: analysisModel, thinkingLevel: analysisThinkingLevel } = + resolvePhaseModel(phaseModelEntry); console.log('[AutoMode] Using model for project analysis:', analysisModel); const provider = ProviderFactory.getProviderForModel(analysisModel); @@ -1300,6 +1305,7 @@ Format your response as a structured markdown document.`; allowedTools: ['Read', 'Glob', 'Grep'], abortController, autoLoadClaudeMd, + thinkingLevel: analysisThinkingLevel, }); const options: ExecuteOptions = { @@ -1311,6 +1317,7 @@ Format your response as a structured markdown document.`; abortController, settingSources: sdkOptions.settingSources, sandbox: sdkOptions.sandbox, // Pass sandbox configuration + thinkingLevel: analysisThinkingLevel, // Pass thinking level }; const stream = provider.executeQuery(options); @@ -1954,6 +1961,7 @@ This helps parse your summary correctly in the output logs.`; previousContent?: string; systemPrompt?: string; autoLoadClaudeMd?: boolean; + thinkingLevel?: ThinkingLevel; } ): Promise { const finalProjectPath = options?.projectPath || projectPath; @@ -2052,6 +2060,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, + thinkingLevel: options?.thinkingLevel, }); // Extract model, maxTurns, and allowedTools from SDK options @@ -2096,6 +2105,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting + thinkingLevel: options?.thinkingLevel, // Pass thinking level for extended thinking }; // Execute via provider diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 4673404e..94bdce24 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -28,6 +28,7 @@ import type { BoardBackgroundSettings, WorktreeInfo, PhaseModelConfig, + PhaseModelEntry, } from '../types/settings.js'; import { DEFAULT_GLOBAL_SETTINGS, @@ -157,10 +158,23 @@ export class SettingsService { if (storedVersion < 2) { logger.info('Migrating settings from v1 to v2: disabling sandbox mode'); result.enableSandboxMode = false; - result.version = SETTINGS_VERSION; needsSave = true; } + // Migration v2 -> v3: Convert string phase models to PhaseModelEntry objects + // Note: migratePhaseModels() handles the actual conversion for both v1 and v2 formats + if (storedVersion < 3) { + logger.info( + `Migrating settings from v${storedVersion} to v3: converting phase models to PhaseModelEntry format` + ); + needsSave = true; + } + + // Update version if any migration occurred + if (needsSave) { + result.version = SETTINGS_VERSION; + } + // Save migrated settings if needed if (needsSave) { try { @@ -179,6 +193,7 @@ export class SettingsService { * Migrate legacy enhancementModel/validationModel fields to phaseModels structure * * Handles backwards compatibility for settings created before phaseModels existed. + * Also handles migration from string phase models (v2) to PhaseModelEntry objects (v3). * Legacy fields take precedence over defaults but phaseModels takes precedence over legacy. * * @param settings - Raw settings from file @@ -190,26 +205,51 @@ export class SettingsService { // If phaseModels exists, use it (with defaults for any missing fields) if (settings.phaseModels) { - return { - ...DEFAULT_PHASE_MODELS, - ...settings.phaseModels, - }; + // Merge with defaults and convert any string values to PhaseModelEntry + const merged: PhaseModelConfig = { ...DEFAULT_PHASE_MODELS }; + for (const key of Object.keys(settings.phaseModels) as Array) { + const value = settings.phaseModels[key]; + if (value !== undefined) { + // Convert string to PhaseModelEntry if needed (v2 -> v3 migration) + merged[key] = this.toPhaseModelEntry(value); + } + } + return merged; } // Migrate legacy fields if phaseModels doesn't exist // These were the only two legacy fields that existed if (settings.enhancementModel) { - result.enhancementModel = settings.enhancementModel; + result.enhancementModel = this.toPhaseModelEntry(settings.enhancementModel); logger.debug(`Migrated legacy enhancementModel: ${settings.enhancementModel}`); } if (settings.validationModel) { - result.validationModel = settings.validationModel; + result.validationModel = this.toPhaseModelEntry(settings.validationModel); logger.debug(`Migrated legacy validationModel: ${settings.validationModel}`); } return result; } + /** + * Convert a phase model value to PhaseModelEntry format + * + * Handles migration from string format (v2) to object format (v3). + * - String values like 'sonnet' become { model: 'sonnet' } + * - Object values are returned as-is (with type assertion) + * + * @param value - Phase model value (string or PhaseModelEntry) + * @returns PhaseModelEntry object + */ + private toPhaseModelEntry(value: string | PhaseModelEntry): PhaseModelEntry { + if (typeof value === 'string') { + // v2 format: just a model string + return { model: value as PhaseModelEntry['model'] }; + } + // v3 format: already a PhaseModelEntry object + return value; + } + /** * Update global settings with partial changes * diff --git a/apps/server/src/types/settings.ts b/apps/server/src/types/settings.ts index 1081efa5..a92e706e 100644 --- a/apps/server/src/types/settings.ts +++ b/apps/server/src/types/settings.ts @@ -24,6 +24,7 @@ export type { ProjectSettings, PhaseModelConfig, PhaseModelKey, + PhaseModelEntry, } from '@automaker/types'; export { diff --git a/apps/server/tests/unit/lib/sdk-options.test.ts b/apps/server/tests/unit/lib/sdk-options.test.ts index 3faea516..b442ae1d 100644 --- a/apps/server/tests/unit/lib/sdk-options.test.ts +++ b/apps/server/tests/unit/lib/sdk-options.test.ts @@ -554,4 +554,203 @@ describe('sdk-options.ts', () => { expect(options.abortController).toBe(abortController); }); }); + + describe('getThinkingTokenBudget (from @automaker/types)', () => { + it('should return undefined for "none" thinking level', async () => { + const { getThinkingTokenBudget } = await import('@automaker/types'); + expect(getThinkingTokenBudget('none')).toBeUndefined(); + }); + + it('should return undefined for undefined thinking level', async () => { + const { getThinkingTokenBudget } = await import('@automaker/types'); + expect(getThinkingTokenBudget(undefined)).toBeUndefined(); + }); + + it('should return 1024 for "low" thinking level', async () => { + const { getThinkingTokenBudget } = await import('@automaker/types'); + expect(getThinkingTokenBudget('low')).toBe(1024); + }); + + it('should return 10000 for "medium" thinking level', async () => { + const { getThinkingTokenBudget } = await import('@automaker/types'); + expect(getThinkingTokenBudget('medium')).toBe(10000); + }); + + it('should return 16000 for "high" thinking level', async () => { + const { getThinkingTokenBudget } = await import('@automaker/types'); + expect(getThinkingTokenBudget('high')).toBe(16000); + }); + + it('should return 32000 for "ultrathink" thinking level', async () => { + const { getThinkingTokenBudget } = await import('@automaker/types'); + expect(getThinkingTokenBudget('ultrathink')).toBe(32000); + }); + }); + + describe('THINKING_TOKEN_BUDGET constant', () => { + it('should have correct values for all thinking levels', async () => { + const { THINKING_TOKEN_BUDGET } = await import('@automaker/types'); + + expect(THINKING_TOKEN_BUDGET.none).toBeUndefined(); + expect(THINKING_TOKEN_BUDGET.low).toBe(1024); + expect(THINKING_TOKEN_BUDGET.medium).toBe(10000); + expect(THINKING_TOKEN_BUDGET.high).toBe(16000); + expect(THINKING_TOKEN_BUDGET.ultrathink).toBe(32000); + }); + + it('should have minimum of 1024 for enabled thinking levels', async () => { + const { THINKING_TOKEN_BUDGET } = await import('@automaker/types'); + + // Per Claude SDK docs: minimum is 1024 tokens + expect(THINKING_TOKEN_BUDGET.low).toBeGreaterThanOrEqual(1024); + expect(THINKING_TOKEN_BUDGET.medium).toBeGreaterThanOrEqual(1024); + expect(THINKING_TOKEN_BUDGET.high).toBeGreaterThanOrEqual(1024); + expect(THINKING_TOKEN_BUDGET.ultrathink).toBeGreaterThanOrEqual(1024); + }); + + it('should have ultrathink at or below 32000 to avoid timeouts', async () => { + const { THINKING_TOKEN_BUDGET } = await import('@automaker/types'); + + // Per Claude SDK docs: above 32000 risks timeouts + expect(THINKING_TOKEN_BUDGET.ultrathink).toBeLessThanOrEqual(32000); + }); + }); + + describe('thinking level integration with SDK options', () => { + describe('createSpecGenerationOptions with thinkingLevel', () => { + it('should not include maxThinkingTokens when thinkingLevel is undefined', async () => { + const { createSpecGenerationOptions } = await import('@/lib/sdk-options.js'); + + const options = createSpecGenerationOptions({ cwd: '/test/path' }); + + expect(options.maxThinkingTokens).toBeUndefined(); + }); + + it('should not include maxThinkingTokens when thinkingLevel is "none"', async () => { + const { createSpecGenerationOptions } = await import('@/lib/sdk-options.js'); + + const options = createSpecGenerationOptions({ + cwd: '/test/path', + thinkingLevel: 'none', + }); + + expect(options.maxThinkingTokens).toBeUndefined(); + }); + + it('should include maxThinkingTokens for "low" thinkingLevel', async () => { + const { createSpecGenerationOptions } = await import('@/lib/sdk-options.js'); + + const options = createSpecGenerationOptions({ + cwd: '/test/path', + thinkingLevel: 'low', + }); + + expect(options.maxThinkingTokens).toBe(1024); + }); + + it('should include maxThinkingTokens for "high" thinkingLevel', async () => { + const { createSpecGenerationOptions } = await import('@/lib/sdk-options.js'); + + const options = createSpecGenerationOptions({ + cwd: '/test/path', + thinkingLevel: 'high', + }); + + expect(options.maxThinkingTokens).toBe(16000); + }); + + it('should include maxThinkingTokens for "ultrathink" thinkingLevel', async () => { + const { createSpecGenerationOptions } = await import('@/lib/sdk-options.js'); + + const options = createSpecGenerationOptions({ + cwd: '/test/path', + thinkingLevel: 'ultrathink', + }); + + expect(options.maxThinkingTokens).toBe(32000); + }); + }); + + describe('createAutoModeOptions with thinkingLevel', () => { + it('should not include maxThinkingTokens when thinkingLevel is undefined', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ cwd: '/test/path' }); + + expect(options.maxThinkingTokens).toBeUndefined(); + }); + + it('should include maxThinkingTokens for "medium" thinkingLevel', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ + cwd: '/test/path', + thinkingLevel: 'medium', + }); + + expect(options.maxThinkingTokens).toBe(10000); + }); + + it('should include maxThinkingTokens for "ultrathink" thinkingLevel', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ + cwd: '/test/path', + thinkingLevel: 'ultrathink', + }); + + expect(options.maxThinkingTokens).toBe(32000); + }); + }); + + describe('createChatOptions with thinkingLevel', () => { + it('should include maxThinkingTokens for enabled thinkingLevel', async () => { + const { createChatOptions } = await import('@/lib/sdk-options.js'); + + const options = createChatOptions({ + cwd: '/test/path', + thinkingLevel: 'high', + }); + + expect(options.maxThinkingTokens).toBe(16000); + }); + }); + + describe('createSuggestionsOptions with thinkingLevel', () => { + it('should include maxThinkingTokens for enabled thinkingLevel', async () => { + const { createSuggestionsOptions } = await import('@/lib/sdk-options.js'); + + const options = createSuggestionsOptions({ + cwd: '/test/path', + thinkingLevel: 'low', + }); + + expect(options.maxThinkingTokens).toBe(1024); + }); + }); + + describe('createCustomOptions with thinkingLevel', () => { + it('should include maxThinkingTokens for enabled thinkingLevel', async () => { + const { createCustomOptions } = await import('@/lib/sdk-options.js'); + + const options = createCustomOptions({ + cwd: '/test/path', + thinkingLevel: 'medium', + }); + + expect(options.maxThinkingTokens).toBe(10000); + }); + + it('should not include maxThinkingTokens when thinkingLevel is "none"', async () => { + const { createCustomOptions } = await import('@/lib/sdk-options.js'); + + const options = createCustomOptions({ + cwd: '/test/path', + thinkingLevel: 'none', + }); + + expect(options.maxThinkingTokens).toBeUndefined(); + }); + }); + }); }); diff --git a/apps/server/tests/unit/services/settings-service.test.ts b/apps/server/tests/unit/services/settings-service.test.ts index 666f65d4..ff09b817 100644 --- a/apps/server/tests/unit/services/settings-service.test.ts +++ b/apps/server/tests/unit/services/settings-service.test.ts @@ -597,6 +597,170 @@ describe('settings-service.ts', () => { }); }); + describe('phase model migration (v2 -> v3)', () => { + it('should migrate string phase models to PhaseModelEntry format', async () => { + // Simulate v2 format with string phase models + const v2Settings = { + version: 2, + theme: 'dark', + phaseModels: { + enhancementModel: 'sonnet', + fileDescriptionModel: 'haiku', + imageDescriptionModel: 'haiku', + validationModel: 'sonnet', + specGenerationModel: 'opus', + featureGenerationModel: 'sonnet', + backlogPlanningModel: 'sonnet', + projectAnalysisModel: 'sonnet', + }, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(v2Settings, null, 2)); + + const settings = await settingsService.getGlobalSettings(); + + // Verify all phase models are now PhaseModelEntry objects + expect(settings.phaseModels.enhancementModel).toEqual({ model: 'sonnet' }); + expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'haiku' }); + expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'opus' }); + expect(settings.version).toBe(SETTINGS_VERSION); + }); + + it('should preserve PhaseModelEntry objects during migration', async () => { + // Simulate v3 format (already has PhaseModelEntry objects) + const v3Settings = { + version: 3, + theme: 'dark', + phaseModels: { + enhancementModel: { model: 'sonnet', thinkingLevel: 'high' }, + fileDescriptionModel: { model: 'haiku' }, + imageDescriptionModel: { model: 'haiku', thinkingLevel: 'low' }, + validationModel: { model: 'sonnet' }, + specGenerationModel: { model: 'opus', thinkingLevel: 'ultrathink' }, + featureGenerationModel: { model: 'sonnet' }, + backlogPlanningModel: { model: 'sonnet', thinkingLevel: 'medium' }, + projectAnalysisModel: { model: 'sonnet' }, + }, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(v3Settings, null, 2)); + + const settings = await settingsService.getGlobalSettings(); + + // Verify PhaseModelEntry objects are preserved with thinkingLevel + expect(settings.phaseModels.enhancementModel).toEqual({ + model: 'sonnet', + thinkingLevel: 'high', + }); + expect(settings.phaseModels.specGenerationModel).toEqual({ + model: 'opus', + thinkingLevel: 'ultrathink', + }); + expect(settings.phaseModels.backlogPlanningModel).toEqual({ + model: 'sonnet', + thinkingLevel: 'medium', + }); + }); + + it('should handle mixed format (some string, some object)', async () => { + // Edge case: mixed format (shouldn't happen but handle gracefully) + const mixedSettings = { + version: 2, + theme: 'dark', + phaseModels: { + enhancementModel: 'sonnet', // string + fileDescriptionModel: { model: 'haiku', thinkingLevel: 'low' }, // object + imageDescriptionModel: 'haiku', // string + validationModel: { model: 'opus' }, // object without thinkingLevel + specGenerationModel: 'opus', + featureGenerationModel: 'sonnet', + backlogPlanningModel: 'sonnet', + projectAnalysisModel: 'sonnet', + }, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(mixedSettings, null, 2)); + + const settings = await settingsService.getGlobalSettings(); + + // Strings should be converted to objects + expect(settings.phaseModels.enhancementModel).toEqual({ model: 'sonnet' }); + expect(settings.phaseModels.imageDescriptionModel).toEqual({ model: 'haiku' }); + // Objects should be preserved + expect(settings.phaseModels.fileDescriptionModel).toEqual({ + model: 'haiku', + thinkingLevel: 'low', + }); + expect(settings.phaseModels.validationModel).toEqual({ model: 'opus' }); + }); + + it('should migrate legacy enhancementModel/validationModel fields', async () => { + // Simulate v1 format with legacy fields + const v1Settings = { + version: 1, + theme: 'dark', + enhancementModel: 'haiku', + validationModel: 'opus', + // No phaseModels object + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(v1Settings, null, 2)); + + const settings = await settingsService.getGlobalSettings(); + + // Legacy fields should be migrated to phaseModels + expect(settings.phaseModels.enhancementModel).toEqual({ model: 'haiku' }); + expect(settings.phaseModels.validationModel).toEqual({ model: 'opus' }); + // Other fields should use defaults + expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'opus' }); + }); + + it('should use default phase models when none are configured', async () => { + // Simulate empty settings + const emptySettings = { + version: 1, + theme: 'dark', + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(emptySettings, null, 2)); + + const settings = await settingsService.getGlobalSettings(); + + // Should use DEFAULT_PHASE_MODELS + expect(settings.phaseModels.enhancementModel).toEqual({ model: 'sonnet' }); + expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'haiku' }); + expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'opus' }); + }); + + it('should deep merge phaseModels on update', async () => { + // Create initial settings with some phase models + await settingsService.updateGlobalSettings({ + phaseModels: { + enhancementModel: { model: 'sonnet', thinkingLevel: 'high' }, + }, + }); + + // Update with a different phase model + await settingsService.updateGlobalSettings({ + phaseModels: { + specGenerationModel: { model: 'opus', thinkingLevel: 'ultrathink' }, + }, + }); + + const settings = await settingsService.getGlobalSettings(); + + // Both should be preserved + expect(settings.phaseModels.enhancementModel).toEqual({ + model: 'sonnet', + thinkingLevel: 'high', + }); + expect(settings.phaseModels.specGenerationModel).toEqual({ + model: 'opus', + thinkingLevel: 'ultrathink', + }); + }); + }); + describe('atomicWriteJson', () => { // Skip on Windows as chmod doesn't work the same way (CI runs on Linux) it.skipIf(process.platform === 'win32')( diff --git a/apps/ui/src/components/shared/model-override-trigger.tsx b/apps/ui/src/components/shared/model-override-trigger.tsx index bad7748a..afb1e077 100644 --- a/apps/ui/src/components/shared/model-override-trigger.tsx +++ b/apps/ui/src/components/shared/model-override-trigger.tsx @@ -4,10 +4,22 @@ import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { useAppStore } from '@/store/app-store'; -import type { ModelAlias, CursorModelId, PhaseModelKey } from '@automaker/types'; +import type { ModelAlias, CursorModelId, PhaseModelKey, PhaseModelEntry } from '@automaker/types'; import { PROVIDER_PREFIXES, stripProviderPrefix } from '@automaker/types'; + import { CLAUDE_MODELS, CURSOR_MODELS } from '@/components/views/board-view/shared/model-constants'; +/** + * Extract model string from PhaseModelEntry or string + */ +function extractModel(entry: PhaseModelEntry | string | null): ModelAlias | CursorModelId | null { + if (!entry) return null; + if (typeof entry === 'string') { + return entry as ModelAlias | CursorModelId; + } + return entry.model; +} + export interface ModelOverrideTriggerProps { /** Current effective model (from global settings or explicit override) */ currentModel: ModelAlias | CursorModelId; @@ -53,8 +65,8 @@ export function ModelOverrideTrigger({ const [open, setOpen] = useState(false); const { phaseModels, enabledCursorModels } = useAppStore(); - // Get the global default for this phase - const globalDefault = phase ? phaseModels[phase] : null; + // Get the global default for this phase (extract model string from PhaseModelEntry) + const globalDefault = phase ? extractModel(phaseModels[phase]) : null; // Filter Cursor models to only show enabled ones const availableCursorModels = CURSOR_MODELS.filter((model) => { diff --git a/apps/ui/src/components/shared/use-model-override.ts b/apps/ui/src/components/shared/use-model-override.ts index 7fc78d32..054838c9 100644 --- a/apps/ui/src/components/shared/use-model-override.ts +++ b/apps/ui/src/components/shared/use-model-override.ts @@ -1,6 +1,6 @@ import { useState, useCallback, useMemo } from 'react'; import { useAppStore } from '@/store/app-store'; -import type { ModelAlias, CursorModelId, PhaseModelKey } from '@automaker/types'; +import type { ModelAlias, CursorModelId, PhaseModelKey, PhaseModelEntry } from '@automaker/types'; export interface UseModelOverrideOptions { /** Which phase this override is for */ @@ -24,6 +24,16 @@ export interface UseModelOverrideResult { override: ModelAlias | CursorModelId | null; } +/** + * Extract model string from PhaseModelEntry or string + */ +function extractModel(entry: PhaseModelEntry | string): ModelAlias | CursorModelId { + if (typeof entry === 'string') { + return entry as ModelAlias | CursorModelId; + } + return entry.model; +} + /** * Hook for managing model overrides per phase * @@ -55,7 +65,8 @@ export function useModelOverride({ const { phaseModels } = useAppStore(); const [override, setOverrideState] = useState(initialOverride); - const globalDefault = phaseModels[phase]; + // Extract model string from PhaseModelEntry (handles both old string format and new object format) + const globalDefault = extractModel(phaseModels[phase]); const effectiveModel = useMemo(() => { return override ?? globalDefault; diff --git a/apps/ui/src/components/views/board-view/dialogs/backlog-plan-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/backlog-plan-dialog.tsx index 39f2cdef..69b76bcb 100644 --- a/apps/ui/src/components/views/board-view/dialogs/backlog-plan-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/backlog-plan-dialog.tsx @@ -23,10 +23,26 @@ import { import { getElectronAPI } from '@/lib/electron'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; -import type { BacklogPlanResult, BacklogChange, ModelAlias, CursorModelId } from '@automaker/types'; +import type { + BacklogPlanResult, + BacklogChange, + ModelAlias, + CursorModelId, + PhaseModelEntry, +} from '@automaker/types'; import { ModelOverrideTrigger } from '@/components/shared/model-override-trigger'; import { useAppStore } from '@/store/app-store'; +/** + * Extract model string from PhaseModelEntry or string + */ +function extractModel(entry: PhaseModelEntry | string): ModelAlias | CursorModelId { + if (typeof entry === 'string') { + return entry as ModelAlias | CursorModelId; + } + return entry.model; +} + interface BacklogPlanDialogProps { open: boolean; onClose: () => void; @@ -88,8 +104,8 @@ export function BacklogPlanDialog({ // Start generation in background setIsGeneratingPlan(true); - // Use model override if set, otherwise use global default - const effectiveModel = modelOverride || phaseModels.backlogPlanningModel; + // Use model override if set, otherwise use global default (extract model string from PhaseModelEntry) + const effectiveModel = modelOverride || extractModel(phaseModels.backlogPlanningModel); const result = await api.backlogPlan.generate(projectPath, prompt, effectiveModel); if (!result.success) { setIsGeneratingPlan(false); @@ -365,8 +381,8 @@ export function BacklogPlanDialog({ } }; - // Get effective model (override or global default) - const effectiveModel = modelOverride || phaseModels.backlogPlanningModel; + // Get effective model (override or global default) - extract model string from PhaseModelEntry + const effectiveModel = modelOverride || extractModel(phaseModels.backlogPlanningModel); return ( !isOpen && onClose()}> diff --git a/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts index 90fef96e..fa7d62dc 100644 --- a/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts +++ b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts @@ -7,11 +7,24 @@ import { IssueValidationEvent, StoredValidation, } from '@/lib/electron'; -import type { LinkedPRInfo } from '@automaker/types'; +import type { LinkedPRInfo, PhaseModelEntry, ModelAlias, CursorModelId } from '@automaker/types'; import { useAppStore } from '@/store/app-store'; import { toast } from 'sonner'; import { isValidationStale } from '../utils'; +/** + * Extract model string from PhaseModelEntry or string (handles both formats) + */ +function extractModel( + entry: PhaseModelEntry | string | undefined +): ModelAlias | CursorModelId | undefined { + if (!entry) return undefined; + if (typeof entry === 'string') { + return entry as ModelAlias | CursorModelId; + } + return entry.model; +} + interface UseIssueValidationOptions { selectedIssue: GitHubIssue | null; showValidationDialog: boolean; @@ -244,7 +257,8 @@ export function useIssueValidation({ }); // Use provided model override or fall back to phaseModels.validationModel - const modelToUse = model || phaseModels.validationModel; + // Extract model string from PhaseModelEntry (handles both old string format and new object format) + const modelToUse = model || extractModel(phaseModels.validationModel); try { const api = getElectronAPI(); diff --git a/apps/ui/src/components/views/settings-view/phase-models/phase-model-selector.tsx b/apps/ui/src/components/views/settings-view/phase-models/phase-model-selector.tsx index e783cd99..0315fe88 100644 --- a/apps/ui/src/components/views/settings-view/phase-models/phase-model-selector.tsx +++ b/apps/ui/src/components/views/settings-view/phase-models/phase-model-selector.tsx @@ -1,16 +1,27 @@ import * as React from 'react'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/store/app-store'; -import type { ModelAlias, CursorModelId, GroupedModel } from '@automaker/types'; +import type { + ModelAlias, + CursorModelId, + GroupedModel, + PhaseModelEntry, + ThinkingLevel, +} from '@automaker/types'; import { stripProviderPrefix, - CURSOR_MODEL_GROUPS, STANDALONE_CURSOR_MODELS, getModelGroup, isGroupSelected, getSelectedVariant, + isCursorModel, } from '@automaker/types'; -import { CLAUDE_MODELS, CURSOR_MODELS } from '@/components/views/board-view/shared/model-constants'; +import { + CLAUDE_MODELS, + CURSOR_MODELS, + THINKING_LEVELS, + THINKING_LEVEL_LABELS, +} from '@/components/views/board-view/shared/model-constants'; import { Check, ChevronsUpDown, Star, Brain, Sparkles, ChevronRight } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { @@ -27,8 +38,8 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover interface PhaseModelSelectorProps { label: string; description: string; - value: ModelAlias | CursorModelId; - onChange: (model: ModelAlias | CursorModelId) => void; + value: PhaseModelEntry; + onChange: (entry: PhaseModelEntry) => void; } export function PhaseModelSelector({ @@ -39,10 +50,16 @@ export function PhaseModelSelector({ }: PhaseModelSelectorProps) { const [open, setOpen] = React.useState(false); const [expandedGroup, setExpandedGroup] = React.useState(null); + const [expandedClaudeModel, setExpandedClaudeModel] = React.useState(null); const commandListRef = React.useRef(null); const expandedTriggerRef = React.useRef(null); + const expandedClaudeTriggerRef = React.useRef(null); const { enabledCursorModels, favoriteModels, toggleFavoriteModel } = useAppStore(); + // Extract model and thinking level from value + const selectedModel = value.model; + const selectedThinkingLevel = value.thinkingLevel || 'none'; + // Close expanded group when trigger scrolls out of view React.useEffect(() => { const triggerElement = expandedTriggerRef.current; @@ -66,6 +83,29 @@ export function PhaseModelSelector({ return () => observer.disconnect(); }, [expandedGroup]); + // Close expanded Claude model popover when trigger scrolls out of view + React.useEffect(() => { + const triggerElement = expandedClaudeTriggerRef.current; + const listElement = commandListRef.current; + if (!triggerElement || !listElement || !expandedClaudeModel) return; + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (!entry.isIntersecting) { + setExpandedClaudeModel(null); + } + }, + { + root: listElement, + threshold: 0.1, + } + ); + + observer.observe(triggerElement); + return () => observer.disconnect(); + }, [expandedClaudeModel]); + // Filter Cursor models to only show enabled ones const availableCursorModels = CURSOR_MODELS.filter((model) => { const cursorId = stripProviderPrefix(model.id) as CursorModelId; @@ -74,18 +114,31 @@ export function PhaseModelSelector({ // Helper to find current selected model details const currentModel = React.useMemo(() => { - const claudeModel = CLAUDE_MODELS.find((m) => m.id === value); - if (claudeModel) return { ...claudeModel, icon: Brain }; + const claudeModel = CLAUDE_MODELS.find((m) => m.id === selectedModel); + if (claudeModel) { + // Add thinking level to label if not 'none' + const thinkingLabel = + selectedThinkingLevel !== 'none' + ? ` (${THINKING_LEVEL_LABELS[selectedThinkingLevel]} Thinking)` + : ''; + return { + ...claudeModel, + label: `${claudeModel.label}${thinkingLabel}`, + icon: Brain, + }; + } - const cursorModel = availableCursorModels.find((m) => stripProviderPrefix(m.id) === value); + const cursorModel = availableCursorModels.find( + (m) => stripProviderPrefix(m.id) === selectedModel + ); if (cursorModel) return { ...cursorModel, icon: Sparkles }; - // Check if value is part of a grouped model - const group = getModelGroup(value as CursorModelId); + // Check if selectedModel is part of a grouped model + const group = getModelGroup(selectedModel as CursorModelId); if (group) { - const variant = getSelectedVariant(group, value as CursorModelId); + const variant = getSelectedVariant(group, selectedModel as CursorModelId); return { - id: value, + id: selectedModel, label: `${group.label} (${variant?.label || 'Unknown'})`, description: group.description, provider: 'cursor' as const, @@ -94,7 +147,7 @@ export function PhaseModelSelector({ } return null; - }, [value, availableCursorModels]); + }, [selectedModel, selectedThinkingLevel, availableCursorModels]); // Compute grouped vs standalone Cursor models const { groupedModels, standaloneCursorModels } = React.useMemo(() => { @@ -156,26 +209,24 @@ export function PhaseModelSelector({ return { favorites: favs, claude: cModels, cursor: curModels }; }, [favoriteModels, availableCursorModels]); - const renderModelItem = (model: (typeof CLAUDE_MODELS)[0], type: 'claude' | 'cursor') => { - const isClaude = type === 'claude'; - // For Claude, value is model.id. For Cursor, it's stripped ID. - const modelValue = isClaude ? model.id : stripProviderPrefix(model.id); - const isSelected = value === modelValue; + // Render Cursor model item (no thinking level needed) + const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => { + const modelValue = stripProviderPrefix(model.id); + const isSelected = selectedModel === modelValue; const isFavorite = favoriteModels.includes(model.id); - const Icon = isClaude ? Brain : Sparkles; return ( { - onChange(modelValue as ModelAlias | CursorModelId); + onChange({ model: modelValue as CursorModelId }); setOpen(false); }} className="group flex items-center justify-between py-2" >
- { + const isSelected = selectedModel === model.id; + const isFavorite = favoriteModels.includes(model.id); + const isExpanded = expandedClaudeModel === model.id; + const currentThinking = isSelected ? selectedThinkingLevel : 'none'; + + return ( + setExpandedClaudeModel(isExpanded ? null : (model.id as ModelAlias))} + className="p-0 data-[selected=true]:bg-transparent" + > + { + if (!isOpen) { + setExpandedClaudeModel(null); + } + }} + > + +
+
+ +
+ + {model.label} + + + {isSelected && currentThinking !== 'none' + ? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}` + : model.description} + +
+
+ +
+ + {isSelected && } + +
+
+
+ e.preventDefault()} + > +
+
+ Thinking Level +
+ {THINKING_LEVELS.map((level) => ( + + ))} +
+
+
+
+ ); + }; + // Render a grouped model with secondary popover for variant selection const renderGroupedModelItem = (group: GroupedModel) => { - const groupIsSelected = isGroupSelected(group, value as CursorModelId); - const selectedVariant = getSelectedVariant(group, value as CursorModelId); + const groupIsSelected = isGroupSelected(group, selectedModel as CursorModelId); + const selectedVariant = getSelectedVariant(group, selectedModel as CursorModelId); const isExpanded = expandedGroup === group.baseId; const variantTypeLabel = @@ -293,14 +472,14 @@ export function PhaseModelSelector({ ))} @@ -388,11 +567,11 @@ export function PhaseModelSelector({ return renderGroupedModelItem(filteredGroup); } } + // Standalone Cursor model + return renderCursorModelItem(model); } - return renderModelItem( - model, - model.provider === 'claude' ? 'claude' : 'cursor' - ); + // Claude model + return renderClaudeModelItem(model); }); })()} @@ -402,7 +581,7 @@ export function PhaseModelSelector({ {claude.length > 0 && ( - {claude.map((model) => renderModelItem(model, 'claude'))} + {claude.map((model) => renderClaudeModelItem(model))} )} @@ -411,7 +590,7 @@ export function PhaseModelSelector({ {/* Grouped models with secondary popover */} {groupedModels.map((group) => renderGroupedModelItem(group))} {/* Standalone models */} - {standaloneCursorModels.map((model) => renderModelItem(model, 'cursor'))} + {standaloneCursorModels.map((model) => renderCursorModelItem(model))} )} diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index b717b3fb..06f5fa97 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -10,6 +10,7 @@ import type { CursorModelId, PhaseModelConfig, PhaseModelKey, + PhaseModelEntry, MCPServerConfig, FeatureStatusWithPipeline, PipelineConfig, @@ -786,7 +787,7 @@ export interface AppActions { setValidationModel: (model: ModelAlias) => void; // Phase Model actions - setPhaseModel: (phase: PhaseModelKey, model: ModelAlias | CursorModelId) => Promise; + setPhaseModel: (phase: PhaseModelKey, entry: PhaseModelEntry) => Promise; setPhaseModels: (models: Partial) => Promise; resetPhaseModels: () => Promise; toggleFavoriteModel: (modelId: string) => void; @@ -1652,11 +1653,11 @@ export const useAppStore = create()( setValidationModel: (model) => set({ validationModel: model }), // Phase Model actions - setPhaseModel: async (phase, model) => { + setPhaseModel: async (phase, entry) => { set((state) => ({ phaseModels: { ...state.phaseModels, - [phase]: model, + [phase]: entry, }, })); // Sync to server settings file diff --git a/libs/model-resolver/src/index.ts b/libs/model-resolver/src/index.ts index 4e30c7f2..8b170764 100644 --- a/libs/model-resolver/src/index.ts +++ b/libs/model-resolver/src/index.ts @@ -13,4 +13,9 @@ export { } from '@automaker/types'; // Export resolver functions -export { resolveModelString, getEffectiveModel } from './resolver.js'; +export { + resolveModelString, + getEffectiveModel, + resolvePhaseModel, + type ResolvedPhaseModel, +} from './resolver.js'; diff --git a/libs/model-resolver/src/resolver.ts b/libs/model-resolver/src/resolver.ts index 944b0fe1..b77eb9cb 100644 --- a/libs/model-resolver/src/resolver.ts +++ b/libs/model-resolver/src/resolver.ts @@ -15,6 +15,8 @@ import { PROVIDER_PREFIXES, isCursorModel, stripProviderPrefix, + type PhaseModelEntry, + type ThinkingLevel, } from '@automaker/types'; /** @@ -98,3 +100,72 @@ export function getEffectiveModel( ): string { return resolveModelString(explicitModel || sessionModel, defaultModel); } + +/** + * Result of resolving a phase model entry + */ +export interface ResolvedPhaseModel { + /** Resolved model string (full model ID) */ + model: string; + /** Optional thinking level for extended thinking */ + thinkingLevel?: ThinkingLevel; +} + +/** + * Resolve a phase model entry to a model string and thinking level + * + * Handles both legacy format (string) and new format (PhaseModelEntry object). + * This centralizes the pattern used across phase model routes. + * + * @param phaseModel - Phase model entry (string or PhaseModelEntry object) + * @param defaultModel - Fallback model if resolution fails + * @returns Resolved model string and optional thinking level + * + * @remarks + * - For Cursor models, `thinkingLevel` is returned as `undefined` since Cursor + * handles thinking internally via model variants (e.g., 'claude-sonnet-4-thinking') + * - Defensively handles null/undefined from corrupted settings JSON + * + * @example + * ```ts + * const phaseModel = settings?.phaseModels?.enhancementModel || DEFAULT_PHASE_MODELS.enhancementModel; + * const { model, thinkingLevel } = resolvePhaseModel(phaseModel); + * ``` + */ +export function resolvePhaseModel( + phaseModel: string | PhaseModelEntry | null | undefined, + defaultModel: string = DEFAULT_MODELS.claude +): ResolvedPhaseModel { + console.log( + `[ModelResolver] resolvePhaseModel called with:`, + JSON.stringify(phaseModel), + `type: ${typeof phaseModel}` + ); + + // Handle null/undefined (defensive against corrupted JSON) + if (!phaseModel) { + console.log(`[ModelResolver] phaseModel is null/undefined, using default`); + return { + model: resolveModelString(undefined, defaultModel), + thinkingLevel: undefined, + }; + } + + // Handle legacy string format + if (typeof phaseModel === 'string') { + console.log(`[ModelResolver] phaseModel is string format (legacy): "${phaseModel}"`); + return { + model: resolveModelString(phaseModel, defaultModel), + thinkingLevel: undefined, + }; + } + + // Handle new PhaseModelEntry object format + console.log( + `[ModelResolver] phaseModel is object format: model="${phaseModel.model}", thinkingLevel="${phaseModel.thinkingLevel}"` + ); + return { + model: resolveModelString(phaseModel.model, defaultModel), + thinkingLevel: phaseModel.thinkingLevel, + }; +} diff --git a/libs/model-resolver/tests/resolver.test.ts b/libs/model-resolver/tests/resolver.test.ts index b0bd0c2b..459fa7df 100644 --- a/libs/model-resolver/tests/resolver.test.ts +++ b/libs/model-resolver/tests/resolver.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { resolveModelString, getEffectiveModel } from '../src/resolver'; -import { CLAUDE_MODEL_MAP, CURSOR_MODEL_MAP, DEFAULT_MODELS } from '@automaker/types'; +import { resolveModelString, getEffectiveModel, resolvePhaseModel } from '../src/resolver'; +import { + CLAUDE_MODEL_MAP, + CURSOR_MODEL_MAP, + DEFAULT_MODELS, + type PhaseModelEntry, +} from '@automaker/types'; describe('model-resolver', () => { let consoleLogSpy: ReturnType; @@ -353,4 +358,182 @@ describe('model-resolver', () => { expect(DEFAULT_MODELS.claude).toContain('claude-'); }); }); + + describe('resolvePhaseModel', () => { + describe('with null/undefined input (defensive handling)', () => { + it('should return default model when phaseModel is null', () => { + const result = resolvePhaseModel(null); + + expect(result.model).toBe(DEFAULT_MODELS.claude); + expect(result.thinkingLevel).toBeUndefined(); + }); + + it('should return default model when phaseModel is undefined', () => { + const result = resolvePhaseModel(undefined); + + expect(result.model).toBe(DEFAULT_MODELS.claude); + expect(result.thinkingLevel).toBeUndefined(); + }); + + it('should use custom default when phaseModel is null', () => { + const customDefault = 'claude-opus-4-20241113'; + const result = resolvePhaseModel(null, customDefault); + + expect(result.model).toBe(customDefault); + expect(result.thinkingLevel).toBeUndefined(); + }); + }); + + describe('with legacy string format (v2 settings)', () => { + it('should resolve Claude alias string', () => { + const result = resolvePhaseModel('sonnet'); + + expect(result.model).toBe(CLAUDE_MODEL_MAP.sonnet); + expect(result.thinkingLevel).toBeUndefined(); + }); + + it('should resolve opus alias string', () => { + const result = resolvePhaseModel('opus'); + + expect(result.model).toBe(CLAUDE_MODEL_MAP.opus); + expect(result.thinkingLevel).toBeUndefined(); + }); + + it('should resolve haiku alias string', () => { + const result = resolvePhaseModel('haiku'); + + expect(result.model).toBe(CLAUDE_MODEL_MAP.haiku); + expect(result.thinkingLevel).toBeUndefined(); + }); + + it('should pass through full Claude model string', () => { + const fullModel = 'claude-sonnet-4-20250514'; + const result = resolvePhaseModel(fullModel); + + expect(result.model).toBe(fullModel); + expect(result.thinkingLevel).toBeUndefined(); + }); + + it('should handle Cursor model string', () => { + const result = resolvePhaseModel('cursor-auto'); + + expect(result.model).toBe('cursor-auto'); + expect(result.thinkingLevel).toBeUndefined(); + }); + }); + + describe('with PhaseModelEntry object format (v3 settings)', () => { + it('should resolve model from entry without thinkingLevel', () => { + const entry: PhaseModelEntry = { model: 'sonnet' }; + const result = resolvePhaseModel(entry); + + expect(result.model).toBe(CLAUDE_MODEL_MAP.sonnet); + expect(result.thinkingLevel).toBeUndefined(); + }); + + it('should resolve model and return thinkingLevel none', () => { + const entry: PhaseModelEntry = { model: 'opus', thinkingLevel: 'none' }; + const result = resolvePhaseModel(entry); + + expect(result.model).toBe(CLAUDE_MODEL_MAP.opus); + expect(result.thinkingLevel).toBe('none'); + }); + + it('should resolve model and return thinkingLevel low', () => { + const entry: PhaseModelEntry = { model: 'sonnet', thinkingLevel: 'low' }; + const result = resolvePhaseModel(entry); + + expect(result.model).toBe(CLAUDE_MODEL_MAP.sonnet); + expect(result.thinkingLevel).toBe('low'); + }); + + it('should resolve model and return thinkingLevel medium', () => { + const entry: PhaseModelEntry = { model: 'haiku', thinkingLevel: 'medium' }; + const result = resolvePhaseModel(entry); + + expect(result.model).toBe(CLAUDE_MODEL_MAP.haiku); + expect(result.thinkingLevel).toBe('medium'); + }); + + it('should resolve model and return thinkingLevel high', () => { + const entry: PhaseModelEntry = { model: 'opus', thinkingLevel: 'high' }; + const result = resolvePhaseModel(entry); + + expect(result.model).toBe(CLAUDE_MODEL_MAP.opus); + expect(result.thinkingLevel).toBe('high'); + }); + + it('should resolve model and return thinkingLevel ultrathink', () => { + const entry: PhaseModelEntry = { model: 'opus', thinkingLevel: 'ultrathink' }; + const result = resolvePhaseModel(entry); + + expect(result.model).toBe(CLAUDE_MODEL_MAP.opus); + expect(result.thinkingLevel).toBe('ultrathink'); + }); + + it('should handle full Claude model string in entry', () => { + const entry: PhaseModelEntry = { + model: 'claude-opus-4-5-20251101', + thinkingLevel: 'high', + }; + const result = resolvePhaseModel(entry); + + expect(result.model).toBe('claude-opus-4-5-20251101'); + expect(result.thinkingLevel).toBe('high'); + }); + }); + + describe('with Cursor models (thinkingLevel should be preserved but unused)', () => { + it('should handle Cursor model entry without thinkingLevel', () => { + const entry: PhaseModelEntry = { model: 'auto' }; + const result = resolvePhaseModel(entry); + + expect(result.model).toBe('cursor-auto'); + expect(result.thinkingLevel).toBeUndefined(); + }); + + it('should preserve thinkingLevel even for Cursor models (caller handles)', () => { + // Note: thinkingLevel is meaningless for Cursor but we don't filter it + // The calling code should check isCursorModel() before using thinkingLevel + const entry: PhaseModelEntry = { model: 'composer-1', thinkingLevel: 'high' }; + const result = resolvePhaseModel(entry); + + expect(result.model).toBe('cursor-composer-1'); + expect(result.thinkingLevel).toBe('high'); + }); + + it('should handle cursor-prefixed model in entry', () => { + const entry: PhaseModelEntry = { model: 'cursor-gpt-4o' as any }; + const result = resolvePhaseModel(entry); + + expect(result.model).toBe('cursor-gpt-4o'); + }); + }); + + describe('edge cases', () => { + it('should handle empty string model in entry', () => { + const entry: PhaseModelEntry = { model: '' as any }; + const result = resolvePhaseModel(entry); + + expect(result.model).toBe(DEFAULT_MODELS.claude); + expect(result.thinkingLevel).toBeUndefined(); + }); + + it('should handle unknown model alias in entry', () => { + const entry: PhaseModelEntry = { model: 'unknown-model' as any }; + const result = resolvePhaseModel(entry); + + expect(result.model).toBe(DEFAULT_MODELS.claude); + }); + + it('should use custom default for unknown model in entry', () => { + const entry: PhaseModelEntry = { model: 'invalid' as any, thinkingLevel: 'high' }; + const customDefault = 'claude-haiku-4-5-20251001'; + const result = resolvePhaseModel(entry, customDefault); + + expect(result.model).toBe(customDefault); + expect(result.thinkingLevel).toBe('high'); + }); + }); + }); }); diff --git a/libs/types/src/feature.ts b/libs/types/src/feature.ts index 593b626b..eee6b3ea 100644 --- a/libs/types/src/feature.ts +++ b/libs/types/src/feature.ts @@ -2,7 +2,7 @@ * Feature types for AutoMaker feature management */ -import type { PlanningMode } from './settings.js'; +import type { PlanningMode, ThinkingLevel } from './settings.js'; export interface FeatureImagePath { id: string; @@ -38,7 +38,7 @@ export interface Feature { // Branch info - worktree path is derived at runtime from branchName branchName?: string; // Name of the feature branch (undefined = use current worktree) skipTests?: boolean; - thinkingLevel?: string; + thinkingLevel?: ThinkingLevel; planningMode?: PlanningMode; requirePlanApproval?: boolean; planSpec?: { diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index b7dd69d0..30742b86 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -71,6 +71,7 @@ export type { PlanningMode, ThinkingLevel, ModelProvider, + PhaseModelEntry, PhaseModelConfig, PhaseModelKey, KeyboardShortcuts, @@ -95,8 +96,10 @@ export { SETTINGS_VERSION, CREDENTIALS_VERSION, PROJECT_SETTINGS_VERSION, + THINKING_TOKEN_BUDGET, profileHasThinking, getProfileModelString, + getThinkingTokenBudget, } from './settings.js'; // Model display constants diff --git a/libs/types/src/provider.ts b/libs/types/src/provider.ts index 2193c87a..de721612 100644 --- a/libs/types/src/provider.ts +++ b/libs/types/src/provider.ts @@ -2,6 +2,8 @@ * Shared types for AI model providers */ +import type { ThinkingLevel } from './settings.js'; + /** * Configuration for a provider instance */ @@ -84,6 +86,12 @@ export interface ExecuteOptions { * Default: false (allows edits) */ readOnly?: boolean; + /** + * Extended thinking level for Claude models. + * Controls the amount of reasoning tokens allocated. + * Only applies to Claude models; Cursor models handle thinking internally. + */ + thinkingLevel?: ThinkingLevel; } /** diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index c0b5640f..55375809 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -70,9 +70,46 @@ export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full'; /** ThinkingLevel - Extended thinking levels for Claude models (reasoning intensity) */ export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink'; +/** + * Thinking token budget mapping based on Claude SDK documentation. + * @see https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking + * + * - Minimum budget: 1,024 tokens + * - Complex tasks starting point: 16,000+ tokens + * - Above 32,000: Risk of timeouts (batch processing recommended) + */ +export const THINKING_TOKEN_BUDGET: Record = { + none: undefined, // Thinking disabled + low: 1024, // Minimum per docs + medium: 10000, // Light reasoning + high: 16000, // Complex tasks (recommended starting point) + ultrathink: 32000, // Maximum safe (above this risks timeouts) +}; + +/** + * Convert thinking level to SDK maxThinkingTokens value + */ +export function getThinkingTokenBudget(level: ThinkingLevel | undefined): number | undefined { + if (!level || level === 'none') return undefined; + return THINKING_TOKEN_BUDGET[level]; +} + /** ModelProvider - AI model provider for credentials and API key management */ export type ModelProvider = 'claude' | 'cursor'; +/** + * PhaseModelEntry - Configuration for a single phase model + * + * Encapsulates both the model selection and optional thinking level + * for Claude models. Cursor models handle thinking internally. + */ +export interface PhaseModelEntry { + /** The model to use (Claude alias or Cursor model ID) */ + model: ModelAlias | CursorModelId; + /** Extended thinking level (only applies to Claude models, defaults to 'none') */ + thinkingLevel?: ThinkingLevel; +} + /** * PhaseModelConfig - Configuration for AI models used in different application phases * @@ -83,25 +120,25 @@ export type ModelProvider = 'claude' | 'cursor'; export interface PhaseModelConfig { // Quick tasks - recommend fast/cheap models (Haiku, Cursor auto) /** Model for enhancing feature names and descriptions */ - enhancementModel: ModelAlias | CursorModelId; + enhancementModel: PhaseModelEntry; /** Model for generating file context descriptions */ - fileDescriptionModel: ModelAlias | CursorModelId; + fileDescriptionModel: PhaseModelEntry; /** Model for analyzing and describing context images */ - imageDescriptionModel: ModelAlias | CursorModelId; + imageDescriptionModel: PhaseModelEntry; // Validation tasks - recommend smart models (Sonnet, Opus) /** Model for validating and improving GitHub issues */ - validationModel: ModelAlias | CursorModelId; + validationModel: PhaseModelEntry; // Generation tasks - recommend powerful models (Opus, Sonnet) /** Model for generating full application specifications */ - specGenerationModel: ModelAlias | CursorModelId; + specGenerationModel: PhaseModelEntry; /** Model for creating features from specifications */ - featureGenerationModel: ModelAlias | CursorModelId; + featureGenerationModel: PhaseModelEntry; /** Model for reorganizing and prioritizing backlog */ - backlogPlanningModel: ModelAlias | CursorModelId; + backlogPlanningModel: PhaseModelEntry; /** Model for analyzing project structure */ - projectAnalysisModel: ModelAlias | CursorModelId; + projectAnalysisModel: PhaseModelEntry; } /** Keys of PhaseModelConfig for type-safe access */ @@ -559,22 +596,22 @@ export interface ProjectSettings { /** Default phase model configuration - sensible defaults for each task type */ export const DEFAULT_PHASE_MODELS: PhaseModelConfig = { // Quick tasks - use fast models for speed and cost - enhancementModel: 'sonnet', - fileDescriptionModel: 'haiku', - imageDescriptionModel: 'haiku', + enhancementModel: { model: 'sonnet' }, + fileDescriptionModel: { model: 'haiku' }, + imageDescriptionModel: { model: 'haiku' }, // Validation - use smart models for accuracy - validationModel: 'sonnet', + validationModel: { model: 'sonnet' }, // Generation - use powerful models for quality - specGenerationModel: 'opus', - featureGenerationModel: 'sonnet', - backlogPlanningModel: 'sonnet', - projectAnalysisModel: 'sonnet', + specGenerationModel: { model: 'opus' }, + featureGenerationModel: { model: 'sonnet' }, + backlogPlanningModel: { model: 'sonnet' }, + projectAnalysisModel: { model: 'sonnet' }, }; /** Current version of the global settings schema */ -export const SETTINGS_VERSION = 2; +export const SETTINGS_VERSION = 3; /** Current version of the credentials schema */ export const CREDENTIALS_VERSION = 1; /** Current version of the project settings schema */