From 81d300391dcecc50d959f2cdd85570567f539d46 Mon Sep 17 00:00:00 2001 From: Shirone Date: Fri, 2 Jan 2026 14:55:52 +0100 Subject: [PATCH] feat: enhance SDK options with thinking level support - Introduced a new function, buildThinkingOptions, to handle the conversion of ThinkingLevel to maxThinkingTokens for the Claude SDK. - Updated existing SDK option creation functions to incorporate thinking options, ensuring that maxThinkingTokens are included based on the specified thinking level. - Enhanced the settings service to support migration of phase models to include thinking levels, improving compatibility with new configurations. - Added comprehensive tests for thinking level integration and migration logic, ensuring robust functionality across the application. This update significantly improves the SDK's configurability and performance by allowing for more nuanced control over reasoning capabilities. --- apps/server/src/lib/sdk-options.ts | 50 +++- apps/server/src/providers/claude-provider.ts | 7 + .../app-spec/generate-features-from-spec.ts | 7 +- .../src/routes/app-spec/generate-spec.ts | 7 +- .../src/routes/backlog-plan/generate-plan.ts | 10 +- .../routes/context/routes/describe-file.ts | 14 +- .../routes/context/routes/describe-image.ts | 7 +- .../routes/github/routes/validate-issue.ts | 10 +- .../suggestions/generate-suggestions.ts | 7 +- apps/server/src/services/auto-mode-service.ts | 20 +- apps/server/src/services/settings-service.ts | 54 +++- apps/server/src/types/settings.ts | 1 + .../server/tests/unit/lib/sdk-options.test.ts | 199 ++++++++++++++ .../unit/services/settings-service.test.ts | 164 ++++++++++++ .../shared/model-override-trigger.tsx | 18 +- .../components/shared/use-model-override.ts | 15 +- .../dialogs/backlog-plan-dialog.tsx | 26 +- .../hooks/use-issue-validation.ts | 18 +- .../phase-models/phase-model-selector.tsx | 243 +++++++++++++++--- apps/ui/src/store/app-store.ts | 7 +- libs/model-resolver/src/index.ts | 7 +- libs/model-resolver/src/resolver.ts | 71 +++++ libs/model-resolver/tests/resolver.test.ts | 187 +++++++++++++- libs/types/src/feature.ts | 4 +- libs/types/src/index.ts | 3 + libs/types/src/provider.ts | 8 + libs/types/src/settings.ts | 71 +++-- 27 files changed, 1134 insertions(+), 101 deletions(-) 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 */