From 38d0e4103a38e5391b7720d6941701f24da6f1f6 Mon Sep 17 00:00:00 2001 From: Kacper Date: Tue, 30 Dec 2025 15:26:40 +0100 Subject: [PATCH] feat(server): Add Cursor provider routing to spec generation routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Cursor model support to generate-spec.ts and generate-features-from-spec.ts routes, allowing them to use Cursor models when configured in phaseModels settings. - Both routes now detect Cursor models via isCursorModel() - Route to ProviderFactory for Cursor models, Claude SDK for Claude models - Use resolveModelString() for proper model ID resolution - Extract JSON from Cursor responses using shared json-extractor utility 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../app-spec/generate-features-from-spec.ts | 130 ++++++---- .../src/routes/app-spec/generate-spec.ts | 239 +++++++++++------- 2 files changed, 239 insertions(+), 130 deletions(-) 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 f39d753a..8b8e4a4a 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 @@ -9,8 +9,10 @@ import { query } from '@anthropic-ai/claude-agent-sdk'; import * as secureFs from '../../lib/secure-fs.js'; import type { EventEmitter } from '../../lib/events.js'; import { createLogger } from '@automaker/utils'; -import { DEFAULT_PHASE_MODELS } from '@automaker/types'; +import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types'; +import { resolveModelString } from '@automaker/model-resolver'; import { createFeatureGenerationOptions } from '../../lib/sdk-options.js'; +import { ProviderFactory } from '../../providers/provider-factory.js'; import { logAuthStatus } from './common.js'; import { parseAndCreateFeatures } from './parse-and-create-features.js'; import { getAppSpecPath } from '@automaker/platform'; @@ -109,47 +111,33 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge const settings = await settingsService?.getGlobalSettings(); const featureGenerationModel = settings?.phaseModels?.featureGenerationModel || DEFAULT_PHASE_MODELS.featureGenerationModel; + const model = resolveModelString(featureGenerationModel); - logger.info('Using model:', featureGenerationModel); - - const options = createFeatureGenerationOptions({ - cwd: projectPath, - abortController, - autoLoadClaudeMd, - model: featureGenerationModel, - }); - - logger.debug('SDK Options:', JSON.stringify(options, null, 2)); - logger.info('Calling Claude Agent SDK query() for features...'); - - logAuthStatus('Right before SDK query() for features'); - - let stream; - try { - stream = query({ prompt, options }); - logger.debug('query() returned stream successfully'); - } catch (queryError) { - logger.error('❌ query() threw an exception:'); - logger.error('Error:', queryError); - throw queryError; - } + logger.info('Using model:', model); let responseText = ''; let messageCount = 0; - logger.debug('Starting to iterate over feature stream...'); + // Route to appropriate provider based on model type + if (isCursorModel(model)) { + // Use Cursor provider for Cursor models + logger.info('[FeatureGeneration] Using Cursor provider'); - try { - for await (const msg of stream) { + const provider = ProviderFactory.getProviderForModel(model); + + for await (const msg of provider.executeQuery({ + prompt, + model, + cwd: projectPath, + maxTurns: 250, + allowedTools: ['Read', 'Glob', 'Grep'], + abortController, + })) { messageCount++; - logger.debug( - `Feature stream message #${messageCount}:`, - JSON.stringify({ type: msg.type, subtype: (msg as any).subtype }, null, 2) - ); - if (msg.type === 'assistant' && msg.message.content) { + if (msg.type === 'assistant' && msg.message?.content) { for (const block of msg.message.content) { - if (block.type === 'text') { + if (block.type === 'text' && block.text) { responseText += block.text; logger.debug(`Feature text block received (${block.text.length} chars)`); events.emit('spec-regeneration:event', { @@ -159,18 +147,74 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge }); } } - } else if (msg.type === 'result' && (msg as any).subtype === 'success') { - logger.debug('Received success result for features'); - responseText = (msg as any).result || responseText; - } else if ((msg as { type: string }).type === 'error') { - logger.error('❌ Received error message from feature stream:'); - logger.error('Error message:', JSON.stringify(msg, null, 2)); + } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { + // Use result if it's a final accumulated message + if (msg.result.length > responseText.length) { + responseText = msg.result; + } } } - } catch (streamError) { - logger.error('❌ Error while iterating feature stream:'); - logger.error('Stream error:', streamError); - throw streamError; + } else { + // Use Claude SDK for Claude models + logger.info('[FeatureGeneration] Using Claude SDK'); + + const options = createFeatureGenerationOptions({ + cwd: projectPath, + abortController, + autoLoadClaudeMd, + model, + }); + + logger.debug('SDK Options:', JSON.stringify(options, null, 2)); + logger.info('Calling Claude Agent SDK query() for features...'); + + logAuthStatus('Right before SDK query() for features'); + + let stream; + try { + stream = query({ prompt, options }); + logger.debug('query() returned stream successfully'); + } catch (queryError) { + logger.error('❌ query() threw an exception:'); + logger.error('Error:', queryError); + throw queryError; + } + + logger.debug('Starting to iterate over feature stream...'); + + try { + for await (const msg of stream) { + messageCount++; + logger.debug( + `Feature stream message #${messageCount}:`, + JSON.stringify({ type: msg.type, subtype: (msg as any).subtype }, null, 2) + ); + + if (msg.type === 'assistant' && msg.message.content) { + for (const block of msg.message.content) { + if (block.type === 'text') { + responseText += block.text; + logger.debug(`Feature text block received (${block.text.length} chars)`); + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: block.text, + projectPath: projectPath, + }); + } + } + } else if (msg.type === 'result' && (msg as any).subtype === 'success') { + logger.debug('Received success result for features'); + responseText = (msg as any).result || responseText; + } else if ((msg as { type: string }).type === 'error') { + logger.error('❌ Received error message from feature stream:'); + logger.error('Error message:', JSON.stringify(msg, null, 2)); + } + } + } catch (streamError) { + logger.error('❌ Error while iterating feature stream:'); + logger.error('Stream error:', streamError); + throw streamError; + } } logger.info(`Feature stream complete. Total messages: ${messageCount}`); diff --git a/apps/server/src/routes/app-spec/generate-spec.ts b/apps/server/src/routes/app-spec/generate-spec.ts index 1797509e..87ccf1e7 100644 --- a/apps/server/src/routes/app-spec/generate-spec.ts +++ b/apps/server/src/routes/app-spec/generate-spec.ts @@ -16,8 +16,11 @@ import { type SpecOutput, } from '../../lib/app-spec-format.js'; import { createLogger } from '@automaker/utils'; -import { DEFAULT_PHASE_MODELS } from '@automaker/types'; +import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types'; +import { resolveModelString } 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'; import { logAuthStatus } from './common.js'; import { generateFeaturesFromSpec } from './generate-features-from-spec.js'; import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform'; @@ -101,106 +104,168 @@ ${getStructuredSpecPromptInstruction()}`; const settings = await settingsService?.getGlobalSettings(); const specGenerationModel = settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel; + const model = resolveModelString(specGenerationModel); - logger.info('Using model:', specGenerationModel); - - const options = createSpecGenerationOptions({ - cwd: projectPath, - abortController, - autoLoadClaudeMd, - model: specGenerationModel, - outputFormat: { - type: 'json_schema', - schema: specOutputSchema, - }, - }); - - logger.debug('SDK Options:', JSON.stringify(options, null, 2)); - logger.info('Calling Claude Agent SDK query()...'); - - // Log auth status right before the SDK call - logAuthStatus('Right before SDK query()'); - - let stream; - try { - stream = query({ prompt, options }); - logger.debug('query() returned stream successfully'); - } catch (queryError) { - logger.error('❌ query() threw an exception:'); - logger.error('Error:', queryError); - throw queryError; - } + logger.info('Using model:', model); let responseText = ''; let messageCount = 0; let structuredOutput: SpecOutput | null = null; - logger.info('Starting to iterate over stream...'); + // Route to appropriate provider based on model type + if (isCursorModel(model)) { + // Use Cursor provider for Cursor models + logger.info('[SpecGeneration] Using Cursor provider'); - try { - for await (const msg of stream) { + const provider = ProviderFactory.getProviderForModel(model); + + // For Cursor, include the JSON schema in the prompt + const cursorPrompt = `${prompt} + +IMPORTANT: You must respond with a valid JSON object matching this schema: +${JSON.stringify(specOutputSchema, null, 2)}`; + + for await (const msg of provider.executeQuery({ + prompt: cursorPrompt, + model, + cwd: projectPath, + maxTurns: 250, + allowedTools: ['Read', 'Glob', 'Grep'], + abortController, + })) { messageCount++; - logger.info( - `Stream message #${messageCount}: type=${msg.type}, subtype=${(msg as any).subtype}` - ); - if (msg.type === 'assistant') { - const msgAny = msg as any; - if (msgAny.message?.content) { - for (const block of msgAny.message.content) { - if (block.type === 'text') { - responseText += block.text; - logger.info( - `Text block received (${block.text.length} chars), total now: ${responseText.length} chars` - ); - events.emit('spec-regeneration:event', { - type: 'spec_regeneration_progress', - content: block.text, - projectPath: projectPath, - }); - } else if (block.type === 'tool_use') { - logger.info('Tool use:', block.name); - events.emit('spec-regeneration:event', { - type: 'spec_tool', - tool: block.name, - input: block.input, - }); - } + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + responseText += block.text; + logger.info( + `Text block received (${block.text.length} chars), total now: ${responseText.length} chars` + ); + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: block.text, + projectPath: projectPath, + }); + } else if (block.type === 'tool_use') { + logger.info('Tool use:', block.name); + events.emit('spec-regeneration:event', { + type: 'spec_tool', + tool: block.name, + input: block.input, + }); } } - } else if (msg.type === 'result' && (msg as any).subtype === 'success') { - logger.info('Received success result'); - // Check for structured output - this is the reliable way to get spec data - const resultMsg = msg as any; - if (resultMsg.structured_output) { - structuredOutput = resultMsg.structured_output as SpecOutput; - logger.info('✅ Received structured output'); - logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2)); - } else { - logger.warn('⚠️ No structured output in result, will fall back to text parsing'); + } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { + // Use result if it's a final accumulated message + if (msg.result.length > responseText.length) { + responseText = msg.result; } - } else if (msg.type === 'result') { - // Handle error result types - const subtype = (msg as any).subtype; - logger.info(`Result message: subtype=${subtype}`); - if (subtype === 'error_max_turns') { - logger.error('❌ Hit max turns limit!'); - } else if (subtype === 'error_max_structured_output_retries') { - logger.error('❌ Failed to produce valid structured output after retries'); - throw new Error('Could not produce valid spec output'); - } - } else if ((msg as { type: string }).type === 'error') { - logger.error('❌ Received error message from stream:'); - logger.error('Error message:', JSON.stringify(msg, null, 2)); - } else if (msg.type === 'user') { - // Log user messages (tool results) - logger.info(`User message (tool result): ${JSON.stringify(msg).substring(0, 500)}`); } } - } catch (streamError) { - logger.error('❌ Error while iterating stream:'); - logger.error('Stream error:', streamError); - throw streamError; + + // Parse JSON from the response text using shared utility + if (responseText) { + structuredOutput = extractJson(responseText, { logger }); + } + } else { + // Use Claude SDK for Claude models + logger.info('[SpecGeneration] Using Claude SDK'); + + const options = createSpecGenerationOptions({ + cwd: projectPath, + abortController, + autoLoadClaudeMd, + model, + outputFormat: { + type: 'json_schema', + schema: specOutputSchema, + }, + }); + + logger.debug('SDK Options:', JSON.stringify(options, null, 2)); + logger.info('Calling Claude Agent SDK query()...'); + + // Log auth status right before the SDK call + logAuthStatus('Right before SDK query()'); + + let stream; + try { + stream = query({ prompt, options }); + logger.debug('query() returned stream successfully'); + } catch (queryError) { + logger.error('❌ query() threw an exception:'); + logger.error('Error:', queryError); + throw queryError; + } + + logger.info('Starting to iterate over stream...'); + + try { + for await (const msg of stream) { + messageCount++; + logger.info( + `Stream message #${messageCount}: type=${msg.type}, subtype=${(msg as any).subtype}` + ); + + if (msg.type === 'assistant') { + const msgAny = msg as any; + if (msgAny.message?.content) { + for (const block of msgAny.message.content) { + if (block.type === 'text') { + responseText += block.text; + logger.info( + `Text block received (${block.text.length} chars), total now: ${responseText.length} chars` + ); + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: block.text, + projectPath: projectPath, + }); + } else if (block.type === 'tool_use') { + logger.info('Tool use:', block.name); + events.emit('spec-regeneration:event', { + type: 'spec_tool', + tool: block.name, + input: block.input, + }); + } + } + } + } else if (msg.type === 'result' && (msg as any).subtype === 'success') { + logger.info('Received success result'); + // Check for structured output - this is the reliable way to get spec data + const resultMsg = msg as any; + if (resultMsg.structured_output) { + structuredOutput = resultMsg.structured_output as SpecOutput; + logger.info('✅ Received structured output'); + logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2)); + } else { + logger.warn('⚠️ No structured output in result, will fall back to text parsing'); + } + } else if (msg.type === 'result') { + // Handle error result types + const subtype = (msg as any).subtype; + logger.info(`Result message: subtype=${subtype}`); + if (subtype === 'error_max_turns') { + logger.error('❌ Hit max turns limit!'); + } else if (subtype === 'error_max_structured_output_retries') { + logger.error('❌ Failed to produce valid structured output after retries'); + throw new Error('Could not produce valid spec output'); + } + } else if ((msg as { type: string }).type === 'error') { + logger.error('❌ Received error message from stream:'); + logger.error('Error message:', JSON.stringify(msg, null, 2)); + } else if (msg.type === 'user') { + // Log user messages (tool results) + logger.info(`User message (tool result): ${JSON.stringify(msg).substring(0, 500)}`); + } + } + } catch (streamError) { + logger.error('❌ Error while iterating stream:'); + logger.error('Stream error:', streamError); + throw streamError; + } } logger.info(`Stream iteration complete. Total messages: ${messageCount}`);