diff --git a/Dockerfile b/Dockerfile index b31ffd09..f40b1287 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,6 +59,11 @@ FROM node:22-slim AS server ARG GIT_COMMIT_SHA=unknown LABEL automaker.git.commit.sha="${GIT_COMMIT_SHA}" +# Build arguments for user ID matching (allows matching host user for mounted volumes) +# Override at build time: docker build --build-arg UID=$(id -u) --build-arg GID=$(id -g) ... +ARG UID=1001 +ARG GID=1001 + # Install git, curl, bash (for terminal), gosu (for user switching), and GitHub CLI (pinned version, multi-arch) RUN apt-get update && apt-get install -y --no-install-recommends \ git curl bash gosu ca-certificates openssh-client \ @@ -79,8 +84,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN npm install -g @anthropic-ai/claude-code # Create non-root user with home directory BEFORE installing Cursor CLI -RUN groupadd -g 1001 automaker && \ - useradd -u 1001 -g automaker -m -d /home/automaker -s /bin/bash automaker && \ +# Uses UID/GID build args to match host user for mounted volume permissions +# Use -o flag to allow non-unique IDs (GID 1000 may already exist as 'node' group) +RUN groupadd -o -g ${GID} automaker && \ + useradd -o -u ${UID} -g automaker -m -d /home/automaker -s /bin/bash automaker && \ mkdir -p /home/automaker/.local/bin && \ mkdir -p /home/automaker/.cursor && \ chown -R automaker:automaker /home/automaker && \ diff --git a/Dockerfile.dev b/Dockerfile.dev index 87ac6bf6..1acd7742 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -27,9 +27,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Install Claude CLI globally RUN npm install -g @anthropic-ai/claude-code -# Create non-root user -RUN groupadd -g 1001 automaker && \ - useradd -u 1001 -g automaker -m -d /home/automaker -s /bin/bash automaker && \ +# Build arguments for user ID matching (allows matching host user for mounted volumes) +# Override at build time: docker-compose build --build-arg UID=$(id -u) --build-arg GID=$(id -g) +ARG UID=1001 +ARG GID=1001 + +# Create non-root user with configurable UID/GID +# Use -o flag to allow non-unique IDs (GID 1000 may already exist as 'node' group) +RUN groupadd -o -g ${GID} automaker && \ + useradd -o -u ${UID} -g automaker -m -d /home/automaker -s /bin/bash automaker && \ mkdir -p /home/automaker/.local/bin && \ mkdir -p /home/automaker/.cursor && \ chown -R automaker:automaker /home/automaker && \ diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index ba86bfad..ecdd46af 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -99,6 +99,8 @@ export class ClaudeProvider extends BaseProvider { ...(maxThinkingTokens && { maxThinkingTokens }), // Subagents configuration for specialized task delegation ...(options.agents && { agents: options.agents }), + // Pass through outputFormat for structured JSON outputs + ...(options.outputFormat && { outputFormat: options.outputFormat }), }; // Build prompt payload diff --git a/apps/server/src/providers/index.ts b/apps/server/src/providers/index.ts index a1da283b..b53695f6 100644 --- a/apps/server/src/providers/index.ts +++ b/apps/server/src/providers/index.ts @@ -30,3 +30,11 @@ export { OpencodeProvider } from './opencode-provider.js'; // Provider factory export { ProviderFactory } from './provider-factory.js'; + +// Simple query service - unified interface for basic AI queries +export { simpleQuery, streamingQuery } from './simple-query-service.js'; +export type { + SimpleQueryOptions, + SimpleQueryResult, + StreamingQueryOptions, +} from './simple-query-service.js'; diff --git a/apps/server/src/providers/simple-query-service.ts b/apps/server/src/providers/simple-query-service.ts new file mode 100644 index 00000000..b37ef732 --- /dev/null +++ b/apps/server/src/providers/simple-query-service.ts @@ -0,0 +1,240 @@ +/** + * Simple Query Service - Simplified interface for basic AI queries + * + * Use this for routes that need simple text responses without + * complex event handling. This service abstracts away the provider + * selection and streaming details, providing a clean interface + * for common query patterns. + * + * Benefits: + * - No direct SDK imports needed in route files + * - Consistent provider routing based on model + * - Automatic text extraction from streaming responses + * - Structured output support for JSON schema responses + * - Eliminates duplicate extractTextFromStream() functions + */ + +import { ProviderFactory } from './provider-factory.js'; +import type { ProviderMessage, ContentBlock, ThinkingLevel } from '@automaker/types'; + +/** + * Options for simple query execution + */ +export interface SimpleQueryOptions { + /** The prompt to send to the AI (can be text or multi-part content) */ + prompt: string | Array<{ type: string; text?: string; source?: object }>; + /** Model to use (with or without provider prefix) */ + model?: string; + /** Working directory for the query */ + cwd: string; + /** System prompt (combined with user prompt for some providers) */ + systemPrompt?: string; + /** Maximum turns for agentic operations (default: 1) */ + maxTurns?: number; + /** Tools to allow (default: [] for simple queries) */ + allowedTools?: string[]; + /** Abort controller for cancellation */ + abortController?: AbortController; + /** Structured output format for JSON responses */ + outputFormat?: { + type: 'json_schema'; + schema: Record; + }; + /** Thinking level for Claude models */ + thinkingLevel?: ThinkingLevel; + /** If true, runs in read-only mode (no file writes) */ + readOnly?: boolean; + /** Setting sources for CLAUDE.md loading */ + settingSources?: Array<'user' | 'project' | 'local'>; +} + +/** + * Result from a simple query + */ +export interface SimpleQueryResult { + /** The accumulated text response */ + text: string; + /** Structured output if outputFormat was specified and provider supports it */ + structured_output?: Record; +} + +/** + * Options for streaming query execution + */ +export interface StreamingQueryOptions extends SimpleQueryOptions { + /** Callback for each text chunk received */ + onText?: (text: string) => void; + /** Callback for tool use events */ + onToolUse?: (tool: string, input: unknown) => void; + /** Callback for thinking blocks (if available) */ + onThinking?: (thinking: string) => void; +} + +/** + * Default model to use when none specified + */ +const DEFAULT_MODEL = 'claude-sonnet-4-20250514'; + +/** + * Execute a simple query and return the text result + * + * Use this for simple, non-streaming queries where you just need + * the final text response. For more complex use cases with progress + * callbacks, use streamingQuery() instead. + * + * @example + * ```typescript + * const result = await simpleQuery({ + * prompt: 'Generate a title for: user authentication', + * cwd: process.cwd(), + * systemPrompt: 'You are a title generator...', + * maxTurns: 1, + * allowedTools: [], + * }); + * console.log(result.text); // "Add user authentication" + * ``` + */ +export async function simpleQuery(options: SimpleQueryOptions): Promise { + const model = options.model || DEFAULT_MODEL; + const provider = ProviderFactory.getProviderForModel(model); + + let responseText = ''; + let structuredOutput: Record | undefined; + + // Build provider options + const providerOptions = { + prompt: options.prompt, + model: model, + cwd: options.cwd, + systemPrompt: options.systemPrompt, + maxTurns: options.maxTurns ?? 1, + allowedTools: options.allowedTools ?? [], + abortController: options.abortController, + outputFormat: options.outputFormat, + thinkingLevel: options.thinkingLevel, + readOnly: options.readOnly, + settingSources: options.settingSources, + }; + + for await (const msg of provider.executeQuery(providerOptions)) { + // Handle error messages + if (msg.type === 'error') { + const errorMessage = msg.error || 'Provider returned an error'; + throw new Error(errorMessage); + } + + // Extract text from assistant messages + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + responseText += block.text; + } + } + } + + // Handle result messages + if (msg.type === 'result') { + if (msg.subtype === 'success') { + // Use result text if longer than accumulated text + if (msg.result && msg.result.length > responseText.length) { + responseText = msg.result; + } + // Capture structured output if present + if (msg.structured_output) { + structuredOutput = msg.structured_output; + } + } else if (msg.subtype === 'error_max_turns') { + // Max turns reached - return what we have + break; + } else if (msg.subtype === 'error_max_structured_output_retries') { + throw new Error('Could not produce valid structured output after retries'); + } + } + } + + return { text: responseText, structured_output: structuredOutput }; +} + +/** + * Execute a streaming query with event callbacks + * + * Use this for queries where you need real-time progress updates, + * such as when displaying streaming output to a user. + * + * @example + * ```typescript + * const result = await streamingQuery({ + * prompt: 'Analyze this project and suggest improvements', + * cwd: '/path/to/project', + * maxTurns: 250, + * allowedTools: ['Read', 'Glob', 'Grep'], + * onText: (text) => emitProgress(text), + * onToolUse: (tool, input) => emitToolUse(tool, input), + * }); + * ``` + */ +export async function streamingQuery(options: StreamingQueryOptions): Promise { + const model = options.model || DEFAULT_MODEL; + const provider = ProviderFactory.getProviderForModel(model); + + let responseText = ''; + let structuredOutput: Record | undefined; + + // Build provider options + const providerOptions = { + prompt: options.prompt, + model: model, + cwd: options.cwd, + systemPrompt: options.systemPrompt, + maxTurns: options.maxTurns ?? 250, + allowedTools: options.allowedTools ?? ['Read', 'Glob', 'Grep'], + abortController: options.abortController, + outputFormat: options.outputFormat, + thinkingLevel: options.thinkingLevel, + readOnly: options.readOnly, + settingSources: options.settingSources, + }; + + for await (const msg of provider.executeQuery(providerOptions)) { + // Handle error messages + if (msg.type === 'error') { + const errorMessage = msg.error || 'Provider returned an error'; + throw new Error(errorMessage); + } + + // Extract content from assistant messages + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + responseText += block.text; + options.onText?.(block.text); + } else if (block.type === 'tool_use' && block.name) { + options.onToolUse?.(block.name, block.input); + } else if (block.type === 'thinking' && block.thinking) { + options.onThinking?.(block.thinking); + } + } + } + + // Handle result messages + if (msg.type === 'result') { + if (msg.subtype === 'success') { + // Use result text if longer than accumulated text + if (msg.result && msg.result.length > responseText.length) { + responseText = msg.result; + } + // Capture structured output if present + if (msg.structured_output) { + structuredOutput = msg.structured_output; + } + } else if (msg.subtype === 'error_max_turns') { + // Max turns reached - return what we have + break; + } else if (msg.subtype === 'error_max_structured_output_retries') { + throw new Error('Could not produce valid structured output after retries'); + } + } + } + + return { text: responseText, structured_output: structuredOutput }; +} 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 d89e5eff..55bf792b 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 @@ -5,15 +5,12 @@ * (defaults to Sonnet for balanced speed and quality). */ -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, isCursorModel, stripProviderPrefix } from '@automaker/types'; +import { DEFAULT_PHASE_MODELS } from '@automaker/types'; 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'; +import { streamingQuery } from '../../providers/simple-query-service.js'; import { parseAndCreateFeatures } from './parse-and-create-features.js'; import { getAppSpecPath } from '@automaker/platform'; import type { SettingsService } from '../../services/settings-service.js'; @@ -115,121 +112,30 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge logger.info('Using model:', model); - let responseText = ''; - let messageCount = 0; + // Use streamingQuery with event callbacks + const result = await streamingQuery({ + prompt, + model, + cwd: projectPath, + maxTurns: 250, + allowedTools: ['Read', 'Glob', 'Grep'], + abortController, + thinkingLevel, + readOnly: true, // Feature generation only reads code, doesn't write + settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + onText: (text) => { + logger.debug(`Feature text block received (${text.length} chars)`); + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: text, + projectPath: projectPath, + }); + }, + }); - // Route to appropriate provider based on model type - if (isCursorModel(model)) { - // Use Cursor provider for Cursor models - logger.info('[FeatureGeneration] Using Cursor provider'); + const responseText = result.text; - const provider = ProviderFactory.getProviderForModel(model); - // Strip provider prefix - providers expect bare model IDs - const bareModel = stripProviderPrefix(model); - - // Add explicit instructions for Cursor to return JSON in response - const cursorPrompt = `${prompt} - -CRITICAL INSTRUCTIONS: -1. DO NOT write any files. Return the JSON in your response only. -2. Respond with ONLY a JSON object - no explanations, no markdown, just raw JSON. -3. Your entire response should be valid JSON starting with { and ending with }. No text before or after.`; - - for await (const msg of provider.executeQuery({ - prompt: cursorPrompt, - model: bareModel, - cwd: projectPath, - maxTurns: 250, - allowedTools: ['Read', 'Glob', 'Grep'], - abortController, - readOnly: true, // Feature generation only reads code, doesn't write - })) { - messageCount++; - - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text' && block.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.subtype === 'success' && msg.result) { - // Use result if it's a final accumulated message - if (msg.result.length > responseText.length) { - responseText = msg.result; - } - } - } - } else { - // Use Claude SDK for Claude models - logger.info('[FeatureGeneration] Using Claude SDK'); - - const options = createFeatureGenerationOptions({ - cwd: projectPath, - abortController, - autoLoadClaudeMd, - model, - thinkingLevel, // Pass thinking level for extended thinking - }); - - 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}`); + logger.info(`Feature stream complete.`); logger.info(`Feature response length: ${responseText.length} chars`); logger.info('========== FULL RESPONSE TEXT =========='); logger.info(responseText); diff --git a/apps/server/src/routes/app-spec/generate-spec.ts b/apps/server/src/routes/app-spec/generate-spec.ts index fe293da2..4af42b48 100644 --- a/apps/server/src/routes/app-spec/generate-spec.ts +++ b/apps/server/src/routes/app-spec/generate-spec.ts @@ -5,8 +5,6 @@ * (defaults to Opus for high-quality specification generation). */ -import { query } from '@anthropic-ai/claude-agent-sdk'; -import path from 'path'; import * as secureFs from '../../lib/secure-fs.js'; import type { EventEmitter } from '../../lib/events.js'; import { @@ -16,12 +14,10 @@ import { type SpecOutput, } from '../../lib/app-spec-format.js'; import { createLogger } from '@automaker/utils'; -import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types'; +import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types'; 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'; -import { logAuthStatus } from './common.js'; +import { streamingQuery } from '../../providers/simple-query-service.js'; import { generateFeaturesFromSpec } from './generate-features-from-spec.js'; import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform'; import type { SettingsService } from '../../services/settings-service.js'; @@ -109,21 +105,15 @@ ${getStructuredSpecPromptInstruction()}`; logger.info('Using model:', model); let responseText = ''; - let messageCount = 0; let structuredOutput: SpecOutput | null = null; - // Route to appropriate provider based on model type - if (isCursorModel(model)) { - // Use Cursor provider for Cursor models - logger.info('[SpecGeneration] Using Cursor provider'); + // Determine if we should use structured output (Claude supports it, Cursor doesn't) + const useStructuredOutput = !isCursorModel(model); - const provider = ProviderFactory.getProviderForModel(model); - // Strip provider prefix - providers expect bare model IDs - const bareModel = stripProviderPrefix(model); - - // For Cursor, include the JSON schema in the prompt with clear instructions - // to return JSON in the response (not write to a file) - const cursorPrompt = `${prompt} + // Build the final prompt - for Cursor, include JSON schema instructions + let finalPrompt = prompt; + if (!useStructuredOutput) { + finalPrompt = `${prompt} CRITICAL INSTRUCTIONS: 1. DO NOT write any files. DO NOT create any files like "project_specification.json". @@ -133,153 +123,57 @@ CRITICAL INSTRUCTIONS: ${JSON.stringify(specOutputSchema, null, 2)} Your entire response should be valid JSON starting with { and ending with }. No text before or after.`; - - for await (const msg of provider.executeQuery({ - prompt: cursorPrompt, - model: bareModel, - cwd: projectPath, - maxTurns: 250, - allowedTools: ['Read', 'Glob', 'Grep'], - abortController, - readOnly: true, // Spec generation only reads code, we write the spec ourselves - })) { - messageCount++; - - 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.subtype === 'success' && msg.result) { - // Use result if it's a final accumulated message - if (msg.result.length > responseText.length) { - responseText = msg.result; - } - } - } - - // 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, - thinkingLevel, // Pass thinking level for extended thinking - 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}`); + // Use streamingQuery with event callbacks + const result = await streamingQuery({ + prompt: finalPrompt, + model, + cwd: projectPath, + maxTurns: 250, + allowedTools: ['Read', 'Glob', 'Grep'], + abortController, + thinkingLevel, + readOnly: true, // Spec generation only reads code, we write the spec ourselves + settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + outputFormat: useStructuredOutput + ? { + type: 'json_schema', + schema: specOutputSchema, + } + : undefined, + onText: (text) => { + responseText += text; + logger.info( + `Text block received (${text.length} chars), total now: ${responseText.length} chars` + ); + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: text, + projectPath: projectPath, + }); + }, + onToolUse: (tool, input) => { + logger.info('Tool use:', tool); + events.emit('spec-regeneration:event', { + type: 'spec_tool', + tool, + input, + }); + }, + }); + + // Get structured output if available + if (result.structured_output) { + structuredOutput = result.structured_output as unknown as SpecOutput; + logger.info('✅ Received structured output'); + logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2)); + } else if (!useStructuredOutput && responseText) { + // For non-Claude providers, parse JSON from response text + structuredOutput = extractJson(responseText, { logger }); + } + + logger.info(`Stream iteration complete.`); logger.info(`Response text length: ${responseText.length} chars`); // Determine XML content to save diff --git a/apps/server/src/routes/context/routes/describe-file.ts b/apps/server/src/routes/context/routes/describe-file.ts index 1e312ff3..ca8c2b4a 100644 --- a/apps/server/src/routes/context/routes/describe-file.ts +++ b/apps/server/src/routes/context/routes/describe-file.ts @@ -11,13 +11,11 @@ */ import type { Request, Response } from 'express'; -import { query } from '@anthropic-ai/claude-agent-sdk'; import { createLogger } from '@automaker/utils'; -import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types'; +import { DEFAULT_PHASE_MODELS } from '@automaker/types'; import { PathNotAllowedError } from '@automaker/platform'; import { resolvePhaseModel } from '@automaker/model-resolver'; -import { createCustomOptions } from '../../../lib/sdk-options.js'; -import { ProviderFactory } from '../../../providers/provider-factory.js'; +import { simpleQuery } from '../../../providers/simple-query-service.js'; import * as secureFs from '../../../lib/secure-fs.js'; import * as path from 'path'; import type { SettingsService } from '../../../services/settings-service.js'; @@ -49,31 +47,6 @@ interface DescribeFileErrorResponse { error: string; } -/** - * Extract text content from Claude SDK response messages - */ -async function extractTextFromStream( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - stream: AsyncIterable -): Promise { - let responseText = ''; - - for await (const msg of stream) { - if (msg.type === 'assistant' && msg.message?.content) { - const blocks = msg.message.content as Array<{ type: string; text?: string }>; - for (const block of blocks) { - if (block.type === 'text' && block.text) { - responseText += block.text; - } - } - } else if (msg.type === 'result' && msg.subtype === 'success') { - responseText = msg.result || responseText; - } - } - - return responseText; -} - /** * Create the describe-file request handler * @@ -159,16 +132,14 @@ export function createDescribeFileHandler( // Build prompt with file content passed as structured data // The file content is included directly, not via tool invocation - const instructionText = `Analyze the following file and provide a 1-2 sentence description suitable for use as context in an AI coding assistant. Focus on what the file contains, its purpose, and why an AI agent might want to use this context in the future (e.g., "API documentation for the authentication endpoints", "Configuration file for database connections", "Coding style guidelines for the project"). + const prompt = `Analyze the following file and provide a 1-2 sentence description suitable for use as context in an AI coding assistant. Focus on what the file contains, its purpose, and why an AI agent might want to use this context in the future (e.g., "API documentation for the authentication endpoints", "Configuration file for database connections", "Coding style guidelines for the project"). Respond with ONLY the description text, no additional formatting, preamble, or explanation. -File: ${fileName}${truncated ? ' (truncated)' : ''}`; +File: ${fileName}${truncated ? ' (truncated)' : ''} - const promptContent = [ - { type: 'text' as const, text: instructionText }, - { type: 'text' as const, text: `\n\n--- FILE CONTENT ---\n${contentToAnalyze}` }, - ]; +--- FILE CONTENT --- +${contentToAnalyze}`; // Use the file's directory as the working directory const cwd = path.dirname(resolvedPath); @@ -190,67 +161,19 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`; logger.info(`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`); - let description: string; + // Use simpleQuery - provider abstraction handles routing to correct provider + const result = await simpleQuery({ + prompt, + model, + cwd, + maxTurns: 1, + allowedTools: [], + thinkingLevel, + readOnly: true, // File description only reads, doesn't write + settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + }); - // Route to appropriate provider based on model type - if (isCursorModel(model)) { - // Use Cursor provider for Cursor models - logger.info(`Using Cursor provider for model: ${model}`); - - const provider = ProviderFactory.getProviderForModel(model); - // Strip provider prefix - providers expect bare model IDs - const bareModel = stripProviderPrefix(model); - - // Build a simple text prompt for Cursor (no multi-part content blocks) - const cursorPrompt = `${instructionText}\n\n--- FILE CONTENT ---\n${contentToAnalyze}`; - - let responseText = ''; - for await (const msg of provider.executeQuery({ - prompt: cursorPrompt, - model: bareModel, - cwd, - maxTurns: 1, - allowedTools: [], - readOnly: true, // File description only reads, doesn't write - })) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text' && block.text) { - responseText += block.text; - } - } - } - } - description = responseText; - } else { - // Use Claude SDK for Claude models - logger.info(`Using Claude SDK for model: ${model}`); - - // Use centralized SDK options with proper cwd validation - // No tools needed since we're passing file content directly - const sdkOptions = createCustomOptions({ - cwd, - model, - maxTurns: 1, - allowedTools: [], - autoLoadClaudeMd, - thinkingLevel, // Pass thinking level for extended thinking - }); - - const promptGenerator = (async function* () { - yield { - type: 'user' as const, - session_id: '', - message: { role: 'user' as const, content: promptContent }, - parent_tool_use_id: null, - }; - })(); - - const stream = query({ prompt: promptGenerator, options: sdkOptions }); - - // Extract the description from the response - description = await extractTextFromStream(stream); - } + const description = result.text; if (!description || description.trim().length === 0) { logger.warn('Received empty response from Claude'); diff --git a/apps/server/src/routes/context/routes/describe-image.ts b/apps/server/src/routes/context/routes/describe-image.ts index 0ff8e143..e5e50963 100644 --- a/apps/server/src/routes/context/routes/describe-image.ts +++ b/apps/server/src/routes/context/routes/describe-image.ts @@ -12,12 +12,10 @@ */ 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, stripProviderPrefix } from '@automaker/types'; +import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types'; import { resolvePhaseModel } from '@automaker/model-resolver'; -import { createCustomOptions } from '../../../lib/sdk-options.js'; -import { ProviderFactory } from '../../../providers/provider-factory.js'; +import { simpleQuery } from '../../../providers/simple-query-service.js'; import * as secureFs from '../../../lib/secure-fs.js'; import * as path from 'path'; import type { SettingsService } from '../../../services/settings-service.js'; @@ -178,57 +176,10 @@ function mapDescribeImageError(rawMessage: string | undefined): { return baseResponse; } -/** - * Extract text content from Claude SDK response messages and log high-signal stream events. - */ -async function extractTextFromStream( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - stream: AsyncIterable, - requestId: string -): Promise { - let responseText = ''; - let messageCount = 0; - - logger.info(`[${requestId}] [Stream] Begin reading SDK stream...`); - - for await (const msg of stream) { - messageCount++; - const msgType = msg?.type; - const msgSubtype = msg?.subtype; - - // Keep this concise but informative. Full error object is logged in catch blocks. - logger.info( - `[${requestId}] [Stream] #${messageCount} type=${String(msgType)} subtype=${String(msgSubtype ?? '')}` - ); - - if (msgType === 'assistant' && msg.message?.content) { - const blocks = msg.message.content as Array<{ type: string; text?: string }>; - logger.info(`[${requestId}] [Stream] assistant blocks=${blocks.length}`); - for (const block of blocks) { - if (block.type === 'text' && block.text) { - responseText += block.text; - } - } - } - - if (msgType === 'result' && msgSubtype === 'success') { - if (typeof msg.result === 'string' && msg.result.length > 0) { - responseText = msg.result; - } - } - } - - logger.info( - `[${requestId}] [Stream] End of stream. messages=${messageCount} textLength=${responseText.length}` - ); - - return responseText; -} - /** * Create the describe-image request handler * - * Uses Claude SDK query with multi-part content blocks to include the image (base64), + * Uses the provider abstraction with multi-part content blocks to include the image (base64), * matching the agent runner behavior. * * @param settingsService - Optional settings service for loading autoLoadClaudeMd setting @@ -309,27 +260,6 @@ export function createDescribeImageHandler( `[${requestId}] image meta filename=${imageData.filename} mime=${imageData.mimeType} base64Len=${base64Length} estBytes=${estimatedBytes}` ); - // Build multi-part prompt with image block (no Read tool required) - const instructionText = - `Describe this image in 1-2 sentences suitable for use as context in an AI coding assistant. ` + - `Focus on what the image shows and its purpose (e.g., "UI mockup showing login form with email/password fields", ` + - `"Architecture diagram of microservices", "Screenshot of error message in terminal").\n\n` + - `Respond with ONLY the description text, no additional formatting, preamble, or explanation.`; - - const promptContent = [ - { type: 'text' as const, text: instructionText }, - { - type: 'image' as const, - source: { - type: 'base64' as const, - media_type: imageData.mimeType, - data: imageData.base64, - }, - }, - ]; - - logger.info(`[${requestId}] Built multi-part prompt blocks=${promptContent.length}`); - const cwd = path.dirname(actualPath); logger.info(`[${requestId}] Using cwd=${cwd}`); @@ -348,85 +278,59 @@ export function createDescribeImageHandler( logger.info(`[${requestId}] Using model: ${model}`); - let description: string; + // Build the instruction text + const instructionText = + `Describe this image in 1-2 sentences suitable for use as context in an AI coding assistant. ` + + `Focus on what the image shows and its purpose (e.g., "UI mockup showing login form with email/password fields", ` + + `"Architecture diagram of microservices", "Screenshot of error message in terminal").\n\n` + + `Respond with ONLY the description text, no additional formatting, preamble, or explanation.`; + + // Build prompt based on provider capability + // Some providers (like Cursor) may not support image content blocks + let prompt: string | Array<{ type: string; text?: string; source?: object }>; - // Route to appropriate provider based on model type if (isCursorModel(model)) { - // Use Cursor provider for Cursor models - // Note: Cursor may have limited support for image content blocks - logger.info(`[${requestId}] Using Cursor provider for model: ${model}`); - - const provider = ProviderFactory.getProviderForModel(model); - // Strip provider prefix - providers expect bare model IDs - const bareModel = stripProviderPrefix(model); - - // Build prompt with image reference for Cursor - // Note: Cursor CLI may not support base64 image blocks directly, - // so we include the image path as context - const cursorPrompt = `${instructionText}\n\nImage file: ${actualPath}\nMIME type: ${imageData.mimeType}`; - - let responseText = ''; - const queryStart = Date.now(); - for await (const msg of provider.executeQuery({ - prompt: cursorPrompt, - model: bareModel, - cwd, - maxTurns: 1, - allowedTools: ['Read'], // Allow Read tool so Cursor can read the image if needed - readOnly: true, // Image description only reads, doesn't write - })) { - 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(`[${requestId}] Cursor query completed in ${Date.now() - queryStart}ms`); - description = responseText; + // Cursor may not support base64 image blocks directly + // Use text prompt with image path reference + logger.info(`[${requestId}] Using text prompt for Cursor model`); + prompt = `${instructionText}\n\nImage file: ${actualPath}\nMIME type: ${imageData.mimeType}`; } else { - // Use Claude SDK for Claude models (supports image content blocks) - logger.info(`[${requestId}] Using Claude SDK for model: ${model}`); - - // Use the same centralized option builder used across the server (validates cwd) - const sdkOptions = createCustomOptions({ - cwd, - model, - maxTurns: 1, - allowedTools: [], - autoLoadClaudeMd, - thinkingLevel, // Pass thinking level for extended thinking - }); - - logger.info( - `[${requestId}] SDK options model=${sdkOptions.model} maxTurns=${sdkOptions.maxTurns} allowedTools=${JSON.stringify( - sdkOptions.allowedTools - )}` - ); - - const promptGenerator = (async function* () { - yield { - type: 'user' as const, - session_id: '', - message: { role: 'user' as const, content: promptContent }, - parent_tool_use_id: null, - }; - })(); - - logger.info(`[${requestId}] Calling query()...`); - const queryStart = Date.now(); - const stream = query({ prompt: promptGenerator, options: sdkOptions }); - logger.info(`[${requestId}] query() returned stream in ${Date.now() - queryStart}ms`); - - // Extract the description from the response - const extractStart = Date.now(); - description = await extractTextFromStream(stream, requestId); - logger.info(`[${requestId}] extractMs=${Date.now() - extractStart}`); + // Claude and other vision-capable models support multi-part prompts with images + logger.info(`[${requestId}] Using multi-part prompt with image block`); + prompt = [ + { type: 'text', text: instructionText }, + { + type: 'image', + source: { + type: 'base64', + media_type: imageData.mimeType, + data: imageData.base64, + }, + }, + ]; } + logger.info(`[${requestId}] Calling simpleQuery...`); + const queryStart = Date.now(); + + // Use simpleQuery - provider abstraction handles routing + const result = await simpleQuery({ + prompt, + model, + cwd, + maxTurns: 1, + allowedTools: isCursorModel(model) ? ['Read'] : [], // Allow Read for Cursor to read image if needed + thinkingLevel, + readOnly: true, // Image description only reads, doesn't write + settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + }); + + logger.info(`[${requestId}] simpleQuery completed in ${Date.now() - queryStart}ms`); + + const description = result.text; + if (!description || description.trim().length === 0) { - logger.warn(`[${requestId}] Received empty response from Claude`); + logger.warn(`[${requestId}] Received empty response from AI`); const response: DescribeImageErrorResponse = { success: false, error: 'Failed to generate description - empty response', diff --git a/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/apps/server/src/routes/enhance-prompt/routes/enhance.ts index 73043284..5861b418 100644 --- a/apps/server/src/routes/enhance-prompt/routes/enhance.ts +++ b/apps/server/src/routes/enhance-prompt/routes/enhance.ts @@ -1,23 +1,16 @@ /** * POST /enhance-prompt endpoint - Enhance user input text * - * Uses Claude AI or Cursor to enhance text based on the specified enhancement mode. - * Supports modes: improve, technical, simplify, acceptance + * Uses the provider abstraction to enhance text based on the specified + * enhancement mode. Works with any configured provider (Claude, Cursor, etc.). + * Supports modes: improve, technical, simplify, acceptance, ux-reviewer */ import type { Request, Response } from 'express'; -import { query } from '@anthropic-ai/claude-agent-sdk'; import { createLogger } from '@automaker/utils'; import { resolveModelString } from '@automaker/model-resolver'; -import { - CLAUDE_MODEL_MAP, - isCursorModel, - isOpencodeModel, - stripProviderPrefix, - ThinkingLevel, - getThinkingTokenBudget, -} from '@automaker/types'; -import { ProviderFactory } from '../../../providers/provider-factory.js'; +import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types'; +import { simpleQuery } from '../../../providers/simple-query-service.js'; import type { SettingsService } from '../../../services/settings-service.js'; import { getPromptCustomization } from '../../../lib/settings-helpers.js'; import { @@ -38,7 +31,7 @@ interface EnhanceRequestBody { enhancementMode: string; /** Optional model override */ model?: string; - /** Optional thinking level for Claude models (ignored for Cursor models) */ + /** Optional thinking level for Claude models */ thinkingLevel?: ThinkingLevel; } @@ -58,80 +51,6 @@ interface EnhanceErrorResponse { error: string; } -/** - * Extract text content from Claude SDK response messages - * - * @param stream - The async iterable from the query function - * @returns The extracted text content - */ -async function extractTextFromStream( - stream: AsyncIterable<{ - type: string; - subtype?: string; - result?: string; - message?: { - content?: Array<{ type: string; text?: string }>; - }; - }> -): Promise { - let responseText = ''; - - for await (const msg of stream) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text' && block.text) { - responseText += block.text; - } - } - } else if (msg.type === 'result' && msg.subtype === 'success') { - responseText = msg.result || responseText; - } - } - - return responseText; -} - -/** - * Execute enhancement using a provider (Cursor, OpenCode, etc.) - * - * @param prompt - The enhancement prompt - * @param model - The model to use - * @returns The enhanced text - */ -async function executeWithProvider(prompt: string, model: string): Promise { - const provider = ProviderFactory.getProviderForModel(model); - // Strip provider prefix - providers expect bare model IDs - const bareModel = stripProviderPrefix(model); - - let responseText = ''; - - for await (const msg of provider.executeQuery({ - prompt, - model: bareModel, - cwd: process.cwd(), // Enhancement doesn't need a specific working directory - readOnly: true, // Prompt enhancement only generates text, doesn't write files - })) { - if (msg.type === 'error') { - // Throw error with the message from the provider - const errorMessage = msg.error || 'Provider returned an error'; - throw new Error(errorMessage); - } else if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text' && block.text) { - responseText += block.text; - } - } - } 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; - } - } - } - - return responseText; -} - /** * Create the enhance request handler * @@ -200,7 +119,6 @@ export function createEnhanceHandler( logger.debug(`Using ${validMode} system prompt (length: ${systemPrompt.length} chars)`); // Build the user prompt with few-shot examples - // This helps the model understand this is text transformation, not a coding task const userPrompt = buildUserPrompt(validMode, trimmedText, true); // Resolve the model - use the passed model, default to sonnet for quality @@ -208,47 +126,20 @@ export function createEnhanceHandler( logger.debug(`Using model: ${resolvedModel}`); - let enhancedText: string; + // Use simpleQuery - provider abstraction handles routing to correct provider + // The system prompt is combined with user prompt since some providers + // don't have a separate system prompt concept + const result = await simpleQuery({ + prompt: `${systemPrompt}\n\n${userPrompt}`, + model: resolvedModel, + cwd: process.cwd(), // Enhancement doesn't need a specific working directory + maxTurns: 1, + allowedTools: [], + thinkingLevel, + readOnly: true, // Prompt enhancement only generates text, doesn't write files + }); - // Route to appropriate provider based on model - if (isCursorModel(resolvedModel)) { - // Use Cursor provider for Cursor models - logger.info(`Using Cursor provider for model: ${resolvedModel}`); - - // Cursor doesn't have a separate system prompt concept, so combine them - const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`; - enhancedText = await executeWithProvider(combinedPrompt, resolvedModel); - } else if (isOpencodeModel(resolvedModel)) { - // Use OpenCode provider for OpenCode models (static and dynamic) - logger.info(`Using OpenCode provider for model: ${resolvedModel}`); - - // OpenCode CLI handles the system prompt, so combine them - const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`; - enhancedText = await executeWithProvider(combinedPrompt, resolvedModel); - } else { - // Use Claude SDK for Claude models - logger.info(`Using Claude provider for model: ${resolvedModel}`); - - // Convert thinkingLevel to maxThinkingTokens for SDK - const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel); - const queryOptions: Parameters[0]['options'] = { - model: resolvedModel, - systemPrompt, - maxTurns: 1, - allowedTools: [], - permissionMode: 'acceptEdits', - }; - if (maxThinkingTokens) { - queryOptions.maxThinkingTokens = maxThinkingTokens; - } - - const stream = query({ - prompt: userPrompt, - options: queryOptions, - }); - - enhancedText = await extractTextFromStream(stream); - } + const enhancedText = result.text; if (!enhancedText || enhancedText.trim().length === 0) { logger.warn('Received empty response from AI'); diff --git a/apps/server/src/routes/features/routes/generate-title.ts b/apps/server/src/routes/features/routes/generate-title.ts index 2602de03..a838e5aa 100644 --- a/apps/server/src/routes/features/routes/generate-title.ts +++ b/apps/server/src/routes/features/routes/generate-title.ts @@ -1,13 +1,14 @@ /** * POST /features/generate-title endpoint - Generate a concise title from description * - * Uses Claude Haiku to generate a short, descriptive title from feature description. + * Uses the provider abstraction to generate a short, descriptive title + * from a feature description. Works with any configured provider (Claude, Cursor, etc.). */ import type { Request, Response } from 'express'; -import { query } from '@anthropic-ai/claude-agent-sdk'; import { createLogger } from '@automaker/utils'; import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver'; +import { simpleQuery } from '../../../providers/simple-query-service.js'; const logger = createLogger('GenerateTitle'); @@ -34,33 +35,6 @@ Rules: - No quotes, periods, or extra formatting - Capture the essence of the feature in a scannable way`; -async function extractTextFromStream( - stream: AsyncIterable<{ - type: string; - subtype?: string; - result?: string; - message?: { - content?: Array<{ type: string; text?: string }>; - }; - }> -): Promise { - let responseText = ''; - - for await (const msg of stream) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text' && block.text) { - responseText += block.text; - } - } - } else if (msg.type === 'result' && msg.subtype === 'success') { - responseText = msg.result || responseText; - } - } - - return responseText; -} - export function createGenerateTitleHandler(): (req: Request, res: Response) => Promise { return async (req: Request, res: Response): Promise => { try { @@ -89,21 +63,19 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`; - const stream = query({ - prompt: userPrompt, - options: { - model: CLAUDE_MODEL_MAP.haiku, - systemPrompt: SYSTEM_PROMPT, - maxTurns: 1, - allowedTools: [], - permissionMode: 'default', - }, + // Use simpleQuery - provider abstraction handles all the streaming/extraction + const result = await simpleQuery({ + prompt: `${SYSTEM_PROMPT}\n\n${userPrompt}`, + model: CLAUDE_MODEL_MAP.haiku, + cwd: process.cwd(), + maxTurns: 1, + allowedTools: [], }); - const title = await extractTextFromStream(stream); + const title = result.text; if (!title || title.trim().length === 0) { - logger.warn('Received empty response from Claude'); + logger.warn('Received empty response from AI'); const response: GenerateTitleErrorResponse = { success: false, error: 'Failed to generate title - empty response', diff --git a/apps/server/src/routes/github/routes/validate-issue.ts b/apps/server/src/routes/github/routes/validate-issue.ts index 237036ae..aaa83c9a 100644 --- a/apps/server/src/routes/github/routes/validate-issue.ts +++ b/apps/server/src/routes/github/routes/validate-issue.ts @@ -1,5 +1,5 @@ /** - * POST /validate-issue endpoint - Validate a GitHub issue using Claude SDK or Cursor (async) + * POST /validate-issue endpoint - Validate a GitHub issue using provider abstraction (async) * * Scans the codebase to determine if an issue is valid, invalid, or needs clarification. * Runs asynchronously and emits events for progress and completion. @@ -7,7 +7,6 @@ */ import type { Request, Response } from 'express'; -import { query } from '@anthropic-ai/claude-agent-sdk'; import type { EventEmitter } from '../../../lib/events.js'; import type { IssueValidationResult, @@ -18,12 +17,11 @@ import type { LinkedPRInfo, ThinkingLevel, } from '@automaker/types'; -import { isCursorModel, DEFAULT_PHASE_MODELS, stripProviderPrefix } 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'; -import { ProviderFactory } from '../../../providers/provider-factory.js'; +import { streamingQuery } from '../../../providers/simple-query-service.js'; import { issueValidationSchema, ISSUE_VALIDATION_SYSTEM_PROMPT, @@ -102,7 +100,7 @@ async function runValidation( try { // Build the prompt (include comments and linked PRs if provided) - const prompt = buildValidationPrompt( + const basePrompt = buildValidationPrompt( issueNumber, issueTitle, issueBody, @@ -111,20 +109,15 @@ async function runValidation( linkedPRs ); - let validationResult: IssueValidationResult | null = null; let responseText = ''; - // Route to appropriate provider based on model - if (isCursorModel(model)) { - // Use Cursor provider for Cursor models - logger.info(`Using Cursor provider for validation with model: ${model}`); + // Determine if we should use structured output (Claude supports it, Cursor doesn't) + const useStructuredOutput = !isCursorModel(model); - const provider = ProviderFactory.getProviderForModel(model); - // Strip provider prefix - providers expect bare model IDs - const bareModel = stripProviderPrefix(model); - - // For Cursor, include the system prompt and schema in the user prompt - const cursorPrompt = `${ISSUE_VALIDATION_SYSTEM_PROMPT} + // Build the final prompt - for Cursor, include system prompt and JSON schema instructions + let finalPrompt = basePrompt; + if (!useStructuredOutput) { + finalPrompt = `${ISSUE_VALIDATION_SYSTEM_PROMPT} CRITICAL INSTRUCTIONS: 1. DO NOT write any files. Return the JSON in your response only. @@ -135,121 +128,71 @@ ${JSON.stringify(issueValidationSchema, null, 2)} Your entire response should be valid JSON starting with { and ending with }. No text before or after. -${prompt}`; - - for await (const msg of provider.executeQuery({ - prompt: cursorPrompt, - model: bareModel, - cwd: projectPath, - readOnly: true, // Issue validation only reads code, doesn't write - })) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text' && block.text) { - responseText += block.text; - - // Emit progress event - const progressEvent: IssueValidationEvent = { - type: 'issue_validation_progress', - issueNumber, - content: block.text, - projectPath, - }; - events.emit('issue-validation:event', progressEvent); - } - } - } 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; - } - } - } - - // Parse JSON from the response text using shared utility - if (responseText) { - validationResult = extractJson(responseText, { logger }); - } - } else { - // Use Claude SDK for Claude models - logger.info(`Using Claude provider for validation with model: ${model}`); - - // Load autoLoadClaudeMd setting - const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( - projectPath, - settingsService, - '[ValidateIssue]' - ); - - // Use thinkingLevel from request if provided, otherwise fall back to settings - let effectiveThinkingLevel: ThinkingLevel | undefined = thinkingLevel; - if (!effectiveThinkingLevel) { - const settings = await settingsService?.getGlobalSettings(); - const phaseModelEntry = - settings?.phaseModels?.validationModel || DEFAULT_PHASE_MODELS.validationModel; - const resolved = resolvePhaseModel(phaseModelEntry); - effectiveThinkingLevel = resolved.thinkingLevel; - } - - // Create SDK options with structured output and abort controller - const options = createSuggestionsOptions({ - cwd: projectPath, - model: model as ModelAlias, - systemPrompt: ISSUE_VALIDATION_SYSTEM_PROMPT, - abortController, - autoLoadClaudeMd, - thinkingLevel: effectiveThinkingLevel, - outputFormat: { - type: 'json_schema', - schema: issueValidationSchema as Record, - }, - }); - - // Execute the query - const stream = query({ prompt, options }); - - for await (const msg of stream) { - // Collect assistant text for debugging and emit progress - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text') { - responseText += block.text; - - // Emit progress event - const progressEvent: IssueValidationEvent = { - type: 'issue_validation_progress', - issueNumber, - content: block.text, - projectPath, - }; - events.emit('issue-validation:event', progressEvent); - } - } - } - - // Extract structured output on success - if (msg.type === 'result' && msg.subtype === 'success') { - const resultMsg = msg as { structured_output?: IssueValidationResult }; - if (resultMsg.structured_output) { - validationResult = resultMsg.structured_output; - logger.debug('Received structured output:', validationResult); - } - } - - // Handle errors - if (msg.type === 'result') { - const resultMsg = msg as { subtype?: string }; - if (resultMsg.subtype === 'error_max_structured_output_retries') { - logger.error('Failed to produce valid structured output after retries'); - throw new Error('Could not produce valid validation output'); - } - } - } +${basePrompt}`; } + // Load autoLoadClaudeMd setting + const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( + projectPath, + settingsService, + '[ValidateIssue]' + ); + + // Use thinkingLevel from request if provided, otherwise fall back to settings + let effectiveThinkingLevel: ThinkingLevel | undefined = thinkingLevel; + if (!effectiveThinkingLevel) { + const settings = await settingsService?.getGlobalSettings(); + const phaseModelEntry = + settings?.phaseModels?.validationModel || DEFAULT_PHASE_MODELS.validationModel; + const resolved = resolvePhaseModel(phaseModelEntry); + effectiveThinkingLevel = resolved.thinkingLevel; + } + + logger.info(`Using model: ${model}`); + + // Use streamingQuery with event callbacks + const result = await streamingQuery({ + prompt: finalPrompt, + model: model as string, + cwd: projectPath, + systemPrompt: useStructuredOutput ? ISSUE_VALIDATION_SYSTEM_PROMPT : undefined, + abortController, + thinkingLevel: effectiveThinkingLevel, + readOnly: true, // Issue validation only reads code, doesn't write + settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + outputFormat: useStructuredOutput + ? { + type: 'json_schema', + schema: issueValidationSchema as Record, + } + : undefined, + onText: (text) => { + responseText += text; + // Emit progress event + const progressEvent: IssueValidationEvent = { + type: 'issue_validation_progress', + issueNumber, + content: text, + projectPath, + }; + events.emit('issue-validation:event', progressEvent); + }, + }); + // Clear timeout clearTimeout(timeoutId); + // Get validation result from structured output or parse from text + let validationResult: IssueValidationResult | null = null; + + if (result.structured_output) { + validationResult = result.structured_output as unknown as IssueValidationResult; + logger.debug('Received structured output:', validationResult); + } else if (responseText) { + // Parse JSON from response text + validationResult = extractJson(responseText, { logger }); + } + // Require validation result if (!validationResult) { logger.error('No validation result received from AI provider'); @@ -299,7 +242,7 @@ ${prompt}`; /** * Creates the handler for validating GitHub issues against the codebase. * - * Uses Claude SDK with: + * Uses the provider abstraction with: * - Read-only tools (Read, Glob, Grep) for codebase analysis * - JSON schema structured output for reliable parsing * - System prompt guiding the validation process diff --git a/apps/server/src/routes/suggestions/generate-suggestions.ts b/apps/server/src/routes/suggestions/generate-suggestions.ts index 2cf7925f..0766c41b 100644 --- a/apps/server/src/routes/suggestions/generate-suggestions.ts +++ b/apps/server/src/routes/suggestions/generate-suggestions.ts @@ -5,19 +5,12 @@ * (AI Suggestions in the UI). Supports both Claude and Cursor models. */ -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, - stripProviderPrefix, - type ThinkingLevel, -} from '@automaker/types'; +import { DEFAULT_PHASE_MODELS, isCursorModel, type ThinkingLevel } from '@automaker/types'; 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'; +import { streamingQuery } from '../../providers/simple-query-service.js'; import { FeatureLoader } from '../../services/feature-loader.js'; import { getAppSpecPath } from '@automaker/platform'; import * as secureFs from '../../lib/secure-fs.js'; @@ -204,19 +197,14 @@ The response will be automatically formatted as structured JSON.`; logger.info('[Suggestions] Using model:', model); let responseText = ''; - let structuredOutput: { suggestions: Array> } | null = null; - // Route to appropriate provider based on model type - if (isCursorModel(model)) { - // Use Cursor provider for Cursor models - logger.info('[Suggestions] Using Cursor provider'); + // Determine if we should use structured output (Claude supports it, Cursor doesn't) + const useStructuredOutput = !isCursorModel(model); - const provider = ProviderFactory.getProviderForModel(model); - // Strip provider prefix - providers expect bare model IDs - const bareModel = stripProviderPrefix(model); - - // For Cursor, include the JSON schema in the prompt with clear instructions - const cursorPrompt = `${prompt} + // Build the final prompt - for Cursor, include JSON schema instructions + let finalPrompt = prompt; + if (!useStructuredOutput) { + finalPrompt = `${prompt} CRITICAL INSTRUCTIONS: 1. DO NOT write any files. Return the JSON in your response only. @@ -226,104 +214,60 @@ CRITICAL INSTRUCTIONS: ${JSON.stringify(suggestionsSchema, null, 2)} Your entire response should be valid JSON starting with { and ending with }. No text before or after.`; - - for await (const msg of provider.executeQuery({ - prompt: cursorPrompt, - model: bareModel, - cwd: projectPath, - maxTurns: 250, - allowedTools: ['Read', 'Glob', 'Grep'], - abortController, - readOnly: true, // Suggestions only reads code, doesn't write - })) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text' && block.text) { - responseText += block.text; - events.emit('suggestions:event', { - type: 'suggestions_progress', - content: block.text, - }); - } else if (block.type === 'tool_use') { - events.emit('suggestions:event', { - type: 'suggestions_tool', - tool: block.name, - input: block.input, - }); - } - } - } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { - // Use result if it's a final accumulated message (from Cursor provider) - logger.info('[Suggestions] Received result from Cursor, length:', msg.result.length); - logger.info('[Suggestions] Previous responseText length:', responseText.length); - if (msg.result.length > responseText.length) { - logger.info('[Suggestions] Using Cursor result (longer than accumulated text)'); - responseText = msg.result; - } else { - logger.info('[Suggestions] Keeping accumulated text (longer than Cursor result)'); - } - } - } - } else { - // Use Claude SDK for Claude models - logger.info('[Suggestions] Using Claude SDK'); - - const options = createSuggestionsOptions({ - cwd: projectPath, - abortController, - autoLoadClaudeMd, - model, // Pass the model from settings - thinkingLevel, // Pass thinking level for extended thinking - outputFormat: { - type: 'json_schema', - schema: suggestionsSchema, - }, - }); - - const stream = query({ prompt, options }); - - for await (const msg of stream) { - if (msg.type === 'assistant' && msg.message.content) { - for (const block of msg.message.content) { - if (block.type === 'text') { - responseText += block.text; - events.emit('suggestions:event', { - type: 'suggestions_progress', - content: block.text, - }); - } else if (block.type === 'tool_use') { - events.emit('suggestions:event', { - type: 'suggestions_tool', - tool: block.name, - input: block.input, - }); - } - } - } else if (msg.type === 'result' && msg.subtype === 'success') { - // Check for structured output - const resultMsg = msg as any; - if (resultMsg.structured_output) { - structuredOutput = resultMsg.structured_output as { - suggestions: Array>; - }; - logger.debug('Received structured output:', structuredOutput); - } - } else if (msg.type === 'result') { - const resultMsg = msg as any; - if (resultMsg.subtype === 'error_max_structured_output_retries') { - logger.error('Failed to produce valid structured output after retries'); - throw new Error('Could not produce valid suggestions output'); - } else if (resultMsg.subtype === 'error_max_turns') { - logger.error('Hit max turns limit before completing suggestions generation'); - logger.warn(`Response text length: ${responseText.length} chars`); - // Still try to parse what we have - } - } - } } + // Use streamingQuery with event callbacks + const result = await streamingQuery({ + prompt: finalPrompt, + model, + cwd: projectPath, + maxTurns: 250, + allowedTools: ['Read', 'Glob', 'Grep'], + abortController, + thinkingLevel, + readOnly: true, // Suggestions only reads code, doesn't write + settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + outputFormat: useStructuredOutput + ? { + type: 'json_schema', + schema: suggestionsSchema, + } + : undefined, + onText: (text) => { + responseText += text; + events.emit('suggestions:event', { + type: 'suggestions_progress', + content: text, + }); + }, + onToolUse: (tool, input) => { + events.emit('suggestions:event', { + type: 'suggestions_tool', + tool, + input, + }); + }, + }); + // Use structured output if available, otherwise fall back to parsing text try { + let structuredOutput: { suggestions: Array> } | null = null; + + if (result.structured_output) { + structuredOutput = result.structured_output as { + suggestions: Array>; + }; + logger.debug('Received structured output:', structuredOutput); + } else if (responseText) { + // Fallback: try to parse from text using shared extraction utility + logger.warn('No structured output received, attempting to parse from text'); + structuredOutput = extractJsonWithArray<{ suggestions: Array> }>( + responseText, + 'suggestions', + { logger } + ); + } + if (structuredOutput && structuredOutput.suggestions) { // Use structured output directly events.emit('suggestions:event', { @@ -334,24 +278,7 @@ Your entire response should be valid JSON starting with { and ending with }. No })), }); } else { - // Fallback: try to parse from text using shared extraction utility - logger.warn('No structured output received, attempting to parse from text'); - const parsed = extractJsonWithArray<{ suggestions: Array> }>( - responseText, - 'suggestions', - { logger } - ); - if (parsed && parsed.suggestions) { - events.emit('suggestions:event', { - type: 'suggestions_complete', - suggestions: parsed.suggestions.map((s: Record, i: number) => ({ - ...s, - id: s.id || `suggestion-${Date.now()}-${i}`, - })), - }); - } else { - throw new Error('No valid JSON found in response'); - } + throw new Error('No valid JSON found in response'); } } catch (error) { // Log the parsing error for debugging diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index b830a297..a587e1eb 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -10,11 +10,14 @@ */ import { ProviderFactory } from '../providers/provider-factory.js'; +import { simpleQuery } from '../providers/simple-query-service.js'; import type { ExecuteOptions, Feature, ModelProvider, PipelineStep, + FeatureStatusWithPipeline, + PipelineConfig, ThinkingLevel, PlanningMode, } from '@automaker/types'; @@ -83,6 +86,26 @@ interface PlanSpec { tasks?: ParsedTask[]; } +/** + * Information about pipeline status when resuming a feature. + * Used to determine how to handle features stuck in pipeline execution. + * + * @property {boolean} isPipeline - Whether the feature is in a pipeline step + * @property {string | null} stepId - ID of the current pipeline step (e.g., 'step_123') + * @property {number} stepIndex - Index of the step in the sorted pipeline steps (-1 if not found) + * @property {number} totalSteps - Total number of steps in the pipeline + * @property {PipelineStep | null} step - The pipeline step configuration, or null if step not found + * @property {PipelineConfig | null} config - The full pipeline configuration, or null if no pipeline + */ +interface PipelineStatusInfo { + isPipeline: boolean; + stepId: string | null; + stepIndex: number; + totalSteps: number; + step: PipelineStep | null; + config: PipelineConfig | null; +} + /** * Parse tasks from generated spec content * Looks for the ```tasks code block and extracts task lines @@ -917,6 +940,25 @@ Complete the pipeline step instructions above. Review the previous work and appl throw new Error('already running'); } + // Load feature to check status + const feature = await this.loadFeature(projectPath, featureId); + if (!feature) { + throw new Error(`Feature ${featureId} not found`); + } + + // Check if feature is stuck in a pipeline step + const pipelineInfo = await this.detectPipelineStatus( + projectPath, + featureId, + (feature.status || '') as FeatureStatusWithPipeline + ); + + if (pipelineInfo.isPipeline) { + // Feature stuck in pipeline - use pipeline resume + return this.resumePipelineFeature(projectPath, feature, useWorktrees, pipelineInfo); + } + + // Normal resume flow for non-pipeline features // Check if context exists in .automaker directory const featureDir = getFeatureDir(projectPath, featureId); const contextPath = path.join(featureDir, 'agent-output.md'); @@ -936,11 +978,252 @@ Complete the pipeline step instructions above. Review the previous work and appl } // No context, start fresh - executeFeature will handle adding to runningFeatures - // Remove the temporary entry we added - this.runningFeatures.delete(featureId); return this.executeFeature(projectPath, featureId, useWorktrees, false); } + /** + * Resume a feature that crashed during pipeline execution. + * Handles multiple edge cases to ensure robust recovery: + * - No context file: Restart entire pipeline from beginning + * - Step deleted from config: Complete feature without remaining pipeline steps + * - Valid step exists: Resume from the crashed step and continue + * + * @param {string} projectPath - Absolute path to the project directory + * @param {Feature} feature - The feature object (already loaded to avoid redundant reads) + * @param {boolean} useWorktrees - Whether to use git worktrees for isolation + * @param {PipelineStatusInfo} pipelineInfo - Information about the pipeline status from detectPipelineStatus() + * @returns {Promise} Resolves when resume operation completes or throws on error + * @throws {Error} If pipeline config is null but stepIndex is valid (should never happen) + * @private + */ + private async resumePipelineFeature( + projectPath: string, + feature: Feature, + useWorktrees: boolean, + pipelineInfo: PipelineStatusInfo + ): Promise { + const featureId = feature.id; + console.log( + `[AutoMode] Resuming feature ${featureId} from pipeline step ${pipelineInfo.stepId}` + ); + + // Check for context file + const featureDir = getFeatureDir(projectPath, featureId); + const contextPath = path.join(featureDir, 'agent-output.md'); + + let hasContext = false; + try { + await secureFs.access(contextPath); + hasContext = true; + } catch { + // No context + } + + // Edge Case 1: No context file - restart entire pipeline from beginning + if (!hasContext) { + console.warn( + `[AutoMode] No context found for pipeline feature ${featureId}, restarting from beginning` + ); + + // Reset status to in_progress and start fresh + await this.updateFeatureStatus(projectPath, featureId, 'in_progress'); + + return this.executeFeature(projectPath, featureId, useWorktrees, false); + } + + // Edge Case 2: Step no longer exists in pipeline config + if (pipelineInfo.stepIndex === -1) { + console.warn( + `[AutoMode] Step ${pipelineInfo.stepId} no longer exists in pipeline, completing feature without pipeline` + ); + + const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; + + await this.updateFeatureStatus(projectPath, featureId, finalStatus); + + this.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + passes: true, + message: + 'Pipeline step no longer exists - feature completed without remaining pipeline steps', + projectPath, + }); + + return; + } + + // Normal case: Valid pipeline step exists, has context + // Resume from the stuck step (re-execute the step that crashed) + if (!pipelineInfo.config) { + throw new Error('Pipeline config is null but stepIndex is valid - this should not happen'); + } + + return this.resumeFromPipelineStep( + projectPath, + feature, + useWorktrees, + pipelineInfo.stepIndex, + pipelineInfo.config + ); + } + + /** + * Resume pipeline execution from a specific step index. + * Re-executes the step that crashed (to handle partial completion), + * then continues executing all remaining pipeline steps in order. + * + * This method handles the complete pipeline resume workflow: + * - Validates feature and step index + * - Locates or creates git worktree if needed + * - Executes remaining steps starting from the crashed step + * - Updates feature status to verified/waiting_approval when complete + * - Emits progress events throughout execution + * + * @param {string} projectPath - Absolute path to the project directory + * @param {Feature} feature - The feature object (already loaded to avoid redundant reads) + * @param {boolean} useWorktrees - Whether to use git worktrees for isolation + * @param {number} startFromStepIndex - Zero-based index of the step to resume from + * @param {PipelineConfig} pipelineConfig - Pipeline config passed from detectPipelineStatus to avoid re-reading + * @returns {Promise} Resolves when pipeline execution completes successfully + * @throws {Error} If feature not found, step index invalid, or pipeline execution fails + * @private + */ + private async resumeFromPipelineStep( + projectPath: string, + feature: Feature, + useWorktrees: boolean, + startFromStepIndex: number, + pipelineConfig: PipelineConfig + ): Promise { + const featureId = feature.id; + + const sortedSteps = [...pipelineConfig.steps].sort((a, b) => a.order - b.order); + + // Validate step index + if (startFromStepIndex < 0 || startFromStepIndex >= sortedSteps.length) { + throw new Error(`Invalid step index: ${startFromStepIndex}`); + } + + // Get steps to execute (from startFromStepIndex onwards) + const stepsToExecute = sortedSteps.slice(startFromStepIndex); + + console.log( + `[AutoMode] Resuming pipeline for feature ${featureId} from step ${startFromStepIndex + 1}/${sortedSteps.length}` + ); + + // Add to running features immediately + const abortController = new AbortController(); + this.runningFeatures.set(featureId, { + featureId, + projectPath, + worktreePath: null, // Will be set below + branchName: feature.branchName ?? null, + abortController, + isAutoMode: false, + startTime: Date.now(), + }); + + try { + // Validate project path + validateWorkingDirectory(projectPath); + + // Derive workDir from feature.branchName + let worktreePath: string | null = null; + const branchName = feature.branchName; + + if (useWorktrees && branchName) { + worktreePath = await this.findExistingWorktreeForBranch(projectPath, branchName); + if (worktreePath) { + console.log(`[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}`); + } else { + console.warn( + `[AutoMode] Worktree for branch "${branchName}" not found, using project path` + ); + } + } + + const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath); + validateWorkingDirectory(workDir); + + // Update running feature with worktree info + const runningFeature = this.runningFeatures.get(featureId); + if (runningFeature) { + runningFeature.worktreePath = worktreePath; + runningFeature.branchName = branchName ?? null; + } + + // Emit resume event + this.emitAutoModeEvent('auto_mode_feature_start', { + featureId, + projectPath, + feature: { + id: featureId, + title: feature.title || 'Resuming Pipeline', + description: feature.description, + }, + }); + + this.emitAutoModeEvent('auto_mode_progress', { + featureId, + content: `Resuming from pipeline step ${startFromStepIndex + 1}/${sortedSteps.length}`, + projectPath, + }); + + // Load autoLoadClaudeMd setting + const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( + projectPath, + this.settingsService, + '[AutoMode]' + ); + + // Execute remaining pipeline steps (starting from crashed step) + await this.executePipelineSteps( + projectPath, + featureId, + feature, + stepsToExecute, + workDir, + abortController, + autoLoadClaudeMd + ); + + // Determine final status + const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; + await this.updateFeatureStatus(projectPath, featureId, finalStatus); + + console.log('[AutoMode] Pipeline resume completed successfully'); + + this.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + passes: true, + message: 'Pipeline resumed and completed successfully', + projectPath, + }); + } catch (error) { + const errorInfo = classifyError(error); + + if (errorInfo.isAbort) { + this.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + passes: false, + message: 'Pipeline resume stopped by user', + projectPath, + }); + } else { + console.error(`[AutoMode] Pipeline resume failed for feature ${featureId}:`, error); + await this.updateFeatureStatus(projectPath, featureId, 'backlog'); + this.emitAutoModeEvent('auto_mode_error', { + featureId, + error: errorInfo.message, + errorType: errorInfo.type, + projectPath, + }); + } + } finally { + this.runningFeatures.delete(featureId); + } + } + /** * Follow up on a feature with additional instructions */ @@ -2885,6 +3168,111 @@ Review the previous work and continue the implementation. If the feature appears }); } + /** + * Detect if a feature is stuck in a pipeline step and extract step information. + * Parses the feature status to determine if it's a pipeline status (e.g., 'pipeline_step_xyz'), + * loads the pipeline configuration, and validates that the step still exists. + * + * This method handles several scenarios: + * - Non-pipeline status: Returns default PipelineStatusInfo with isPipeline=false + * - Invalid pipeline status format: Returns isPipeline=true but null step info + * - Step deleted from config: Returns stepIndex=-1 to signal missing step + * - Valid pipeline step: Returns full step information and config + * + * @param {string} projectPath - Absolute path to the project directory + * @param {string} featureId - Unique identifier of the feature + * @param {FeatureStatusWithPipeline} currentStatus - Current feature status (may include pipeline step info) + * @returns {Promise} Information about the pipeline status and step + * @private + */ + private async detectPipelineStatus( + projectPath: string, + featureId: string, + currentStatus: FeatureStatusWithPipeline + ): Promise { + // Check if status is pipeline format using PipelineService + const isPipeline = pipelineService.isPipelineStatus(currentStatus); + + if (!isPipeline) { + return { + isPipeline: false, + stepId: null, + stepIndex: -1, + totalSteps: 0, + step: null, + config: null, + }; + } + + // Extract step ID using PipelineService + const stepId = pipelineService.getStepIdFromStatus(currentStatus); + + if (!stepId) { + console.warn( + `[AutoMode] Feature ${featureId} has invalid pipeline status format: ${currentStatus}` + ); + return { + isPipeline: true, + stepId: null, + stepIndex: -1, + totalSteps: 0, + step: null, + config: null, + }; + } + + // Load pipeline config + const config = await pipelineService.getPipelineConfig(projectPath); + + if (!config || config.steps.length === 0) { + // Pipeline config doesn't exist or empty - feature stuck with invalid pipeline status + console.warn( + `[AutoMode] Feature ${featureId} has pipeline status but no pipeline config exists` + ); + return { + isPipeline: true, + stepId, + stepIndex: -1, + totalSteps: 0, + step: null, + config: null, + }; + } + + // Find the step directly from config (already loaded, avoid redundant file read) + const sortedSteps = [...config.steps].sort((a, b) => a.order - b.order); + const stepIndex = sortedSteps.findIndex((s) => s.id === stepId); + const step = stepIndex === -1 ? null : sortedSteps[stepIndex]; + + if (!step) { + // Step not found in current config - step was deleted/changed + console.warn( + `[AutoMode] Feature ${featureId} stuck in step ${stepId} which no longer exists in pipeline config` + ); + return { + isPipeline: true, + stepId, + stepIndex: -1, + totalSteps: sortedSteps.length, + step: null, + config, + }; + } + + console.log( + `[AutoMode] Detected pipeline status for feature ${featureId}: step ${stepIndex + 1}/${sortedSteps.length} (${step.name})` + ); + + return { + isPipeline: true, + stepId, + stepIndex, + totalSteps: sortedSteps.length, + step, + config, + }; + } + /** * Build a focused prompt for executing a single task. * Each task gets minimal context to keep the agent focused. @@ -3193,40 +3581,23 @@ IMPORTANT: Only include NON-OBVIOUS learnings with real reasoning. Skip trivial If nothing notable: {"learnings": []}`; try { - // Import query dynamically to avoid circular dependencies - const { query } = await import('@anthropic-ai/claude-agent-sdk'); - // Get model from phase settings const settings = await this.settingsService?.getGlobalSettings(); const phaseModelEntry = settings?.phaseModels?.memoryExtractionModel || DEFAULT_PHASE_MODELS.memoryExtractionModel; const { model } = resolvePhaseModel(phaseModelEntry); - const stream = query({ + const result = await simpleQuery({ prompt: userPrompt, - options: { - model, - maxTurns: 1, - allowedTools: [], - permissionMode: 'acceptEdits', - systemPrompt: - 'You are a JSON extraction assistant. You MUST respond with ONLY valid JSON, no explanations, no markdown, no other text. Extract learnings from the provided implementation context and return them as JSON.', - }, + model, + cwd: projectPath, + maxTurns: 1, + allowedTools: [], + systemPrompt: + 'You are a JSON extraction assistant. You MUST respond with ONLY valid JSON, no explanations, no markdown, no other text. Extract learnings from the provided implementation context and return them as JSON.', }); - // Extract text from stream - let responseText = ''; - for await (const msg of stream) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text' && block.text) { - responseText += block.text; - } - } - } else if (msg.type === 'result' && msg.subtype === 'success') { - responseText = msg.result || responseText; - } - } + const responseText = result.text; console.log(`[AutoMode] Learning extraction response: ${responseText.length} chars`); console.log(`[AutoMode] Response preview: ${responseText.substring(0, 300)}`); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx index 20e88f6e..7dfa4bef 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx @@ -117,73 +117,90 @@ export function CardActions({ )} )} - {!isCurrentAutoTask && feature.status === 'in_progress' && ( - <> - {/* Approve Plan button - shows when plan is generated and waiting for approval */} - {feature.planSpec?.status === 'generated' && onApprovePlan && ( - - )} - {feature.skipTests && onManualVerify ? ( - - ) : onResume ? ( - - ) : null} - {onViewOutput && !feature.skipTests && ( - - )} - - )} + {!isCurrentAutoTask && + (feature.status === 'in_progress' || + (typeof feature.status === 'string' && feature.status.startsWith('pipeline_'))) && ( + <> + {/* Approve Plan button - shows when plan is generated and waiting for approval */} + {feature.planSpec?.status === 'generated' && onApprovePlan && ( + + )} + {feature.skipTests && onManualVerify ? ( + + ) : onResume ? ( + + ) : onVerify ? ( + + ) : null} + {onViewOutput && !feature.skipTests && ( + + )} + + )} {!isCurrentAutoTask && feature.status === 'verified' && ( <> {/* Logs button */} diff --git a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx index 736f3c40..dfee5c30 100644 --- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -21,7 +21,8 @@ import { FeatureTextFilePath as DescriptionTextFilePath, ImagePreviewMap, } from '@/components/ui/description-image-dropzone'; -import { Play, Cpu, FolderKanban } from 'lucide-react'; +import { Play, Cpu, FolderKanban, Settings2 } from 'lucide-react'; +import { useNavigate } from '@tanstack/react-router'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import { modelSupportsThinking } from '@/lib/utils'; @@ -33,7 +34,7 @@ import { PlanningMode, Feature, } from '@/store/app-store'; -import type { ReasoningEffort, PhaseModelEntry } from '@automaker/types'; +import type { ReasoningEffort, PhaseModelEntry, AgentModel } from '@automaker/types'; import { supportsReasoningEffort, isClaudeModel } from '@automaker/types'; import { TestingTabContent, @@ -152,6 +153,7 @@ export function AddFeatureDialog({ forceCurrentBranchMode, }: AddFeatureDialogProps) { const isSpawnMode = !!parentFeature; + const navigate = useNavigate(); const [workMode, setWorkMode] = useState('current'); // Form state @@ -187,7 +189,8 @@ export function AddFeatureDialog({ const [selectedAncestorIds, setSelectedAncestorIds] = useState>(new Set()); // Get defaults from store - const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore(); + const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees, defaultFeatureModel } = + useAppStore(); // Track previous open state to detect when dialog opens const wasOpenRef = useRef(false); @@ -207,7 +210,7 @@ export function AddFeatureDialog({ ); setPlanningMode(defaultPlanningMode); setRequirePlanApproval(defaultRequirePlanApproval); - setModelEntry({ model: 'opus' }); + setModelEntry(defaultFeatureModel); // Initialize description history (empty for new feature) setDescriptionHistory([]); @@ -228,6 +231,7 @@ export function AddFeatureDialog({ defaultBranch, defaultPlanningMode, defaultRequirePlanApproval, + defaultFeatureModel, useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode, @@ -318,7 +322,7 @@ export function AddFeatureDialog({ // When a non-main worktree is selected, use its branch name for custom mode setBranchName(selectedNonMainWorktreeBranch || ''); setPriority(2); - setModelEntry({ model: 'opus' }); + setModelEntry(defaultFeatureModel); setWorkMode( getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode) ); @@ -473,9 +477,31 @@ export function AddFeatureDialog({ {/* AI & Execution Section */}
-
- - AI & Execution +
+
+ + AI & Execution +
+ + + + + + +

