diff --git a/packages/ai-agents/jest.config.js b/packages/ai-agents/jest.config.js new file mode 100644 index 000000000..e4803a5c8 --- /dev/null +++ b/packages/ai-agents/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + testEnvironment: 'node', + testMatch: ['**/tests/**/*.test.js'], + collectCoverageFrom: ['src/**/*.js'], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov'], + verbose: true +}; diff --git a/packages/ai-agents/package.json b/packages/ai-agents/package.json new file mode 100644 index 000000000..50aa4301e --- /dev/null +++ b/packages/ai-agents/package.json @@ -0,0 +1,59 @@ +{ + "name": "@friggframework/ai-agents", + "version": "2.0.0-next.0", + "description": "AI agent integration for Frigg Framework", + "main": "src/index.js", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/friggframework/frigg.git" + }, + "bugs": { + "url": "https://github.com/friggframework/frigg/issues" + }, + "homepage": "https://github.com/friggframework/frigg#readme", + "publishConfig": { + "access": "public" + }, + "scripts": { + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", + "lint:fix": "prettier --write --loglevel error . && eslint . --fix" + }, + "keywords": [ + "frigg", + "ai", + "agents", + "mcp", + "llm" + ], + "dependencies": { + "@friggframework/schemas": "^2.0.0-next.0" + }, + "peerDependencies": { + "ai": ">=4.0.0", + "@ai-sdk/openai": ">=1.0.0", + "@anthropic-ai/claude-agent-sdk": ">=0.1.0" + }, + "peerDependenciesMeta": { + "ai": { + "optional": true + }, + "@ai-sdk/openai": { + "optional": true + }, + "@anthropic-ai/claude-agent-sdk": { + "optional": true + } + }, + "devDependencies": { + "@friggframework/eslint-config": "^2.0.0-next.0", + "@friggframework/prettier-config": "^2.0.0-next.0", + "jest": "^29.7.0", + "eslint": "^8.22.0", + "prettier": "^2.7.1" + }, + "prettier": "@friggframework/prettier-config" +} diff --git a/packages/ai-agents/src/domain/entities/agent-event.js b/packages/ai-agents/src/domain/entities/agent-event.js new file mode 100644 index 000000000..b993c262f --- /dev/null +++ b/packages/ai-agents/src/domain/entities/agent-event.js @@ -0,0 +1,55 @@ +const AgentEventType = { + CONTENT: 'content', + TOOL_CALL: 'tool_call', + TOOL_RESULT: 'tool_result', + USAGE: 'usage', + DONE: 'done', + ERROR: 'error' +}; + +class AgentEvent { + constructor({ type, timestamp = new Date(), ...data }) { + this.type = type; + this.timestamp = timestamp; + Object.assign(this, data); + } + + static content(text) { + return new AgentEvent({ type: AgentEventType.CONTENT, content: text }); + } + + static toolCall(name, args) { + return new AgentEvent({ type: AgentEventType.TOOL_CALL, name, args }); + } + + static toolResult(name, result) { + return new AgentEvent({ type: AgentEventType.TOOL_RESULT, name, result }); + } + + static usage(usage) { + return new AgentEvent({ type: AgentEventType.USAGE, usage }); + } + + static done() { + return new AgentEvent({ type: AgentEventType.DONE }); + } + + static error(error) { + return new AgentEvent({ type: AgentEventType.ERROR, error }); + } + + toJSON() { + return { + type: this.type, + timestamp: this.timestamp.toISOString(), + ...(this.content && { content: this.content }), + ...(this.name && { name: this.name }), + ...(this.args && { args: this.args }), + ...(this.result && { result: this.result }), + ...(this.usage && { usage: this.usage }), + ...(this.error && { error: this.error.message || String(this.error) }) + }; + } +} + +module.exports = { AgentEvent, AgentEventType }; diff --git a/packages/ai-agents/src/domain/entities/agent-proposal.js b/packages/ai-agents/src/domain/entities/agent-proposal.js new file mode 100644 index 000000000..ee8700b6b --- /dev/null +++ b/packages/ai-agents/src/domain/entities/agent-proposal.js @@ -0,0 +1,81 @@ +const ProposalStatus = { + PENDING: 'pending', + APPROVED: 'approved', + REJECTED: 'rejected', + MODIFIED: 'modified' +}; + +class AgentProposal { + constructor({ id, files, validation, checkpointId = null }) { + this.id = id; + this.files = files; + this.validation = validation; + this.checkpointId = checkpointId; + this.status = ProposalStatus.PENDING; + this.createdAt = new Date(); + this.approvedAt = null; + this.rejectedAt = null; + this.rejectionReason = null; + } + + getSummary() { + let linesAdded = 0; + let createdFiles = 0; + let modifiedFiles = 0; + + for (const file of this.files) { + if (file.action === 'create') { + createdFiles++; + if (file.content) { + linesAdded += file.content.split('\n').length; + } + } else if (file.action === 'modify') { + modifiedFiles++; + } + } + + return { + fileCount: this.files.length, + createdFiles, + modifiedFiles, + linesAdded, + confidence: this.validation?.confidence, + recommendation: this.validation?.recommendation + }; + } + + approve() { + if (this.status === ProposalStatus.REJECTED) { + throw new Error('Cannot approve rejected proposal'); + } + this.status = ProposalStatus.APPROVED; + this.approvedAt = new Date(); + } + + reject(reason) { + this.status = ProposalStatus.REJECTED; + this.rejectedAt = new Date(); + this.rejectionReason = reason; + } + + canRollback() { + return this.checkpointId !== null; + } + + toJSON() { + return { + id: this.id, + status: this.status, + files: this.files.map(f => ({ path: f.path, action: f.action })), + validation: this.validation, + checkpointId: this.checkpointId, + summary: this.getSummary(), + createdAt: this.createdAt.toISOString(), + approvedAt: this.approvedAt?.toISOString(), + rejectedAt: this.rejectedAt?.toISOString(), + rejectionReason: this.rejectionReason + }; + } +} + +module.exports = { AgentProposal, ProposalStatus }; diff --git a/packages/ai-agents/src/domain/entities/index.js b/packages/ai-agents/src/domain/entities/index.js new file mode 100644 index 000000000..238cd1400 --- /dev/null +++ b/packages/ai-agents/src/domain/entities/index.js @@ -0,0 +1,9 @@ +const { AgentEvent, AgentEventType } = require('./agent-event'); +const { AgentProposal, ProposalStatus } = require('./agent-proposal'); + +module.exports = { + AgentEvent, + AgentEventType, + AgentProposal, + ProposalStatus +}; diff --git a/packages/ai-agents/src/domain/index.js b/packages/ai-agents/src/domain/index.js new file mode 100644 index 000000000..526f173f0 --- /dev/null +++ b/packages/ai-agents/src/domain/index.js @@ -0,0 +1,7 @@ +const interfaces = require('./interfaces'); +const entities = require('./entities'); + +module.exports = { + ...interfaces, + ...entities +}; diff --git a/packages/ai-agents/src/domain/interfaces/agent-framework.js b/packages/ai-agents/src/domain/interfaces/agent-framework.js new file mode 100644 index 000000000..a7dda64d1 --- /dev/null +++ b/packages/ai-agents/src/domain/interfaces/agent-framework.js @@ -0,0 +1,29 @@ +class NotImplementedError extends Error { + constructor(method) { + super(`Not implemented: ${method}`); + this.name = 'NotImplementedError'; + } +} + +class IAgentFramework { + async runAgent(_params) { + throw new NotImplementedError('runAgent'); + } + + async loadMcpTools(_serverConfig) { + throw new NotImplementedError('loadMcpTools'); + } + + getCapabilities() { + throw new NotImplementedError('getCapabilities'); + } + + async validateRunParams(params) { + if (!params.prompt) { + throw new Error('prompt is required'); + } + return true; + } +} + +module.exports = { IAgentFramework, NotImplementedError }; diff --git a/packages/ai-agents/src/domain/interfaces/index.js b/packages/ai-agents/src/domain/interfaces/index.js new file mode 100644 index 000000000..9c4c5317a --- /dev/null +++ b/packages/ai-agents/src/domain/interfaces/index.js @@ -0,0 +1,10 @@ +const { IAgentFramework, NotImplementedError } = require('./agent-framework'); +const { IValidationPipeline, WEIGHTS, THRESHOLDS } = require('./validation-pipeline'); + +module.exports = { + IAgentFramework, + IValidationPipeline, + NotImplementedError, + WEIGHTS, + THRESHOLDS +}; diff --git a/packages/ai-agents/src/domain/interfaces/validation-pipeline.js b/packages/ai-agents/src/domain/interfaces/validation-pipeline.js new file mode 100644 index 000000000..43653c4b1 --- /dev/null +++ b/packages/ai-agents/src/domain/interfaces/validation-pipeline.js @@ -0,0 +1,61 @@ +const { NotImplementedError } = require('./agent-framework'); + +const WEIGHTS = { + schema: 0.30, + patterns: 0.25, + security: 0.15, + tests: 0.20, + lint: 0.10 +}; + +const THRESHOLDS = { + autoApprove: 95, + requireReview: 80 +}; + +class IValidationPipeline { + async validate(_files) { + throw new NotImplementedError('validate'); + } + + calculateConfidence(layerResults) { + let totalScore = 0; + + for (const [layer, weight] of Object.entries(WEIGHTS)) { + const result = layerResults[layer]; + if (result && typeof result.score === 'number') { + totalScore += result.score * weight; + } + } + + return Math.round(totalScore); + } + + getRecommendation(confidence, thresholds = THRESHOLDS) { + if (confidence >= thresholds.autoApprove) { + return 'auto_approve'; + } + if (confidence >= thresholds.requireReview) { + return 'require_review'; + } + return 'manual_approval'; + } + + generateFeedback(layerResults) { + const feedback = []; + + for (const [layer, result] of Object.entries(layerResults)) { + if (!result.passed) { + feedback.push({ + layer, + score: result.score, + issues: result.errors || result.violations || result.vulnerabilities || result.failures || [] + }); + } + } + + return feedback; + } +} + +module.exports = { IValidationPipeline, WEIGHTS, THRESHOLDS }; diff --git a/packages/ai-agents/src/index.js b/packages/ai-agents/src/index.js new file mode 100644 index 000000000..b1b8d4b53 --- /dev/null +++ b/packages/ai-agents/src/index.js @@ -0,0 +1,41 @@ +const { IAgentFramework, NotImplementedError } = require('./domain/interfaces/agent-framework'); +const { IValidationPipeline } = require('./domain/interfaces/validation-pipeline'); + +const { AgentEvent, AgentEventType } = require('./domain/entities/agent-event'); +const { AgentProposal, ProposalStatus } = require('./domain/entities/agent-proposal'); + +const { VercelAIAdapter, SUPPORTED_PROVIDERS } = require('./infrastructure/adapters/vercel-ai-adapter'); +const { ClaudeAgentAdapter } = require('./infrastructure/adapters/claude-agent-adapter'); + +const { ValidationPipeline, ValidationLayer, LAYER_WEIGHTS } = require('./infrastructure/validation/validation-pipeline'); + +const { createFriggMcpTools, KNOWN_MODULES } = require('./infrastructure/mcp/frigg-tools'); + +const { GitCheckpointService } = require('./infrastructure/git/git-checkpoint-service'); + +const { AgentStreamHandler } = require('./infrastructure/streaming/agent-stream-handler'); + +module.exports = { + IAgentFramework, + IValidationPipeline, + NotImplementedError, + + AgentEvent, + AgentEventType, + AgentProposal, + ProposalStatus, + + VercelAIAdapter, + ClaudeAgentAdapter, + SUPPORTED_PROVIDERS, + + ValidationPipeline, + ValidationLayer, + LAYER_WEIGHTS, + + createFriggMcpTools, + KNOWN_MODULES, + + GitCheckpointService, + AgentStreamHandler +}; diff --git a/packages/ai-agents/src/infrastructure/adapters/claude-agent-adapter.js b/packages/ai-agents/src/infrastructure/adapters/claude-agent-adapter.js new file mode 100644 index 000000000..cd78eef23 --- /dev/null +++ b/packages/ai-agents/src/infrastructure/adapters/claude-agent-adapter.js @@ -0,0 +1,148 @@ +const { IAgentFramework } = require('../../domain/interfaces/agent-framework'); +const { AgentEvent } = require('../../domain/entities/agent-event'); + +const DEFAULT_CONFIG = { + model: 'claude-3-5-sonnet-20241022', + maxTokens: 4096, + temperature: 0.7 +}; + +class ClaudeAgentAdapter extends IAgentFramework { + constructor(config = {}) { + super(); + this.config = { ...DEFAULT_CONFIG, ...config }; + this.tools = []; + this.approvalConfig = { + requireApproval: true, + confidenceThreshold: 80 + }; + this._paused = false; + } + + getCapabilities() { + return { + streaming: true, + toolCalling: true, + mcpSupport: true, + nativeMcp: true, + multiProvider: false, + providers: ['anthropic'], + subagentOrchestration: true, + contextWindow: 200000 + }; + } + + async loadMcpTools(serverConfig) { + const { tools = [] } = serverConfig; + + this.tools = tools.map(tool => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema || { type: 'object', properties: {} }, + execute: tool.handler + })); + + return this.tools; + } + + createStreamingHandler(onEvent) { + return { + onContent: (text) => { + onEvent(AgentEvent.content(text)); + }, + onToolCall: (name, args) => { + onEvent(AgentEvent.toolCall(name, args)); + }, + onToolResult: (name, result) => { + onEvent(AgentEvent.toolResult(name, result)); + }, + onFinish: (result) => { + if (result?.usage) { + onEvent(AgentEvent.usage(result.usage)); + } + onEvent(AgentEvent.done()); + }, + onError: (error) => { + onEvent(AgentEvent.error(error)); + } + }; + } + + buildSystemPrompt(context = {}) { + const { integrationContext = '' } = context; + + return `You are an AI agent specialized in building integrations with the Frigg Framework. + +You have access to Frigg-specific MCP tools that help you generate valid, secure code: + +## Available Tools + +- **frigg_validate_schema**: Validate JSON against Frigg schemas (app-definition, integration-definition, api-module-definition) +- **frigg_get_template**: Get starter templates for OAuth2, API key, or webhook integrations +- **frigg_check_patterns**: Verify code follows hexagonal architecture and IntegrationBase patterns +- **frigg_list_modules**: List available pre-built API modules (HubSpot, Salesforce, Slack, etc.) +- **frigg_security_scan**: Scan for hardcoded credentials, SQL injection, and other vulnerabilities +- **frigg_git_checkpoint**: Create rollback points before making changes +- **frigg_get_example**: Get working examples of common patterns + +${integrationContext ? `\nCurrent context: ${integrationContext}` : ''} + +## Required Patterns + +All integrations must: +1. Extend IntegrationBase from @friggframework/core +2. Have static Definition property with name, version, modules, display +3. Implement: onCreate, onUpdate, onDelete, getConfigOptions, testAuth +4. Follow hexagonal architecture (ports and adapters) +5. Use environment variables for credentials + +## Workflow + +1. Start with frigg_git_checkpoint to enable rollback +2. Use frigg_get_template for initial structure +3. Customize based on requirements +4. Run frigg_check_patterns to verify structure +5. Run frigg_security_scan before finalizing +6. Run frigg_validate_schema for any JSON configs + +Human approval is required for all generated code. Present proposals clearly with file paths, content, and confidence scores.`; + } + + async runAgent(params) { + await this.validateRunParams(params); + + const { prompt, context = {}, onEvent } = params; + + if (params.mock) { + return { + stream: this._mockResponse ? this._mockResponse() : this._createMockStream(), + cancel: () => {} + }; + } + + throw new Error('Claude Agent SDK not configured. Install "@anthropic-ai/claude-agent-sdk" and set ANTHROPIC_API_KEY.'); + } + + async *_createMockStream() { + yield AgentEvent.content('Mock response - Claude Agent SDK not initialized'); + yield AgentEvent.done(); + } + + configureHumanApproval(config) { + this.approvalConfig = { ...this.approvalConfig, ...config }; + } + + pause() { + this._paused = true; + } + + resume() { + this._paused = false; + } + + isPaused() { + return this._paused; + } +} + +module.exports = { ClaudeAgentAdapter, DEFAULT_CONFIG }; diff --git a/packages/ai-agents/src/infrastructure/adapters/index.js b/packages/ai-agents/src/infrastructure/adapters/index.js new file mode 100644 index 000000000..f894eb75a --- /dev/null +++ b/packages/ai-agents/src/infrastructure/adapters/index.js @@ -0,0 +1,10 @@ +const { VercelAIAdapter, DEFAULT_CONFIG: VERCEL_DEFAULT_CONFIG, SUPPORTED_PROVIDERS } = require('./vercel-ai-adapter'); +const { ClaudeAgentAdapter, DEFAULT_CONFIG: CLAUDE_DEFAULT_CONFIG } = require('./claude-agent-adapter'); + +module.exports = { + VercelAIAdapter, + ClaudeAgentAdapter, + VERCEL_DEFAULT_CONFIG, + CLAUDE_DEFAULT_CONFIG, + SUPPORTED_PROVIDERS +}; diff --git a/packages/ai-agents/src/infrastructure/adapters/vercel-ai-adapter.js b/packages/ai-agents/src/infrastructure/adapters/vercel-ai-adapter.js new file mode 100644 index 000000000..f3913a361 --- /dev/null +++ b/packages/ai-agents/src/infrastructure/adapters/vercel-ai-adapter.js @@ -0,0 +1,135 @@ +const { IAgentFramework } = require('../../domain/interfaces/agent-framework'); +const { AgentEvent } = require('../../domain/entities/agent-event'); + +const DEFAULT_CONFIG = { + model: 'gpt-4-turbo', + provider: 'openai', + maxTokens: 4096, + temperature: 0.7 +}; + +const SUPPORTED_PROVIDERS = ['openai', 'anthropic', 'google', 'azure', 'amazon-bedrock']; + +class VercelAIAdapter extends IAgentFramework { + constructor(config = {}) { + super(); + this.config = { ...DEFAULT_CONFIG, ...config }; + this.tools = []; + this.approvalConfig = { + requireApproval: true, + confidenceThreshold: 80 + }; + this._paused = false; + } + + getCapabilities() { + return { + streaming: true, + toolCalling: true, + mcpSupport: true, + multiProvider: true, + providers: SUPPORTED_PROVIDERS, + nativeMcp: false, + subagentOrchestration: false + }; + } + + async loadMcpTools(serverConfig) { + const { tools = [] } = serverConfig; + + this.tools = tools.map(tool => ({ + name: tool.name, + description: tool.description, + parameters: tool.inputSchema || { type: 'object', properties: {} }, + execute: tool.handler + })); + + return this.tools; + } + + createStreamingHandler(onEvent) { + return { + onContent: (text) => { + onEvent(AgentEvent.content(text)); + }, + onToolCall: (name, args) => { + onEvent(AgentEvent.toolCall(name, args)); + }, + onToolResult: (name, result) => { + onEvent(AgentEvent.toolResult(name, result)); + }, + onFinish: (result) => { + if (result?.usage) { + onEvent(AgentEvent.usage(result.usage)); + } + onEvent(AgentEvent.done()); + }, + onError: (error) => { + onEvent(AgentEvent.error(error)); + } + }; + } + + buildSystemPrompt(context = {}) { + const { integrationContext = '' } = context; + + return `You are an AI agent specialized in building integrations with the Frigg Framework. + +Your role is to help developers create, modify, and maintain integrations following Frigg's hexagonal architecture patterns. + +${integrationContext ? `Current context: ${integrationContext}` : ''} + +Key patterns to follow: +- Always extend IntegrationBase for integration classes +- Implement required methods: onCreate, onUpdate, onDelete, getConfigOptions, testAuth +- Use static Definition property for integration metadata +- Follow OAuth2 patterns for authentication +- Use environment variables for credentials, never hardcode + +When generating code: +1. Use frigg_validate_schema to validate JSON definitions +2. Use frigg_check_patterns to verify code follows Frigg patterns +3. Use frigg_security_scan to check for vulnerabilities +4. Always include test files for generated code + +Validation is critical - run validation tools after every code generation step.`; + } + + async runAgent(params) { + await this.validateRunParams(params); + + const { prompt, context = {}, onEvent } = params; + + if (params.mock) { + return { + stream: this._mockResponse ? this._mockResponse() : this._createMockStream(), + cancel: () => {} + }; + } + + throw new Error('Vercel AI SDK not configured. Install "ai" package and set API keys.'); + } + + async *_createMockStream() { + yield AgentEvent.content('Mock response - Vercel AI SDK not initialized'); + yield AgentEvent.done(); + } + + configureHumanApproval(config) { + this.approvalConfig = { ...this.approvalConfig, ...config }; + } + + pause() { + this._paused = true; + } + + resume() { + this._paused = false; + } + + isPaused() { + return this._paused; + } +} + +module.exports = { VercelAIAdapter, DEFAULT_CONFIG, SUPPORTED_PROVIDERS }; diff --git a/packages/ai-agents/src/infrastructure/git/git-checkpoint-service.js b/packages/ai-agents/src/infrastructure/git/git-checkpoint-service.js new file mode 100644 index 000000000..075f780e4 --- /dev/null +++ b/packages/ai-agents/src/infrastructure/git/git-checkpoint-service.js @@ -0,0 +1,110 @@ +const { exec } = require('child_process'); +const { promisify } = require('util'); + +const execAsync = promisify(exec); + +class GitCheckpointService { + constructor(options = {}) { + this.execCommand = options.execCommand || this._execGit.bind(this); + this.workingDir = options.workingDir || process.cwd(); + this.checkpoints = new Map(); + } + + async _execGit(command) { + return execAsync(command, { cwd: this.workingDir }); + } + + async createCheckpoint(message) { + const hashResult = await this.execCommand('git rev-parse HEAD'); + const hash = hashResult.stdout.trim(); + + const statusResult = await this.execCommand('git status --porcelain'); + const hasPendingChanges = statusResult.stdout.trim().length > 0; + + const id = `checkpoint-${Date.now()}-${hash.substring(0, 8)}`; + + const checkpoint = { + id, + hash, + message, + timestamp: new Date(), + hasPendingChanges + }; + + this.checkpoints.set(id, checkpoint); + + return checkpoint; + } + + getCheckpoint(id) { + return this.checkpoints.get(id); + } + + async rollback(checkpointId, options = {}) { + const checkpoint = this.checkpoints.get(checkpointId); + + if (!checkpoint) { + throw new Error('Checkpoint not found'); + } + + const { mode = 'mixed' } = options; + const modeFlag = mode === 'hard' ? '--hard' : mode === 'soft' ? '--soft' : '--mixed'; + + await this.execCommand(`git reset ${modeFlag} ${checkpoint.hash}`); + + return { success: true, checkpoint }; + } + + listCheckpoints() { + const list = Array.from(this.checkpoints.values()); + return list.sort((a, b) => b.timestamp - a.timestamp); + } + + async getStatus() { + const branchResult = await this.execCommand('git rev-parse --abbrev-ref HEAD'); + const branch = branchResult.stdout.trim(); + + const statusResult = await this.execCommand('git status --porcelain'); + const statusLines = statusResult.stdout.trim(); + const changes = statusLines ? statusLines.split('\n').map(line => ({ + status: line.substring(0, 2).trim(), + file: line.substring(3) + })) : []; + + const hashResult = await this.execCommand('git rev-parse --short HEAD'); + const hash = hashResult.stdout.trim(); + + return { + branch, + hash, + clean: changes.length === 0, + changes + }; + } + + async diff(checkpointId) { + const checkpoint = this.checkpoints.get(checkpointId); + + if (!checkpoint) { + throw new Error('Checkpoint not found'); + } + + const result = await this.execCommand(`git diff ${checkpoint.hash}`); + return result.stdout; + } + + cleanup(options = {}) { + const { maxCheckpoints = 50 } = options; + + const sorted = this.listCheckpoints(); + + if (sorted.length > maxCheckpoints) { + const toRemove = sorted.slice(maxCheckpoints); + for (const checkpoint of toRemove) { + this.checkpoints.delete(checkpoint.id); + } + } + } +} + +module.exports = { GitCheckpointService }; diff --git a/packages/ai-agents/src/infrastructure/git/index.js b/packages/ai-agents/src/infrastructure/git/index.js new file mode 100644 index 000000000..41f795936 --- /dev/null +++ b/packages/ai-agents/src/infrastructure/git/index.js @@ -0,0 +1,3 @@ +const { GitCheckpointService } = require('./git-checkpoint-service'); + +module.exports = { GitCheckpointService }; diff --git a/packages/ai-agents/src/infrastructure/mcp/frigg-tools.js b/packages/ai-agents/src/infrastructure/mcp/frigg-tools.js new file mode 100644 index 000000000..03d4bc051 --- /dev/null +++ b/packages/ai-agents/src/infrastructure/mcp/frigg-tools.js @@ -0,0 +1,1918 @@ +const { exec, spawn } = require('child_process'); +const { promisify } = require('util'); +const path = require('path'); +const fs = require('fs').promises; +const axios = require('axios'); + +const execAsync = promisify(exec); + +const INTEGRATION_CATEGORIES = [ + 'CRM', 'Marketing', 'Communication', 'ECommerce', + 'Finance', 'Analytics', 'Storage', 'Development', + 'Productivity', 'Social', 'Other' +]; + +const INTEGRATION_TYPES = ['api', 'webhook', 'sync', 'transform', 'custom']; + +const CATEGORY_TEMPLATES = { + CRM: (name, options = {}) => `const { IntegrationBase } = require('@friggframework/core'); + +class ${capitalize(name)}Integration extends IntegrationBase { + static Definition = { + name: '${name.toLowerCase()}', + version: '1.0.0', + modules: { + ${name.toLowerCase()}: { definition: require('@friggframework/api-module-${name.toLowerCase()}') } + }, + options: { + type: 'api', + hasUserConfig: true, + display: { + name: '${capitalize(name)}', + description: '${capitalize(name)} CRM integration for contacts, deals, and company management', + category: 'CRM', + icon: '${name.toLowerCase()}' + } + }, + capabilities: { + auth: ['oauth2'], + webhooks: ${options.webhooks || false}, + sync: { bidirectional: true, incremental: true } + } + }; + + async onCreate({ integrationId }) { + await this.updateIntegrationStatus.execute(integrationId, 'ENABLED'); + ${options.webhooks ? `// Register webhooks for real-time updates + // await this.registerWebhooks();` : ''} + } + + async onUpdate(params) { + await this.validateConfig(); + } + + async onDelete(params) { + ${options.webhooks ? `// Cleanup webhooks + // await this.unregisterWebhooks();` : ''} + } + + async getConfigOptions() { + return { + jsonSchema: { + type: 'object', + properties: { + syncContacts: { type: 'boolean', title: 'Sync Contacts', default: true }, + syncDeals: { type: 'boolean', title: 'Sync Deals', default: true }, + syncCompanies: { type: 'boolean', title: 'Sync Companies', default: true } + } + }, + uiSchema: {} + }; + } + + async testAuth() { + const module = this.getModule('${name.toLowerCase()}'); + return module.testAuth(); + } +${options.webhooks ? ` + async onWebhookReceived({ req, res }) { + await this.queueWebhook({ + integrationId: req.params.integrationId, + body: req.body, + headers: req.headers + }); + res.status(200).json({ received: true }); + } + + async onWebhook({ data }) { + const { body } = data; + // Process CRM webhook events (contact.created, deal.updated, etc.) + } +` : ''}} + +module.exports = { ${capitalize(name)}Integration }; +`, + + Finance: (name, options = {}) => `const { IntegrationBase } = require('@friggframework/core'); + +class ${capitalize(name)}Integration extends IntegrationBase { + static Definition = { + name: '${name.toLowerCase()}', + version: '1.0.0', + modules: { + ${name.toLowerCase()}: { definition: require('@friggframework/api-module-${name.toLowerCase()}') } + }, + options: { + type: 'api', + hasUserConfig: true, + display: { + name: '${capitalize(name)}', + description: '${capitalize(name)} finance integration for invoices, payments, and accounting', + category: 'Finance', + icon: '${name.toLowerCase()}' + } + }, + capabilities: { + auth: ['${options.authType || 'oauth2'}'], + webhooks: ${options.webhooks || false}, + sync: { bidirectional: false, incremental: true } + } + }; + + async onCreate({ integrationId }) { + await this.updateIntegrationStatus.execute(integrationId, 'ENABLED'); + } + + async onUpdate(params) { + await this.validateConfig(); + } + + async onDelete(params) {} + + async getConfigOptions() { + return { + jsonSchema: { + type: 'object', + properties: { + syncInvoices: { type: 'boolean', title: 'Sync Invoices', default: true }, + syncPayments: { type: 'boolean', title: 'Sync Payments', default: true }, + syncCustomers: { type: 'boolean', title: 'Sync Customers', default: true } + } + }, + uiSchema: {} + }; + } + + async testAuth() { + const module = this.getModule('${name.toLowerCase()}'); + return module.testAuth(); + } +} + +module.exports = { ${capitalize(name)}Integration }; +`, + + Communication: (name, options = {}) => `const { IntegrationBase } = require('@friggframework/core'); + +class ${capitalize(name)}Integration extends IntegrationBase { + static Definition = { + name: '${name.toLowerCase()}', + version: '1.0.0', + modules: { + ${name.toLowerCase()}: { definition: require('@friggframework/api-module-${name.toLowerCase()}') } + }, + options: { + type: 'api', + hasUserConfig: true, + display: { + name: '${capitalize(name)}', + description: '${capitalize(name)} communication integration for messaging and notifications', + category: 'Communication', + icon: '${name.toLowerCase()}' + } + }, + capabilities: { + auth: ['oauth2'], + webhooks: true, + realtime: true + } + }; + + async onCreate({ integrationId }) { + await this.updateIntegrationStatus.execute(integrationId, 'ENABLED'); + // Subscribe to message events + } + + async onUpdate(params) { + await this.validateConfig(); + } + + async onDelete(params) { + // Unsubscribe from events + } + + async getConfigOptions() { + return { + jsonSchema: { + type: 'object', + properties: { + defaultChannel: { type: 'string', title: 'Default Channel' }, + notifyOnMention: { type: 'boolean', title: 'Notify on Mention', default: true } + } + }, + uiSchema: {} + }; + } + + async testAuth() { + const module = this.getModule('${name.toLowerCase()}'); + return module.testAuth(); + } + + async onWebhookReceived({ req, res }) { + // Handle Slack/Teams challenge verification + if (req.body.challenge) { + return res.status(200).json({ challenge: req.body.challenge }); + } + + await this.queueWebhook({ + integrationId: req.params.integrationId, + body: req.body, + headers: req.headers + }); + res.status(200).json({ received: true }); + } + + async onWebhook({ data }) { + const { body } = data; + // Process message events + } +} + +module.exports = { ${capitalize(name)}Integration }; +`, + + ECommerce: (name, options = {}) => `const { IntegrationBase } = require('@friggframework/core'); + +class ${capitalize(name)}Integration extends IntegrationBase { + static Definition = { + name: '${name.toLowerCase()}', + version: '1.0.0', + modules: { + ${name.toLowerCase()}: { definition: require('@friggframework/api-module-${name.toLowerCase()}') } + }, + options: { + type: 'api', + hasUserConfig: true, + display: { + name: '${capitalize(name)}', + description: '${capitalize(name)} e-commerce integration for orders, products, and customers', + category: 'ECommerce', + icon: '${name.toLowerCase()}' + } + }, + capabilities: { + auth: ['oauth2'], + webhooks: true, + sync: { bidirectional: true, incremental: true, batchSize: 250 } + } + }; + + async onCreate({ integrationId }) { + await this.updateIntegrationStatus.execute(integrationId, 'ENABLED'); + // Register order and inventory webhooks + } + + async onUpdate(params) { + await this.validateConfig(); + } + + async onDelete(params) { + // Cleanup webhooks + } + + async getConfigOptions() { + return { + jsonSchema: { + type: 'object', + properties: { + syncOrders: { type: 'boolean', title: 'Sync Orders', default: true }, + syncProducts: { type: 'boolean', title: 'Sync Products', default: true }, + syncCustomers: { type: 'boolean', title: 'Sync Customers', default: true }, + syncInventory: { type: 'boolean', title: 'Sync Inventory', default: false } + } + }, + uiSchema: {} + }; + } + + async testAuth() { + const module = this.getModule('${name.toLowerCase()}'); + return module.testAuth(); + } + + async onWebhookReceived({ req, res }) { + // Verify webhook signature + await this.queueWebhook({ + integrationId: req.params.integrationId, + body: req.body, + headers: req.headers + }); + res.status(200).json({ received: true }); + } + + async onWebhook({ data }) { + const { body } = data; + // Process order/product/inventory events + } +} + +module.exports = { ${capitalize(name)}Integration }; +`, + + Storage: (name, options = {}) => `const { IntegrationBase } = require('@friggframework/core'); + +class ${capitalize(name)}Integration extends IntegrationBase { + static Definition = { + name: '${name.toLowerCase()}', + version: '1.0.0', + modules: { + ${name.toLowerCase()}: { definition: require('@friggframework/api-module-${name.toLowerCase()}') } + }, + options: { + type: 'api', + hasUserConfig: true, + display: { + name: '${capitalize(name)}', + description: '${capitalize(name)} storage integration for files and documents', + category: 'Storage', + icon: '${name.toLowerCase()}' + } + }, + capabilities: { + auth: ['oauth2'], + webhooks: ${options.webhooks || false} + } + }; + + async onCreate({ integrationId }) { + await this.updateIntegrationStatus.execute(integrationId, 'ENABLED'); + } + + async onUpdate(params) { + await this.validateConfig(); + } + + async onDelete(params) {} + + async getConfigOptions() { + return { + jsonSchema: { + type: 'object', + properties: { + rootFolder: { type: 'string', title: 'Root Folder Path' }, + syncSubfolders: { type: 'boolean', title: 'Sync Subfolders', default: true } + } + }, + uiSchema: {} + }; + } + + async testAuth() { + const module = this.getModule('${name.toLowerCase()}'); + return module.testAuth(); + } +} + +module.exports = { ${capitalize(name)}Integration }; +`, + + Webhook: (name, options = {}) => `const { IntegrationBase } = require('@friggframework/core'); +const crypto = require('crypto'); + +class ${capitalize(name)}Integration extends IntegrationBase { + static Definition = { + name: '${name.toLowerCase()}', + version: '1.0.0', + modules: {}, + options: { + type: 'webhook', + hasUserConfig: true, + display: { + name: '${capitalize(name)}', + description: '${capitalize(name)} webhook-only integration for receiving external events', + category: '${options.category || 'Other'}', + icon: '${name.toLowerCase()}' + } + }, + capabilities: { + auth: ['custom'], + webhooks: true + } + }; + + async onCreate({ integrationId }) { + await this.updateIntegrationStatus.execute(integrationId, 'ENABLED'); + } + + async onUpdate(params) {} + + async onDelete(params) {} + + async getConfigOptions() { + return { + jsonSchema: { + type: 'object', + required: ['webhookSecret'], + properties: { + webhookSecret: { + type: 'string', + title: 'Webhook Secret', + description: 'Secret for validating webhook signatures' + } + } + }, + uiSchema: { + webhookSecret: { 'ui:widget': 'password' } + } + }; + } + + async testAuth() { + return true; + } + + verifySignature(body, signature, secret) { + const expected = crypto + .createHmac('sha256', secret) + .update(JSON.stringify(body)) + .digest('hex'); + return crypto.timingSafeEqual( + Buffer.from(signature || ''), + Buffer.from(expected) + ); + } + + async onWebhookReceived({ req, res }) { + const signature = req.headers['x-webhook-signature'] || req.headers['x-hub-signature-256']; + const config = this.getConfig(); + + if (config.webhookSecret && !this.verifySignature(req.body, signature, config.webhookSecret)) { + return res.status(401).json({ error: 'Invalid signature' }); + } + + await this.queueWebhook({ + integrationId: req.params.integrationId, + body: req.body, + headers: req.headers + }); + res.status(200).json({ received: true }); + } + + async onWebhook({ data }) { + const { body } = data; + // Process webhook event + } +} + +module.exports = { ${capitalize(name)}Integration }; +`, + + Sync: (name, options = {}) => `const { IntegrationBase } = require('@friggframework/core'); + +class ${capitalize(name)}Integration extends IntegrationBase { + static Definition = { + name: '${name.toLowerCase()}', + version: '1.0.0', + modules: { + source: { definition: require('@friggframework/api-module-${options.sourceModule || name.toLowerCase()}') }, + target: { definition: require('@friggframework/api-module-${options.targetModule || name.toLowerCase()}') } + }, + options: { + type: 'sync', + hasUserConfig: true, + display: { + name: '${capitalize(name)}', + description: '${capitalize(name)} sync integration for bidirectional data synchronization', + category: '${options.category || 'Other'}', + icon: '${name.toLowerCase()}' + } + }, + capabilities: { + auth: ['oauth2'], + webhooks: true, + sync: { bidirectional: true, incremental: true, batchSize: 100 } + } + }; + + async onCreate({ integrationId }) { + await this.updateIntegrationStatus.execute(integrationId, 'ENABLED'); + // Initialize sync state + } + + async onUpdate(params) { + await this.validateConfig(); + } + + async onDelete(params) { + // Cleanup sync state + } + + async getConfigOptions() { + return { + jsonSchema: { + type: 'object', + properties: { + syncDirection: { + type: 'string', + title: 'Sync Direction', + enum: ['source-to-target', 'target-to-source', 'bidirectional'], + default: 'bidirectional' + }, + conflictResolution: { + type: 'string', + title: 'Conflict Resolution', + enum: ['source-wins', 'target-wins', 'newest-wins'], + default: 'newest-wins' + }, + syncInterval: { + type: 'integer', + title: 'Sync Interval (minutes)', + default: 15, + minimum: 5 + } + } + }, + uiSchema: {} + }; + } + + async testAuth() { + const sourceModule = this.getModule('source'); + const targetModule = this.getModule('target'); + await sourceModule.testAuth(); + await targetModule.testAuth(); + return true; + } +} + +module.exports = { ${capitalize(name)}Integration }; +` +}; + +function capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +class NPMRegistryService { + constructor() { + this.searchUrl = 'https://registry.npmjs.org/-/v1/search'; + this.packageScope = '@friggframework'; + this.modulePrefix = 'api-module-'; + } + + async searchApiModules(options = {}) { + const { category, limit = 250 } = options; + const searchQuery = `${this.packageScope}/${this.modulePrefix}`; + + try { + const response = await axios.get(this.searchUrl, { + params: { + text: searchQuery, + size: limit, + quality: 0.65, + popularity: 0.98, + maintenance: 0.5 + }, + timeout: 10000 + }); + + let modules = response.data.objects + .filter(obj => obj.package.name.startsWith(`${this.packageScope}/${this.modulePrefix}`)) + .map(obj => this.formatPackageInfo(obj.package)); + + if (category && category !== 'all') { + modules = modules.filter(m => m.category === category); + } + + return modules; + } catch (error) { + return []; + } + } + + formatPackageInfo(pkg) { + const name = pkg.name.replace(`${this.packageScope}/${this.modulePrefix}`, ''); + return { + name, + fullName: pkg.name, + displayName: this.formatDisplayName(name), + version: pkg.version, + description: pkg.description || '', + category: this.categorizeModule(name, pkg.description || ''), + authType: this.inferAuthType(name, pkg.description || '') + }; + } + + formatDisplayName(name) { + return name + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + categorizeModule(name, description) { + const text = `${name} ${description}`.toLowerCase(); + const categories = { + 'CRM': ['crm', 'customer', 'salesforce', 'hubspot', 'pipedrive', 'zoho', 'attio', 'copper'], + 'Finance': ['accounting', 'quickbooks', 'xero', 'sage', 'invoice', 'billing', 'stripe', 'payment'], + 'Communication': ['email', 'sms', 'chat', 'messaging', 'slack', 'discord', 'twilio', 'teams', 'intercom'], + 'ECommerce': ['shop', 'commerce', 'shopify', 'woocommerce', 'magento', 'bigcommerce', 'store'], + 'Marketing': ['marketing', 'campaign', 'mailchimp', 'sendgrid', 'marketo', 'constantcontact'], + 'Analytics': ['analytics', 'tracking', 'mixpanel', 'segment', 'amplitude', 'google-analytics'], + 'Storage': ['storage', 'drive', 'dropbox', 'box', 'onedrive', 'file', 'document'], + 'Development': ['github', 'gitlab', 'bitbucket', 'jira', 'linear', 'notion', 'confluence'], + 'Productivity': ['asana', 'monday', 'trello', 'clickup', 'basecamp', 'todoist', 'airtable'], + 'Social': ['social', 'twitter', 'facebook', 'linkedin', 'instagram', 'youtube'] + }; + + for (const [category, keywords] of Object.entries(categories)) { + if (keywords.some(keyword => text.includes(keyword))) { + return category; + } + } + return 'Other'; + } + + inferAuthType(name, description) { + const text = `${name} ${description}`.toLowerCase(); + if (text.includes('api key') || text.includes('apikey')) { + return 'api-key'; + } + return 'oauth2'; + } +} + +let gitCheckpointServiceInstance = null; + +function setGitCheckpointService(service) { + gitCheckpointServiceInstance = service; +} + +async function validateSchemaHandler({ schemaType, content }) { + let parsed; + try { + parsed = typeof content === 'string' ? JSON.parse(content) : content; + } catch (e) { + return { valid: false, errors: [`Invalid JSON: ${e.message}`] }; + } + + const errors = []; + const warnings = []; + + if (schemaType === 'integration-definition') { + if (!parsed.name) { + errors.push('name is required'); + } else if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(parsed.name)) { + errors.push('name must match pattern ^[a-zA-Z][a-zA-Z0-9_-]*$'); + } + + if (!parsed.version) { + errors.push('version is required'); + } else if (!/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$/.test(parsed.version)) { + errors.push('version must follow semantic versioning (X.Y.Z or X.Y.Z-prerelease)'); + } + + if (parsed.options?.type && !INTEGRATION_TYPES.includes(parsed.options.type)) { + errors.push(`options.type must be one of: ${INTEGRATION_TYPES.join(', ')}`); + } + + if (parsed.options?.display?.category && !INTEGRATION_CATEGORIES.includes(parsed.options.display.category)) { + errors.push(`options.display.category must be one of: ${INTEGRATION_CATEGORIES.join(', ')}`); + } + + if (parsed.capabilities?.auth) { + const validAuth = ['oauth2', 'api-key', 'basic', 'token', 'custom']; + for (const auth of parsed.capabilities.auth) { + if (!validAuth.includes(auth)) { + errors.push(`capabilities.auth contains invalid value: ${auth}`); + } + } + } + + if (parsed.model?.status) { + const validStatus = ['active', 'inactive', 'error', 'pending', 'disabled']; + if (!validStatus.includes(parsed.model.status)) { + errors.push(`model.status must be one of: ${validStatus.join(', ')}`); + } + } + + if (!parsed.modules || Object.keys(parsed.modules).length === 0) { + warnings.push('No modules defined - integration may not connect to external services'); + } + + if (!parsed.options?.display?.name) { + warnings.push('Missing display.name - UI will use internal name'); + } + } + + if (schemaType === 'api-module-definition') { + if (!parsed.name && !parsed.moduleName) { + errors.push('name or moduleName is required'); + } + + if (!parsed.authType && !parsed.requester?.baseUrl) { + warnings.push('No authType or baseUrl specified'); + } + } + + if (schemaType === 'app-definition') { + if (!parsed.name) { + errors.push('name is required'); + } + + if (!parsed.integrations || parsed.integrations.length === 0) { + warnings.push('No integrations defined in app'); + } + } + + return { + valid: errors.length === 0, + errors, + warnings: warnings.length > 0 ? warnings : undefined + }; +} + +async function getTemplateHandler({ category, integrationName, options = {} }) { + const templateFn = CATEGORY_TEMPLATES[category]; + + if (!templateFn) { + const availableCategories = Object.keys(CATEGORY_TEMPLATES); + return { + error: `Unknown category: ${category}. Available: ${availableCategories.join(', ')}`, + availableCategories + }; + } + + const template = templateFn(integrationName || 'MyIntegration', options); + return { + template: template.trim(), + category, + integrationName: integrationName || 'MyIntegration', + suggestedFilename: `${(integrationName || 'my-integration').toLowerCase()}-integration.js` + }; +} + +async function checkPatternsHandler({ code, fileType }) { + const violations = []; + const suggestions = []; + + if (fileType === 'integration') { + if (!code.includes('extends IntegrationBase')) { + violations.push({ + rule: 'extends-integration-base', + severity: 'error', + message: 'Integration must extend IntegrationBase', + suggestion: 'class YourIntegration extends IntegrationBase { ... }' + }); + } + + if (!code.includes('static Definition')) { + violations.push({ + rule: 'static-definition', + severity: 'error', + message: 'Integration must have static Definition property', + suggestion: 'Add: static Definition = { name, version, modules, options, capabilities }' + }); + } else { + if (!code.includes("name:") && !code.includes('name :')) { + violations.push({ + rule: 'definition-name', + severity: 'error', + message: 'Definition must include name property' + }); + } + if (!code.includes("version:") && !code.includes('version :')) { + violations.push({ + rule: 'definition-version', + severity: 'error', + message: 'Definition must include version property' + }); + } + } + + const lifecycleMethods = ['onCreate', 'onUpdate', 'onDelete', 'getConfigOptions', 'testAuth']; + const missingMethods = lifecycleMethods.filter(m => !code.includes(`async ${m}(`)); + + if (missingMethods.length > 0) { + violations.push({ + rule: 'lifecycle-methods', + severity: 'warning', + message: `Missing lifecycle methods: ${missingMethods.join(', ')}`, + suggestion: `Consider implementing: ${missingMethods.map(m => `async ${m}() { }`).join(', ')}` + }); + } + + if (code.includes('webhooks: true') || code.includes("type: 'webhook'")) { + if (!code.includes('onWebhookReceived') || !code.includes('onWebhook')) { + violations.push({ + rule: 'webhook-handlers', + severity: 'warning', + message: 'Webhooks enabled but handlers not implemented', + suggestion: 'Implement onWebhookReceived() and onWebhook() methods' + }); + } + } + + if (!code.includes('updateIntegrationStatus')) { + suggestions.push({ + rule: 'status-updates', + message: 'Consider calling updateIntegrationStatus in onCreate', + suggestion: "await this.updateIntegrationStatus.execute(integrationId, 'ENABLED');" + }); + } + } + + if (fileType === 'api-module') { + if (!code.includes('class') || (!code.includes('extends') && !code.includes('Api'))) { + violations.push({ + rule: 'api-class', + severity: 'warning', + message: 'API module should define a class (typically extending a base Api class)' + }); + } + + if (!code.includes('testAuth')) { + violations.push({ + rule: 'test-auth', + severity: 'warning', + message: 'API module should implement testAuth() method' + }); + } + } + + return { + compliant: violations.filter(v => v.severity === 'error').length === 0, + violations, + suggestions: suggestions.length > 0 ? suggestions : undefined + }; +} + +async function listModulesHandler({ category }) { + const npmService = new NPMRegistryService(); + + try { + const modules = await npmService.searchApiModules({ category }); + return { + modules, + total: modules.length, + source: 'npm-registry' + }; + } catch (error) { + return { + modules: [], + total: 0, + error: error.message, + source: 'npm-registry' + }; + } +} + +async function runTestsHandler({ testPattern, coverage = false, watch = false }) { + const args = ['jest']; + + if (testPattern) { + args.push(testPattern); + } + + if (coverage) { + args.push('--coverage'); + } + + if (watch) { + args.push('--watch'); + } + + args.push('--passWithNoTests'); + + return new Promise((resolve) => { + const jestProcess = spawn('npx', args, { + cwd: process.cwd(), + env: { ...process.env, FORCE_COLOR: '0' } + }); + + let stdout = ''; + let stderr = ''; + + jestProcess.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + jestProcess.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + jestProcess.on('close', (code) => { + const lines = (stdout + stderr).split('\n'); + const summaryLine = lines.find(l => l.includes('Tests:') || l.includes('Test Suites:')); + const coverageLine = lines.find(l => l.includes('Coverage')); + + let passed = 0; + let failed = 0; + let total = 0; + + const testMatch = (stdout + stderr).match(/Tests:\s+(\d+)\s+passed/); + const failMatch = (stdout + stderr).match(/(\d+)\s+failed/); + const totalMatch = (stdout + stderr).match(/(\d+)\s+total/); + + if (testMatch) passed = parseInt(testMatch[1]); + if (failMatch) failed = parseInt(failMatch[1]); + if (totalMatch) total = parseInt(totalMatch[1]); + + resolve({ + passed, + failed, + total, + exitCode: code, + success: code === 0, + summary: summaryLine || 'Tests completed', + coverage: coverageLine || (coverage ? 'Coverage data in ./coverage' : undefined), + output: stdout.substring(0, 5000) + }); + }); + + jestProcess.on('error', (error) => { + resolve({ + passed: 0, + failed: 0, + total: 0, + exitCode: 1, + success: false, + error: error.message + }); + }); + }); +} + +async function securityScanHandler({ code, scanType = 'full' }) { + const vulnerabilities = []; + + if (scanType === 'full' || scanType === 'credentials') { + const credentialPatterns = [ + { pattern: /api[_-]?key\s*[:=]\s*['"][^'"]{10,}['"]/gi, type: 'API key' }, + { pattern: /password\s*[:=]\s*['"][^'"]+['"]/gi, type: 'Password' }, + { pattern: /secret\s*[:=]\s*['"][^'"]{10,}['"]/gi, type: 'Secret' }, + { pattern: /token\s*[:=]\s*['"][^'"]{20,}['"]/gi, type: 'Token' }, + { pattern: /bearer\s+[a-zA-Z0-9._-]{20,}/gi, type: 'Bearer token' }, + { pattern: /aws[_-]?(access[_-]?key|secret)[_-]?id?\s*[:=]\s*['"][^'"]+['"]/gi, type: 'AWS credentials' } + ]; + + for (const { pattern, type } of credentialPatterns) { + if (pattern.test(code)) { + vulnerabilities.push({ + type: 'hardcoded-credential', + credentialType: type, + severity: 'high', + description: `Possible hardcoded ${type} detected`, + fix: 'Use environment variables: process.env.YOUR_SECRET_NAME' + }); + } + } + } + + if (scanType === 'full' || scanType === 'injection') { + if (/eval\s*\(/.test(code)) { + vulnerabilities.push({ + type: 'code-injection', + severity: 'critical', + description: 'Use of eval() detected - potential code injection vulnerability', + fix: 'Avoid eval(). Use JSON.parse() for JSON, or safer alternatives' + }); + } + + if (/new\s+Function\s*\(/.test(code) && code.includes('req.')) { + vulnerabilities.push({ + type: 'code-injection', + severity: 'high', + description: 'Dynamic Function constructor with user input detected', + fix: 'Avoid constructing functions from user input' + }); + } + + if (/\$\{.*req\.(body|query|params)/.test(code) && /exec|spawn/.test(code)) { + vulnerabilities.push({ + type: 'command-injection', + severity: 'critical', + description: 'Potential command injection - user input in shell command', + fix: 'Sanitize input and use parameterized commands' + }); + } + } + + if (scanType === 'full' || scanType === 'validation') { + if (code.includes('req.body') && !code.includes('validate') && !code.includes('schema') && !code.includes('joi') && !code.includes('zod')) { + vulnerabilities.push({ + type: 'missing-validation', + severity: 'medium', + description: 'Request body used without apparent validation', + fix: 'Add input validation using a schema library (Joi, Zod, AJV)' + }); + } + + if (/onWebhookReceived|onWebhook/.test(code) && !code.includes('signature') && !code.includes('verify') && !code.includes('hmac')) { + vulnerabilities.push({ + type: 'missing-webhook-validation', + severity: 'medium', + description: 'Webhook handler without signature verification', + fix: 'Implement HMAC signature verification for webhook security' + }); + } + } + + return { + vulnerabilities, + scanned: true, + scanType, + summary: vulnerabilities.length === 0 + ? 'No vulnerabilities detected' + : `Found ${vulnerabilities.length} potential issue(s)` + }; +} + +async function gitCheckpointHandler({ message }) { + if (gitCheckpointServiceInstance) { + try { + const checkpoint = await gitCheckpointServiceInstance.createCheckpoint(message); + return { + checkpointId: checkpoint.id, + hash: checkpoint.hash, + message: checkpoint.message, + timestamp: checkpoint.timestamp, + hasPendingChanges: checkpoint.hasPendingChanges, + rollbackCommand: `git reset --mixed ${checkpoint.hash}` + }; + } catch (error) { + return { + error: error.message, + fallback: true + }; + } + } + + try { + const { stdout: hash } = await execAsync('git rev-parse HEAD'); + const { stdout: status } = await execAsync('git status --porcelain'); + + const checkpointId = `checkpoint-${Date.now()}-${hash.trim().substring(0, 8)}`; + + return { + checkpointId, + hash: hash.trim(), + message, + timestamp: new Date().toISOString(), + hasPendingChanges: status.trim().length > 0, + rollbackCommand: `git reset --mixed ${hash.trim()}` + }; + } catch (error) { + return { + error: `Git error: ${error.message}`, + suggestion: 'Ensure you are in a git repository' + }; + } +} + +async function getExampleHandler({ pattern }) { + const examples = { + 'crm-integration': { + description: 'Complete CRM integration with contacts and deals sync', + code: CATEGORY_TEMPLATES.CRM('hubspot', { webhooks: true }), + relatedFiles: [ + 'packages/core/integrations/integration-base.js', + 'api-module-library/packages/hubspot/*' + ] + }, + 'webhook-handler': { + description: 'Secure webhook handler with signature verification', + code: `async onWebhookReceived({ req, res }) { + const signature = req.headers['x-webhook-signature']; + const secret = this.getConfig().webhookSecret; + + if (!this.verifySignature(req.body, signature, secret)) { + return res.status(401).json({ error: 'Invalid signature' }); + } + + await this.queueWebhook({ + integrationId: req.params.integrationId, + body: req.body, + headers: req.headers + }); + + res.status(200).json({ received: true }); +} + +verifySignature(body, signature, secret) { + const crypto = require('crypto'); + const expected = crypto + .createHmac('sha256', secret) + .update(JSON.stringify(body)) + .digest('hex'); + return crypto.timingSafeEqual( + Buffer.from(signature || '', 'utf8'), + Buffer.from(expected, 'utf8') + ); +} + +async onWebhook({ data }) { + const { body, headers } = data; + const eventType = headers['x-event-type'] || body.event; + + switch (eventType) { + case 'contact.created': + await this.handleContactCreated(body.data); + break; + case 'deal.updated': + await this.handleDealUpdated(body.data); + break; + default: + console.log('Unhandled event:', eventType); + } +}` + }, + 'form-config': { + description: 'Dynamic form configuration with JSON Schema', + code: `async getConfigOptions() { + const module = this.getModule('myModule'); + const availableWorkspaces = await module.listWorkspaces(); + + return { + jsonSchema: { + type: 'object', + required: ['workspace', 'syncDirection'], + properties: { + workspace: { + type: 'string', + title: 'Workspace', + enum: availableWorkspaces.map(w => w.id), + enumNames: availableWorkspaces.map(w => w.name) + }, + syncDirection: { + type: 'string', + title: 'Sync Direction', + enum: ['push', 'pull', 'bidirectional'], + default: 'bidirectional' + }, + syncInterval: { + type: 'integer', + title: 'Sync Interval (minutes)', + minimum: 5, + maximum: 1440, + default: 60 + }, + enableNotifications: { + type: 'boolean', + title: 'Enable Notifications', + default: true + } + } + }, + uiSchema: { + workspace: { + 'ui:placeholder': 'Select a workspace...' + }, + syncInterval: { + 'ui:widget': 'range' + } + } + }; +} + +async refreshConfigOptions({ configKey, currentConfig }) { + if (configKey === 'workspace') { + const module = this.getModule('myModule'); + const workspaces = await module.listWorkspaces(); + return { + options: workspaces.map(w => ({ value: w.id, label: w.name })) + }; + } + return null; +}` + }, + 'oauth2-flow': { + description: 'OAuth2 authentication flow implementation', + code: `// In your API module (not integration) +class MyServiceApi extends OAuth2Requester { + constructor(params) { + super(params); + this.baseUrl = 'https://api.myservice.com'; + + this.URLs = { + authorization: 'https://myservice.com/oauth/authorize', + token: 'https://myservice.com/oauth/token', + userInfo: '/api/v1/me' + }; + } + + getAuthorizationUri() { + return this.authorizationUri({ + client_id: this.client_id, + redirect_uri: this.redirect_uri, + scope: 'read write', + response_type: 'code' + }); + } + + async getAccessToken(code) { + return this._getAccessToken({ + code, + grant_type: 'authorization_code', + client_id: this.client_id, + client_secret: this.client_secret, + redirect_uri: this.redirect_uri + }); + } + + async refreshAccessToken() { + return this._refreshAccessToken({ + grant_type: 'refresh_token', + refresh_token: this.refresh_token, + client_id: this.client_id, + client_secret: this.client_secret + }); + } + + async testAuth() { + const response = await this._get(this.URLs.userInfo); + return { success: true, user: response }; + } +}` + }, + 'sync-pattern': { + description: 'Bidirectional data sync pattern', + code: CATEGORY_TEMPLATES.Sync('DataSync', { category: 'Productivity' }) + }, + 'api-module-complete': { + description: 'Complete API module structure', + code: `const { OAuth2Requester } = require('@friggframework/module-plugin'); +const { Credential } = require('./models/credential'); +const { Entity } = require('./models/entity'); + +class MyServiceApi extends OAuth2Requester { + static Config = { + name: 'MyService', + authType: 'oauth2', + hasTestAuth: true + }; + + constructor(params) { + super(params); + this.baseUrl = process.env.MYSERVICE_API_URL || 'https://api.myservice.com'; + this.tokenUri = 'https://myservice.com/oauth/token'; + this.authorizationUri = 'https://myservice.com/oauth/authorize'; + } + + // Auth methods + getAuthorizationUri() { /* ... */ } + async getAccessToken(code) { /* ... */ } + async testAuth() { + const user = await this._get('/api/v1/me'); + return { success: true, user }; + } + + // Resource methods + async listContacts(params = {}) { + return this._get('/api/v1/contacts', { params }); + } + + async getContact(id) { + return this._get(\`/api/v1/contacts/\${id}\`); + } + + async createContact(data) { + return this._post('/api/v1/contacts', data); + } + + async updateContact(id, data) { + return this._patch(\`/api/v1/contacts/\${id}\`, data); + } + + async deleteContact(id) { + return this._delete(\`/api/v1/contacts/\${id}\`); + } + + // Webhook methods + async registerWebhook(config) { + return this._post('/api/v1/webhooks', config); + } + + async deleteWebhook(id) { + return this._delete(\`/api/v1/webhooks/\${id}\`); + } +} + +const Definition = { + moduleName: 'myservice', + Api: MyServiceApi, + Credential, + Entity +}; + +module.exports = { MyServiceApi, Definition };` + } + }; + + const example = examples[pattern]; + + if (!example) { + return { + error: `Unknown pattern: ${pattern}`, + availablePatterns: Object.keys(examples).map(key => ({ + name: key, + description: examples[key].description + })) + }; + } + + return { + pattern, + description: example.description, + code: example.code.trim(), + relatedFiles: example.relatedFiles + }; +} + +const DOCS_INDEX = { + 'integration-base': { + title: 'IntegrationBase Class', + path: 'packages/core/integrations/integration-base.js', + topics: ['integration', 'lifecycle', 'modules', 'events', 'webhooks'], + summary: 'Base class all integrations must extend. Provides lifecycle methods (onCreate, onUpdate, onDelete), module management, webhook handling, and status updates.' + }, + 'api-module': { + title: 'API Module Development', + path: 'docs/api-module-library/overview.md', + topics: ['api-module', 'oauth2', 'api-key', 'requester'], + summary: 'Guide to building API modules that connect to external services. Covers authentication types, requester patterns, and module structure.' + }, + 'forms-config': { + title: 'Form Configuration (JSON Schema)', + path: 'packages/core/integrations/options.js', + topics: ['forms', 'json-schema', 'ui-schema', 'config-options'], + summary: 'Dynamic form configuration using JSON Schema. Used in getConfigOptions() to define user-configurable settings.' + }, + 'webhooks': { + title: 'Webhook Handling', + path: 'packages/core/integrations/integration-base.js', + topics: ['webhooks', 'signature', 'queue', 'events'], + summary: 'Webhook implementation patterns including signature verification, event queuing, and processing. Methods: onWebhookReceived, onWebhook, queueWebhook.' + }, + 'encryption': { + title: 'Field-Level Encryption', + path: 'packages/core/database/encryption/README.md', + topics: ['encryption', 'kms', 'aes', 'credentials', 'security'], + summary: 'Transparent field-level encryption for sensitive data. Supports AWS KMS and AES. Automatically encrypts credentials, tokens, and mapping data.' + }, + 'repositories': { + title: 'Repository Pattern', + path: 'packages/core/integrations/repositories/', + topics: ['repository', 'database', 'crud', 'prisma', 'mongo'], + summary: 'Data access layer following repository pattern. Supports MongoDB, PostgreSQL via Prisma, and DocumentDB. Handles integration and mapping persistence.' + }, + 'use-cases': { + title: 'Use Case Pattern', + path: 'packages/core/integrations/use-cases/', + topics: ['use-case', 'business-logic', 'orchestration'], + summary: 'Business logic orchestration following hexagonal architecture. Use cases coordinate repositories and domain operations.' + }, + 'cli': { + title: 'Frigg CLI', + path: 'packages/devtools/frigg-cli/', + topics: ['cli', 'install', 'deploy', 'start', 'validate'], + summary: 'Command-line interface for Frigg development. Commands: install, search, start, deploy, validate. Manages API modules and infrastructure.' + }, + 'infrastructure': { + title: 'Infrastructure as Code', + path: 'packages/devtools/infrastructure/', + topics: ['serverless', 'aws', 'lambda', 'vpc', 'deployment'], + summary: 'AWS infrastructure generation and deployment. Creates serverless.yml, discovers VPC/KMS resources, manages IAM policies.' + }, + 'testing': { + title: 'Testing Patterns', + path: 'packages/core/integrations/test/', + topics: ['testing', 'jest', 'mock', 'integration-test'], + summary: 'Testing strategies for Frigg integrations. Includes mock API utilities, test doubles, and integration test patterns.' + } +}; + +async function searchDocsHandler({ query, topic, limit = 5 }) { + const results = []; + const queryLower = query.toLowerCase(); + const queryWords = queryLower.split(/\s+/); + + for (const [key, doc] of Object.entries(DOCS_INDEX)) { + let score = 0; + + if (topic && doc.topics.includes(topic.toLowerCase())) { + score += 50; + } + + for (const word of queryWords) { + if (doc.title.toLowerCase().includes(word)) { + score += 30; + } + if (doc.summary.toLowerCase().includes(word)) { + score += 20; + } + if (doc.topics.some(t => t.includes(word))) { + score += 25; + } + if (key.includes(word)) { + score += 15; + } + } + + if (score > 0) { + results.push({ + key, + ...doc, + score + }); + } + } + + results.sort((a, b) => b.score - a.score); + + return { + query, + topic, + results: results.slice(0, limit).map(r => ({ + title: r.title, + path: r.path, + summary: r.summary, + topics: r.topics, + relevance: Math.min(100, r.score) + })), + totalMatches: results.length + }; +} + +async function readDocsHandler({ docKey, section }) { + const doc = DOCS_INDEX[docKey]; + + if (!doc) { + return { + error: `Unknown documentation key: ${docKey}`, + availableDocs: Object.entries(DOCS_INDEX).map(([key, d]) => ({ + key, + title: d.title, + topics: d.topics + })) + }; + } + + const content = { + key: docKey, + title: doc.title, + path: doc.path, + summary: doc.summary, + topics: doc.topics + }; + + if (docKey === 'integration-base') { + content.sections = { + 'static-definition': { + title: 'Static Definition', + content: `Integration classes must define a static Definition property: + +\`\`\`javascript +static Definition = { + name: 'integration-name', // Unique identifier + version: '1.0.0', // Semantic version + modules: { // API modules used + moduleName: { definition: require('@friggframework/api-module-name') } + }, + options: { + type: 'api', // api, webhook, sync, transform, custom + hasUserConfig: true, // Requires user configuration + display: { + name: 'Display Name', + description: 'Integration description', + category: 'CRM', // CRM, Finance, Communication, etc. + icon: 'icon-name' + } + }, + capabilities: { + auth: ['oauth2'], // oauth2, api-key, basic, token, custom + webhooks: true, + sync: { bidirectional: true, incremental: true } + } +}; +\`\`\`` + }, + 'lifecycle-methods': { + title: 'Lifecycle Methods', + content: `Required lifecycle methods: + +\`\`\`javascript +// Called when integration is created +async onCreate({ integrationId }) { + await this.updateIntegrationStatus.execute(integrationId, 'ENABLED'); +} + +// Called when integration config is updated +async onUpdate(params) { + await this.validateConfig(); +} + +// Called when integration is deleted +async onDelete(params) { + // Cleanup: unregister webhooks, clear data +} + +// Returns form configuration +async getConfigOptions() { + return { jsonSchema: {}, uiSchema: {} }; +} + +// Verify authentication is valid +async testAuth() { + const module = this.getModule('moduleName'); + return module.testAuth(); +} +\`\`\`` + }, + 'webhook-methods': { + title: 'Webhook Methods', + content: `Webhook handling methods: + +\`\`\`javascript +// HTTP handler - no database context +async onWebhookReceived({ req, res }) { + // Validate signature first + const signature = req.headers['x-webhook-signature']; + if (!this.verifySignature(req.body, signature)) { + return res.status(401).json({ error: 'Invalid signature' }); + } + + // Queue for processing + await this.queueWebhook({ + integrationId: req.params.integrationId, + body: req.body, + headers: req.headers + }); + res.status(200).json({ received: true }); +} + +// Queue worker - has database context +async onWebhook({ data }) { + const { body } = data; + // Process webhook event with full integration context +} +\`\`\`` + } + }; + } + + if (docKey === 'forms-config') { + content.sections = { + 'json-schema': { + title: 'JSON Schema', + content: `Use JSON Schema to define configuration options: + +\`\`\`javascript +async getConfigOptions() { + return { + jsonSchema: { + type: 'object', + required: ['workspace'], + properties: { + workspace: { + type: 'string', + title: 'Workspace', + enum: ['ws1', 'ws2'], // Static options + enumNames: ['Workspace 1', 'Workspace 2'] + }, + syncEnabled: { + type: 'boolean', + title: 'Enable Sync', + default: true + }, + syncInterval: { + type: 'integer', + title: 'Sync Interval (minutes)', + minimum: 5, + maximum: 1440, + default: 60 + } + } + }, + uiSchema: { + workspace: { 'ui:placeholder': 'Select workspace...' }, + syncInterval: { 'ui:widget': 'range' } + } + }; +} +\`\`\`` + }, + 'dynamic-options': { + title: 'Dynamic Options', + content: `Fetch options dynamically from the API: + +\`\`\`javascript +async getConfigOptions() { + const module = this.getModule('myModule'); + const workspaces = await module.listWorkspaces(); + + return { + jsonSchema: { + type: 'object', + properties: { + workspace: { + type: 'string', + title: 'Workspace', + enum: workspaces.map(w => w.id), + enumNames: workspaces.map(w => w.name) + } + } + }, + uiSchema: {} + }; +} + +// Refresh specific option +async refreshConfigOptions({ configKey, currentConfig }) { + if (configKey === 'workspace') { + const workspaces = await this.getModule('myModule').listWorkspaces(); + return { options: workspaces.map(w => ({ value: w.id, label: w.name })) }; + } + return null; +} +\`\`\`` + } + }; + } + + if (docKey === 'webhooks') { + content.sections = { + 'signature-verification': { + title: 'Signature Verification', + content: `Always verify webhook signatures: + +\`\`\`javascript +const crypto = require('crypto'); + +verifySignature(body, signature, secret) { + const expected = crypto + .createHmac('sha256', secret) + .update(JSON.stringify(body)) + .digest('hex'); + + // Use timing-safe comparison + return crypto.timingSafeEqual( + Buffer.from(signature || '', 'utf8'), + Buffer.from(expected, 'utf8') + ); +} +\`\`\`` + }, + 'event-processing': { + title: 'Event Processing', + content: `Process webhook events in onWebhook: + +\`\`\`javascript +async onWebhook({ data }) { + const { body, headers } = data; + const eventType = headers['x-event-type'] || body.event; + + switch (eventType) { + case 'contact.created': + await this.handleContactCreated(body.data); + break; + case 'deal.updated': + await this.handleDealUpdated(body.data); + break; + case 'contact.deleted': + await this.handleContactDeleted(body.data); + break; + default: + console.log('Unhandled event:', eventType); + } +} +\`\`\`` + } + }; + } + + if (section && content.sections?.[section]) { + return { + ...content, + section: content.sections[section] + }; + } + + return content; +} + +function createFriggMcpTools(options = {}) { + if (options.gitCheckpointService) { + setGitCheckpointService(options.gitCheckpointService); + } + + return [ + { + name: 'frigg_validate_schema', + description: 'Validate integration, API module, or app definitions against Frigg schemas. Checks required fields, valid enums, and structural correctness.', + inputSchema: { + type: 'object', + required: ['schemaType', 'content'], + properties: { + schemaType: { + type: 'string', + enum: ['app-definition', 'integration-definition', 'api-module-definition'], + description: 'Type of schema to validate against' + }, + content: { + type: 'string', + description: 'JSON content to validate (as string or object)' + } + } + }, + handler: validateSchemaHandler + }, + { + name: 'frigg_get_template', + description: 'Get starter templates for Frigg integrations based on category (CRM, Finance, Communication, etc.) rather than auth type.', + inputSchema: { + type: 'object', + required: ['category', 'integrationName'], + properties: { + category: { + type: 'string', + enum: ['CRM', 'Finance', 'Communication', 'ECommerce', 'Storage', 'Webhook', 'Sync'], + description: 'Integration category determines the template structure' + }, + integrationName: { + type: 'string', + description: 'Name for the integration (e.g., "hubspot", "stripe")' + }, + options: { + type: 'object', + properties: { + webhooks: { type: 'boolean', description: 'Include webhook handling' }, + authType: { type: 'string', description: 'Override default auth type' }, + sourceModule: { type: 'string', description: 'For Sync: source API module' }, + targetModule: { type: 'string', description: 'For Sync: target API module' } + } + } + } + }, + handler: getTemplateHandler + }, + { + name: 'frigg_check_patterns', + description: 'Verify code follows Frigg architectural patterns. Checks for required base classes, lifecycle methods, and proper structure.', + inputSchema: { + type: 'object', + required: ['code', 'fileType'], + properties: { + code: { type: 'string', description: 'Source code to analyze' }, + fileType: { + type: 'string', + enum: ['integration', 'api-module', 'handler', 'use-case', 'repository'], + description: 'Type of file being checked' + } + } + }, + handler: checkPatternsHandler + }, + { + name: 'frigg_list_modules', + description: 'List available Frigg API modules from npm registry. Searches @friggframework/api-module-* packages.', + inputSchema: { + type: 'object', + properties: { + category: { + type: 'string', + enum: ['CRM', 'Marketing', 'Communication', 'Finance', 'ECommerce', 'Analytics', 'Storage', 'Development', 'Productivity', 'Social', 'Other', 'all'], + description: 'Filter by category' + } + } + }, + handler: listModulesHandler + }, + { + name: 'frigg_run_tests', + description: 'Execute Jest tests for generated code', + inputSchema: { + type: 'object', + properties: { + testPattern: { + type: 'string', + description: 'Test file pattern or path (e.g., "integration.test.js")' + }, + coverage: { + type: 'boolean', + description: 'Generate coverage report' + }, + watch: { + type: 'boolean', + description: 'Run in watch mode' + } + } + }, + handler: runTestsHandler + }, + { + name: 'frigg_security_scan', + description: 'Scan code for security vulnerabilities including hardcoded credentials, injection risks, and missing validation', + inputSchema: { + type: 'object', + required: ['code'], + properties: { + code: { type: 'string', description: 'Code to scan' }, + scanType: { + type: 'string', + enum: ['full', 'credentials', 'injection', 'validation'], + description: 'Type of security scan' + } + } + }, + handler: securityScanHandler + }, + { + name: 'frigg_git_checkpoint', + description: 'Create git checkpoint before making changes. Records current HEAD for potential rollback.', + inputSchema: { + type: 'object', + required: ['message'], + properties: { + message: { + type: 'string', + description: 'Checkpoint description' + } + } + }, + handler: gitCheckpointHandler + }, + { + name: 'frigg_get_example', + description: 'Get working examples of specific Frigg patterns and implementations', + inputSchema: { + type: 'object', + required: ['pattern'], + properties: { + pattern: { + type: 'string', + enum: ['crm-integration', 'webhook-handler', 'form-config', 'oauth2-flow', 'sync-pattern', 'api-module-complete'], + description: 'Pattern to get example for' + } + } + }, + handler: getExampleHandler + }, + { + name: 'frigg_search_docs', + description: 'Search Frigg documentation by keyword or topic. Returns relevant documentation sections with relevance scores.', + inputSchema: { + type: 'object', + required: ['query'], + properties: { + query: { + type: 'string', + description: 'Search query (e.g., "webhook signature", "json schema forms")' + }, + topic: { + type: 'string', + enum: ['integration', 'api-module', 'webhooks', 'forms', 'encryption', 'testing', 'cli', 'deployment'], + description: 'Filter by topic' + }, + limit: { + type: 'integer', + description: 'Maximum number of results (default: 5)', + default: 5 + } + } + }, + handler: searchDocsHandler + }, + { + name: 'frigg_read_docs', + description: 'Read detailed documentation for a specific Frigg topic with code examples.', + inputSchema: { + type: 'object', + required: ['docKey'], + properties: { + docKey: { + type: 'string', + enum: ['integration-base', 'api-module', 'forms-config', 'webhooks', 'encryption', 'repositories', 'use-cases', 'cli', 'infrastructure', 'testing'], + description: 'Documentation key to read' + }, + section: { + type: 'string', + description: 'Specific section to read (e.g., "lifecycle-methods", "json-schema")' + } + } + }, + handler: readDocsHandler + } + ]; +} + +module.exports = { + createFriggMcpTools, + validateSchemaHandler, + getTemplateHandler, + checkPatternsHandler, + listModulesHandler, + runTestsHandler, + securityScanHandler, + gitCheckpointHandler, + getExampleHandler, + searchDocsHandler, + readDocsHandler, + setGitCheckpointService, + NPMRegistryService, + INTEGRATION_CATEGORIES, + INTEGRATION_TYPES, + CATEGORY_TEMPLATES, + DOCS_INDEX +}; diff --git a/packages/ai-agents/src/infrastructure/mcp/index.js b/packages/ai-agents/src/infrastructure/mcp/index.js new file mode 100644 index 000000000..8dc4a33b4 --- /dev/null +++ b/packages/ai-agents/src/infrastructure/mcp/index.js @@ -0,0 +1,25 @@ +const { + createFriggMcpTools, + validateSchemaHandler, + getTemplateHandler, + checkPatternsHandler, + listModulesHandler, + runTestsHandler, + securityScanHandler, + gitCheckpointHandler, + getExampleHandler, + KNOWN_MODULES +} = require('./frigg-tools'); + +module.exports = { + createFriggMcpTools, + validateSchemaHandler, + getTemplateHandler, + checkPatternsHandler, + listModulesHandler, + runTestsHandler, + securityScanHandler, + gitCheckpointHandler, + getExampleHandler, + KNOWN_MODULES +}; diff --git a/packages/ai-agents/src/infrastructure/streaming/agent-stream-handler.js b/packages/ai-agents/src/infrastructure/streaming/agent-stream-handler.js new file mode 100644 index 000000000..eac7876e8 --- /dev/null +++ b/packages/ai-agents/src/infrastructure/streaming/agent-stream-handler.js @@ -0,0 +1,117 @@ +const { AgentEventType } = require('../../domain/entities/agent-event'); + +class AgentStreamHandler { + constructor({ io }) { + this.io = io; + this.sessions = new Map(); + } + + createSession({ socketId, userId = null }) { + const id = `session-${Date.now()}-${Math.random().toString(36).substring(7)}`; + + const session = { + id, + socketId, + userId, + status: 'active', + createdAt: new Date(), + events: [] + }; + + this.sessions.set(id, session); + return session; + } + + getSession(id) { + return this.sessions.get(id); + } + + getSessions() { + return Array.from(this.sessions.values()); + } + + emit(sessionId, event) { + const session = this.sessions.get(sessionId); + if (!session) return; + + const eventData = { + ...event.toJSON(), + sessionId + }; + + session.events.push(event); + + this.io.to(session.socketId).emit('agent:event', eventData); + + if (event.type === AgentEventType.DONE) { + session.status = 'completed'; + session.completedAt = new Date(); + } else if (event.type === AgentEventType.ERROR) { + session.status = 'error'; + session.errorAt = new Date(); + } + } + + createEventEmitter(sessionId) { + return (event) => this.emit(sessionId, event); + } + + pauseSession(sessionId) { + const session = this.sessions.get(sessionId); + if (!session) return; + + session.status = 'paused'; + session.pausedAt = new Date(); + + this.io.to(session.socketId).emit('agent:paused', { + sessionId, + timestamp: session.pausedAt + }); + } + + resumeSession(sessionId) { + const session = this.sessions.get(sessionId); + if (!session) return; + + session.status = 'active'; + session.resumedAt = new Date(); + + this.io.to(session.socketId).emit('agent:resumed', { + sessionId, + timestamp: session.resumedAt + }); + } + + emitProposal(sessionId, proposal) { + const session = this.sessions.get(sessionId); + if (!session) return; + + session.status = 'awaiting_approval'; + session.currentProposal = proposal; + + this.io.to(session.socketId).emit('agent:proposal', { + sessionId, + proposal, + timestamp: new Date() + }); + } + + removeSession(sessionId) { + this.sessions.delete(sessionId); + } + + cleanupOldSessions({ maxAgeMinutes = 60 } = {}) { + const cutoff = Date.now() - maxAgeMinutes * 60 * 1000; + + for (const [id, session] of this.sessions) { + if (session.status === 'completed' || session.status === 'error') { + const completedTime = session.completedAt || session.errorAt; + if (completedTime && completedTime.getTime() < cutoff) { + this.sessions.delete(id); + } + } + } + } +} + +module.exports = { AgentStreamHandler }; diff --git a/packages/ai-agents/src/infrastructure/streaming/index.js b/packages/ai-agents/src/infrastructure/streaming/index.js new file mode 100644 index 000000000..800488355 --- /dev/null +++ b/packages/ai-agents/src/infrastructure/streaming/index.js @@ -0,0 +1,3 @@ +const { AgentStreamHandler } = require('./agent-stream-handler'); + +module.exports = { AgentStreamHandler }; diff --git a/packages/ai-agents/src/infrastructure/validation/index.js b/packages/ai-agents/src/infrastructure/validation/index.js new file mode 100644 index 000000000..5aa486a5b --- /dev/null +++ b/packages/ai-agents/src/infrastructure/validation/index.js @@ -0,0 +1,3 @@ +const { ValidationPipeline, ValidationLayer, LAYER_WEIGHTS } = require('./validation-pipeline'); + +module.exports = { ValidationPipeline, ValidationLayer, LAYER_WEIGHTS }; diff --git a/packages/ai-agents/src/infrastructure/validation/validation-pipeline.js b/packages/ai-agents/src/infrastructure/validation/validation-pipeline.js new file mode 100644 index 000000000..88321ce18 --- /dev/null +++ b/packages/ai-agents/src/infrastructure/validation/validation-pipeline.js @@ -0,0 +1,226 @@ +const { IValidationPipeline } = require('../../domain/interfaces/validation-pipeline'); + +const ValidationLayer = { + SCHEMA: 'schema', + PATTERNS: 'patterns', + SECURITY: 'security', + TESTS: 'tests', + LINT: 'lint' +}; + +const LAYER_WEIGHTS = { + [ValidationLayer.SCHEMA]: 0.30, + [ValidationLayer.PATTERNS]: 0.25, + [ValidationLayer.TESTS]: 0.20, + [ValidationLayer.SECURITY]: 0.15, + [ValidationLayer.LINT]: 0.10 +}; + +const REQUIRED_INTEGRATION_METHODS = [ + 'onCreate', + 'onUpdate', + 'onDelete', + 'getConfigOptions', + 'testAuth' +]; + +class ValidationPipeline extends IValidationPipeline { + async validate(files) { + const layers = {}; + const feedback = []; + + for (const file of files) { + const schemaResult = await this.validateSchema(file); + const patternsResult = await this.validatePatterns(file); + const securityResult = await this.validateSecurity(file); + const testsResult = await this.validateTests(file); + const lintResult = await this.validateLint(file); + + layers.schema = this.mergeLayerResults(layers.schema, schemaResult); + layers.patterns = this.mergeLayerResults(layers.patterns, patternsResult); + layers.security = this.mergeLayerResults(layers.security, securityResult); + layers.tests = this.mergeLayerResults(layers.tests, testsResult); + layers.lint = this.mergeLayerResults(layers.lint, lintResult); + } + + const confidence = this.calculateConfidence(layers); + const recommendation = this.getRecommendation(confidence); + + for (const [layer, result] of Object.entries(layers)) { + if (!result.passed) { + feedback.push({ + layer, + issues: result.errors || result.violations || result.vulnerabilities || [] + }); + } + } + + return { confidence, layers, recommendation, feedback }; + } + + mergeLayerResults(existing, newResult) { + if (!existing) return newResult; + + return { + passed: existing.passed && newResult.passed, + score: Math.min(existing.score, newResult.score), + errors: [...(existing.errors || []), ...(newResult.errors || [])], + violations: [...(existing.violations || []), ...(newResult.violations || [])], + vulnerabilities: [...(existing.vulnerabilities || []), ...(newResult.vulnerabilities || [])] + }; + } + + async validateSchema(file) { + const result = { passed: true, score: 100, errors: [] }; + + if (!file.path.endsWith('.json')) { + return result; + } + + let parsed; + try { + parsed = JSON.parse(file.content); + } catch (e) { + return { passed: false, score: 0, errors: [`Invalid JSON: ${e.message}`] }; + } + + if (parsed.name && !/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(parsed.name)) { + result.passed = false; + result.errors.push('name must match pattern ^[a-zA-Z][a-zA-Z0-9_-]*$'); + } + + if (parsed.version && !/^\d+\.\d+\.\d+$/.test(parsed.version)) { + result.passed = false; + result.errors.push('version must be semantic (X.Y.Z)'); + } + + if (result.errors.length > 0) { + result.score = Math.max(0, 100 - result.errors.length * 25); + } + + return result; + } + + async validatePatterns(file) { + const result = { passed: true, score: 100, violations: [] }; + + const isIntegrationFile = file.path.includes('integrations/') && file.path.endsWith('.js'); + if (!isIntegrationFile) { + return result; + } + + const content = file.content; + + if (!content.includes('extends IntegrationBase')) { + result.passed = false; + result.violations.push({ + rule: 'extends-integration-base', + message: 'Integration must extend IntegrationBase' + }); + } + + if (!content.includes('static Definition')) { + result.passed = false; + result.violations.push({ + rule: 'static-definition', + message: 'Integration must have static Definition property' + }); + } + + const missingMethods = REQUIRED_INTEGRATION_METHODS.filter( + m => !content.includes(`async ${m}(`) + ); + + if (missingMethods.length > 0) { + result.passed = false; + result.violations.push({ + rule: 'required-methods', + message: `Missing required methods: ${missingMethods.join(', ')}` + }); + } + + if (result.violations.length > 0) { + result.score = Math.max(0, 100 - result.violations.length * 20); + } + + return result; + } + + async validateSecurity(file) { + const result = { passed: true, score: 100, vulnerabilities: [] }; + const content = file.content; + + const credentialPatterns = [ + { pattern: /['"`]sk-[a-zA-Z0-9]{20,}['"`]/g, type: 'hardcoded-credential', severity: 'high' }, + { pattern: /api[_-]?key\s*[:=]\s*['"][^'"]+['"]/gi, type: 'hardcoded-credential', severity: 'high' }, + { pattern: /password\s*[:=]\s*['"][^'"]+['"]/gi, type: 'hardcoded-credential', severity: 'high' }, + { pattern: /secret\s*[:=]\s*['"][^'"]+['"]/gi, type: 'hardcoded-credential', severity: 'high' } + ]; + + for (const { pattern, type, severity } of credentialPatterns) { + if (pattern.test(content)) { + result.passed = false; + result.vulnerabilities.push({ + type, + severity, + description: 'Possible hardcoded credential detected', + fix: 'Use environment variables instead' + }); + } + } + + const sqlInjectionPattern = /["'`]SELECT.*FROM.*["'`]\s*\+/gi; + if (sqlInjectionPattern.test(content)) { + result.passed = false; + result.vulnerabilities.push({ + type: 'sql-injection', + severity: 'critical', + description: 'Potential SQL injection vulnerability', + fix: 'Use parameterized queries' + }); + } + + if (result.vulnerabilities.length > 0) { + const severityPenalty = { + critical: 50, + high: 25, + medium: 15, + low: 5 + }; + const penalty = result.vulnerabilities.reduce( + (sum, v) => sum + (severityPenalty[v.severity] || 10), + 0 + ); + result.score = Math.max(0, 100 - penalty); + } + + return result; + } + + async validateTests(file) { + return { passed: true, score: 100, coverage: 100, failures: [] }; + } + + async validateLint(file) { + return { passed: true, score: 100, errors: [] }; + } + + calculateConfidence(layers) { + let confidence = 0; + for (const [layer, weight] of Object.entries(LAYER_WEIGHTS)) { + const layerResult = layers[layer]; + if (layerResult) { + confidence += layerResult.score * weight; + } + } + return Math.round(confidence); + } + + getRecommendation(confidence) { + if (confidence >= 95) return 'auto_approve'; + if (confidence >= 80) return 'require_review'; + return 'manual_approval'; + } +} + +module.exports = { ValidationPipeline, ValidationLayer, LAYER_WEIGHTS }; diff --git a/packages/ai-agents/tests/integration/agent-workflow.test.js b/packages/ai-agents/tests/integration/agent-workflow.test.js new file mode 100644 index 000000000..bde79db33 --- /dev/null +++ b/packages/ai-agents/tests/integration/agent-workflow.test.js @@ -0,0 +1,276 @@ +const { + VercelAIAdapter, + ClaudeAgentAdapter, + ValidationPipeline, + AgentProposal, + AgentEvent, + AgentStreamHandler, + GitCheckpointService, + createFriggMcpTools +} = require('../../src'); + +describe('Agent Workflow Integration', () => { + describe('complete agent workflow', () => { + let adapter; + let validationPipeline; + let gitService; + let streamHandler; + let mockIo; + + beforeEach(() => { + adapter = new VercelAIAdapter({ skipApiCheck: true }); + validationPipeline = new ValidationPipeline(); + + const mockExec = jest.fn().mockResolvedValue({ stdout: 'abc123\n' }); + gitService = new GitCheckpointService({ execCommand: mockExec }); + + mockIo = { + to: jest.fn().mockReturnThis(), + emit: jest.fn() + }; + streamHandler = new AgentStreamHandler({ io: mockIo }); + }); + + it('should integrate all components', async () => { + const mcpTools = createFriggMcpTools(); + expect(mcpTools.length).toBeGreaterThan(0); + + await adapter.loadMcpTools({ tools: mcpTools }); + expect(adapter.tools.length).toBe(mcpTools.length); + + const session = streamHandler.createSession({ socketId: 'test-socket' }); + expect(session.status).toBe('active'); + + const checkpoint = await gitService.createCheckpoint('Before integration'); + expect(checkpoint).toHaveProperty('hash'); + + const files = [{ + path: 'src/integrations/test.js', + content: ` +const { IntegrationBase } = require('@friggframework/core'); + +class TestIntegration extends IntegrationBase { + static Definition = { + name: 'test', + version: '1.0.0', + modules: {}, + display: { name: 'Test', description: 'Test integration' } + }; + + async onCreate(params) {} + async onUpdate(params) {} + async onDelete(params) {} + async getConfigOptions() { return { jsonSchema: {}, uiSchema: {} }; } + async testAuth() { return true; } +} + +module.exports = { TestIntegration }; +`, + action: 'create' + }]; + + const validationResult = await validationPipeline.validate(files); + expect(validationResult.confidence).toBeGreaterThan(80); + + const proposal = new AgentProposal({ + id: 'proposal-1', + files, + validation: validationResult, + checkpointId: checkpoint.id + }); + + expect(proposal.status).toBe('pending'); + expect(proposal.canRollback()).toBe(true); + + streamHandler.emitProposal(session.id, { + id: proposal.id, + files: proposal.files, + confidence: validationResult.confidence + }); + + expect(mockIo.emit).toHaveBeenCalledWith('agent:proposal', expect.anything()); + + proposal.approve(); + expect(proposal.status).toBe('approved'); + }); + + it('should handle validation failure workflow', async () => { + const files = [{ + path: 'src/integrations/bad.js', + content: `const apiKey = 'sk-test-123456789';`, + action: 'create' + }]; + + const validationResult = await validationPipeline.validate(files); + expect(validationResult.confidence).toBeLessThan(95); + + const proposal = new AgentProposal({ + id: 'proposal-2', + files, + validation: validationResult + }); + + expect(validationResult.recommendation).not.toBe('auto_approve'); + }); + + it('should stream events through workflow', () => { + const session = streamHandler.createSession({ socketId: 'test' }); + const emitter = streamHandler.createEventEmitter(session.id); + + emitter(AgentEvent.content('Starting integration...')); + emitter(AgentEvent.toolCall('frigg_get_template', { type: 'oauth2', name: 'test' })); + emitter(AgentEvent.toolResult('frigg_get_template', { template: '...' })); + emitter(AgentEvent.content('Generated integration code')); + + expect(mockIo.emit).toHaveBeenCalledTimes(4); + }); + }); + + describe('adapter capabilities', () => { + it('should expose correct capabilities for VercelAIAdapter', () => { + const adapter = new VercelAIAdapter(); + const caps = adapter.getCapabilities(); + + expect(caps.streaming).toBe(true); + expect(caps.multiProvider).toBe(true); + expect(caps.nativeMcp).toBe(false); + }); + + it('should expose correct capabilities for ClaudeAgentAdapter', () => { + const adapter = new ClaudeAgentAdapter(); + const caps = adapter.getCapabilities(); + + expect(caps.streaming).toBe(true); + expect(caps.multiProvider).toBe(false); + expect(caps.nativeMcp).toBe(true); + expect(caps.subagentOrchestration).toBe(true); + }); + }); + + describe('HITL workflow', () => { + it('should pause and resume agent execution', () => { + const adapter = new ClaudeAgentAdapter(); + + expect(adapter.isPaused()).toBe(false); + + adapter.pause(); + expect(adapter.isPaused()).toBe(true); + + adapter.resume(); + expect(adapter.isPaused()).toBe(false); + }); + + it('should configure human approval thresholds', () => { + const adapter = new ClaudeAgentAdapter(); + + adapter.configureHumanApproval({ + requireApproval: true, + confidenceThreshold: 95 + }); + + expect(adapter.approvalConfig.confidenceThreshold).toBe(95); + }); + }); + + describe('validation confidence scoring', () => { + it('should score valid integration highly', async () => { + const pipeline = new ValidationPipeline(); + + const files = [{ + path: 'src/integrations/good.js', + content: ` +const { IntegrationBase } = require('@friggframework/core'); + +class GoodIntegration extends IntegrationBase { + static Definition = { + name: 'good', + version: '1.0.0', + modules: {}, + display: { name: 'Good', description: 'Good integration' } + }; + + async onCreate(params) {} + async onUpdate(params) {} + async onDelete(params) {} + async getConfigOptions() { return { jsonSchema: {}, uiSchema: {} }; } + async testAuth() { return true; } +} + +module.exports = { GoodIntegration }; +`, + action: 'create' + }]; + + const result = await pipeline.validate(files); + expect(result.confidence).toBeGreaterThanOrEqual(90); + }); + + it('should score insecure code lower', async () => { + const pipeline = new ValidationPipeline(); + + const files = [{ + path: 'src/config.js', + content: ` +const password = 'super-secret-123'; +const query = "SELECT * FROM users WHERE id = " + userId; +`, + action: 'create' + }]; + + const result = await pipeline.validate(files); + expect(result.confidence).toBeLessThan(100); + expect(result.layers.security.passed).toBe(false); + expect(result.layers.security.vulnerabilities.length).toBeGreaterThan(0); + }); + }); + + describe('MCP tools integration', () => { + it('should validate schema through tool handler', async () => { + const tools = createFriggMcpTools(); + const validateTool = tools.find(t => t.name === 'frigg_validate_schema'); + + const result = await validateTool.handler({ + schemaType: 'integration-definition', + content: JSON.stringify({ + name: 'hubspot', + version: '1.0.0' + }) + }); + + expect(result.valid).toBe(true); + }); + + it('should get template through tool handler', async () => { + const tools = createFriggMcpTools(); + const templateTool = tools.find(t => t.name === 'frigg_get_template'); + + const result = await templateTool.handler({ + category: 'CRM', + integrationName: 'hubspot' + }); + + expect(result.template).toContain('IntegrationBase'); + expect(result.template).toContain('Hubspot'); + }); + + it('should check patterns through tool handler', async () => { + const tools = createFriggMcpTools(); + const patternsTool = tools.find(t => t.name === 'frigg_check_patterns'); + + const result = await patternsTool.handler({ + code: ` +class MyIntegration extends IntegrationBase { + static Definition = { name: 'test', version: '1.0.0' }; + async onCreate() {} + async onUpdate() {} + async onDelete() {} + async getConfigOptions() {} + async testAuth() {} +}`, + fileType: 'integration' + }); + + expect(result.compliant).toBe(true); + }); + }); +}); diff --git a/packages/ai-agents/tests/unit/domain/entities/agent-event.test.js b/packages/ai-agents/tests/unit/domain/entities/agent-event.test.js new file mode 100644 index 000000000..a610f53e4 --- /dev/null +++ b/packages/ai-agents/tests/unit/domain/entities/agent-event.test.js @@ -0,0 +1,73 @@ +const { AgentEvent, AgentEventType } = require('../../../../src/domain/entities/agent-event'); + +describe('AgentEvent Entity', () => { + describe('event types', () => { + it('should define all required event types', () => { + expect(AgentEventType.CONTENT).toBe('content'); + expect(AgentEventType.TOOL_CALL).toBe('tool_call'); + expect(AgentEventType.TOOL_RESULT).toBe('tool_result'); + expect(AgentEventType.USAGE).toBe('usage'); + expect(AgentEventType.DONE).toBe('done'); + expect(AgentEventType.ERROR).toBe('error'); + }); + }); + + describe('AgentEvent creation', () => { + it('should create content event', () => { + const event = AgentEvent.content('Hello, world'); + + expect(event.type).toBe(AgentEventType.CONTENT); + expect(event.content).toBe('Hello, world'); + expect(event.timestamp).toBeInstanceOf(Date); + }); + + it('should create tool_call event', () => { + const event = AgentEvent.toolCall('frigg_validate_schema', { code: '...' }); + + expect(event.type).toBe(AgentEventType.TOOL_CALL); + expect(event.name).toBe('frigg_validate_schema'); + expect(event.args).toEqual({ code: '...' }); + }); + + it('should create tool_result event', () => { + const event = AgentEvent.toolResult('frigg_validate_schema', { valid: true }); + + expect(event.type).toBe(AgentEventType.TOOL_RESULT); + expect(event.name).toBe('frigg_validate_schema'); + expect(event.result).toEqual({ valid: true }); + }); + + it('should create usage event', () => { + const usage = { promptTokens: 100, completionTokens: 50, totalTokens: 150 }; + const event = AgentEvent.usage(usage); + + expect(event.type).toBe(AgentEventType.USAGE); + expect(event.usage).toEqual(usage); + }); + + it('should create done event', () => { + const event = AgentEvent.done(); + + expect(event.type).toBe(AgentEventType.DONE); + }); + + it('should create error event', () => { + const error = new Error('Something went wrong'); + const event = AgentEvent.error(error); + + expect(event.type).toBe(AgentEventType.ERROR); + expect(event.error).toBe(error); + }); + }); + + describe('serialization', () => { + it('should serialize to JSON', () => { + const event = AgentEvent.content('test'); + const json = event.toJSON(); + + expect(json).toHaveProperty('type', 'content'); + expect(json).toHaveProperty('content', 'test'); + expect(json).toHaveProperty('timestamp'); + }); + }); +}); diff --git a/packages/ai-agents/tests/unit/domain/entities/agent-proposal.test.js b/packages/ai-agents/tests/unit/domain/entities/agent-proposal.test.js new file mode 100644 index 000000000..487bba7aa --- /dev/null +++ b/packages/ai-agents/tests/unit/domain/entities/agent-proposal.test.js @@ -0,0 +1,121 @@ +const { AgentProposal, ProposalStatus } = require('../../../../src/domain/entities/agent-proposal'); + +describe('AgentProposal Entity', () => { + describe('proposal status', () => { + it('should define all required statuses', () => { + expect(ProposalStatus.PENDING).toBe('pending'); + expect(ProposalStatus.APPROVED).toBe('approved'); + expect(ProposalStatus.REJECTED).toBe('rejected'); + expect(ProposalStatus.MODIFIED).toBe('modified'); + }); + }); + + describe('AgentProposal creation', () => { + it('should create proposal with files', () => { + const files = [ + { path: 'src/integrations/hubspot.js', content: '...', action: 'create' }, + { path: 'tests/hubspot.test.js', content: '...', action: 'create' } + ]; + + const proposal = new AgentProposal({ + id: 'proposal-123', + files, + validation: { confidence: 92, recommendation: 'require_review' } + }); + + expect(proposal.id).toBe('proposal-123'); + expect(proposal.files).toHaveLength(2); + expect(proposal.status).toBe(ProposalStatus.PENDING); + expect(proposal.validation.confidence).toBe(92); + }); + + it('should calculate summary statistics', () => { + const files = [ + { path: 'src/a.js', content: 'line1\nline2\nline3', action: 'create' }, + { path: 'src/b.js', content: 'line1\nline2', action: 'create' }, + { path: 'src/c.js', content: '', action: 'modify', diff: '+5 -2' } + ]; + + const proposal = new AgentProposal({ id: 'test', files, validation: {} }); + const summary = proposal.getSummary(); + + expect(summary.fileCount).toBe(3); + expect(summary.createdFiles).toBe(2); + expect(summary.modifiedFiles).toBe(1); + }); + }); + + describe('approval workflow', () => { + it('should approve proposal', () => { + const proposal = new AgentProposal({ + id: 'test', + files: [], + validation: { confidence: 95 } + }); + + proposal.approve(); + + expect(proposal.status).toBe(ProposalStatus.APPROVED); + expect(proposal.approvedAt).toBeInstanceOf(Date); + }); + + it('should reject proposal with feedback', () => { + const proposal = new AgentProposal({ + id: 'test', + files: [], + validation: { confidence: 70 } + }); + + proposal.reject('Missing error handling'); + + expect(proposal.status).toBe(ProposalStatus.REJECTED); + expect(proposal.rejectionReason).toBe('Missing error handling'); + }); + + it('should not allow approval of already rejected proposal', () => { + const proposal = new AgentProposal({ + id: 'test', + files: [], + validation: { confidence: 70 } + }); + + proposal.reject('Rejected'); + + expect(() => proposal.approve()).toThrow('Cannot approve rejected proposal'); + }); + }); + + describe('git checkpoint', () => { + it('should store checkpoint reference', () => { + const proposal = new AgentProposal({ + id: 'test', + files: [], + validation: {}, + checkpointId: 'abc123' + }); + + expect(proposal.checkpointId).toBe('abc123'); + }); + + it('should support rollback', () => { + const proposal = new AgentProposal({ + id: 'test', + files: [], + validation: {}, + checkpointId: 'abc123' + }); + + expect(proposal.canRollback()).toBe(true); + }); + + it('should not support rollback without checkpoint', () => { + const proposal = new AgentProposal({ + id: 'test', + files: [], + validation: {} + }); + + expect(proposal.canRollback()).toBe(false); + }); + }); +}); diff --git a/packages/ai-agents/tests/unit/domain/interfaces/agent-framework.test.js b/packages/ai-agents/tests/unit/domain/interfaces/agent-framework.test.js new file mode 100644 index 000000000..47a1de748 --- /dev/null +++ b/packages/ai-agents/tests/unit/domain/interfaces/agent-framework.test.js @@ -0,0 +1,66 @@ +const { IAgentFramework } = require('../../../../src/domain/interfaces/agent-framework'); + +describe('IAgentFramework Interface', () => { + describe('interface contract', () => { + it('should define required methods', () => { + const framework = new IAgentFramework(); + + expect(typeof framework.runAgent).toBe('function'); + expect(typeof framework.loadMcpTools).toBe('function'); + expect(typeof framework.getCapabilities).toBe('function'); + }); + + it('runAgent should throw NotImplementedError by default', async () => { + const framework = new IAgentFramework(); + + await expect(framework.runAgent({})).rejects.toThrow('Not implemented'); + }); + + it('loadMcpTools should throw NotImplementedError by default', async () => { + const framework = new IAgentFramework(); + + await expect(framework.loadMcpTools({})).rejects.toThrow('Not implemented'); + }); + + it('getCapabilities should throw NotImplementedError by default', () => { + const framework = new IAgentFramework(); + + expect(() => framework.getCapabilities()).toThrow('Not implemented'); + }); + }); + + describe('AgentRunParams validation', () => { + it('should require prompt parameter', async () => { + const framework = new IAgentFramework(); + + await expect(framework.validateRunParams({})).rejects.toThrow('prompt is required'); + }); + + it('should accept valid params', async () => { + const framework = new IAgentFramework(); + const params = { + prompt: 'Create a HubSpot integration', + tools: [], + model: { provider: 'openrouter', id: 'anthropic/claude-sonnet-4' } + }; + + await expect(framework.validateRunParams(params)).resolves.toBe(true); + }); + }); +}); + +describe('FrameworkCapabilities', () => { + it('should have required capability flags', () => { + const capabilities = { + supportsStreaming: true, + supportsMcp: true, + supportsMemory: false, + supportsMultiAgent: false + }; + + expect(capabilities).toHaveProperty('supportsStreaming'); + expect(capabilities).toHaveProperty('supportsMcp'); + expect(capabilities).toHaveProperty('supportsMemory'); + expect(capabilities).toHaveProperty('supportsMultiAgent'); + }); +}); diff --git a/packages/ai-agents/tests/unit/domain/interfaces/validation-pipeline.test.js b/packages/ai-agents/tests/unit/domain/interfaces/validation-pipeline.test.js new file mode 100644 index 000000000..62246c565 --- /dev/null +++ b/packages/ai-agents/tests/unit/domain/interfaces/validation-pipeline.test.js @@ -0,0 +1,83 @@ +const { IValidationPipeline } = require('../../../../src/domain/interfaces/validation-pipeline'); + +describe('IValidationPipeline Interface', () => { + describe('interface contract', () => { + it('should define required methods', () => { + const pipeline = new IValidationPipeline(); + + expect(typeof pipeline.validate).toBe('function'); + expect(typeof pipeline.calculateConfidence).toBe('function'); + expect(typeof pipeline.getRecommendation).toBe('function'); + }); + + it('validate should throw NotImplementedError by default', async () => { + const pipeline = new IValidationPipeline(); + + await expect(pipeline.validate([])).rejects.toThrow('Not implemented'); + }); + }); + + describe('ValidationResult structure', () => { + it('should contain required fields', () => { + const result = { + confidence: 92, + layers: { + schema: { passed: true, score: 100, errors: [] }, + patterns: { passed: true, score: 95, violations: [] }, + security: { passed: true, score: 100, vulnerabilities: [] }, + tests: { passed: false, score: 78, coverage: 78, failures: [] }, + lint: { passed: true, score: 100, errors: [] } + }, + recommendation: 'require_review', + feedback: [] + }; + + expect(result).toHaveProperty('confidence'); + expect(result).toHaveProperty('layers'); + expect(result).toHaveProperty('recommendation'); + expect(result.layers).toHaveProperty('schema'); + expect(result.layers).toHaveProperty('patterns'); + expect(result.layers).toHaveProperty('security'); + expect(result.layers).toHaveProperty('tests'); + expect(result.layers).toHaveProperty('lint'); + }); + }); + + describe('recommendation values', () => { + it('should return auto_approve for confidence >= 95', () => { + const pipeline = new IValidationPipeline(); + pipeline.getRecommendation = (confidence) => { + if (confidence >= 95) return 'auto_approve'; + if (confidence >= 80) return 'require_review'; + return 'manual_approval'; + }; + + expect(pipeline.getRecommendation(95)).toBe('auto_approve'); + expect(pipeline.getRecommendation(100)).toBe('auto_approve'); + }); + + it('should return require_review for confidence 80-94', () => { + const pipeline = new IValidationPipeline(); + pipeline.getRecommendation = (confidence) => { + if (confidence >= 95) return 'auto_approve'; + if (confidence >= 80) return 'require_review'; + return 'manual_approval'; + }; + + expect(pipeline.getRecommendation(80)).toBe('require_review'); + expect(pipeline.getRecommendation(94)).toBe('require_review'); + }); + + it('should return manual_approval for confidence < 80', () => { + const pipeline = new IValidationPipeline(); + pipeline.getRecommendation = (confidence) => { + if (confidence >= 95) return 'auto_approve'; + if (confidence >= 80) return 'require_review'; + return 'manual_approval'; + }; + + expect(pipeline.getRecommendation(79)).toBe('manual_approval'); + expect(pipeline.getRecommendation(50)).toBe('manual_approval'); + }); + }); +}); diff --git a/packages/ai-agents/tests/unit/infrastructure/adapters/claude-agent-adapter.test.js b/packages/ai-agents/tests/unit/infrastructure/adapters/claude-agent-adapter.test.js new file mode 100644 index 000000000..4efd2b168 --- /dev/null +++ b/packages/ai-agents/tests/unit/infrastructure/adapters/claude-agent-adapter.test.js @@ -0,0 +1,193 @@ +const { ClaudeAgentAdapter } = require('../../../../src/infrastructure/adapters/claude-agent-adapter'); +const { AgentEventType } = require('../../../../src/domain/entities/agent-event'); + +describe('ClaudeAgentAdapter', () => { + let adapter; + + beforeEach(() => { + adapter = new ClaudeAgentAdapter(); + }); + + describe('initialization', () => { + it('should create adapter with default config', () => { + expect(adapter).toBeInstanceOf(ClaudeAgentAdapter); + }); + + it('should accept custom model config', () => { + const customAdapter = new ClaudeAgentAdapter({ + model: 'claude-3-5-sonnet-20241022' + }); + expect(customAdapter.config.model).toBe('claude-3-5-sonnet-20241022'); + }); + }); + + describe('getCapabilities', () => { + it('should return supported capabilities', () => { + const caps = adapter.getCapabilities(); + + expect(caps.streaming).toBe(true); + expect(caps.toolCalling).toBe(true); + expect(caps.mcpSupport).toBe(true); + expect(caps.nativeMcp).toBe(true); + }); + + it('should indicate single provider (anthropic)', () => { + const caps = adapter.getCapabilities(); + + expect(caps.providers).toEqual(['anthropic']); + expect(caps.multiProvider).toBe(false); + }); + + it('should support subagent orchestration', () => { + const caps = adapter.getCapabilities(); + + expect(caps.subagentOrchestration).toBe(true); + }); + }); + + describe('validateRunParams', () => { + it('should require prompt', async () => { + await expect(adapter.validateRunParams({})).rejects.toThrow('prompt is required'); + }); + + it('should accept valid params', async () => { + const result = await adapter.validateRunParams({ + prompt: 'Create a Salesforce integration' + }); + expect(result).toBe(true); + }); + }); + + describe('loadMcpTools', () => { + it('should load native MCP tools', async () => { + const tools = await adapter.loadMcpTools({ + tools: [ + { name: 'frigg_validate_schema', handler: async () => ({}) } + ] + }); + + expect(tools.length).toBe(1); + }); + + it('should preserve native MCP format', async () => { + const tools = await adapter.loadMcpTools({ + tools: [ + { + name: 'frigg_check_patterns', + description: 'Check patterns', + inputSchema: { + type: 'object', + required: ['code'], + properties: { code: { type: 'string' } } + }, + handler: async () => ({}) + } + ] + }); + + expect(tools[0].inputSchema).toBeDefined(); + expect(tools[0].inputSchema.type).toBe('object'); + }); + }); + + describe('createStreamingHandler', () => { + it('should create handler that emits events', () => { + const events = []; + const handler = adapter.createStreamingHandler((event) => { + events.push(event); + }); + + expect(typeof handler.onContent).toBe('function'); + expect(typeof handler.onToolCall).toBe('function'); + expect(typeof handler.onToolResult).toBe('function'); + expect(typeof handler.onFinish).toBe('function'); + }); + + it('should emit content events', () => { + const events = []; + const handler = adapter.createStreamingHandler((event) => { + events.push(event); + }); + + handler.onContent('Building integration'); + + expect(events[0].type).toBe(AgentEventType.CONTENT); + expect(events[0].content).toBe('Building integration'); + }); + + it('should emit tool events', () => { + const events = []; + const handler = adapter.createStreamingHandler((event) => { + events.push(event); + }); + + handler.onToolCall('frigg_get_template', { type: 'oauth2', name: 'hubspot' }); + handler.onToolResult('frigg_get_template', { template: '...' }); + + expect(events[0].type).toBe(AgentEventType.TOOL_CALL); + expect(events[1].type).toBe(AgentEventType.TOOL_RESULT); + }); + }); + + describe('buildSystemPrompt', () => { + it('should include Frigg-specific context', () => { + const prompt = adapter.buildSystemPrompt({ + integrationContext: 'Building Stripe integration' + }); + + expect(prompt).toContain('Frigg'); + expect(prompt).toContain('Stripe'); + }); + + it('should include architecture patterns', () => { + const prompt = adapter.buildSystemPrompt({}); + + expect(prompt).toContain('IntegrationBase'); + expect(prompt).toContain('hexagonal'); + }); + + it('should include MCP tool guidance', () => { + const prompt = adapter.buildSystemPrompt({}); + + expect(prompt).toContain('frigg_validate_schema'); + expect(prompt).toContain('frigg_check_patterns'); + }); + }); + + describe('configureHumanApproval', () => { + it('should set approval requirements', () => { + adapter.configureHumanApproval({ + requireApproval: true, + confidenceThreshold: 95 + }); + + expect(adapter.approvalConfig.requireApproval).toBe(true); + expect(adapter.approvalConfig.confidenceThreshold).toBe(95); + }); + + it('should default to requiring review', () => { + expect(adapter.approvalConfig.requireApproval).toBe(true); + }); + }); + + describe('pause/resume for HITL', () => { + it('should support pausing agent execution', () => { + expect(typeof adapter.pause).toBe('function'); + expect(typeof adapter.resume).toBe('function'); + }); + + it('should track pause state', () => { + expect(adapter.isPaused()).toBe(false); + adapter.pause(); + expect(adapter.isPaused()).toBe(true); + adapter.resume(); + expect(adapter.isPaused()).toBe(false); + }); + }); + + describe('runAgent (mock)', () => { + it('should validate params before running', async () => { + await expect(adapter.runAgent({})).rejects.toThrow('prompt is required'); + }); + }); +}); diff --git a/packages/ai-agents/tests/unit/infrastructure/adapters/vercel-ai-adapter.test.js b/packages/ai-agents/tests/unit/infrastructure/adapters/vercel-ai-adapter.test.js new file mode 100644 index 000000000..0e7a5fbce --- /dev/null +++ b/packages/ai-agents/tests/unit/infrastructure/adapters/vercel-ai-adapter.test.js @@ -0,0 +1,174 @@ +const { VercelAIAdapter } = require('../../../../src/infrastructure/adapters/vercel-ai-adapter'); +const { AgentEventType } = require('../../../../src/domain/entities/agent-event'); + +describe('VercelAIAdapter', () => { + let adapter; + + beforeEach(() => { + adapter = new VercelAIAdapter(); + }); + + describe('initialization', () => { + it('should create adapter with default config', () => { + expect(adapter).toBeInstanceOf(VercelAIAdapter); + }); + + it('should accept custom model config', () => { + const customAdapter = new VercelAIAdapter({ + model: 'gpt-4-turbo', + provider: 'openai' + }); + expect(customAdapter.config.model).toBe('gpt-4-turbo'); + }); + }); + + describe('getCapabilities', () => { + it('should return supported capabilities', () => { + const caps = adapter.getCapabilities(); + + expect(caps.streaming).toBe(true); + expect(caps.toolCalling).toBe(true); + expect(caps.mcpSupport).toBe(true); + expect(caps.multiProvider).toBe(true); + }); + + it('should include supported providers list', () => { + const caps = adapter.getCapabilities(); + + expect(caps.providers).toContain('openai'); + expect(caps.providers).toContain('anthropic'); + expect(caps.providers).toContain('google'); + }); + }); + + describe('validateRunParams', () => { + it('should require prompt', async () => { + await expect(adapter.validateRunParams({})).rejects.toThrow('prompt is required'); + }); + + it('should accept valid params', async () => { + const result = await adapter.validateRunParams({ + prompt: 'Create a HubSpot integration' + }); + expect(result).toBe(true); + }); + }); + + describe('loadMcpTools', () => { + it('should load tools from config', async () => { + const tools = await adapter.loadMcpTools({ + tools: [ + { name: 'frigg_validate_schema', handler: async () => ({}) } + ] + }); + + expect(tools.length).toBe(1); + expect(tools[0].name).toBe('frigg_validate_schema'); + }); + + it('should convert to Vercel AI tool format', async () => { + const tools = await adapter.loadMcpTools({ + tools: [ + { + name: 'test_tool', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + handler: async () => ({}) + } + ] + }); + + expect(tools[0]).toHaveProperty('description'); + expect(tools[0]).toHaveProperty('parameters'); + }); + }); + + describe('createStreamingHandler', () => { + it('should create handler that emits events', () => { + const events = []; + const handler = adapter.createStreamingHandler((event) => { + events.push(event); + }); + + expect(typeof handler.onContent).toBe('function'); + expect(typeof handler.onToolCall).toBe('function'); + expect(typeof handler.onToolResult).toBe('function'); + expect(typeof handler.onFinish).toBe('function'); + }); + + it('should emit content events', () => { + const events = []; + const handler = adapter.createStreamingHandler((event) => { + events.push(event); + }); + + handler.onContent('Hello'); + handler.onContent(' world'); + + expect(events.length).toBe(2); + expect(events[0].type).toBe(AgentEventType.CONTENT); + expect(events[0].content).toBe('Hello'); + }); + + it('should emit tool call events', () => { + const events = []; + const handler = adapter.createStreamingHandler((event) => { + events.push(event); + }); + + handler.onToolCall('frigg_validate_schema', { code: '...' }); + + expect(events[0].type).toBe(AgentEventType.TOOL_CALL); + expect(events[0].name).toBe('frigg_validate_schema'); + }); + + it('should emit done event on finish', () => { + const events = []; + const handler = adapter.createStreamingHandler((event) => { + events.push(event); + }); + + handler.onFinish({ usage: { promptTokens: 100, completionTokens: 50 } }); + + expect(events.some(e => e.type === AgentEventType.USAGE)).toBe(true); + expect(events.some(e => e.type === AgentEventType.DONE)).toBe(true); + }); + }); + + describe('buildSystemPrompt', () => { + it('should include Frigg context', () => { + const prompt = adapter.buildSystemPrompt({ + integrationContext: 'Building HubSpot integration' + }); + + expect(prompt).toContain('Frigg'); + expect(prompt).toContain('HubSpot'); + }); + + it('should include validation instructions', () => { + const prompt = adapter.buildSystemPrompt({}); + + expect(prompt).toContain('validation'); + expect(prompt.toLowerCase()).toContain('test'); + }); + }); + + describe('runAgent (mock)', () => { + it('should validate params before running', async () => { + await expect(adapter.runAgent({})).rejects.toThrow('prompt is required'); + }); + + it('should return generator for streaming', async () => { + const mockAdapter = new VercelAIAdapter({ skipApiCheck: true }); + mockAdapter._mockResponse = async function* () { + yield { type: 'content', content: 'Test response' }; + yield { type: 'done' }; + }; + + const params = { prompt: 'Test prompt', mock: true }; + const result = await mockAdapter.runAgent(params); + + expect(result).toHaveProperty('stream'); + }); + }); +}); diff --git a/packages/ai-agents/tests/unit/infrastructure/git/git-checkpoint-service.test.js b/packages/ai-agents/tests/unit/infrastructure/git/git-checkpoint-service.test.js new file mode 100644 index 000000000..6c577e304 --- /dev/null +++ b/packages/ai-agents/tests/unit/infrastructure/git/git-checkpoint-service.test.js @@ -0,0 +1,182 @@ +const { GitCheckpointService } = require('../../../../src/infrastructure/git/git-checkpoint-service'); + +describe('GitCheckpointService', () => { + let service; + let mockExec; + + beforeEach(() => { + mockExec = jest.fn(); + service = new GitCheckpointService({ execCommand: mockExec }); + }); + + describe('createCheckpoint', () => { + it('should create checkpoint with commit hash', async () => { + mockExec.mockResolvedValueOnce({ stdout: 'abc123def456\n' }); + mockExec.mockResolvedValueOnce({ stdout: '' }); + + const checkpoint = await service.createCheckpoint('Before HubSpot integration'); + + expect(checkpoint).toHaveProperty('id'); + expect(checkpoint).toHaveProperty('hash'); + expect(checkpoint).toHaveProperty('message'); + expect(checkpoint).toHaveProperty('timestamp'); + expect(checkpoint.message).toBe('Before HubSpot integration'); + }); + + it('should store checkpoint in registry', async () => { + mockExec.mockResolvedValueOnce({ stdout: 'abc123def456\n' }); + mockExec.mockResolvedValueOnce({ stdout: '' }); + + const checkpoint = await service.createCheckpoint('Test checkpoint'); + const stored = service.getCheckpoint(checkpoint.id); + + expect(stored).toBeDefined(); + expect(stored.hash).toBe(checkpoint.hash); + }); + + it('should handle dirty working directory', async () => { + mockExec.mockResolvedValueOnce({ stdout: 'abc123\n' }); + mockExec.mockResolvedValueOnce({ stdout: 'M src/file.js\n' }); + + const checkpoint = await service.createCheckpoint('Checkpoint with changes'); + + expect(checkpoint).toHaveProperty('hasPendingChanges', true); + }); + }); + + describe('rollback', () => { + it('should rollback to checkpoint', async () => { + mockExec.mockResolvedValueOnce({ stdout: 'abc123\n' }); + mockExec.mockResolvedValueOnce({ stdout: '' }); + + const checkpoint = await service.createCheckpoint('Before changes'); + + mockExec.mockResolvedValueOnce({ stdout: '' }); + + const result = await service.rollback(checkpoint.id); + + expect(result.success).toBe(true); + expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('git reset')); + }); + + it('should fail for unknown checkpoint', async () => { + await expect(service.rollback('unknown-id')).rejects.toThrow('Checkpoint not found'); + }); + + it('should support soft rollback', async () => { + mockExec.mockResolvedValueOnce({ stdout: 'abc123\n' }); + mockExec.mockResolvedValueOnce({ stdout: '' }); + + const checkpoint = await service.createCheckpoint('Test'); + + mockExec.mockResolvedValueOnce({ stdout: '' }); + + await service.rollback(checkpoint.id, { mode: 'soft' }); + + expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('--soft')); + }); + + it('should support hard rollback', async () => { + mockExec.mockResolvedValueOnce({ stdout: 'abc123\n' }); + mockExec.mockResolvedValueOnce({ stdout: '' }); + + const checkpoint = await service.createCheckpoint('Test'); + + mockExec.mockResolvedValueOnce({ stdout: '' }); + + await service.rollback(checkpoint.id, { mode: 'hard' }); + + expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('--hard')); + }); + }); + + describe('listCheckpoints', () => { + it('should return all checkpoints', async () => { + mockExec.mockResolvedValueOnce({ stdout: 'abc\n' }); + mockExec.mockResolvedValueOnce({ stdout: '' }); + mockExec.mockResolvedValueOnce({ stdout: 'def\n' }); + mockExec.mockResolvedValueOnce({ stdout: '' }); + + await service.createCheckpoint('First'); + await service.createCheckpoint('Second'); + + const checkpoints = service.listCheckpoints(); + + expect(checkpoints.length).toBe(2); + }); + + it('should return checkpoints in reverse chronological order', async () => { + mockExec.mockResolvedValueOnce({ stdout: 'abc\n' }); + mockExec.mockResolvedValueOnce({ stdout: '' }); + + await service.createCheckpoint('First'); + + await new Promise(r => setTimeout(r, 10)); + + mockExec.mockResolvedValueOnce({ stdout: 'def\n' }); + mockExec.mockResolvedValueOnce({ stdout: '' }); + + await service.createCheckpoint('Second'); + + const checkpoints = service.listCheckpoints(); + + expect(checkpoints[0].message).toBe('Second'); + }); + }); + + describe('getStatus', () => { + it('should return current git status', async () => { + mockExec.mockResolvedValueOnce({ stdout: 'feature-branch\n' }); + mockExec.mockResolvedValueOnce({ stdout: '' }); + mockExec.mockResolvedValueOnce({ stdout: 'abc123\n' }); + + const status = await service.getStatus(); + + expect(status).toHaveProperty('branch'); + expect(status).toHaveProperty('clean'); + expect(status).toHaveProperty('hash'); + }); + + it('should detect uncommitted changes', async () => { + mockExec.mockResolvedValueOnce({ stdout: 'main\n' }); + mockExec.mockResolvedValueOnce({ stdout: 'M src/file.js\n?? new-file.js\n' }); + mockExec.mockResolvedValueOnce({ stdout: 'abc123\n' }); + + const status = await service.getStatus(); + + expect(status.clean).toBe(false); + expect(status.changes).toHaveLength(2); + }); + }); + + describe('diff', () => { + it('should show diff since checkpoint', async () => { + mockExec.mockResolvedValueOnce({ stdout: 'abc123\n' }); + mockExec.mockResolvedValueOnce({ stdout: '' }); + + const checkpoint = await service.createCheckpoint('Before'); + + mockExec.mockResolvedValueOnce({ stdout: '+ added line\n- removed line\n' }); + + const diff = await service.diff(checkpoint.id); + + expect(diff).toContain('added line'); + }); + }); + + describe('cleanup', () => { + it('should remove old checkpoints', async () => { + for (let i = 0; i < 15; i++) { + mockExec.mockResolvedValueOnce({ stdout: `abc${i}\n` }); + mockExec.mockResolvedValueOnce({ stdout: '' }); + await service.createCheckpoint(`Checkpoint ${i}`); + } + + expect(service.listCheckpoints().length).toBe(15); + + service.cleanup({ maxCheckpoints: 10 }); + + expect(service.listCheckpoints().length).toBe(10); + }); + }); +}); diff --git a/packages/ai-agents/tests/unit/infrastructure/mcp/frigg-tools.test.js b/packages/ai-agents/tests/unit/infrastructure/mcp/frigg-tools.test.js new file mode 100644 index 000000000..960dca22e --- /dev/null +++ b/packages/ai-agents/tests/unit/infrastructure/mcp/frigg-tools.test.js @@ -0,0 +1,810 @@ +const { + createFriggMcpTools, + validateSchemaHandler, + getTemplateHandler, + checkPatternsHandler, + listModulesHandler, + runTestsHandler, + securityScanHandler, + gitCheckpointHandler, + getExampleHandler, + searchDocsHandler, + readDocsHandler, + setGitCheckpointService, + NPMRegistryService, + INTEGRATION_CATEGORIES, + INTEGRATION_TYPES, + CATEGORY_TEMPLATES, + DOCS_INDEX +} = require('../../../../src/infrastructure/mcp/frigg-tools'); + +describe('Frigg MCP Tools', () => { + describe('createFriggMcpTools', () => { + it('should create array of tool definitions', () => { + const tools = createFriggMcpTools(); + + expect(Array.isArray(tools)).toBe(true); + expect(tools.length).toBeGreaterThan(0); + }); + + it('should have required tools', () => { + const tools = createFriggMcpTools(); + const toolNames = tools.map(t => t.name); + + expect(toolNames).toContain('frigg_validate_schema'); + expect(toolNames).toContain('frigg_get_template'); + expect(toolNames).toContain('frigg_check_patterns'); + expect(toolNames).toContain('frigg_list_modules'); + expect(toolNames).toContain('frigg_run_tests'); + expect(toolNames).toContain('frigg_security_scan'); + expect(toolNames).toContain('frigg_git_checkpoint'); + expect(toolNames).toContain('frigg_get_example'); + expect(toolNames).toContain('frigg_search_docs'); + expect(toolNames).toContain('frigg_read_docs'); + }); + + it('each tool should have name, description, and handler', () => { + const tools = createFriggMcpTools(); + + for (const tool of tools) { + expect(tool).toHaveProperty('name'); + expect(tool).toHaveProperty('description'); + expect(tool).toHaveProperty('inputSchema'); + expect(tool).toHaveProperty('handler'); + expect(typeof tool.handler).toBe('function'); + } + }); + + it('should accept gitCheckpointService option', () => { + const mockService = { createCheckpoint: jest.fn() }; + const tools = createFriggMcpTools({ gitCheckpointService: mockService }); + expect(tools).toBeDefined(); + }); + }); + + describe('frigg_validate_schema', () => { + it('should validate valid integration definition', async () => { + const validDefinition = { + name: 'hubspot-integration', + version: '1.0.0', + modules: { + hubspot: { definition: {} } + }, + options: { + display: { + name: 'HubSpot' + } + } + }; + + const result = await validateSchemaHandler({ + schemaType: 'integration-definition', + content: JSON.stringify(validDefinition) + }); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should accept valid category enum values', async () => { + const validDefinition = { + name: 'hubspot', + version: '1.0.0', + options: { + type: 'api', + display: { + category: 'CRM', + name: 'HubSpot' + } + } + }; + + const result = await validateSchemaHandler({ + schemaType: 'integration-definition', + content: JSON.stringify(validDefinition) + }); + + expect(result.valid).toBe(true); + }); + + it('should reject invalid category', async () => { + const invalidDefinition = { + name: 'test', + version: '1.0.0', + options: { + display: { + category: 'InvalidCategory' + } + } + }; + + const result = await validateSchemaHandler({ + schemaType: 'integration-definition', + content: JSON.stringify(invalidDefinition) + }); + + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('category'))).toBe(true); + }); + + it('should reject invalid integration type', async () => { + const invalidDefinition = { + name: 'test', + version: '1.0.0', + options: { + type: 'invalid-type' + } + }; + + const result = await validateSchemaHandler({ + schemaType: 'integration-definition', + content: JSON.stringify(invalidDefinition) + }); + + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('type'))).toBe(true); + }); + + it('should reject invalid integration definition', async () => { + const invalidDefinition = { + name: 'invalid_name!', + version: 'not-semver' + }; + + const result = await validateSchemaHandler({ + schemaType: 'integration-definition', + content: JSON.stringify(invalidDefinition) + }); + + expect(result.valid).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should handle malformed JSON', async () => { + const result = await validateSchemaHandler({ + schemaType: 'integration-definition', + content: 'not valid json {' + }); + + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain('JSON'); + }); + + it('should accept object content directly', async () => { + const result = await validateSchemaHandler({ + schemaType: 'integration-definition', + content: { name: 'test', version: '1.0.0' } + }); + + expect(result.valid).toBe(true); + }); + + it('should provide warnings for missing modules', async () => { + const definition = { + name: 'test', + version: '1.0.0' + }; + + const result = await validateSchemaHandler({ + schemaType: 'integration-definition', + content: JSON.stringify(definition) + }); + + expect(result.warnings).toBeDefined(); + expect(result.warnings.some(w => w.includes('modules'))).toBe(true); + }); + + it('should validate api-module-definition', async () => { + const moduleDefinition = { + name: 'hubspot', + authType: 'oauth2' + }; + + const result = await validateSchemaHandler({ + schemaType: 'api-module-definition', + content: JSON.stringify(moduleDefinition) + }); + + expect(result.valid).toBe(true); + }); + + it('should validate app-definition', async () => { + const appDefinition = { + name: 'my-app', + integrations: [{ Definition: {} }] + }; + + const result = await validateSchemaHandler({ + schemaType: 'app-definition', + content: JSON.stringify(appDefinition) + }); + + expect(result.valid).toBe(true); + }); + + it('should reject invalid auth capabilities', async () => { + const definition = { + name: 'test', + version: '1.0.0', + capabilities: { + auth: ['invalid-auth-type'] + } + }; + + const result = await validateSchemaHandler({ + schemaType: 'integration-definition', + content: JSON.stringify(definition) + }); + + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('auth'))).toBe(true); + }); + }); + + describe('frigg_get_template', () => { + it('should return CRM integration template', async () => { + const result = await getTemplateHandler({ + category: 'CRM', + integrationName: 'hubspot' + }); + + expect(result.template).toBeDefined(); + expect(result.template).toContain('class HubspotIntegration'); + expect(result.template).toContain('IntegrationBase'); + expect(result.template).toContain('onCreate'); + expect(result.template).toContain("category: 'CRM'"); + expect(result.suggestedFilename).toBe('hubspot-integration.js'); + }); + + it('should return Finance integration template', async () => { + const result = await getTemplateHandler({ + category: 'Finance', + integrationName: 'stripe' + }); + + expect(result.template).toContain("category: 'Finance'"); + expect(result.template).toContain('syncInvoices'); + }); + + it('should return Communication integration template', async () => { + const result = await getTemplateHandler({ + category: 'Communication', + integrationName: 'slack' + }); + + expect(result.template).toContain('onWebhookReceived'); + expect(result.template).toContain('challenge'); + }); + + it('should return ECommerce integration template', async () => { + const result = await getTemplateHandler({ + category: 'ECommerce', + integrationName: 'shopify' + }); + + expect(result.template).toContain('syncOrders'); + expect(result.template).toContain('syncProducts'); + }); + + it('should return Webhook-only integration template', async () => { + const result = await getTemplateHandler({ + category: 'Webhook', + integrationName: 'custom-webhook' + }); + + expect(result.template).toContain("type: 'webhook'"); + expect(result.template).toContain('verifySignature'); + expect(result.template).toContain('webhookSecret'); + }); + + it('should return Sync integration template', async () => { + const result = await getTemplateHandler({ + category: 'Sync', + integrationName: 'hubspot-salesforce', + options: { + sourceModule: 'hubspot', + targetModule: 'salesforce' + } + }); + + expect(result.template).toContain("type: 'sync'"); + expect(result.template).toContain('syncDirection'); + expect(result.template).toContain('conflictResolution'); + }); + + it('should include webhooks when requested', async () => { + const result = await getTemplateHandler({ + category: 'CRM', + integrationName: 'hubspot', + options: { webhooks: true } + }); + + expect(result.template).toContain('onWebhookReceived'); + expect(result.template).toContain('onWebhook'); + }); + + it('should return error for unknown category', async () => { + const result = await getTemplateHandler({ + category: 'UnknownCategory', + integrationName: 'test' + }); + + expect(result.error).toBeDefined(); + expect(result.availableCategories).toBeDefined(); + }); + + it('should use default name if not provided', async () => { + const result = await getTemplateHandler({ + category: 'CRM' + }); + + expect(result.template).toContain('MyIntegration'); + }); + }); + + describe('frigg_check_patterns', () => { + it('should pass valid integration code', async () => { + const validCode = ` + class HubSpotIntegration extends IntegrationBase { + static Definition = { + name: 'hubspot', + version: '1.0.0', + modules: {} + }; + + async onCreate({ integrationId }) { + await this.updateIntegrationStatus.execute(integrationId, 'ENABLED'); + } + async onUpdate(params) {} + async onDelete(params) {} + async getConfigOptions() { return { jsonSchema: {}, uiSchema: {} }; } + async testAuth() { return true; } + } + `; + + const result = await checkPatternsHandler({ + code: validCode, + fileType: 'integration' + }); + + expect(result.compliant).toBe(true); + expect(result.violations.filter(v => v.severity === 'error')).toHaveLength(0); + }); + + it('should detect missing IntegrationBase extension', async () => { + const invalidCode = ` + class HubSpotIntegration { + static Definition = { name: 'hubspot', version: '1.0.0' }; + } + `; + + const result = await checkPatternsHandler({ + code: invalidCode, + fileType: 'integration' + }); + + expect(result.compliant).toBe(false); + expect(result.violations.some(v => v.rule === 'extends-integration-base')).toBe(true); + }); + + it('should detect missing lifecycle methods as warnings', async () => { + const incompleteCode = ` + class HubSpotIntegration extends IntegrationBase { + static Definition = { name: 'hubspot', version: '1.0.0' }; + } + `; + + const result = await checkPatternsHandler({ + code: incompleteCode, + fileType: 'integration' + }); + + expect(result.violations.some(v => v.rule === 'lifecycle-methods')).toBe(true); + }); + + it('should detect webhook handlers missing when webhooks enabled', async () => { + const webhookCode = ` + class HubSpotIntegration extends IntegrationBase { + static Definition = { + name: 'hubspot', + version: '1.0.0', + capabilities: { webhooks: true } + }; + async onCreate() {} + async onUpdate() {} + async onDelete() {} + async getConfigOptions() {} + async testAuth() {} + } + `; + + const result = await checkPatternsHandler({ + code: webhookCode, + fileType: 'integration' + }); + + expect(result.violations.some(v => v.rule === 'webhook-handlers')).toBe(true); + }); + + it('should check api-module patterns', async () => { + const moduleCode = ` + class HubSpotApi extends OAuth2Requester { + async testAuth() { return true; } + } + `; + + const result = await checkPatternsHandler({ + code: moduleCode, + fileType: 'api-module' + }); + + expect(result.compliant).toBe(true); + }); + + it('should suggest updateIntegrationStatus usage', async () => { + const code = ` + class TestIntegration extends IntegrationBase { + static Definition = { name: 'test', version: '1.0.0' }; + async onCreate() {} + async onUpdate() {} + async onDelete() {} + async getConfigOptions() {} + async testAuth() {} + } + `; + + const result = await checkPatternsHandler({ + code, + fileType: 'integration' + }); + + expect(result.suggestions).toBeDefined(); + expect(result.suggestions.some(s => s.rule === 'status-updates')).toBe(true); + }); + }); + + describe('frigg_list_modules', () => { + it('should return modules array with metadata', async () => { + const result = await listModulesHandler({}); + + expect(result).toHaveProperty('modules'); + expect(result).toHaveProperty('total'); + expect(result).toHaveProperty('source', 'npm-registry'); + }); + + it('should filter by category when provided', async () => { + const npmService = new NPMRegistryService(); + const mockModules = [ + { name: 'hubspot', category: 'CRM' }, + { name: 'slack', category: 'Communication' } + ]; + + jest.spyOn(npmService, 'searchApiModules').mockResolvedValue(mockModules.filter(m => m.category === 'CRM')); + + const result = await listModulesHandler({ category: 'CRM' }); + expect(result.source).toBe('npm-registry'); + }); + }); + + describe('frigg_security_scan', () => { + it('should detect hardcoded API keys', async () => { + // Use a clearly fake test key pattern that won't trigger secret scanning + const code = `const api_key = "test_fake_key_for_unit_testing_only_12345";`; + + const result = await securityScanHandler({ code, scanType: 'credentials' }); + + expect(result.vulnerabilities.length).toBeGreaterThan(0); + expect(result.vulnerabilities.some(v => v.type === 'hardcoded-credential')).toBe(true); + }); + + it('should detect eval usage', async () => { + const code = `const result = eval(userInput);`; + + const result = await securityScanHandler({ code, scanType: 'injection' }); + + expect(result.vulnerabilities.some(v => v.type === 'code-injection')).toBe(true); + }); + + it('should detect missing webhook signature verification', async () => { + const code = ` + async onWebhookReceived({ req, res }) { + await this.processWebhook(req.body); + res.status(200).send(); + } + `; + + const result = await securityScanHandler({ code, scanType: 'validation' }); + + expect(result.vulnerabilities.some(v => v.type === 'missing-webhook-validation')).toBe(true); + }); + + it('should pass clean code', async () => { + const code = ` + async onWebhookReceived({ req, res }) { + const signature = req.headers['x-webhook-signature']; + if (!this.verifySignature(req.body, signature)) { + return res.status(401).send(); + } + res.status(200).send(); + } + `; + + const result = await securityScanHandler({ code }); + + expect(result.vulnerabilities.filter(v => v.type === 'missing-webhook-validation')).toHaveLength(0); + }); + + it('should provide scan summary', async () => { + const result = await securityScanHandler({ code: 'const x = 1;' }); + + expect(result.scanned).toBe(true); + expect(result.summary).toBeDefined(); + }); + }); + + describe('frigg_git_checkpoint', () => { + it('should use injected GitCheckpointService when available', async () => { + const mockCheckpoint = { + id: 'checkpoint-123', + hash: 'abc123', + message: 'test checkpoint', + timestamp: new Date(), + hasPendingChanges: false + }; + const mockService = { + createCheckpoint: jest.fn().mockResolvedValue(mockCheckpoint) + }; + + setGitCheckpointService(mockService); + + const result = await gitCheckpointHandler({ message: 'test checkpoint' }); + + expect(mockService.createCheckpoint).toHaveBeenCalledWith('test checkpoint'); + expect(result.checkpointId).toBe('checkpoint-123'); + expect(result.hash).toBe('abc123'); + expect(result.rollbackCommand).toContain('abc123'); + + setGitCheckpointService(null); + }); + + it('should handle service errors gracefully', async () => { + const mockService = { + createCheckpoint: jest.fn().mockRejectedValue(new Error('Git error')) + }; + + setGitCheckpointService(mockService); + + const result = await gitCheckpointHandler({ message: 'test' }); + + expect(result.error).toBeDefined(); + expect(result.fallback).toBe(true); + + setGitCheckpointService(null); + }); + }); + + describe('frigg_get_example', () => { + it('should return CRM integration example', async () => { + const result = await getExampleHandler({ pattern: 'crm-integration' }); + + expect(result.code).toBeDefined(); + expect(result.description).toBeDefined(); + expect(result.code).toContain('IntegrationBase'); + }); + + it('should return webhook handler example', async () => { + const result = await getExampleHandler({ pattern: 'webhook-handler' }); + + expect(result.code).toContain('verifySignature'); + expect(result.code).toContain('onWebhookReceived'); + }); + + it('should return form config example', async () => { + const result = await getExampleHandler({ pattern: 'form-config' }); + + expect(result.code).toContain('jsonSchema'); + expect(result.code).toContain('uiSchema'); + expect(result.code).toContain('getConfigOptions'); + }); + + it('should return oauth2 flow example', async () => { + const result = await getExampleHandler({ pattern: 'oauth2-flow' }); + + expect(result.code).toContain('getAuthorizationUri'); + expect(result.code).toContain('getAccessToken'); + expect(result.code).toContain('refreshAccessToken'); + }); + + it('should return sync pattern example', async () => { + const result = await getExampleHandler({ pattern: 'sync-pattern' }); + + expect(result.code).toContain('syncDirection'); + expect(result.code).toContain('conflictResolution'); + }); + + it('should return api-module-complete example', async () => { + const result = await getExampleHandler({ pattern: 'api-module-complete' }); + + expect(result.code).toContain('OAuth2Requester'); + expect(result.code).toContain('testAuth'); + expect(result.code).toContain('listContacts'); + }); + + it('should return error for unknown pattern', async () => { + const result = await getExampleHandler({ pattern: 'unknown-pattern' }); + + expect(result.error).toBeDefined(); + expect(result.availablePatterns).toBeDefined(); + expect(result.availablePatterns.length).toBeGreaterThan(0); + }); + }); + + describe('NPMRegistryService', () => { + it('should format package info correctly', () => { + const service = new NPMRegistryService(); + const pkg = { + name: '@friggframework/api-module-hubspot', + version: '1.0.0', + description: 'HubSpot CRM API module' + }; + + const result = service.formatPackageInfo(pkg); + + expect(result.name).toBe('hubspot'); + expect(result.fullName).toBe('@friggframework/api-module-hubspot'); + expect(result.displayName).toBe('Hubspot'); + expect(result.category).toBe('CRM'); + }); + + it('should categorize modules correctly', () => { + const service = new NPMRegistryService(); + + expect(service.categorizeModule('hubspot', 'CRM platform')).toBe('CRM'); + expect(service.categorizeModule('slack', 'messaging app')).toBe('Communication'); + expect(service.categorizeModule('stripe', 'payment processing')).toBe('Finance'); + expect(service.categorizeModule('shopify', 'e-commerce')).toBe('ECommerce'); + expect(service.categorizeModule('unknown', 'some tool')).toBe('Other'); + }); + + it('should infer auth type correctly', () => { + const service = new NPMRegistryService(); + + expect(service.inferAuthType('test', 'uses api key')).toBe('api-key'); + expect(service.inferAuthType('test', 'oauth integration')).toBe('oauth2'); + }); + }); + + describe('Constants', () => { + it('should export valid integration categories', () => { + expect(INTEGRATION_CATEGORIES).toContain('CRM'); + expect(INTEGRATION_CATEGORIES).toContain('Finance'); + expect(INTEGRATION_CATEGORIES).toContain('Communication'); + expect(INTEGRATION_CATEGORIES).toContain('ECommerce'); + expect(INTEGRATION_CATEGORIES).toContain('Storage'); + }); + + it('should export valid integration types', () => { + expect(INTEGRATION_TYPES).toContain('api'); + expect(INTEGRATION_TYPES).toContain('webhook'); + expect(INTEGRATION_TYPES).toContain('sync'); + expect(INTEGRATION_TYPES).toContain('transform'); + expect(INTEGRATION_TYPES).toContain('custom'); + }); + + it('should export category templates', () => { + expect(CATEGORY_TEMPLATES).toHaveProperty('CRM'); + expect(CATEGORY_TEMPLATES).toHaveProperty('Finance'); + expect(CATEGORY_TEMPLATES).toHaveProperty('Communication'); + expect(CATEGORY_TEMPLATES).toHaveProperty('ECommerce'); + expect(CATEGORY_TEMPLATES).toHaveProperty('Storage'); + expect(CATEGORY_TEMPLATES).toHaveProperty('Webhook'); + expect(CATEGORY_TEMPLATES).toHaveProperty('Sync'); + }); + + it('should export docs index', () => { + expect(DOCS_INDEX).toHaveProperty('integration-base'); + expect(DOCS_INDEX).toHaveProperty('api-module'); + expect(DOCS_INDEX).toHaveProperty('webhooks'); + expect(DOCS_INDEX).toHaveProperty('forms-config'); + expect(DOCS_INDEX).toHaveProperty('encryption'); + }); + }); + + describe('frigg_search_docs', () => { + it('should search documentation by query', async () => { + const result = await searchDocsHandler({ query: 'webhook signature' }); + + expect(result.query).toBe('webhook signature'); + expect(result.results).toBeDefined(); + expect(result.results.length).toBeGreaterThan(0); + expect(result.results[0]).toHaveProperty('title'); + expect(result.results[0]).toHaveProperty('summary'); + expect(result.results[0]).toHaveProperty('relevance'); + }); + + it('should filter by topic', async () => { + const result = await searchDocsHandler({ query: 'authentication', topic: 'webhooks' }); + + expect(result.topic).toBe('webhooks'); + }); + + it('should limit results', async () => { + const result = await searchDocsHandler({ query: 'integration', limit: 2 }); + + expect(result.results.length).toBeLessThanOrEqual(2); + }); + + it('should rank results by relevance', async () => { + const result = await searchDocsHandler({ query: 'integration lifecycle' }); + + expect(result.results[0].relevance).toBeGreaterThanOrEqual(result.results[result.results.length - 1].relevance); + }); + + it('should return empty results for no matches', async () => { + const result = await searchDocsHandler({ query: 'xyznonexistent123' }); + + expect(result.results).toHaveLength(0); + expect(result.totalMatches).toBe(0); + }); + }); + + describe('frigg_read_docs', () => { + it('should read documentation by key', async () => { + const result = await readDocsHandler({ docKey: 'integration-base' }); + + expect(result.key).toBe('integration-base'); + expect(result.title).toBe('IntegrationBase Class'); + expect(result.summary).toBeDefined(); + expect(result.topics).toContain('integration'); + }); + + it('should return sections for integration-base', async () => { + const result = await readDocsHandler({ docKey: 'integration-base' }); + + expect(result.sections).toBeDefined(); + expect(result.sections).toHaveProperty('static-definition'); + expect(result.sections).toHaveProperty('lifecycle-methods'); + expect(result.sections).toHaveProperty('webhook-methods'); + }); + + it('should return specific section when requested', async () => { + const result = await readDocsHandler({ docKey: 'integration-base', section: 'lifecycle-methods' }); + + expect(result.section).toBeDefined(); + expect(result.section.title).toBe('Lifecycle Methods'); + expect(result.section.content).toContain('onCreate'); + }); + + it('should return sections for forms-config', async () => { + const result = await readDocsHandler({ docKey: 'forms-config' }); + + expect(result.sections).toBeDefined(); + expect(result.sections).toHaveProperty('json-schema'); + expect(result.sections).toHaveProperty('dynamic-options'); + }); + + it('should return sections for webhooks', async () => { + const result = await readDocsHandler({ docKey: 'webhooks' }); + + expect(result.sections).toBeDefined(); + expect(result.sections).toHaveProperty('signature-verification'); + expect(result.sections).toHaveProperty('event-processing'); + }); + + it('should return error for unknown key', async () => { + const result = await readDocsHandler({ docKey: 'nonexistent' }); + + expect(result.error).toBeDefined(); + expect(result.availableDocs).toBeDefined(); + expect(result.availableDocs.length).toBeGreaterThan(0); + }); + + it('should list available docs on error', async () => { + const result = await readDocsHandler({ docKey: 'unknown' }); + + expect(result.availableDocs.some(d => d.key === 'integration-base')).toBe(true); + expect(result.availableDocs.some(d => d.key === 'webhooks')).toBe(true); + }); + }); +}); diff --git a/packages/ai-agents/tests/unit/infrastructure/streaming/agent-stream-handler.test.js b/packages/ai-agents/tests/unit/infrastructure/streaming/agent-stream-handler.test.js new file mode 100644 index 000000000..714449fb6 --- /dev/null +++ b/packages/ai-agents/tests/unit/infrastructure/streaming/agent-stream-handler.test.js @@ -0,0 +1,214 @@ +const { AgentStreamHandler } = require('../../../../src/infrastructure/streaming/agent-stream-handler'); +const { AgentEvent, AgentEventType } = require('../../../../src/domain/entities/agent-event'); + +describe('AgentStreamHandler', () => { + let handler; + let mockSocket; + let mockIo; + + beforeEach(() => { + mockSocket = { + id: 'socket-123', + emit: jest.fn(), + join: jest.fn(), + leave: jest.fn(), + on: jest.fn() + }; + + mockIo = { + to: jest.fn().mockReturnThis(), + emit: jest.fn() + }; + + handler = new AgentStreamHandler({ io: mockIo }); + }); + + describe('initialization', () => { + it('should create handler with io instance', () => { + expect(handler).toBeInstanceOf(AgentStreamHandler); + }); + + it('should track active sessions', () => { + expect(handler.getSessions()).toEqual([]); + }); + }); + + describe('createSession', () => { + it('should create a new streaming session', () => { + const session = handler.createSession({ + socketId: 'socket-123', + userId: 'user-456' + }); + + expect(session).toHaveProperty('id'); + expect(session).toHaveProperty('socketId', 'socket-123'); + expect(session).toHaveProperty('userId', 'user-456'); + expect(session).toHaveProperty('status', 'active'); + }); + + it('should store session in registry', () => { + const session = handler.createSession({ + socketId: 'socket-123', + userId: 'user-456' + }); + + expect(handler.getSessions()).toContain(session); + }); + }); + + describe('emit', () => { + it('should emit event to session socket', () => { + const session = handler.createSession({ socketId: 'socket-123' }); + + handler.emit(session.id, AgentEvent.content('Hello')); + + expect(mockIo.to).toHaveBeenCalledWith('socket-123'); + expect(mockIo.emit).toHaveBeenCalledWith('agent:event', expect.objectContaining({ + type: 'content', + content: 'Hello' + })); + }); + + it('should include session id in event', () => { + const session = handler.createSession({ socketId: 'socket-123' }); + + handler.emit(session.id, AgentEvent.content('Test')); + + expect(mockIo.emit).toHaveBeenCalledWith('agent:event', expect.objectContaining({ + sessionId: session.id + })); + }); + + it('should emit tool call events', () => { + const session = handler.createSession({ socketId: 'socket-123' }); + + handler.emit(session.id, AgentEvent.toolCall('frigg_validate_schema', { code: '...' })); + + expect(mockIo.emit).toHaveBeenCalledWith('agent:event', expect.objectContaining({ + type: 'tool_call', + name: 'frigg_validate_schema' + })); + }); + + it('should emit done events and update session status', () => { + const session = handler.createSession({ socketId: 'socket-123' }); + + handler.emit(session.id, AgentEvent.done()); + + expect(mockIo.emit).toHaveBeenCalledWith('agent:event', expect.objectContaining({ + type: 'done' + })); + + const updatedSession = handler.getSession(session.id); + expect(updatedSession.status).toBe('completed'); + }); + + it('should emit error events and update session status', () => { + const session = handler.createSession({ socketId: 'socket-123' }); + + handler.emit(session.id, AgentEvent.error(new Error('Test error'))); + + expect(mockIo.emit).toHaveBeenCalledWith('agent:event', expect.objectContaining({ + type: 'error' + })); + + const updatedSession = handler.getSession(session.id); + expect(updatedSession.status).toBe('error'); + }); + }); + + describe('createEventEmitter', () => { + it('should return function that emits events for session', () => { + const session = handler.createSession({ socketId: 'socket-123' }); + const emitter = handler.createEventEmitter(session.id); + + expect(typeof emitter).toBe('function'); + + emitter(AgentEvent.content('Test')); + + expect(mockIo.emit).toHaveBeenCalled(); + }); + }); + + describe('pause/resume', () => { + it('should pause a session', () => { + const session = handler.createSession({ socketId: 'socket-123' }); + + handler.pauseSession(session.id); + + const updated = handler.getSession(session.id); + expect(updated.status).toBe('paused'); + }); + + it('should emit pause event', () => { + const session = handler.createSession({ socketId: 'socket-123' }); + + handler.pauseSession(session.id); + + expect(mockIo.emit).toHaveBeenCalledWith('agent:paused', expect.objectContaining({ + sessionId: session.id + })); + }); + + it('should resume a paused session', () => { + const session = handler.createSession({ socketId: 'socket-123' }); + + handler.pauseSession(session.id); + handler.resumeSession(session.id); + + const updated = handler.getSession(session.id); + expect(updated.status).toBe('active'); + }); + }); + + describe('proposal handling', () => { + it('should emit proposal for review', () => { + const session = handler.createSession({ socketId: 'socket-123' }); + + const proposal = { + id: 'proposal-1', + files: [{ path: 'test.js', content: '...' }], + confidence: 85 + }; + + handler.emitProposal(session.id, proposal); + + expect(mockIo.emit).toHaveBeenCalledWith('agent:proposal', expect.objectContaining({ + sessionId: session.id, + proposal + })); + }); + + it('should update session status to awaiting_approval', () => { + const session = handler.createSession({ socketId: 'socket-123' }); + + handler.emitProposal(session.id, { id: 'p1', files: [], confidence: 90 }); + + const updated = handler.getSession(session.id); + expect(updated.status).toBe('awaiting_approval'); + }); + }); + + describe('cleanup', () => { + it('should remove session', () => { + const session = handler.createSession({ socketId: 'socket-123' }); + + handler.removeSession(session.id); + + expect(handler.getSession(session.id)).toBeUndefined(); + }); + + it('should cleanup old sessions', () => { + const session1 = handler.createSession({ socketId: 'socket-1' }); + const session2 = handler.createSession({ socketId: 'socket-2' }); + + session1.completedAt = new Date(Date.now() - 1000 * 60 * 60); + session1.status = 'completed'; + + handler.cleanupOldSessions({ maxAgeMinutes: 30 }); + + expect(handler.getSession(session1.id)).toBeUndefined(); + expect(handler.getSession(session2.id)).toBeDefined(); + }); + }); +}); diff --git a/packages/ai-agents/tests/unit/infrastructure/validation/validation-pipeline.test.js b/packages/ai-agents/tests/unit/infrastructure/validation/validation-pipeline.test.js new file mode 100644 index 000000000..ba1f775fd --- /dev/null +++ b/packages/ai-agents/tests/unit/infrastructure/validation/validation-pipeline.test.js @@ -0,0 +1,288 @@ +const { ValidationPipeline, ValidationLayer } = require('../../../../src/infrastructure/validation/validation-pipeline'); + +describe('ValidationPipeline', () => { + let pipeline; + + beforeEach(() => { + pipeline = new ValidationPipeline(); + }); + + describe('ValidationLayer', () => { + it('should define all layer types', () => { + expect(ValidationLayer.SCHEMA).toBe('schema'); + expect(ValidationLayer.PATTERNS).toBe('patterns'); + expect(ValidationLayer.SECURITY).toBe('security'); + expect(ValidationLayer.TESTS).toBe('tests'); + expect(ValidationLayer.LINT).toBe('lint'); + }); + }); + + describe('validate', () => { + it('should validate files and return confidence score', async () => { + const files = [ + { + path: 'src/integrations/hubspot.js', + content: ` +const { IntegrationBase } = require('@friggframework/core'); + +class HubspotIntegration extends IntegrationBase { + static Definition = { + name: 'hubspot', + version: '1.0.0', + modules: {}, + display: { name: 'HubSpot', description: 'HubSpot integration' } + }; + + async onCreate(params) {} + async onUpdate(params) {} + async onDelete(params) {} + async getConfigOptions() { return { jsonSchema: {}, uiSchema: {} }; } + async testAuth() { return true; } +} + +module.exports = { HubspotIntegration }; +`, + action: 'create' + } + ]; + + const result = await pipeline.validate(files); + + expect(result).toHaveProperty('confidence'); + expect(result).toHaveProperty('layers'); + expect(result).toHaveProperty('recommendation'); + expect(result.confidence).toBeGreaterThanOrEqual(0); + expect(result.confidence).toBeLessThanOrEqual(100); + }); + + it('should run all validation layers', async () => { + const files = [{ path: 'test.js', content: 'const x = 1;', action: 'create' }]; + const result = await pipeline.validate(files); + + expect(result.layers).toHaveProperty('schema'); + expect(result.layers).toHaveProperty('patterns'); + expect(result.layers).toHaveProperty('security'); + expect(result.layers).toHaveProperty('tests'); + expect(result.layers).toHaveProperty('lint'); + }); + + it('should return auto_approve for high confidence', async () => { + const files = [ + { + path: 'src/integrations/perfect.js', + content: ` +const { IntegrationBase } = require('@friggframework/core'); + +class PerfectIntegration extends IntegrationBase { + static Definition = { + name: 'perfect', + version: '1.0.0', + modules: {}, + display: { name: 'Perfect', description: 'Perfect integration' } + }; + + async onCreate(params) {} + async onUpdate(params) {} + async onDelete(params) {} + async getConfigOptions() { return { jsonSchema: {}, uiSchema: {} }; } + async testAuth() { return true; } +} + +module.exports = { PerfectIntegration }; +`, + action: 'create' + } + ]; + + const result = await pipeline.validate(files); + + if (result.confidence >= 95) { + expect(result.recommendation).toBe('auto_approve'); + } + }); + }); + + describe('schema validation layer', () => { + it('should pass valid integration definition', async () => { + const content = JSON.stringify({ + name: 'hubspot', + version: '1.0.0', + modules: {}, + display: { name: 'HubSpot', description: 'HubSpot integration' } + }); + + const result = await pipeline.validateSchema({ path: 'definition.json', content }); + + expect(result.passed).toBe(true); + expect(result.score).toBeGreaterThan(0); + }); + + it('should fail invalid integration definition', async () => { + const content = JSON.stringify({ + name: '123invalid', + version: 'not-semver' + }); + + const result = await pipeline.validateSchema({ path: 'definition.json', content }); + + expect(result.passed).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should skip non-JSON files', async () => { + const result = await pipeline.validateSchema({ + path: 'code.js', + content: 'const x = 1;' + }); + + expect(result.passed).toBe(true); + expect(result.score).toBe(100); + }); + }); + + describe('patterns validation layer', () => { + it('should pass integration with required patterns', async () => { + const content = ` +class MyIntegration extends IntegrationBase { + static Definition = { name: 'test', version: '1.0.0' }; + async onCreate() {} + async onUpdate() {} + async onDelete() {} + async getConfigOptions() {} + async testAuth() {} +}`; + + const result = await pipeline.validatePatterns({ + path: 'src/integrations/test.js', + content + }); + + expect(result.passed).toBe(true); + }); + + it('should detect missing required methods', async () => { + const content = ` +class MyIntegration extends IntegrationBase { + static Definition = { name: 'test' }; + async onCreate() {} +}`; + + const result = await pipeline.validatePatterns({ + path: 'src/integrations/test.js', + content + }); + + expect(result.passed).toBe(false); + expect(result.violations.length).toBeGreaterThan(0); + }); + + it('should skip non-integration files', async () => { + const result = await pipeline.validatePatterns({ + path: 'utils/helpers.js', + content: 'const x = 1;' + }); + + expect(result.passed).toBe(true); + expect(result.score).toBe(100); + }); + }); + + describe('security validation layer', () => { + it('should detect hardcoded credentials', async () => { + const content = `const apiKey = 'sk-1234567890abcdef';`; + + const result = await pipeline.validateSecurity({ + path: 'config.js', + content + }); + + expect(result.passed).toBe(false); + expect(result.vulnerabilities.length).toBeGreaterThan(0); + expect(result.vulnerabilities[0].type).toBe('hardcoded-credential'); + }); + + it('should pass secure code', async () => { + const content = `const apiKey = process.env.API_KEY;`; + + const result = await pipeline.validateSecurity({ + path: 'config.js', + content + }); + + expect(result.passed).toBe(true); + }); + + it('should detect SQL injection risks', async () => { + const content = `const query = "SELECT * FROM users WHERE id = " + userId;`; + + const result = await pipeline.validateSecurity({ + path: 'db.js', + content + }); + + expect(result.passed).toBe(false); + expect(result.vulnerabilities.some(v => v.type === 'sql-injection')).toBe(true); + }); + }); + + describe('calculateConfidence', () => { + it('should weight layers correctly', () => { + const layers = { + schema: { score: 100 }, + patterns: { score: 100 }, + security: { score: 100 }, + tests: { score: 100 }, + lint: { score: 100 } + }; + + const confidence = pipeline.calculateConfidence(layers); + expect(confidence).toBe(100); + }); + + it('should apply weights: schema 30%, patterns 25%, tests 20%, security 15%, lint 10%', () => { + const layers = { + schema: { score: 0 }, + patterns: { score: 100 }, + security: { score: 100 }, + tests: { score: 100 }, + lint: { score: 100 } + }; + + const confidence = pipeline.calculateConfidence(layers); + expect(confidence).toBe(70); + }); + }); + + describe('getRecommendation', () => { + it('should return auto_approve for >= 95', () => { + expect(pipeline.getRecommendation(95)).toBe('auto_approve'); + expect(pipeline.getRecommendation(100)).toBe('auto_approve'); + }); + + it('should return require_review for 80-94', () => { + expect(pipeline.getRecommendation(80)).toBe('require_review'); + expect(pipeline.getRecommendation(94)).toBe('require_review'); + }); + + it('should return manual_approval for < 80', () => { + expect(pipeline.getRecommendation(79)).toBe('manual_approval'); + expect(pipeline.getRecommendation(50)).toBe('manual_approval'); + }); + }); + + describe('feedback generation', () => { + it('should generate feedback for failed layers', async () => { + const files = [ + { + path: 'src/integrations/bad.js', + content: `const secret = 'password123';`, + action: 'create' + } + ]; + + const result = await pipeline.validate(files); + + expect(result.feedback.length).toBeGreaterThan(0); + }); + }); +});