diff --git a/.qualops/.qualopsrc.configurable-agent-test.json b/.qualops/.qualopsrc.configurable-agent-test.json new file mode 100644 index 0000000..550726b --- /dev/null +++ b/.qualops/.qualopsrc.configurable-agent-test.json @@ -0,0 +1,91 @@ +{ + "$schema": "../qualops-config.schema.json", + "ai": { + "reviewStage": { + "provider": "openai-compatible", + "model": "gpt-4.1", + "inputPerMillion": 2.0, + "outputPerMillion": 8.0, + "temperature": 0 + }, + "fixStage": { + "provider": "openai-compatible", + "model": "gpt-4.1", + "inputPerMillion": 2.0, + "outputPerMillion": 8.0, + "temperature": 0 + }, + "judgeStage": { + "provider": "openai-compatible", + "model": "gpt-4.1", + "inputPerMillion": 2.0, + "outputPerMillion": 8.0, + "temperature": 0 + } + }, + "performance": { + "maxFileSizeKB": 500, + "maxFilesPerBatch": 15, + "maxTokensPerFile": 8000, + "timeoutSeconds": 300, + "throttling": { + "enabled": true, + "maxRequestsPerMinute": 50 + } + }, + "review": { + "minConfidence": 7, + "maxConcurrentFiles": 3, + "pipeline": [ + { + "name": "agenticSecurityAudit", + "enabled": true, + "mode": "agentic", + "agentic": { + "maxTurns": 15, + "enabledSubagents": [ + "security-analyzer", + "dependency-tracer", + "breaking-change-detector" + ], + "systemPrompt": "You are a security-focused code reviewer analyzing the QualOps codebase. Focus on:\n1. Command injection vulnerabilities in shell executions\n2. Path traversal in file operations\n3. Credential exposure in logs or error messages\n4. Unsafe deserialization or eval usage\n5. Cross-file security implications" + } + } + ] + }, + "fix": { + "enabled": false + }, + "report": { + "outputFormat": "html", + "includedSeverities": [ + "critical", + "high", + "medium", + "low" + ], + "enableRootCauseExtraction": false + }, + "github": { + "enabled": false, + "postComments": false, + "skipOnDraft": false, + "blockPipeline": false, + "maxInlineComments": 50 + }, + "skipPatterns": [ + "node_modules/**", + ".git/**", + "dist/**", + "build/**", + "coverage/**", + "reports/**", + ".qualops-cache/**", + "*.min.js", + "*.bundle.js", + "examples/**", + "docs/**", + "evals/datasets/**", + "**/*.d.ts" + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 13e4ec7..b47670e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `baseUrl` and `apiKey` fields added to `aiStageConfig` schema for `openai-compatible` providers, with `OPENAI_BASE_URL` / `OPENAI_API_KEY` env-var fallbacks resolved in `getResolvedStageConfig`. +- `openai-compatible` provider support for agentic review mode via `@eggai/configurable-agent` (Vercel AI SDK v5 agent loop). Any provider with a custom `baseUrl` can now run the full agentic security audit without SDK-specific adapters. - Provider-dialect smoke spec: `npm run test:smoke` runs the 4 AI caller stages migrated in PR #145 (`file-reviewer`, `validation-resolver`, `dedup-resolver`, `root-cause-extract`) against each real provider (`anthropic`, `openai`, `bedrock`, `github`) using a slice fixture as input. Validates that the structured-output dialect path returns a zod-validated response without throwing. Implemented as a Jest spec under `tests/smoke/` with its own `jest.smoke.config.ts` — not picked up by default `npm test` (whose `roots` are limited to `tests/unit/`). Provider config comes from `ConfigService` + the existing `PROVIDER_DEFAULTS` table, not a duplicated table. Providers with missing credentials are `describe.skip()`-ed; providers with malformed credentials fail loudly via the provider class's own `validateApiKey()`. Input is a slice fixture under `evals/datasets/inbox/smoke-sql-injection/`, loosely following TDR 0002. Nightly + manual CI workflow at `.github/workflows/provider-dialect-smoke.yml`. Automates the unchecked manual smoke item from PR #145's test plan; distinct from the deferred per-stage golden-evals item which validates output quality. - `unstructured` dialect for LLM models without `response_format: {type: "json_schema"}` support (e.g. Llama, Qwen2.5, DeepSeek-V3, Phi, older o1-series). When `isUnstructured()` is true, the pipeline runs a full prose path: `ProseFileReviewer` → `ProseValidationResolver` → `ProseDeduplicationResolver` → `session/prose-report.md`. No JSON parsing, no structured schemas — the model writes free-form prose and subsequent stages refine it in-kind. - Bundled litellm model capability snapshot (`src/ai/providers/model-capabilities.json`, 2101 chat models) for automatic `supportsResponseSchema` lookup. Unknown models default to `unstructured` (safe). diff --git a/package.json b/package.json index a8bce73..34b8ba0 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "generate:schema": "ts-node --transpile-only --project tsconfig.lib.json scripts/generate-config-schema.ts" }, "dependencies": { + "@eggai/configurable-agent": "^0.1.0", "@anthropic-ai/claude-agent-sdk": "0.3.169", "@anthropic-ai/sdk": "^0.102.0", "@aws-sdk/client-bedrock-runtime": "^3.1008.0", diff --git a/qualops-config.schema.json b/qualops-config.schema.json index 5139cc7..232f83b 100644 --- a/qualops-config.schema.json +++ b/qualops-config.schema.json @@ -190,6 +190,15 @@ "description": "Optional model output token cap.", "type": "integer", "minimum": 1 + }, + "baseUrl": { + "description": "Base URL for openai-compatible providers. Defaults to OPENAI_BASE_URL env var.", + "type": "string", + "format": "uri" + }, + "apiKey": { + "description": "API key for openai-compatible providers. Defaults to OPENAI_API_KEY env var.", + "type": "string" } }, "passthrough": true @@ -201,7 +210,8 @@ "anthropic", "openai", "bedrock", - "github" + "github", + "openai-compatible" ] }, "nonEmptyString": { diff --git a/src/ai/providers/capabilities.ts b/src/ai/providers/capabilities.ts index ee8557d..457eb57 100644 --- a/src/ai/providers/capabilities.ts +++ b/src/ai/providers/capabilities.ts @@ -56,6 +56,8 @@ export function detectCapabilities(provider: AIProviderName, model: string): Pro return detectAnthropicCapabilities(model); case 'bedrock': return detectBedrockCapabilities(model); + case 'openai-compatible': + return detectOpenAICapabilities(model); default: { const _exhaustive: never = provider; throw new Error(`Unknown provider: ${String(_exhaustive)}`); diff --git a/src/ai/providers/factory.ts b/src/ai/providers/factory.ts index 19f4412..9340a23 100644 --- a/src/ai/providers/factory.ts +++ b/src/ai/providers/factory.ts @@ -43,6 +43,9 @@ export class AIFactory { case AIProviderType.GITHUB: provider = new GitHubModelsProvider(stageConfig); break; + case 'openai-compatible': + provider = new OpenAIProvider(stageConfig); + break; default: { const _exhaustiveCheck: never = stageConfig.provider; throw new Error(`Unknown AI provider: ${_exhaustiveCheck}`); diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index a8708b5..65f966f 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -14,7 +14,7 @@ const severityList = z.array(severity).min(1).meta({ uniqueItems: true, }); export const aiProvider = z - .enum(['anthropic', 'openai', 'bedrock', 'github']) + .enum(['anthropic', 'openai', 'bedrock', 'github', 'openai-compatible']) .meta({ defName: 'aiProvider', description: 'Supported AI provider names.' }); const confidenceScore = z.int().min(1).max(10).meta({ defName: 'confidenceScore', @@ -73,6 +73,12 @@ const aiStageConfigSchema = z .optional() .meta({ description: 'Sampling temperature used by compatible providers.' }), maxTokens: z.int().min(1).optional().meta({ description: 'Optional model output token cap.' }), + baseUrl: z.string().url().optional().meta({ + description: 'Base URL for openai-compatible providers. Defaults to OPENAI_BASE_URL env var.', + }), + apiKey: z.string().optional().meta({ + description: 'API key for openai-compatible providers. Defaults to OPENAI_API_KEY env var.', + }), }) .passthrough() .meta({ diff --git a/src/config/config.ts b/src/config/config.ts index fb92836..755d421 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -333,18 +333,38 @@ export class ConfigService { getResolvedStageConfig(stage: string): ResolvedStageConfig { const raw = this.getAIStageConfig(stage); const { provider, model } = this.resolveModel({ stage: raw }); - return { ...raw, provider, model }; + const credentials = this.resolveCredentials(provider, raw); + return { ...raw, provider, model, ...credentials }; + } + + private resolveCredentials( + provider: AIProviderName, + raw: AIStageConfig, + ): { baseUrl?: string; apiKey?: string } { + if (provider === 'openai-compatible') { + return { + baseUrl: raw.baseUrl ?? process.env.OPENAI_BASE_URL, + apiKey: raw.apiKey ?? process.env.OPENAI_API_KEY, + }; + } + return { + ...(raw.baseUrl !== undefined && { baseUrl: raw.baseUrl }), + ...(raw.apiKey !== undefined && { apiKey: raw.apiKey }), + }; } /** * Maps a resolved provider name to the agent adapter type to use. * Returns `undefined` for providers that do not yet support agentic mode. */ - resolveAgentAdapterType(provider: AIProviderName): 'anthropic' | 'openai' | undefined { - const mapping: Partial> = { + resolveAgentAdapterType( + provider: AIProviderName, + ): 'anthropic' | 'openai' | 'openai-compatible' | undefined { + const mapping: Partial> = { anthropic: 'anthropic', openai: 'openai', github: 'openai', // GitHub Models uses an OpenAI-compatible API + 'openai-compatible': 'openai-compatible', }; return mapping[provider]; } diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 96240e4..4b27d09 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -52,6 +52,8 @@ export type AIStageConfig = { outputPerMillion: number; temperature?: number; maxTokens?: number; + baseUrl?: string; + apiKey?: string; [key: string]: unknown; }; @@ -62,6 +64,8 @@ export type ResolvedStageConfig = { outputPerMillion: number; temperature?: number; maxTokens?: number; + baseUrl?: string; + apiKey?: string; [key: string]: unknown; }; diff --git a/src/stages/review/agentic/adapters/agent-adapter.ts b/src/stages/review/agentic/adapters/agent-adapter.ts index c938484..18af37c 100644 --- a/src/stages/review/agentic/adapters/agent-adapter.ts +++ b/src/stages/review/agentic/adapters/agent-adapter.ts @@ -17,6 +17,8 @@ export interface AgentAdapterParams { skipPatterns?: string[]; toolConfig: ToolConfig; onToolCall?: (turn: number, name: string, input: unknown) => void; + baseUrl?: string; + apiKey?: string; } export interface AgentAdapterResult { diff --git a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts new file mode 100644 index 0000000..40c6db2 --- /dev/null +++ b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts @@ -0,0 +1,142 @@ +import { runAgent, jsonSchema } from '@eggai/configurable-agent/lib'; +import type { AgentConfig, AgentEvent } from '@eggai/configurable-agent/lib'; +import { z } from 'zod'; + +import type { AgentAdapter, AgentAdapterParams, AgentAdapterResult } from './agent-adapter'; +import { logger } from '../../../../shared/utils/logger'; +import { createToolSet } from '../tools'; + +function toJsonSchema(schema: z.ZodObject): Record { + // z.toJSONSchema emits Draft 2020-12 with a $schema key that confuses some providers. + // Strip it and emit a plain draft-07-compatible object instead. + const { $schema: _dropped, ...rest } = z.toJSONSchema(schema) as Record; + return rest; +} + +function buildSystemPrompt(params: AgentAdapterParams, toolNames: string[]): string { + const parts = [params.systemPrompt]; + + const agentEntries = Object.entries(params.agents ?? {}); + if (agentEntries.length > 0) { + parts.push('## Review perspectives\n'); + for (const [name, def] of agentEntries) { + parts.push(`### ${name}\n${def.prompt}`); + } + } + + if (toolNames.length > 0) { + parts.push( + `## Available tools\nThe following tools are available. Use their EXACT names when calling them — do not use aliases like Read, Grep, or Glob:\n${toolNames.map((n) => `- ${n}`).join('\n')}\n\nUse these tools to trace cross-file issues, verify patterns, and confirm findings before reporting.`, + ); + } + + return parts.filter(Boolean).join('\n\n'); +} + +function buildAgentConfig(params: AgentAdapterParams, toolNames: string[]) { + return { + systemPrompt: buildSystemPrompt(params, toolNames), + model: { + provider: 'openai-compatible' as const, + name: params.model, + ...(params.baseUrl && { baseUrl: params.baseUrl }), + ...(params.apiKey !== undefined && { apiKey: params.apiKey }), + }, + agent: { maxSteps: params.maxTurns }, + mcpTools: [], + output: { structured: false }, + safety: { + compaction: { triggerTokens: 100_000, keepRecentMessages: 6 }, + toolOutput: { triggerTokens: 4_000, headChars: 500, tailChars: 500 }, + }, + } satisfies AgentConfig; +} + +export class ConfigurableAgentAdapter implements AgentAdapter { + async run(params: AgentAdapterParams): Promise { + const qualopsTools = await createToolSet(params.cwd, params.toolConfig, params.skipPatterns); + const config = buildAgentConfig( + params, + qualopsTools.tools.map((t) => t.name), + ); + + const tools: Record< + string, + { + description: string; + inputSchema: unknown; + execute: (args: Record) => Promise; + } + > = {}; + for (const def of qualopsTools.tools) { + tools[def.name] = { + description: def.description, + inputSchema: jsonSchema(toJsonSchema(def.schema)), + execute: (args: Record) => def.execute(args), + }; + } + + // Maps configurable-agent error codes to qualops error subtypes. + // Codes not in this map are treated as unrecoverable and will throw. + const ERROR_SUBTYPE_MAP: Record = { + tool_call_on_final_step: 'error_max_turns', + stream_error: 'error_provider_unavailable', + rate_limit_tokens: 'error_rate_limit_tokens', + max_tokens_reached: 'error_max_tokens', + structured_output_failed: 'error_content_filter', + }; + + try { + let output = ''; + let inputTokens: number | undefined; + let outputTokens: number | undefined; + let errorSubtype: string | undefined; + let turnIndex = 0; + + await runAgent( + config, + [{ role: 'user', content: params.userPrompt }], + async (event: AgentEvent) => { + switch (event.type) { + case 'tool_call': + turnIndex++; + logger.info(`[Agentic/ConfigurableAgent] Tool call: ${event.name}`); + params.onToolCall?.(turnIndex, event.name, event.args); + break; + case 'final': + output = event.content; + inputTokens = event.usage?.inputTokens; + outputTokens = event.usage?.outputTokens; + logger.info( + `[Agentic/ConfigurableAgent] Finished. steps=${event.steps}, stopReason=${event.stopReason}`, + ); + break; + case 'error': { + logger.warn( + `[Agentic/ConfigurableAgent] Error: code=${event.code} — ${event.message}`, + ); + const subtype = ERROR_SUBTYPE_MAP[event.code]; + if (!subtype) throw new Error(`Agent failed: ${event.message}`); + errorSubtype = subtype; + if (event.partialContent) { + logger.warn( + `[Agentic/ConfigurableAgent] Recovering partial response (${event.partialContent.length} chars)`, + ); + output = event.partialContent; + } + break; + } + default: + break; + } + }, + undefined, + { tools: tools as never }, + ); + + return { output, inputTokens, outputTokens, errorSubtype }; + } finally { + await qualopsTools.dispose(); + } + } +} diff --git a/src/stages/review/agentic/adapters/index.ts b/src/stages/review/agentic/adapters/index.ts index fe465f4..9a6100d 100644 --- a/src/stages/review/agentic/adapters/index.ts +++ b/src/stages/review/agentic/adapters/index.ts @@ -1,12 +1,17 @@ import type { AgentAdapter } from './agent-adapter'; import { AnthropicAdapter } from './anthropic-adapter'; +import { ConfigurableAgentAdapter } from './configurable-agent-adapter'; import { OpenAIAdapter } from './openai-adapter'; import { ConfigService } from '../../../../config/config'; import type { AIProviderName } from '../../../../shared/types'; -const ADAPTERS: Array<{ type: 'anthropic' | 'openai'; ctor: new () => AgentAdapter }> = [ +const ADAPTERS: Array<{ + type: 'anthropic' | 'openai' | 'openai-compatible'; + ctor: new () => AgentAdapter; +}> = [ { type: 'anthropic', ctor: AnthropicAdapter }, { type: 'openai', ctor: OpenAIAdapter }, + { type: 'openai-compatible', ctor: ConfigurableAgentAdapter }, ]; export function createAgentAdapter(provider: AIProviderName): AgentAdapter { diff --git a/src/stages/review/agentic/agentic-executor.ts b/src/stages/review/agentic/agentic-executor.ts index 812b10b..4eee7f0 100644 --- a/src/stages/review/agentic/agentic-executor.ts +++ b/src/stages/review/agentic/agentic-executor.ts @@ -110,6 +110,8 @@ export class AgenticExecutor { workspaceRoot: this.config.bash?.workspaceRoot ?? this.cwd, }, }, + baseUrl: stageConfig.baseUrl, + apiKey: stageConfig.apiKey, onToolCall: (turn, name, input) => { turnIndex = turn; allToolCalls.push({ turn, name, input }); @@ -126,13 +128,35 @@ export class AgenticExecutor { output: result.errorSubtype ? { error: result.errorSubtype } : result.output, }); - if (result.output) { - logger.info( - `[Agentic] Success result (first 500 chars): ${result.output.substring(0, 500)}`, + // Hard failures: errors where the response cannot be trusted even partially. + // Add new unrecoverable error subtypes here. + const HARD_FAILURES = new Set(['error_rate_limit_tokens', 'error_max_tokens']); + + if (result.errorSubtype && HARD_FAILURES.has(result.errorSubtype)) { + throw new Error( + `[Agentic] Job "${this.job.name}" failed: ${result.errorSubtype}. ` + + `Reduce the number of files reviewed per job or check your model configuration.`, + ); + } + + if (result.errorSubtype) { + logger.warn( + `[Agentic] Job "${this.job.name}" completed with error: ${result.errorSubtype}`, ); + } + + if (result.output) { + logger.info(`[Agentic] Result (first 500 chars): ${result.output.substring(0, 500)}`); const parsed = parseIssuesFromResult(result.output, files, this.job.name, this.cwd); + if (parsed.length === 0 && result.output.trim().length > 0) { + logger.warn( + `[Agentic] Job "${this.job.name}" returned output but no parseable issues — response may be truncated (${result.output.length} chars).`, + ); + } issues.push(...parsed); logger.info(`[Agentic] Parsed ${parsed.length} issues from result`); + } else if (result.errorSubtype) { + logger.warn(`[Agentic] Job "${this.job.name}" returned no output — review incomplete`); } } catch (error) { logger.error( @@ -179,40 +203,6 @@ export class AgenticExecutor { parts.push(content); } - const customPrompt = parts.join('\n\n'); - - return `You are a code reviewer. File contents and diffs are provided below. - -${customPrompt} - -## Process - -1. Analyze the provided code/diffs -2. Use Grep/Glob ONLY if checking external dependencies -3. Output JSON findings - -## Output Format - -\`\`\`json -[ - { - "type": "security|bug|performance|maintainability", - "severity": "critical|high|medium|low", - "description": "What the issue is", - "location": "src/file.ts:42", - "reasoning": "Why this is a problem", - "suggestion": "How to fix it", - "confidence": 8 - } -] -\`\`\` - -If no issues found, output: \`\`\`json\n[]\n\`\`\` - -## Rules - -- confidence >= 7 -- Focus on changed code -- Max 10 tool calls`; + return parts.join('\n\n'); } } diff --git a/src/stages/review/agentic/prompt-builder.ts b/src/stages/review/agentic/prompt-builder.ts index ddcad0c..cbe6f58 100644 --- a/src/stages/review/agentic/prompt-builder.ts +++ b/src/stages/review/agentic/prompt-builder.ts @@ -49,7 +49,7 @@ export function buildFileContext( content = formatFileContent(file.content, budget); } - const header = `## ${file.path}${file.framework ? ` (${file.framework})` : ''}`; + const header = `## ${file.path}`; return `${header}\n\n${content}`; } diff --git a/tests/unit/config/config.spec.ts b/tests/unit/config/config.spec.ts index d172879..aa104cd 100644 --- a/tests/unit/config/config.spec.ts +++ b/tests/unit/config/config.spec.ts @@ -562,6 +562,64 @@ describe('ConfigService', () => { 'AI configuration for stage "review" not found in .qualopsrc.json', ); }); + + it('uses config baseUrl/apiKey for openai-compatible provider', () => { + const instance = ConfigService.getInstance(); + instance.set('ai', { + reviewStage: { + model: { provider: 'openai-compatible', name: 'my-model' } as any, + inputPerMillion: 1, + outputPerMillion: 2, + baseUrl: 'https://my.api/v1', + apiKey: 'sk-config', + }, + }); + const result = instance.getResolvedStageConfig('review'); + expect(result.baseUrl).toBe('https://my.api/v1'); + expect(result.apiKey).toBe('sk-config'); + }); + + it('falls back to OPENAI_BASE_URL/OPENAI_API_KEY env vars for openai-compatible', () => { + const instance = ConfigService.getInstance(); + instance.set('ai', { + reviewStage: { + model: { provider: 'openai-compatible', name: 'my-model' } as any, + inputPerMillion: 1, + outputPerMillion: 2, + }, + }); + const origBase = process.env.OPENAI_BASE_URL; + const origKey = process.env.OPENAI_API_KEY; + process.env.OPENAI_BASE_URL = 'https://env.api/v1'; + process.env.OPENAI_API_KEY = 'sk-env'; + try { + const result = instance.getResolvedStageConfig('review'); + expect(result.baseUrl).toBe('https://env.api/v1'); + expect(result.apiKey).toBe('sk-env'); + } finally { + process.env.OPENAI_BASE_URL = origBase; + process.env.OPENAI_API_KEY = origKey; + } + }); + + it('does not apply OPENAI env vars for non-openai-compatible providers', () => { + const instance = ConfigService.getInstance(); + instance.set('ai', { + reviewStage: { + model: { provider: 'anthropic', name: 'claude-sonnet-4-6' } as any, + inputPerMillion: 3, + outputPerMillion: 15, + }, + }); + const origBase = process.env.OPENAI_BASE_URL; + process.env.OPENAI_BASE_URL = 'https://should-not-appear/v1'; + try { + const result = instance.getResolvedStageConfig('review'); + expect(result.baseUrl).toBeUndefined(); + } finally { + process.env.OPENAI_BASE_URL = origBase; + } + }); }); describe('resolveAgentModel', () => { diff --git a/tests/unit/stages/review/agentic/adapters/index.spec.ts b/tests/unit/stages/review/agentic/adapters/index.spec.ts new file mode 100644 index 0000000..8dd48e2 --- /dev/null +++ b/tests/unit/stages/review/agentic/adapters/index.spec.ts @@ -0,0 +1,70 @@ +jest.mock('@/config/config', () => ({ + ConfigService: { + getInstance: jest.fn(), + }, +})); +jest.mock('@/stages/review/agentic/adapters/anthropic-adapter', () => ({ + AnthropicAdapter: jest.fn().mockImplementation(() => ({ type: 'anthropic' })), +})); +jest.mock('@/stages/review/agentic/adapters/openai-adapter', () => ({ + OpenAIAdapter: jest.fn().mockImplementation(() => ({ type: 'openai' })), +})); +jest.mock('@/stages/review/agentic/adapters/configurable-agent-adapter', () => ({ + ConfigurableAgentAdapter: jest.fn().mockImplementation(() => ({ type: 'openai-compatible' })), +})); + +import { ConfigService } from '@/config/config'; +import type { AIProviderName } from '@/shared/types'; +import { createAgentAdapter } from '@/stages/review/agentic/adapters'; +import { AnthropicAdapter } from '@/stages/review/agentic/adapters/anthropic-adapter'; +import { ConfigurableAgentAdapter } from '@/stages/review/agentic/adapters/configurable-agent-adapter'; +import { OpenAIAdapter } from '@/stages/review/agentic/adapters/openai-adapter'; + +const mockGetInstance = ConfigService.getInstance as jest.MockedFunction< + typeof ConfigService.getInstance +>; + +function setupProvider(adapterType: 'anthropic' | 'openai' | 'openai-compatible' | undefined) { + mockGetInstance.mockReturnValue({ + resolveAgentAdapterType: jest.fn().mockReturnValue(adapterType), + } as unknown as ReturnType); +} + +describe('createAgentAdapter — adapter selection', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns AnthropicAdapter for provider "anthropic"', () => { + setupProvider('anthropic'); + const adapter = createAgentAdapter('anthropic' as AIProviderName); + expect(AnthropicAdapter).toHaveBeenCalledTimes(1); + expect(adapter).toEqual({ type: 'anthropic' }); + }); + + it('returns OpenAIAdapter for provider "openai"', () => { + setupProvider('openai'); + const adapter = createAgentAdapter('openai' as AIProviderName); + expect(OpenAIAdapter).toHaveBeenCalledTimes(1); + expect(adapter).toEqual({ type: 'openai' }); + }); + + it('returns OpenAIAdapter for provider "github" (openai adapter type)', () => { + setupProvider('openai'); + const adapter = createAgentAdapter('github' as AIProviderName); + expect(OpenAIAdapter).toHaveBeenCalledTimes(1); + expect(adapter).toEqual({ type: 'openai' }); + }); + + it('returns ConfigurableAgentAdapter for provider "openai-compatible"', () => { + setupProvider('openai-compatible'); + const adapter = createAgentAdapter('openai-compatible' as AIProviderName); + expect(ConfigurableAgentAdapter).toHaveBeenCalledTimes(1); + expect(adapter).toEqual({ type: 'openai-compatible' }); + }); + + it('throws for a provider with no agentic support', () => { + setupProvider(undefined); + expect(() => createAgentAdapter('bedrock' as AIProviderName)).toThrow( + 'Agentic mode is not implemented for provider: bedrock', + ); + }); +}); diff --git a/tests/unit/stages/review/agentic/agentic-executor.spec.ts b/tests/unit/stages/review/agentic/agentic-executor.spec.ts index 796e2ec..3a31665 100644 --- a/tests/unit/stages/review/agentic/agentic-executor.spec.ts +++ b/tests/unit/stages/review/agentic/agentic-executor.spec.ts @@ -127,9 +127,9 @@ describe('AgenticExecutor — systemPrompt / prompt composition', () => { mockCreateAgentAdapter.mockReset(); }); - it('uses default prompt when neither systemPrompt nor prompt is set', async () => { + it('passes empty system prompt when neither systemPrompt nor prompt is set', async () => { await runExecutor(makeJob()); - expect(capturedParams?.systemPrompt).toContain('You are a code reviewer'); + expect(capturedParams?.systemPrompt).toBe(''); }); it('injects inline systemPrompt into the system message', async () => { diff --git a/tests/unit/stages/review/agentic/prompt-builder.spec.ts b/tests/unit/stages/review/agentic/prompt-builder.spec.ts index 37244af..fec4353 100644 --- a/tests/unit/stages/review/agentic/prompt-builder.spec.ts +++ b/tests/unit/stages/review/agentic/prompt-builder.spec.ts @@ -53,16 +53,6 @@ describe('buildFileContext', () => { expect(result).toContain('## src/foo.ts'); }); - it('includes framework in header when present', () => { - const result = buildFileContext( - file('src/foo.ts', 'x', { framework: 'react' }), - 'full', - 8000, - 50000, - ); - expect(result).toContain('## src/foo.ts (react)'); - }); - it('uses diff content when mode is "diff" and rawDiff is present', () => { const f = file('src/foo.ts', 'x', { rawDiff: '@@ -1 +1 @@ -old\n+new' }); const result = buildFileContext(f, 'diff', 8000, 50000);