Change default model and planning settings for new features

+
+
+
diff --git a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx index 9912201d..ae7d655b 100644 --- a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx @@ -21,7 +21,8 @@ import { FeatureTextFilePath as DescriptionTextFilePath, ImagePreviewMap, } from '@/components/ui/description-image-dropzone'; -import { GitBranch, Cpu, FolderKanban } from 'lucide-react'; +import { GitBranch, Cpu, FolderKanban, Settings2 } from 'lucide-react'; +import { useNavigate } from '@tanstack/react-router'; import { toast } from 'sonner'; import { cn, modelSupportsThinking } from '@/lib/utils'; import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store'; @@ -86,6 +87,7 @@ export function EditFeatureDialog({ isMaximized, allFeatures, }: EditFeatureDialogProps) { + const navigate = useNavigate(); const [editingFeature, setEditingFeature] = useState(feature); // Derive initial workMode from feature's branchName const [workMode, setWorkMode] = useState(() => { @@ -363,9 +365,31 @@ export function EditFeatureDialog({ {/* AI & Execution Section */}
-
- - AI & Execution +
+
+ + AI & Execution +
+ + + + + + +

Change default model and planning settings for new features

+
+
+
diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-effects.ts b/apps/ui/src/components/views/board-view/hooks/use-board-effects.ts index f481c2c2..1a7eda53 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-effects.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-effects.ts @@ -102,7 +102,10 @@ export function useBoardEffects({ const checkAllContexts = async () => { const featuresWithPotentialContext = features.filter( (f) => - f.status === 'in_progress' || f.status === 'waiting_approval' || f.status === 'verified' + f.status === 'in_progress' || + f.status === 'waiting_approval' || + f.status === 'verified' || + (typeof f.status === 'string' && f.status.startsWith('pipeline_')) ); const contextChecks = await Promise.all( featuresWithPotentialContext.map(async (f) => ({ diff --git a/apps/ui/src/components/views/board-view/shared/model-selector.tsx b/apps/ui/src/components/views/board-view/shared/model-selector.tsx index 323190c8..dc95f39f 100644 --- a/apps/ui/src/components/views/board-view/shared/model-selector.tsx +++ b/apps/ui/src/components/views/board-view/shared/model-selector.tsx @@ -4,7 +4,6 @@ import { Badge } from '@/components/ui/badge'; import { Brain, AlertTriangle } from 'lucide-react'; import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon'; import { cn } from '@/lib/utils'; -import type { ModelAlias } from '@/store/app-store'; import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; import { getModelProvider, PROVIDER_PREFIXES, stripProviderPrefix } from '@automaker/types'; @@ -19,6 +18,10 @@ interface ModelSelectorProps { testIdPrefix?: string; } +const CODEX_EMPTY_AVAILABLE_MESSAGE = 'No Codex models available'; +const CODEX_EMPTY_ENABLED_MESSAGE = + 'No Codex models enabled. Enable models in Settings → AI Providers.'; + export function ModelSelector({ selectedModel, onModelSelect, @@ -27,6 +30,8 @@ export function ModelSelector({ const { enabledCursorModels, cursorDefaultModel, + enabledCodexModels, + codexDefaultModel, codexModels, codexModelsLoading, codexModelsError, @@ -49,8 +54,10 @@ export function ModelSelector({ } }, [isCodexAvailable, codexModels.length, codexModelsLoading, fetchCodexModels]); + const enabledCodexModelIds = new Set(enabledCodexModels); + // Transform codex models from store to ModelOption format - const dynamicCodexModels: ModelOption[] = codexModels.map((model) => { + const codexModelOptions: ModelOption[] = codexModels.map((model) => { // Infer badge based on tier let badge: string | undefined; if (model.tier === 'premium') badge = 'Premium'; @@ -67,6 +74,10 @@ export function ModelSelector({ }; }); + const enabledCodexModelOptions = codexModelOptions.filter((model) => + enabledCodexModelIds.has(model.id) + ); + // Filter Cursor models based on enabled models from global settings const filteredCursorModels = CURSOR_MODELS.filter((model) => { // Extract the cursor model ID from the prefixed ID (e.g., "cursor-auto" -> "auto") @@ -74,21 +85,36 @@ export function ModelSelector({ return enabledCursorModels.includes(cursorModelId as any); }); + const hasEnabledCodexModels = enabledCodexModelOptions.length > 0; + const codexDefaultSelection = + codexModelOptions.find((model) => model.id === codexDefaultModel)?.id || + enabledCodexModelOptions[0]?.id || + codexModelOptions[0]?.id; + const handleProviderChange = (provider: ModelProvider) => { if (provider === 'cursor' && selectedProvider !== 'cursor') { // Switch to Cursor's default model (from global settings) onModelSelect(`${PROVIDER_PREFIXES.cursor}${cursorDefaultModel}`); } else if (provider === 'codex' && selectedProvider !== 'codex') { - // Switch to Codex's default model (use isDefault flag from dynamic models) - const defaultModel = codexModels.find((m) => m.isDefault); - const defaultModelId = defaultModel?.id || codexModels[0]?.id || 'codex-gpt-5.2-codex'; - onModelSelect(defaultModelId); + // Switch to Codex's default model (from global settings) + if (codexDefaultSelection) { + onModelSelect(codexDefaultSelection); + } } else if (provider === 'claude' && selectedProvider !== 'claude') { // Switch to Claude's default model onModelSelect('sonnet'); } }; + const showCodexAvailableEmpty = + !codexModelsLoading && !codexModelsError && codexModelOptions.length === 0; + const showCodexEnabledEmpty = + !codexModelsLoading && + !codexModelsError && + codexModelOptions.length > 0 && + !hasEnabledCodexModels; + const showCodexList = !codexModelsLoading && !codexModelsError && hasEnabledCodexModels; + return (
{/* Provider Selection */} @@ -272,7 +298,7 @@ export function ModelSelector({
{/* Loading state */} - {codexModelsLoading && dynamicCodexModels.length === 0 && ( + {codexModelsLoading && codexModelOptions.length === 0 && (
Loading models... @@ -297,15 +323,21 @@ export function ModelSelector({ )} {/* Model list */} - {!codexModelsLoading && !codexModelsError && dynamicCodexModels.length === 0 && ( + {showCodexAvailableEmpty && (
- No Codex models available + {CODEX_EMPTY_AVAILABLE_MESSAGE}
)} - {!codexModelsLoading && dynamicCodexModels.length > 0 && ( + {showCodexEnabledEmpty && ( +
+ {CODEX_EMPTY_ENABLED_MESSAGE} +
+ )} + + {showCodexList && (
- {dynamicCodexModels.map((option) => { + {enabledCodexModelOptions.map((option) => { const isSelected = selectedModel === option.id; return (