diff --git a/jest.config.js b/jest.config.js index fd8ef90..28928eb 100644 --- a/jest.config.js +++ b/jest.config.js @@ -32,15 +32,12 @@ module.exports = { statements: 80 } }, - moduleNameMapper: { - '^@/(.*)$': '/src/$1', - }, verbose: true, clearMocks: true, restoreMocks: true, forceExit: true, - globals: { - 'ts-jest': { + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: { target: 'ES2022', module: 'commonjs', @@ -56,9 +53,13 @@ module.exports = { }, types: ['node', 'jest'] } - } + }] }, transformIgnorePatterns: [ - 'node_modules/(?!(@octokit)/)' - ] + 'node_modules/(?!(@octokit|@anthropic-ai)/)' + ], + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + '^@anthropic-ai/claude-agent-sdk$': '/tests/__mocks__/claude-agent-sdk.ts' + } }; \ No newline at end of file diff --git a/package.json b/package.json index 7a6ac29..a9a1a78 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "node": ">=20.0.0" }, "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.1.1", "@octokit/request-error": "^7.0.0", "@octokit/rest": "^22.0.0", "commander": "^14.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8103a0b..77b8dd7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: ^0.1.1 + version: 0.1.1 '@octokit/request-error': specifier: ^7.0.0 version: 7.0.0 @@ -76,6 +79,10 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@anthropic-ai/claude-agent-sdk@0.1.1': + resolution: {integrity: sha512-+12GQktMFc5Uqz6oVjJbj7Q+GD5QDorKEKtInALKD7VleJwLlFbMYIlm4586owIV5veFvb6bAVofKn9CnYWtvw==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -466,6 +473,67 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@img/sharp-darwin-arm64@0.33.5': + resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.33.5': + resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.0.4': + resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.0.4': + resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.0.4': + resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.0.5': + resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.0.4': + resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.33.5': + resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.33.5': + resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-x64@0.33.5': + resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-win32-x64@0.33.5': + resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@istanbuljs/load-nyc-config@1.1.0': resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} engines: {node: '>=8'} @@ -2123,6 +2191,15 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.30 + '@anthropic-ai/claude-agent-sdk@0.1.1': + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.5 + '@img/sharp-darwin-x64': 0.33.5 + '@img/sharp-linux-arm': 0.33.5 + '@img/sharp-linux-arm64': 0.33.5 + '@img/sharp-linux-x64': 0.33.5 + '@img/sharp-win32-x64': 0.33.5 + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -2459,6 +2536,49 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@img/sharp-darwin-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.0.4 + optional: true + + '@img/sharp-darwin-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.0.5': + optional: true + + '@img/sharp-libvips-linux-x64@1.0.4': + optional: true + + '@img/sharp-linux-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.0.4 + optional: true + + '@img/sharp-linux-arm@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.0.5 + optional: true + + '@img/sharp-linux-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.0.4 + optional: true + + '@img/sharp-win32-x64@0.33.5': + optional: true + '@istanbuljs/load-nyc-config@1.1.0': dependencies: camelcase: 5.3.1 diff --git a/src/services/developer/claude-developer-sdk.ts b/src/services/developer/claude-developer-sdk.ts new file mode 100644 index 0000000..67ccd13 --- /dev/null +++ b/src/services/developer/claude-developer-sdk.ts @@ -0,0 +1,450 @@ +import { + DeveloperInterface, + DeveloperOutput, + DeveloperConfig, + DeveloperDependencies, + DeveloperType, + DeveloperError, + DeveloperErrorCode +} from '@/types/developer.types'; +import { ResponseParser } from './response-parser'; +import { ContextFileManager, ContextFileConfig } from './context-file-manager'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import * as path from 'path'; + +/** + * Claude Developer SDK 기반 구현 + * Anthropic Agent SDK를 활용하여 코드 복잡도를 대폭 감소 + */ +export class ClaudeDeveloperSDK implements DeveloperInterface { + readonly type: DeveloperType = 'claude'; + private isInitialized = false; + private timeoutMs: number; + private responseParser: ResponseParser; + private contextFileManager: ContextFileManager | null = null; + + constructor( + private readonly config: DeveloperConfig, + private readonly dependencies: DeveloperDependencies + ) { + this.timeoutMs = config.timeoutMs; + this.responseParser = new ResponseParser(); + } + + async initialize(): Promise { + try { + // API 키 검증 + if (!this.config.claude?.apiKey) { + throw new DeveloperError( + 'Claude API key is required for SDK mode', + DeveloperErrorCode.INITIALIZATION_FAILED, + 'claude' + ); + } + + this.isInitialized = true; + this.dependencies.logger.info('Claude Developer SDK initialized with API key'); + } catch (error) { + this.dependencies.logger.error('Claude Developer SDK initialization failed', { error }); + + if (error instanceof DeveloperError) { + throw error; + } + + throw new DeveloperError( + 'Claude Developer SDK initialization failed', + DeveloperErrorCode.INITIALIZATION_FAILED, + 'claude', + { originalError: error } + ); + } + } + + async executePrompt(prompt: string, workspaceDir: string): Promise { + if (!this.isInitialized) { + throw new DeveloperError( + 'Claude Developer SDK not initialized', + DeveloperErrorCode.NOT_AVAILABLE, + 'claude' + ); + } + + const startTime = new Date(); + + try { + this.dependencies.logger.debug('Executing Claude prompt via SDK', { + promptLength: prompt.length, + workspaceDir + }); + + // workspace별 Context File Manager 초기화 + await this.initializeContextFileManager(workspaceDir); + + // 긴 컨텍스트 처리 및 최적화된 프롬프트 생성 + const optimizedPrompt = await this.processLongContext(prompt, workspaceDir); + + // SDK를 통한 실행 + const rawOutput = await this.executeWithSDK(optimizedPrompt, workspaceDir); + + this.dependencies.logger.debug('Claude SDK execution completed', { + outputLength: rawOutput.length + }); + + // 응답 파싱 + const parsedOutput = this.responseParser.parseOutput(rawOutput); + + const endTime = new Date(); + const result: any = { + success: parsedOutput.success + }; + + if (parsedOutput.prLink) { + result.prLink = parsedOutput.prLink; + } + + if (parsedOutput.commitHash) { + result.commitHash = parsedOutput.commitHash; + } + + const output: DeveloperOutput = { + rawOutput, + result, + executedCommands: parsedOutput.commands, + modifiedFiles: parsedOutput.modifiedFiles, + metadata: { + startTime, + endTime, + duration: endTime.getTime() - startTime.getTime(), + developerType: 'claude' + } + }; + + return output; + + } catch (error) { + this.dependencies.logger.error('Claude Developer SDK execution failed', { + error, + prompt: prompt.substring(0, 100) + '...', + workspaceDir + }); + + // 타임아웃 에러 처리 + if (error instanceof Error && error.message.includes('timeout')) { + throw new DeveloperError( + 'Claude SDK execution timeout', + DeveloperErrorCode.TIMEOUT, + 'claude', + { originalError: error, timeoutMs: this.timeoutMs } + ); + } + + // 일반적인 실행 에러 + throw new DeveloperError( + 'Claude Developer SDK execution failed', + DeveloperErrorCode.EXECUTION_FAILED, + 'claude', + { originalError: error, prompt, workspaceDir } + ); + } + } + + async cleanup(): Promise { + this.dependencies.logger.info('Starting Claude Developer SDK cleanup'); + + try { + // 컨텍스트 파일 정리 + if (this.contextFileManager) { + try { + await this.contextFileManager.cleanupContextFiles(); + this.dependencies.logger.debug('Context files cleaned up'); + } catch (contextError) { + this.dependencies.logger.warn('Failed to cleanup context files', { error: contextError }); + } + } + + this.isInitialized = false; + this.dependencies.logger.info('Claude Developer SDK cleanup completed successfully'); + } catch (error) { + this.dependencies.logger.error('Claude Developer SDK cleanup failed', { error }); + throw error; + } + } + + async isAvailable(): Promise { + return this.isInitialized; + } + + setTimeout(timeoutMs: number): void { + this.timeoutMs = timeoutMs; + this.dependencies.logger.debug('Claude Developer SDK timeout set', { timeoutMs }); + } + + /** + * Anthropic Agent SDK를 사용하여 프롬프트 실행 + */ + private async executeWithSDK(prompt: string, workspaceDir: string): Promise { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error('Claude SDK execution timeout')); + }, this.timeoutMs); + }); + + const executionPromise = (async () => { + // SDK 설정 + const options: any = { + cwd: workspaceDir, + model: this.config.claude?.model || 'claude-sonnet-4-5-20250929', + allowedTools: ['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep'], + }; + + this.dependencies.logger.debug('Querying Claude SDK', { + model: options.model, + workspaceDir: options.cwd + }); + + // SDK 스트리밍 실행 + const stream = query({ + prompt, + options + }); + + let response = ''; + let hasResult = false; + + for await (const message of stream) { + // Assistant 메시지에서 텍스트 추출 + if (message.type === 'assistant') { + const content = message.message.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block.type === 'text') { + response += block.text; + } + } + } + } + // 결과 메시지 처리 + else if (message.type === 'result') { + hasResult = true; + if (message.is_error) { + throw new Error(`SDK execution error: ${message.subtype}`); + } + if ('result' in message) { + response += message.result; + } + } + } + + return response; + })(); + + return Promise.race([executionPromise, timeoutPromise]); + } + + /** + * workspace별 Context File Manager 초기화 + */ + private async initializeContextFileManager(workspaceDir: string): Promise { + const contextConfig: ContextFileConfig = { + maxContextLength: 8000, + contextDirectory: path.join(workspaceDir, '.ai-devteam', 'context'), + enableMarkdownImports: true + }; + + this.contextFileManager = new ContextFileManager(contextConfig, { + logger: this.dependencies.logger + }); + + await this.contextFileManager.initialize(); + + this.dependencies.logger.debug('Context File Manager initialized for workspace', { + workspaceDir, + contextDirectory: contextConfig.contextDirectory + }); + } + + /** + * 긴 컨텍스트를 파일로 분리하고 최적화된 프롬프트 생성 + */ + private async processLongContext(prompt: string, workspaceDir: string): Promise { + if (!this.contextFileManager) { + return prompt; + } + + if (!this.contextFileManager.shouldSplitContext(prompt)) { + return prompt; + } + + this.dependencies.logger.debug('Processing long context', { + originalLength: prompt.length, + workspaceDir + }); + + try { + const { mainInstruction, contextContent, taskInfo } = this.parsePromptStructure(prompt); + + const contextFiles = await this.contextFileManager.splitLongContext( + contextContent, + 'context' + ); + + let workspaceContextPath = ''; + if (taskInfo && this.contextFileManager) { + workspaceContextPath = await this.contextFileManager.createWorkspaceContext( + workspaceDir, + taskInfo + ); + } + + const optimizedPrompt = this.buildOptimizedPrompt( + mainInstruction, + contextFiles, + workspaceContextPath + ); + + this.dependencies.logger.debug('Context optimization completed', { + originalLength: prompt.length, + optimizedLength: optimizedPrompt.length, + contextFiles: contextFiles.length, + hasWorkspaceContext: !!workspaceContextPath + }); + + return optimizedPrompt; + + } catch (error) { + this.dependencies.logger.warn('Context processing failed, using original prompt', { error }); + return prompt; + } + } + + /** + * 프롬프트를 구조적으로 분석하여 지시사항과 컨텍스트 분리 + */ + private parsePromptStructure(prompt: string): { + mainInstruction: string; + contextContent: string; + taskInfo: { + title: string; + description: string; + requirements: string[]; + constraints?: string[]; + examples?: string[]; + } | undefined; + } { + const lines = prompt.split('\n'); + + let mainInstruction = ''; + let contextContent = ''; + let currentSection = 'instruction'; + + const requirements: string[] = []; + const constraints: string[] = []; + const examples: string[] = []; + + let title = ''; + let description = ''; + + for (const line of lines) { + const trimmedLine = line.trim(); + + if (trimmedLine.match(/^(context|컨텍스트|배경|background):/i)) { + currentSection = 'context'; + continue; + } else if (trimmedLine.match(/^(task|작업|요구사항|requirements?):/i)) { + currentSection = 'task'; + continue; + } else if (trimmedLine.match(/^(제약|constraint|제한)s?:/i)) { + currentSection = 'constraints'; + continue; + } else if (trimmedLine.match(/^(예시|example|sample)s?:/i)) { + currentSection = 'examples'; + continue; + } + + switch (currentSection) { + case 'instruction': + mainInstruction += line + '\n'; + if (!title && trimmedLine.length > 0) { + title = trimmedLine.substring(0, 100); + } + break; + case 'context': + contextContent += line + '\n'; + break; + case 'task': + if (trimmedLine.startsWith('- ') || trimmedLine.match(/^\d+\./)) { + requirements.push(trimmedLine.replace(/^[-\d.]\s*/, '')); + } else if (trimmedLine.length > 0) { + description += trimmedLine + ' '; + } + break; + case 'constraints': + if (trimmedLine.startsWith('- ') || trimmedLine.match(/^\d+\./)) { + constraints.push(trimmedLine.replace(/^[-\d.]\s*/, '')); + } + break; + case 'examples': + if (trimmedLine.length > 0) { + examples.push(trimmedLine); + } + break; + } + } + + const taskInfo = title || requirements.length > 0 ? { + title: title || 'Development Task', + description: description.trim() || mainInstruction.substring(0, 200), + requirements, + ...(constraints.length > 0 && { constraints }), + ...(examples.length > 0 && { examples }) + } : undefined; + + return { + mainInstruction: mainInstruction.trim(), + contextContent: contextContent.trim(), + taskInfo + }; + } + + /** + * 파일 참조를 포함한 최적화된 프롬프트 생성 + */ + private buildOptimizedPrompt( + mainInstruction: string, + contextFiles: any[], + workspaceContextPath?: string + ): string { + const sections: string[] = []; + + if (mainInstruction) { + sections.push(mainInstruction); + } + + if (workspaceContextPath && this.contextFileManager) { + sections.push(`\n# Task Context\n${this.contextFileManager.generateFileReference(workspaceContextPath, 'Task-specific context and requirements')}`); + } + + if (contextFiles.length > 0 && this.contextFileManager) { + sections.push('\n# Additional Context'); + sections.push('Please refer to the following context files:'); + + contextFiles.forEach((file, index) => { + const reference = this.contextFileManager!.generateFileReference( + file.filePath, + `Context part ${index + 1}` + ); + sections.push(reference); + }); + } + + sections.push(` +# Instructions +- Review all referenced context files before proceeding +- Follow the task requirements specified in the context +- Ensure your response addresses all the specified requirements +- Create appropriate files and implement the requested functionality +`); + + return sections.join('\n'); + } +} diff --git a/src/services/developer/developer-factory.ts b/src/services/developer/developer-factory.ts index 330fd73..f3feabd 100644 --- a/src/services/developer/developer-factory.ts +++ b/src/services/developer/developer-factory.ts @@ -6,6 +6,7 @@ import { } from '@/types/developer.types'; import { MockDeveloper } from './mock-developer'; import { ClaudeDeveloper } from './claude-developer'; +import { ClaudeDeveloperSDK } from './claude-developer-sdk'; export class DeveloperFactory { static create( @@ -16,11 +17,17 @@ export class DeveloperFactory { switch (type) { case 'mock': return new MockDeveloper(config, dependencies); - + case 'claude': - // Claude는 API 키 또는 로그인 방식 모두 지원 - return new ClaudeDeveloper(config, dependencies); - + // SDK 모드 또는 CLI 모드 선택 + if (config.useSDK) { + dependencies.logger.info('Creating Claude Developer with SDK mode'); + return new ClaudeDeveloperSDK(config, dependencies); + } else { + dependencies.logger.info('Creating Claude Developer with CLI mode'); + return new ClaudeDeveloper(config, dependencies); + } + case 'gemini': // Gemini 설정 검증 if (!config.gemini?.apiKey) { @@ -29,7 +36,7 @@ export class DeveloperFactory { // GeminiDeveloper 구현 전까지 Mock Developer 사용 // 향후 GeminiDeveloper 클래스 구현 필요 return new MockDeveloper(config, dependencies); - + default: throw new Error(`Unsupported developer type: ${type}`); } diff --git a/src/types/developer.types.ts b/src/types/developer.types.ts index c7e4fda..e4ab94f 100644 --- a/src/types/developer.types.ts +++ b/src/types/developer.types.ts @@ -43,11 +43,14 @@ export interface DeveloperConfig { timeoutMs: number; maxRetries: number; retryDelayMs: number; - + + // SDK 사용 여부 (true: SDK 사용, false: CLI 사용) + useSDK?: boolean; + // CLI 실행 파일 경로 claudeCodePath?: string; geminiCliPath?: string; - + claude?: ClaudeConfig; gemini?: GeminiConfig; mock?: MockConfig; diff --git a/tests/__mocks__/claude-agent-sdk.ts b/tests/__mocks__/claude-agent-sdk.ts new file mode 100644 index 0000000..1aeb9b2 --- /dev/null +++ b/tests/__mocks__/claude-agent-sdk.ts @@ -0,0 +1,12 @@ +/** + * Mock for @anthropic-ai/claude-agent-sdk + */ + +export const query = jest.fn().mockResolvedValue({ + content: 'Mocked response from Claude SDK', + stopReason: 'end_turn' +}); + +export default { + query +}; diff --git a/tests/integration/github-integration.test.ts b/tests/integration/github-integration.test.ts index 1abb074..56e2240 100644 --- a/tests/integration/github-integration.test.ts +++ b/tests/integration/github-integration.test.ts @@ -103,10 +103,16 @@ describe('GitHub Integration Tests', () => { const originalToken = process.env.GITHUB_TOKEN; const originalOwner = process.env.GITHUB_OWNER; const originalProjectNumber = process.env.GITHUB_PROJECT_NUMBER; - + const originalAllowedRepos = process.env.GITHUB_ALLOWED_REPOSITORIES; + const originalGithubRepos = process.env.GITHUB_REPOS; + const originalGithubRepo = process.env.GITHUB_REPO; + process.env.GITHUB_TOKEN = 'env-test-token'; process.env.GITHUB_OWNER = 'test-owner'; process.env.GITHUB_PROJECT_NUMBER = '1'; + delete process.env.GITHUB_ALLOWED_REPOSITORIES; + delete process.env.GITHUB_REPOS; + delete process.env.GITHUB_REPO; try { // When: 환경변수에서 v2 설정을 생성하면 @@ -126,9 +132,15 @@ describe('GitHub Integration Tests', () => { if (originalToken) process.env.GITHUB_TOKEN = originalToken; else delete process.env.GITHUB_TOKEN; if (originalOwner) process.env.GITHUB_OWNER = originalOwner; - else delete process.env.GITHUB_OWNER; + else delete process.env.GITHUB_OWNER; if (originalProjectNumber) process.env.GITHUB_PROJECT_NUMBER = originalProjectNumber; else delete process.env.GITHUB_PROJECT_NUMBER; + if (originalAllowedRepos) process.env.GITHUB_ALLOWED_REPOSITORIES = originalAllowedRepos; + else delete process.env.GITHUB_ALLOWED_REPOSITORIES; + if (originalGithubRepos) process.env.GITHUB_REPOS = originalGithubRepos; + else delete process.env.GITHUB_REPOS; + if (originalGithubRepo) process.env.GITHUB_REPO = originalGithubRepo; + else delete process.env.GITHUB_REPO; } }); diff --git a/tests/unit/services/developer/claude-developer-sdk.test.ts b/tests/unit/services/developer/claude-developer-sdk.test.ts new file mode 100644 index 0000000..f33c68c --- /dev/null +++ b/tests/unit/services/developer/claude-developer-sdk.test.ts @@ -0,0 +1,450 @@ +// Mock the SDK query function +const mockQuery = jest.fn(); + +jest.mock('@anthropic-ai/claude-agent-sdk', () => ({ + query: mockQuery +})); + +// ContextFileManager mock +const mockCleanupContextFiles = jest.fn().mockResolvedValue(undefined); +const mockCreateWorkspaceContext = jest.fn().mockResolvedValue('/tmp/workspace-context.md'); +const mockSplitLongContext = jest.fn().mockResolvedValue([]); +const mockShouldSplitContext = jest.fn().mockReturnValue(false); + +jest.mock('@/services/developer/context-file-manager', () => ({ + ContextFileManager: jest.fn().mockImplementation(() => ({ + initialize: jest.fn().mockResolvedValue(undefined), + createContextFile: jest.fn().mockResolvedValue('test-context-file.md'), + createWorkspaceContext: mockCreateWorkspaceContext, + cleanupContextFiles: mockCleanupContextFiles, + getContextFilePath: jest.fn().mockReturnValue('/tmp/test-context.md'), + splitLongContext: mockSplitLongContext, + shouldSplitContext: mockShouldSplitContext, + generateFileReference: jest.fn().mockImplementation((path, desc) => `@${path}`) + })) +})); + +import { ClaudeDeveloperSDK } from '@/services/developer/claude-developer-sdk'; +import { Logger } from '@/services/logger'; +import { + DeveloperConfig, + DeveloperOutput, + DeveloperErrorCode, + DeveloperError +} from '@/types/developer.types'; + +describe('ClaudeDeveloperSDK', () => { + let developer: ClaudeDeveloperSDK; + let mockLogger: Logger; + let config: DeveloperConfig; + + beforeEach(() => { + // Logger mock + mockLogger = { + info: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn() + } as any; + + // Config + config = { + timeoutMs: 60000, + maxRetries: 3, + retryDelayMs: 1000, + useSDK: true, + claude: { + apiKey: 'test-api-key', + model: 'claude-sonnet-4-5-20250929', + maxTokens: 8192, + temperature: 0.7 + } + }; + + // Reset mocks + jest.clearAllMocks(); + }); + + describe('initialize', () => { + it('초기화 성공 - API 키 존재', async () => { + // Given + developer = new ClaudeDeveloperSDK(config, { logger: mockLogger }); + + // When + await developer.initialize(); + + // Then + expect(await developer.isAvailable()).toBe(true); + expect(mockLogger.info).toHaveBeenCalledWith('Claude Developer SDK initialized with API key'); + }); + + it('초기화 실패 - API 키 없음', async () => { + // Given + const { claude, ...configBase } = config; + const configWithoutKey: DeveloperConfig = { + ...configBase, + timeoutMs: config.timeoutMs, + maxRetries: config.maxRetries, + retryDelayMs: config.retryDelayMs + }; + developer = new ClaudeDeveloperSDK(configWithoutKey, { logger: mockLogger }); + + // When & Then + await expect(developer.initialize()).rejects.toThrow(DeveloperError); + await expect(developer.initialize()).rejects.toMatchObject({ + code: DeveloperErrorCode.INITIALIZATION_FAILED, + developerType: 'claude' + }); + }); + }); + + describe('executePrompt', () => { + beforeEach(async () => { + developer = new ClaudeDeveloperSDK(config, { logger: mockLogger }); + await developer.initialize(); + }); + + it('프롬프트 실행 성공 - 기본 케이스', async () => { + // Given + const prompt = 'Test prompt'; + const workspaceDir = '/test/workspace'; + const expectedOutput = ` +Successfully created files: +- src/test.ts +- tests/test.test.ts + +All tests passing! + `; + + // Mock SDK stream response + const mockStream = (async function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: expectedOutput }] + }, + uuid: 'test-uuid', + session_id: 'test-session', + parent_tool_use_id: null + }; + yield { + type: 'result', + subtype: 'success', + is_error: false, + result: '', + uuid: 'test-uuid', + session_id: 'test-session', + duration_ms: 1000, + duration_api_ms: 800, + num_turns: 1, + total_cost_usd: 0.01, + usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + modelUsage: {}, + permission_denials: [] + }; + })(); + mockQuery.mockReturnValue(mockStream); + + // When + const result: DeveloperOutput = await developer.executePrompt(prompt, workspaceDir); + + // Then + expect(result.result.success).toBe(true); + expect(result.rawOutput).toContain('Successfully created files'); + expect(result.metadata.developerType).toBe('claude'); + expect(mockQuery).toHaveBeenCalledWith( + expect.objectContaining({ + prompt, + options: expect.objectContaining({ + cwd: workspaceDir, + model: 'claude-sonnet-4-5-20250929', + allowedTools: expect.arrayContaining(['Bash', 'Read', 'Write', 'Edit']) + }) + }) + ); + }); + + it('프롬프트 실행 성공 - PR 링크 포함', async () => { + // Given + const prompt = 'Create a PR'; + const workspaceDir = '/test/workspace'; + const expectedOutput = ` +Created PR: https://github.com/test/repo/pull/123 +All changes committed successfully. + `; + + const mockStream = (async function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: expectedOutput }] + }, + uuid: 'test-uuid', + session_id: 'test-session', + parent_tool_use_id: null + }; + yield { + type: 'result', + subtype: 'success', + is_error: false, + result: '', + uuid: 'test-uuid', + session_id: 'test-session', + duration_ms: 1000, + duration_api_ms: 800, + num_turns: 1, + total_cost_usd: 0.01, + usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + modelUsage: {}, + permission_denials: [] + }; + })(); + mockQuery.mockReturnValue(mockStream); + + // When + const result = await developer.executePrompt(prompt, workspaceDir); + + // Then + expect(result.result.success).toBe(true); + expect(result.result.prLink).toBe('https://github.com/test/repo/pull/123'); + }); + + it('프롬프트 실행 실패 - SDK 에러', async () => { + // Given + const prompt = 'Test prompt'; + const workspaceDir = '/test/workspace'; + + const mockStream = (async function* () { + yield { + type: 'result', + subtype: 'error_during_execution', + is_error: true, + uuid: 'test-uuid', + session_id: 'test-session', + duration_ms: 1000, + duration_api_ms: 800, + num_turns: 1, + total_cost_usd: 0.01, + usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + modelUsage: {}, + permission_denials: [] + }; + })(); + mockQuery.mockReturnValue(mockStream); + + // When & Then + await expect(developer.executePrompt(prompt, workspaceDir)).rejects.toThrow(DeveloperError); + }); + + it('프롬프트 실행 실패 - 타임아웃', async () => { + // Given + const prompt = 'Test prompt'; + const workspaceDir = '/test/workspace'; + const shortTimeoutConfig = { ...config, timeoutMs: 100 }; + developer = new ClaudeDeveloperSDK(shortTimeoutConfig, { logger: mockLogger }); + await developer.initialize(); + + // Mock SDK stream that takes too long + const mockStream = (async function* () { + await new Promise(resolve => setTimeout(resolve, 1000)); + yield { + type: 'assistant', + message: { content: [{ type: 'text', text: 'response' }] }, + uuid: 'test-uuid', + session_id: 'test-session', + parent_tool_use_id: null + }; + })(); + mockQuery.mockReturnValue(mockStream); + + // When & Then + await expect(developer.executePrompt(prompt, workspaceDir)).rejects.toThrow(DeveloperError); + await expect(developer.executePrompt(prompt, workspaceDir)).rejects.toMatchObject({ + code: DeveloperErrorCode.TIMEOUT + }); + }); + + it('초기화되지 않은 상태에서 실행 시도', async () => { + // Given + const uninitializedDeveloper = new ClaudeDeveloperSDK(config, { logger: mockLogger }); + + // When & Then + await expect(uninitializedDeveloper.executePrompt('test', '/workspace')).rejects.toThrow( + DeveloperError + ); + await expect(uninitializedDeveloper.executePrompt('test', '/workspace')).rejects.toMatchObject( + { + code: DeveloperErrorCode.NOT_AVAILABLE + } + ); + }); + }); + + describe('cleanup', () => { + beforeEach(async () => { + developer = new ClaudeDeveloperSDK(config, { logger: mockLogger }); + await developer.initialize(); + }); + + it('cleanup 성공', async () => { + // Given + const workspaceDir = '/test/workspace'; + const mockStream = (async function* () { + yield { + type: 'assistant', + message: { content: [{ type: 'text', text: 'test response' }] }, + uuid: 'test-uuid', + session_id: 'test-session', + parent_tool_use_id: null + }; + yield { + type: 'result', + subtype: 'success', + is_error: false, + result: '', + uuid: 'test-uuid', + session_id: 'test-session', + duration_ms: 1000, + duration_api_ms: 800, + num_turns: 1, + total_cost_usd: 0.01, + usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + modelUsage: {}, + permission_denials: [] + }; + })(); + mockQuery.mockReturnValue(mockStream); + + // Execute to initialize context manager + await developer.executePrompt('test', workspaceDir); + + // When + await developer.cleanup(); + + // Then + expect(await developer.isAvailable()).toBe(false); + expect(mockCleanupContextFiles).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Claude Developer SDK cleanup completed successfully' + ); + }); + + it('cleanup 실패 시에도 상태는 정리됨', async () => { + // Given + const workspaceDir = '/workspace/test'; + mockCleanupContextFiles.mockRejectedValueOnce(new Error('Cleanup failed')); + + // Setup mock stream for executePrompt + const mockStream = (async function* () { + yield { + type: 'text', + data: { + textDelta: '작업 완료', + text: '작업 완료' + } + }; + return { + uuid: 'test-uuid', + session_id: 'test-session', + duration_ms: 1000, + duration_api_ms: 800, + num_turns: 1, + total_cost_usd: 0.01, + usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + modelUsage: {}, + permission_denials: [] + }; + })(); + mockQuery.mockReturnValue(mockStream); + + // Execute to initialize context manager + await developer.executePrompt('test', workspaceDir); + + // When + await developer.cleanup(); + + // Then + expect(await developer.isAvailable()).toBe(false); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Failed to cleanup context files', + expect.any(Object) + ); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Claude Developer SDK cleanup completed successfully' + ); + }); + }); + + describe('setTimeout', () => { + beforeEach(async () => { + developer = new ClaudeDeveloperSDK(config, { logger: mockLogger }); + await developer.initialize(); + }); + + it('타임아웃 설정', () => { + // Given + const newTimeout = 120000; + + // When + developer.setTimeout(newTimeout); + + // Then + expect(mockLogger.debug).toHaveBeenCalledWith('Claude Developer SDK timeout set', { + timeoutMs: newTimeout + }); + }); + }); + + describe('긴 컨텍스트 처리', () => { + beforeEach(async () => { + developer = new ClaudeDeveloperSDK(config, { logger: mockLogger }); + await developer.initialize(); + }); + + it('긴 컨텍스트를 분할하여 처리', async () => { + // Given + const longPrompt = 'a'.repeat(10000); + const workspaceDir = '/test/workspace'; + + mockShouldSplitContext.mockReturnValue(true); + mockSplitLongContext.mockResolvedValue([ + { filePath: '/tmp/context-1.md', content: 'part1' }, + { filePath: '/tmp/context-2.md', content: 'part2' } + ]); + + const mockStream = (async function* () { + yield { + type: 'assistant', + message: { content: [{ type: 'text', text: 'success' }] }, + uuid: 'test-uuid', + session_id: 'test-session', + parent_tool_use_id: null + }; + yield { + type: 'result', + subtype: 'success', + is_error: false, + result: '', + uuid: 'test-uuid', + session_id: 'test-session', + duration_ms: 1000, + duration_api_ms: 800, + num_turns: 1, + total_cost_usd: 0.01, + usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + modelUsage: {}, + permission_denials: [] + }; + })(); + mockQuery.mockReturnValue(mockStream); + + // When + const result = await developer.executePrompt(longPrompt, workspaceDir); + + // Then + expect(result.result.success).toBe(true); + expect(mockShouldSplitContext).toHaveBeenCalledWith(longPrompt); + expect(mockSplitLongContext).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/services/developer/developer-factory.test.ts b/tests/unit/services/developer/developer-factory.test.ts index f5034dc..d9c19d6 100644 --- a/tests/unit/services/developer/developer-factory.test.ts +++ b/tests/unit/services/developer/developer-factory.test.ts @@ -1,9 +1,10 @@ import { DeveloperFactory } from '@/services/developer/developer-factory'; import { MockDeveloper } from '@/services/developer/mock-developer'; import { ClaudeDeveloper } from '@/services/developer/claude-developer'; +import { ClaudeDeveloperSDK } from '@/services/developer/claude-developer-sdk'; import { Logger } from '@/services/logger'; -import { - DeveloperConfig, +import { + DeveloperConfig, DeveloperType, DeveloperInterface } from '@/types/developer.types'; @@ -47,11 +48,12 @@ describe('DeveloperFactory', () => { expect(developer.type).toBe('mock'); }); - it('Claude Developer를 생성해야 한다', () => { - // Given: Claude 타입과 설정 + it('Claude Developer를 생성해야 한다 (CLI 모드)', () => { + // Given: Claude 타입과 설정 (useSDK: false) const type: DeveloperType = 'claude'; const claudeConfig = { ...config, + useSDK: false, claude: { apiKey: 'test-api-key', model: 'claude-3' @@ -65,6 +67,29 @@ describe('DeveloperFactory', () => { expect(developer).toBeDefined(); expect(developer).toBeInstanceOf(ClaudeDeveloper); expect(developer.type).toBe('claude'); + expect(mockLogger.info).toHaveBeenCalledWith('Creating Claude Developer with CLI mode'); + }); + + it('Claude Developer SDK를 생성해야 한다 (SDK 모드)', () => { + // Given: Claude 타입과 설정 (useSDK: true) + const type: DeveloperType = 'claude'; + const claudeConfig = { + ...config, + useSDK: true, + claude: { + apiKey: 'test-api-key', + model: 'claude-sonnet-4-5-20250929' + } + }; + + // When: Developer 생성 + const developer = DeveloperFactory.create(type, claudeConfig, { logger: mockLogger }); + + // Then: ClaudeDeveloperSDK 인스턴스 반환 + expect(developer).toBeDefined(); + expect(developer).toBeInstanceOf(ClaudeDeveloperSDK); + expect(developer.type).toBe('claude'); + expect(mockLogger.info).toHaveBeenCalledWith('Creating Claude Developer with SDK mode'); }); it('Gemini Developer를 생성해야 한다 (현재는 Mock 반환)', () => {