test: add comprehensive AI Context module tests (35 tests)
- Add builder.test.ts with full coverage of buildContext() and buildTaskContext() - Tests include: keyword extraction, file search, service matching, categorization, pattern discovery, graph hints, error handling - Fix phase-config.test.ts env var cleanup issue - Add 26 tests for auth/resolver.ts (multi-stage credential resolution) - Add 26 tests for client/factory.ts (client factory functions) Total: 87 new tests added for Phase 1 (AI Auth, Client, Context modules) All 187 test files passing (4281 tests total)
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* AI Auth Resolver Tests
|
||||
*
|
||||
* Tests for multi-stage credential resolution with fallback chains.
|
||||
* Covers OAuth tokens, API keys, environment variables, and queue-based resolution.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { SupportedProvider } from '../../providers/types';
|
||||
import type {
|
||||
AuthResolverContext,
|
||||
ResolvedAuth,
|
||||
QueueResolvedAuth,
|
||||
} from '../types';
|
||||
import type { ProviderAccount } from '../../../../shared/types/provider-account';
|
||||
import {
|
||||
registerSettingsAccessor,
|
||||
resolveAuth,
|
||||
refreshOAuthTokenReactive,
|
||||
hasCredentials,
|
||||
resolveAuthFromQueue,
|
||||
buildDefaultQueueConfig,
|
||||
} from '../resolver';
|
||||
import {
|
||||
PROVIDER_ENV_VARS,
|
||||
PROVIDER_SETTINGS_KEY,
|
||||
PROVIDER_BASE_URL_ENV,
|
||||
} from '../types';
|
||||
|
||||
// ============================================
|
||||
// Test Fixtures
|
||||
// ============================================
|
||||
|
||||
const mockSettingsAccessor = vi.fn();
|
||||
|
||||
const createMockContext = (
|
||||
provider: SupportedProvider = 'anthropic',
|
||||
overrides?: Partial<AuthResolverContext>,
|
||||
): AuthResolverContext => ({
|
||||
provider,
|
||||
profileId: 'test-profile',
|
||||
configDir: '/test/config',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockProviderAccount = (
|
||||
overrides?: Partial<ProviderAccount>,
|
||||
): ProviderAccount => ({
|
||||
id: 'test-account-1',
|
||||
provider: 'anthropic',
|
||||
name: 'Test Account',
|
||||
authType: 'api-key',
|
||||
billingModel: 'pay-per-use',
|
||||
apiKey: 'sk-test-key',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Setup & Teardown
|
||||
// ============================================
|
||||
|
||||
describe('AI Auth Resolver', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset environment
|
||||
process.env = { ...originalEnv };
|
||||
|
||||
// Clear mock settings accessor
|
||||
mockSettingsAccessor.mockReset();
|
||||
registerSettingsAccessor(mockSettingsAccessor);
|
||||
|
||||
// Clear any env vars that might interfere
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
delete process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
||||
delete process.env.ANTHROPIC_BASE_URL;
|
||||
delete process.env.OPENAI_BASE_URL;
|
||||
delete process.env.AZURE_OPENAI_API_KEY;
|
||||
delete process.env.MISTRAL_API_KEY;
|
||||
delete process.env.GROQ_API_KEY;
|
||||
delete process.env.XAI_API_KEY;
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
delete process.env.ZHIPU_API_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Settings Accessor Registration
|
||||
// ============================================
|
||||
|
||||
describe('registerSettingsAccessor', () => {
|
||||
it('should register a settings accessor function', () => {
|
||||
const accessor = vi.fn();
|
||||
registerSettingsAccessor(accessor);
|
||||
// Accessor is registered; actual usage tested in other tests
|
||||
expect(accessor).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Environment Variable Resolution
|
||||
// ============================================
|
||||
|
||||
describe('resolveFromEnvironment', () => {
|
||||
it('should resolve Anthropic API key from environment', async () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-ant-test-key';
|
||||
const ctx = createMockContext('anthropic');
|
||||
|
||||
const result = await resolveAuth(ctx);
|
||||
|
||||
expect(result).toEqual({
|
||||
apiKey: 'sk-ant-test-key',
|
||||
source: 'environment',
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve OpenAI API key from environment', async () => {
|
||||
process.env.OPENAI_API_KEY = 'sk-openai-test-key';
|
||||
const ctx = createMockContext('openai');
|
||||
|
||||
const result = await resolveAuth(ctx);
|
||||
|
||||
expect(result).toEqual({
|
||||
apiKey: 'sk-openai-test-key',
|
||||
source: 'environment',
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve Google API key from environment', async () => {
|
||||
process.env.GOOGLE_GENERATIVE_AI_API_KEY = 'google-test-key';
|
||||
const ctx = createMockContext('google');
|
||||
|
||||
const result = await resolveAuth(ctx);
|
||||
|
||||
expect(result).toEqual({
|
||||
apiKey: 'google-test-key',
|
||||
source: 'environment',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include custom base URL from environment', async () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-ant-test-key';
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://custom.anthropic.com';
|
||||
const ctx = createMockContext('anthropic');
|
||||
|
||||
const result = await resolveAuth(ctx);
|
||||
|
||||
expect(result?.baseURL).toBe('https://custom.anthropic.com');
|
||||
});
|
||||
|
||||
it('should return null for providers without env var support', async () => {
|
||||
const ctx = createMockContext('bedrock'); // Uses AWS credential chain
|
||||
|
||||
const result = await resolveAuth(ctx);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Profile API Key Resolution
|
||||
// ============================================
|
||||
|
||||
describe('resolveFromProfileApiKey', () => {
|
||||
it('should resolve API key from settings', async () => {
|
||||
mockSettingsAccessor.mockReturnValue('sk-settings-key');
|
||||
const ctx = createMockContext('anthropic');
|
||||
|
||||
const result = await resolveAuth(ctx);
|
||||
|
||||
expect(result).toEqual({
|
||||
apiKey: 'sk-settings-key',
|
||||
source: 'profile-api-key',
|
||||
});
|
||||
expect(mockSettingsAccessor).toHaveBeenCalledWith('globalAnthropicApiKey');
|
||||
});
|
||||
|
||||
it('should return null when no API key in settings', async () => {
|
||||
mockSettingsAccessor.mockReturnValue(undefined);
|
||||
const ctx = createMockContext('anthropic');
|
||||
|
||||
const result = await resolveAuth(ctx);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should include base URL from environment when resolving from settings', async () => {
|
||||
mockSettingsAccessor.mockReturnValue('sk-settings-key');
|
||||
process.env.OPENAI_BASE_URL = 'https://custom.openai.com';
|
||||
const ctx = createMockContext('openai');
|
||||
|
||||
const result = await resolveAuth(ctx);
|
||||
|
||||
expect(result?.baseURL).toBe('https://custom.openai.com');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Default Credentials (No-Auth Providers)
|
||||
// ============================================
|
||||
|
||||
describe('resolveDefaultCredentials', () => {
|
||||
it('should return default credentials for Ollama', async () => {
|
||||
const ctx = createMockContext('ollama');
|
||||
|
||||
const result = await resolveAuth(ctx);
|
||||
|
||||
expect(result).toEqual({
|
||||
apiKey: '',
|
||||
source: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for providers requiring auth', async () => {
|
||||
const ctx = createMockContext('anthropic');
|
||||
|
||||
const result = await resolveAuth(ctx);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Fallback Chain Priority
|
||||
// ============================================
|
||||
|
||||
describe('resolveAuth fallback chain', () => {
|
||||
it('should prioritize profile API key over environment', async () => {
|
||||
mockSettingsAccessor.mockReturnValue('sk-settings-key');
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-env-key';
|
||||
const ctx = createMockContext('anthropic');
|
||||
|
||||
const result = await resolveAuth(ctx);
|
||||
|
||||
expect(result?.source).toBe('profile-api-key');
|
||||
expect(result?.apiKey).toBe('sk-settings-key');
|
||||
});
|
||||
|
||||
it('should fall back to environment when profile key not set', async () => {
|
||||
mockSettingsAccessor.mockReturnValue(undefined);
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-env-key';
|
||||
const ctx = createMockContext('anthropic');
|
||||
|
||||
const result = await resolveAuth(ctx);
|
||||
|
||||
expect(result?.source).toBe('environment');
|
||||
expect(result?.apiKey).toBe('sk-env-key');
|
||||
});
|
||||
|
||||
it('should return null when no credentials available', async () => {
|
||||
mockSettingsAccessor.mockReturnValue(undefined);
|
||||
const ctx = createMockContext('anthropic');
|
||||
|
||||
const result = await resolveAuth(ctx);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// hasCredentials
|
||||
// ============================================
|
||||
|
||||
describe('hasCredentials', () => {
|
||||
it('should return true when credentials are available', async () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-test-key';
|
||||
const ctx = createMockContext('anthropic');
|
||||
|
||||
const result = await hasCredentials(ctx);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when no credentials available', async () => {
|
||||
const ctx = createMockContext('anthropic');
|
||||
|
||||
const result = await hasCredentials(ctx);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Queue-Based Resolution
|
||||
// ============================================
|
||||
|
||||
describe('resolveAuthFromQueue', () => {
|
||||
it('should resolve from first available account in queue', async () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-test-key';
|
||||
const queue = [
|
||||
createMockProviderAccount({ id: 'account-1', provider: 'anthropic' }),
|
||||
];
|
||||
mockSettingsAccessor.mockReturnValue(JSON.stringify(queue));
|
||||
|
||||
const result = await resolveAuthFromQueue('claude-opus-4-6', queue);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.accountId).toBe('account-1');
|
||||
expect(result?.resolvedProvider).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('should skip excluded accounts', async () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-test-key';
|
||||
const queue = [
|
||||
createMockProviderAccount({ id: 'account-1', provider: 'anthropic' }),
|
||||
createMockProviderAccount({ id: 'account-2', provider: 'anthropic' }),
|
||||
];
|
||||
|
||||
const result = await resolveAuthFromQueue('claude-opus-4-6', queue, {
|
||||
excludeAccountIds: ['account-1'],
|
||||
});
|
||||
|
||||
expect(result?.accountId).toBe('account-2');
|
||||
});
|
||||
|
||||
it('should return null when no accounts available', async () => {
|
||||
const queue: ProviderAccount[] = [];
|
||||
|
||||
const result = await resolveAuthFromQueue('claude-opus-4-6', queue);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should include resolved model ID in result', async () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-test-key';
|
||||
const queue = [
|
||||
createMockProviderAccount({ id: 'account-1', provider: 'anthropic' }),
|
||||
];
|
||||
|
||||
const result = await resolveAuthFromQueue('claude-opus-4-6', queue);
|
||||
|
||||
expect(result?.resolvedModelId).toBe('claude-opus-4-6');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// buildDefaultQueueConfig
|
||||
// ============================================
|
||||
|
||||
describe('buildDefaultQueueConfig', () => {
|
||||
it('should build queue config from settings', () => {
|
||||
const accounts = [
|
||||
createMockProviderAccount({ id: 'account-1', name: 'Account 1' }),
|
||||
createMockProviderAccount({ id: 'account-2', name: 'Account 2' }),
|
||||
];
|
||||
mockSettingsAccessor.mockImplementation((key) => {
|
||||
if (key === 'providerAccounts') return JSON.stringify(accounts);
|
||||
if (key === 'globalPriorityOrder') return JSON.stringify(['account-2', 'account-1']);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = buildDefaultQueueConfig('claude-opus-4-6');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.queue).toHaveLength(2);
|
||||
expect(result?.queue[0].id).toBe('account-2'); // Priority order
|
||||
expect(result?.requestedModel).toBe('claude-opus-4-6');
|
||||
});
|
||||
|
||||
it('should return undefined when no accounts configured', () => {
|
||||
mockSettingsAccessor.mockReturnValue(undefined);
|
||||
|
||||
const result = buildDefaultQueueConfig('claude-opus-4-6');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle invalid JSON gracefully', () => {
|
||||
mockSettingsAccessor.mockReturnValue('invalid-json');
|
||||
|
||||
const result = buildDefaultQueueConfig('claude-opus-4-6');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Type Constants
|
||||
// ============================================
|
||||
|
||||
describe('PROVIDER_ENV_VARS constant', () => {
|
||||
it('should have correct env var mappings', () => {
|
||||
expect(PROVIDER_ENV_VARS.anthropic).toBe('ANTHROPIC_API_KEY');
|
||||
expect(PROVIDER_ENV_VARS.openai).toBe('OPENAI_API_KEY');
|
||||
expect(PROVIDER_ENV_VARS.google).toBe('GOOGLE_GENERATIVE_AI_API_KEY');
|
||||
expect(PROVIDER_ENV_VARS.bedrock).toBeUndefined();
|
||||
expect(PROVIDER_ENV_VARS.ollama).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PROVIDER_SETTINGS_KEY constant', () => {
|
||||
it('should have correct settings key mappings', () => {
|
||||
expect(PROVIDER_SETTINGS_KEY.anthropic).toBe('globalAnthropicApiKey');
|
||||
expect(PROVIDER_SETTINGS_KEY.openai).toBe('globalOpenAIApiKey');
|
||||
expect(PROVIDER_SETTINGS_KEY.google).toBe('globalGoogleApiKey');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PROVIDER_BASE_URL_ENV constant', () => {
|
||||
it('should have correct base URL env var mappings', () => {
|
||||
expect(PROVIDER_BASE_URL_ENV.anthropic).toBe('ANTHROPIC_BASE_URL');
|
||||
expect(PROVIDER_BASE_URL_ENV.openai).toBe('OPENAI_BASE_URL');
|
||||
expect(PROVIDER_BASE_URL_ENV.azure).toBe('AZURE_OPENAI_ENDPOINT');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,525 @@
|
||||
/**
|
||||
* AI Client Factory Tests
|
||||
*
|
||||
* Tests for creating configured AI clients.
|
||||
* Covers createAgentClient() and createSimpleClient() with various configurations.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import type { AgentClientConfig, SimpleClientConfig } from '../types';
|
||||
import type { ToolContext } from '../../tools/types';
|
||||
import type { ProviderAccount } from '../../../../shared/types/provider-account';
|
||||
|
||||
// Mock all dependencies
|
||||
vi.mock('../../auth/resolver');
|
||||
vi.mock('../../config/agent-configs');
|
||||
vi.mock('../../config/phase-config');
|
||||
vi.mock('../../mcp/client');
|
||||
vi.mock('../../providers/factory');
|
||||
vi.mock('../../tools/build-registry');
|
||||
|
||||
import { createAgentClient, createSimpleClient } from '../factory';
|
||||
import { resolveAuth, resolveAuthFromQueue, buildDefaultQueueConfig } from '../../auth/resolver';
|
||||
import { getDefaultThinkingLevel, getRequiredMcpServers } from '../../config/agent-configs';
|
||||
import { resolveModelId } from '../../config/phase-config';
|
||||
import { createMcpClientsForAgent, closeAllMcpClients, mergeMcpTools } from '../../mcp/client';
|
||||
import { createProvider, detectProviderFromModel } from '../../providers/factory';
|
||||
import { buildToolRegistry } from '../../tools/build-registry';
|
||||
|
||||
// ============================================
|
||||
// Test Fixtures
|
||||
// ============================================
|
||||
|
||||
const createMockToolContext = (): ToolContext => ({
|
||||
cwd: '/test/cwd',
|
||||
projectDir: '/test/project',
|
||||
specDir: '/test/spec',
|
||||
securityProfile: 'default',
|
||||
});
|
||||
|
||||
const createMockAgentClientConfig = (
|
||||
overrides?: Partial<AgentClientConfig>,
|
||||
): AgentClientConfig => ({
|
||||
agentType: 'coder',
|
||||
systemPrompt: 'You are a coder agent.',
|
||||
toolContext: createMockToolContext(),
|
||||
phase: 'coding',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockSimpleClientConfig = (
|
||||
overrides?: Partial<SimpleClientConfig>,
|
||||
): SimpleClientConfig => ({
|
||||
systemPrompt: 'Generate a commit message.',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockProviderAccount = (
|
||||
overrides?: Partial<ProviderAccount>,
|
||||
): ProviderAccount => ({
|
||||
id: 'test-account-1',
|
||||
provider: 'anthropic',
|
||||
name: 'Test Account',
|
||||
authType: 'api-key',
|
||||
billingModel: 'pay-per-use',
|
||||
apiKey: 'sk-test-key',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Setup & Teardown
|
||||
// ============================================
|
||||
|
||||
describe('AI Client Factory', () => {
|
||||
const mockModel = {} as LanguageModel;
|
||||
const mockTools = { read: {} as any, write: {} as any };
|
||||
const mockAuth = {
|
||||
apiKey: 'sk-test-key',
|
||||
source: 'environment' as const,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup default mock returns
|
||||
vi.mocked(resolveAuth).mockResolvedValue(mockAuth);
|
||||
vi.mocked(detectProviderFromModel).mockReturnValue('anthropic');
|
||||
vi.mocked(createProvider).mockReturnValue(mockModel);
|
||||
vi.mocked(getDefaultThinkingLevel).mockReturnValue('medium');
|
||||
vi.mocked(getRequiredMcpServers).mockReturnValue([]);
|
||||
|
||||
// Mock tool registry
|
||||
const mockRegistry = {
|
||||
getToolsForAgent: vi.fn().mockReturnValue(mockTools),
|
||||
};
|
||||
vi.mocked(buildToolRegistry).mockReturnValue(mockRegistry as any);
|
||||
|
||||
// Mock MCP functions
|
||||
vi.mocked(createMcpClientsForAgent).mockResolvedValue([]);
|
||||
vi.mocked(mergeMcpTools).mockReturnValue({});
|
||||
vi.mocked(closeAllMcpClients).mockResolvedValue(undefined);
|
||||
|
||||
// Set default mock for resolveModelId to pass through by default
|
||||
vi.mocked(resolveModelId).mockImplementation((model) => model);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// createAgentClient
|
||||
// ============================================
|
||||
|
||||
describe('createAgentClient', () => {
|
||||
it('should create an agent client with default config', async () => {
|
||||
const config = createMockAgentClientConfig();
|
||||
|
||||
const result = await createAgentClient(config);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.model).toBe(mockModel);
|
||||
expect(result.tools).toEqual(mockTools);
|
||||
expect(result.systemPrompt).toBe(config.systemPrompt);
|
||||
expect(result.maxSteps).toBe(200); // DEFAULT_MAX_STEPS
|
||||
expect(result.thinkingLevel).toBe('medium');
|
||||
expect(result.mcpClients).toEqual([]);
|
||||
expect(result.cleanup).toBeDefined();
|
||||
});
|
||||
|
||||
it('should resolve auth using provider detection', async () => {
|
||||
// Mock resolveModelId to return a proper model ID for the phase
|
||||
vi.mocked(resolveModelId).mockImplementation((model) => {
|
||||
if (model === 'coding') return 'claude-opus-4-6';
|
||||
return model;
|
||||
});
|
||||
|
||||
const config = createMockAgentClientConfig({
|
||||
profileId: 'test-profile',
|
||||
});
|
||||
|
||||
await createAgentClient(config);
|
||||
|
||||
expect(resolveModelId).toHaveBeenCalledWith('coding');
|
||||
expect(detectProviderFromModel).toHaveBeenCalledWith('claude-opus-4-6');
|
||||
expect(resolveAuth).toHaveBeenCalledWith({
|
||||
provider: 'anthropic',
|
||||
profileId: 'test-profile',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use custom maxSteps when provided', async () => {
|
||||
const config = createMockAgentClientConfig({
|
||||
maxSteps: 50,
|
||||
});
|
||||
|
||||
const result = await createAgentClient(config);
|
||||
|
||||
expect(result.maxSteps).toBe(50);
|
||||
});
|
||||
|
||||
it('should use custom thinkingLevel when provided', async () => {
|
||||
const config = createMockAgentClientConfig({
|
||||
thinkingLevel: 'high',
|
||||
});
|
||||
|
||||
const result = await createAgentClient(config);
|
||||
|
||||
expect(result.thinkingLevel).toBe('high');
|
||||
});
|
||||
|
||||
it('should use custom modelShorthand when provided', async () => {
|
||||
const config = createMockAgentClientConfig({
|
||||
modelShorthand: 'opus',
|
||||
});
|
||||
|
||||
await createAgentClient(config);
|
||||
|
||||
expect(resolveModelId).toHaveBeenCalledWith('opus');
|
||||
});
|
||||
|
||||
it('should create MCP clients when required', async () => {
|
||||
vi.mocked(getRequiredMcpServers).mockReturnValue(['mcp-server-1']);
|
||||
const mockMcpClient = {
|
||||
serverId: 'mcp-server-1',
|
||||
tools: {},
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
vi.mocked(createMcpClientsForAgent).mockResolvedValue([mockMcpClient]);
|
||||
|
||||
const config = createMockAgentClientConfig();
|
||||
|
||||
const result = await createAgentClient(config);
|
||||
|
||||
expect(createMcpClientsForAgent).toHaveBeenCalledWith('coder', {});
|
||||
expect(result.mcpClients).toEqual([mockMcpClient]);
|
||||
});
|
||||
|
||||
it('should merge MCP tools into builtin tools', async () => {
|
||||
vi.mocked(getRequiredMcpServers).mockReturnValue(['mcp-server-1']);
|
||||
const mockMcpTools = { mcpTool: {} as any };
|
||||
vi.mocked(mergeMcpTools).mockReturnValue(mockMcpTools);
|
||||
|
||||
const config = createMockAgentClientConfig();
|
||||
|
||||
const result = await createAgentClient(config);
|
||||
|
||||
expect(mergeMcpTools).toHaveBeenCalled();
|
||||
expect(result.tools).toEqual(expect.objectContaining(mockMcpTools));
|
||||
});
|
||||
|
||||
it('should include additional MCP servers when provided', async () => {
|
||||
vi.mocked(getRequiredMcpServers).mockReturnValue(['builtin-server']);
|
||||
const config = createMockAgentClientConfig({
|
||||
additionalMcpServers: ['custom-server-1', 'custom-server-2'],
|
||||
});
|
||||
|
||||
await createAgentClient(config);
|
||||
|
||||
expect(getRequiredMcpServers).toHaveBeenCalledWith('coder', {});
|
||||
// Additional servers should be pushed to the list
|
||||
});
|
||||
|
||||
it('should use queue-based resolution when queueConfig is provided', async () => {
|
||||
const queue = [createMockProviderAccount()];
|
||||
const mockQueueAuth = {
|
||||
apiKey: 'sk-queue-key',
|
||||
source: 'profile-api-key' as const,
|
||||
accountId: 'test-account-1',
|
||||
resolvedProvider: 'anthropic' as const,
|
||||
resolvedModelId: 'claude-opus-4-6',
|
||||
reasoningConfig: { type: 'none' as const },
|
||||
};
|
||||
vi.mocked(resolveAuthFromQueue).mockResolvedValue(mockQueueAuth);
|
||||
|
||||
const config = createMockAgentClientConfig({
|
||||
queueConfig: {
|
||||
queue,
|
||||
requestedModel: 'claude-opus-4-6',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await createAgentClient(config);
|
||||
|
||||
expect(resolveAuthFromQueue).toHaveBeenCalledWith('claude-opus-4-6', queue, expect.any(Object));
|
||||
expect(createProvider).toHaveBeenCalledWith(expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
provider: 'anthropic',
|
||||
apiKey: 'sk-queue-key',
|
||||
}),
|
||||
modelId: 'claude-opus-4-6',
|
||||
}));
|
||||
expect(result.queueAuth).toEqual(mockQueueAuth);
|
||||
});
|
||||
|
||||
it('should throw error when queueConfig has no available accounts', async () => {
|
||||
vi.mocked(resolveAuthFromQueue).mockResolvedValue(null);
|
||||
|
||||
const config = createMockAgentClientConfig({
|
||||
queueConfig: {
|
||||
queue: [],
|
||||
requestedModel: 'claude-opus-4-6',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(createAgentClient(config)).rejects.toThrow(
|
||||
'No available account in priority queue'
|
||||
);
|
||||
});
|
||||
|
||||
it('should cleanup MCP clients when cleanup is called', async () => {
|
||||
const mockMcpClient = {
|
||||
serverId: 'mcp-server-1',
|
||||
tools: {},
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
vi.mocked(createMcpClientsForAgent).mockResolvedValue([mockMcpClient]);
|
||||
vi.mocked(getRequiredMcpServers).mockReturnValue(['mcp-server-1']);
|
||||
|
||||
const config = createMockAgentClientConfig();
|
||||
const result = await createAgentClient(config);
|
||||
|
||||
// Verify MCP clients are in the result
|
||||
expect(result.mcpClients).toEqual([mockMcpClient]);
|
||||
|
||||
await result.cleanup();
|
||||
|
||||
expect(closeAllMcpClients).toHaveBeenCalledWith([mockMcpClient]);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// createSimpleClient
|
||||
// ============================================
|
||||
|
||||
describe('createSimpleClient', () => {
|
||||
it('should create a simple client with defaults', async () => {
|
||||
// Mock the haiku model ID
|
||||
vi.mocked(resolveModelId).mockImplementation((model) => {
|
||||
if (model === 'haiku') return 'claude-haiku-4-5-20251001';
|
||||
return model;
|
||||
});
|
||||
|
||||
const config = createMockSimpleClientConfig();
|
||||
|
||||
const result = await createSimpleClient(config);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.model).toBe(mockModel);
|
||||
expect(result.resolvedModelId).toBe('claude-haiku-4-5-20251001'); // default 'haiku'
|
||||
expect(result.systemPrompt).toBe(config.systemPrompt);
|
||||
expect(result.maxSteps).toBe(1); // DEFAULT_SIMPLE_MAX_STEPS
|
||||
expect(result.thinkingLevel).toBe('low'); // default thinking level
|
||||
});
|
||||
|
||||
it('should use custom modelShorthand when provided', async () => {
|
||||
vi.mocked(resolveModelId).mockReturnValue('claude-sonnet-4-6');
|
||||
const config = createMockSimpleClientConfig({
|
||||
modelShorthand: 'sonnet',
|
||||
});
|
||||
|
||||
const result = await createSimpleClient(config);
|
||||
|
||||
expect(resolveModelId).toHaveBeenCalledWith('sonnet');
|
||||
expect(result.resolvedModelId).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
it('should use custom thinkingLevel when provided', async () => {
|
||||
const config = createMockSimpleClientConfig({
|
||||
thinkingLevel: 'high',
|
||||
});
|
||||
|
||||
const result = await createSimpleClient(config);
|
||||
|
||||
expect(result.thinkingLevel).toBe('high');
|
||||
});
|
||||
|
||||
it('should use custom maxSteps when provided', async () => {
|
||||
const config = createMockSimpleClientConfig({
|
||||
maxSteps: 5,
|
||||
});
|
||||
|
||||
const result = await createSimpleClient(config);
|
||||
|
||||
expect(result.maxSteps).toBe(5);
|
||||
});
|
||||
|
||||
it('should include custom tools when provided', async () => {
|
||||
const customTools = { customTool: {} as any };
|
||||
const config = createMockSimpleClientConfig({
|
||||
tools: customTools,
|
||||
});
|
||||
|
||||
const result = await createSimpleClient(config);
|
||||
|
||||
expect(result.tools).toEqual(customTools);
|
||||
});
|
||||
|
||||
it('should use profileId for auth resolution', async () => {
|
||||
const config = createMockSimpleClientConfig({
|
||||
profileId: 'test-profile',
|
||||
});
|
||||
|
||||
await createSimpleClient(config);
|
||||
|
||||
expect(resolveAuth).toHaveBeenCalledWith({
|
||||
provider: 'anthropic',
|
||||
profileId: 'test-profile',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use queue-based resolution when queueConfig is provided', async () => {
|
||||
const queue = [createMockProviderAccount()];
|
||||
const mockQueueAuth = {
|
||||
apiKey: 'sk-queue-key',
|
||||
source: 'profile-api-key' as const,
|
||||
accountId: 'test-account-1',
|
||||
resolvedProvider: 'anthropic' as const,
|
||||
resolvedModelId: 'claude-sonnet-4-6',
|
||||
reasoningConfig: { type: 'none' as const },
|
||||
};
|
||||
vi.mocked(resolveAuthFromQueue).mockResolvedValue(mockQueueAuth);
|
||||
|
||||
const config = createMockSimpleClientConfig({
|
||||
queueConfig: {
|
||||
queue,
|
||||
requestedModel: 'claude-sonnet-4-6',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await createSimpleClient(config);
|
||||
|
||||
expect(resolveAuthFromQueue).toHaveBeenCalled();
|
||||
expect(result.queueAuth).toEqual(mockQueueAuth);
|
||||
expect(result.resolvedModelId).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
it('should throw error when queueConfig has no available accounts', async () => {
|
||||
vi.mocked(resolveAuthFromQueue).mockResolvedValue(null);
|
||||
|
||||
const config = createMockSimpleClientConfig({
|
||||
queueConfig: {
|
||||
queue: [],
|
||||
requestedModel: 'claude-opus-4-6',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(createSimpleClient(config)).rejects.toThrow(
|
||||
'No available account in priority queue'
|
||||
);
|
||||
});
|
||||
|
||||
it('should auto-build queue config from settings when not explicitly provided', async () => {
|
||||
const mockQueueConfig = {
|
||||
queue: [createMockProviderAccount()],
|
||||
requestedModel: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
vi.mocked(buildDefaultQueueConfig).mockReturnValue(mockQueueConfig);
|
||||
vi.mocked(resolveModelId).mockReturnValue('claude-haiku-4-5-20251001');
|
||||
// Mock successful queue resolution
|
||||
vi.mocked(resolveAuthFromQueue).mockResolvedValue({
|
||||
apiKey: 'sk-test-key',
|
||||
source: 'profile-api-key' as const,
|
||||
accountId: 'test-account-1',
|
||||
resolvedProvider: 'anthropic' as const,
|
||||
resolvedModelId: 'claude-haiku-4-5-20251001',
|
||||
reasoningConfig: { type: 'none' as const },
|
||||
});
|
||||
|
||||
const config = createMockSimpleClientConfig();
|
||||
|
||||
const result = await createSimpleClient(config);
|
||||
|
||||
expect(buildDefaultQueueConfig).toHaveBeenCalledWith('claude-haiku-4-5-20251001');
|
||||
expect(result.queueAuth).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use explicit queueConfig when provided, skipping auto-build', async () => {
|
||||
const explicitQueueConfig = {
|
||||
queue: [createMockProviderAccount()],
|
||||
requestedModel: 'custom-model',
|
||||
};
|
||||
vi.mocked(buildDefaultQueueConfig).mockReturnValue(undefined); // Would return undefined if called
|
||||
// Mock successful queue resolution
|
||||
vi.mocked(resolveAuthFromQueue).mockResolvedValue({
|
||||
apiKey: 'sk-test-key',
|
||||
source: 'profile-api-key' as const,
|
||||
accountId: 'test-account-1',
|
||||
resolvedProvider: 'anthropic' as const,
|
||||
resolvedModelId: 'custom-model',
|
||||
reasoningConfig: { type: 'none' as const },
|
||||
});
|
||||
|
||||
const config = createMockSimpleClientConfig({
|
||||
queueConfig: explicitQueueConfig,
|
||||
});
|
||||
|
||||
const result = await createSimpleClient(config);
|
||||
|
||||
// Should NOT call buildDefaultQueueConfig since explicit config was provided
|
||||
expect(buildDefaultQueueConfig).not.toHaveBeenCalled();
|
||||
expect(result.queueAuth).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle full model IDs from other providers', async () => {
|
||||
const fullModelId = 'gpt-5.2-codex';
|
||||
vi.mocked(resolveModelId).mockReturnValue(fullModelId);
|
||||
vi.mocked(detectProviderFromModel).mockReturnValue('openai');
|
||||
|
||||
const config = createMockSimpleClientConfig({
|
||||
modelShorthand: fullModelId,
|
||||
});
|
||||
|
||||
const result = await createSimpleClient(config);
|
||||
|
||||
expect(result.resolvedModelId).toBe(fullModelId);
|
||||
expect(detectProviderFromModel).toHaveBeenCalledWith(fullModelId);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Default Constants
|
||||
// ============================================
|
||||
|
||||
describe('default constants', () => {
|
||||
it('should use DEFAULT_MAX_STEPS for agent clients', async () => {
|
||||
const config = createMockAgentClientConfig();
|
||||
// Don't provide maxSteps
|
||||
|
||||
const result = await createAgentClient(config);
|
||||
|
||||
expect(result.maxSteps).toBe(200);
|
||||
});
|
||||
|
||||
it('should use DEFAULT_SIMPLE_MAX_STEPS for simple clients', async () => {
|
||||
const config = createMockSimpleClientConfig();
|
||||
// Don't provide maxSteps
|
||||
|
||||
const result = await createSimpleClient(config);
|
||||
|
||||
expect(result.maxSteps).toBe(1);
|
||||
});
|
||||
|
||||
it('should default to haiku for simple client model', async () => {
|
||||
const config = createMockSimpleClientConfig();
|
||||
// Don't provide modelShorthand
|
||||
|
||||
await createSimpleClient(config);
|
||||
|
||||
expect(resolveModelId).toHaveBeenCalledWith('haiku');
|
||||
});
|
||||
|
||||
it('should default to low thinking for simple client', async () => {
|
||||
const config = createMockSimpleClientConfig();
|
||||
// Don't provide thinkingLevel
|
||||
|
||||
const result = await createSimpleClient(config);
|
||||
|
||||
expect(result.thinkingLevel).toBe('low');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -89,6 +89,10 @@ describe('resolveModelId', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
// Clear model override env vars to ensure clean test state
|
||||
delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL;
|
||||
delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL;
|
||||
delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
/**
|
||||
* AI Context Builder Tests
|
||||
*
|
||||
* Tests for context building functionality including keyword extraction,
|
||||
* file search, service matching, categorization, and pattern discovery.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Mock Node.js modules first
|
||||
vi.mock('node:fs');
|
||||
vi.mock('node:path');
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { buildContext, buildTaskContext } from '../builder';
|
||||
import type { BuildContextConfig } from '../builder';
|
||||
import type {
|
||||
SubtaskContext,
|
||||
TaskContext,
|
||||
FileMatch,
|
||||
} from '../types';
|
||||
|
||||
// Mock all dependencies
|
||||
vi.mock('../categorizer.js');
|
||||
vi.mock('../graphiti-integration.js');
|
||||
vi.mock('../keyword-extractor.js');
|
||||
vi.mock('../pattern-discovery.js');
|
||||
vi.mock('../search.js');
|
||||
vi.mock('../service-matcher.js');
|
||||
|
||||
import { categorizeMatches } from '../categorizer.js';
|
||||
import { fetchGraphHints, isMemoryEnabled } from '../graphiti-integration.js';
|
||||
import { extractKeywords } from '../keyword-extractor.js';
|
||||
import { discoverPatterns } from '../pattern-discovery.js';
|
||||
import { searchService } from '../search.js';
|
||||
import { suggestServices } from '../service-matcher.js';
|
||||
|
||||
// ============================================
|
||||
// Test Fixtures
|
||||
// ============================================
|
||||
|
||||
const createMockConfig = (
|
||||
overrides?: Partial<BuildContextConfig>,
|
||||
): BuildContextConfig => ({
|
||||
taskDescription: 'Add user authentication to the API',
|
||||
projectDir: '/test/project',
|
||||
specDir: '/test/spec',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockFileMatch = (
|
||||
overrides?: {
|
||||
path?: string;
|
||||
service?: string;
|
||||
relevanceScore?: number;
|
||||
matchingLines?: [number, string][];
|
||||
},
|
||||
): FileMatch => ({
|
||||
path: overrides?.path ?? '/test/project/src/auth.ts',
|
||||
service: overrides?.service ?? 'auth-service',
|
||||
reason: 'Contains authentication logic',
|
||||
relevanceScore: overrides?.relevanceScore ?? 0.9,
|
||||
matchingLines: overrides?.matchingLines ?? [[1, 'export function authenticate()'], [2, ' return true;']],
|
||||
});
|
||||
|
||||
const createMockServiceInfo = (overrides?: {
|
||||
path?: string;
|
||||
type?: string;
|
||||
language?: string;
|
||||
entry_point?: string;
|
||||
}) => ({
|
||||
path: overrides?.path ?? 'services/auth',
|
||||
type: overrides?.type ?? 'api',
|
||||
language: overrides?.language ?? 'typescript',
|
||||
entry_point: overrides?.entry_point ?? 'index.ts',
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Setup & Teardown
|
||||
// ============================================
|
||||
|
||||
describe('AI Context Builder', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock fs operations
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
||||
if (typeof filePath === 'string') {
|
||||
if (filePath.endsWith('project_index.json')) {
|
||||
return JSON.stringify({
|
||||
services: {
|
||||
'auth-service': createMockServiceInfo(),
|
||||
'user-service': createMockServiceInfo({ path: 'services/user' }),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (filePath.endsWith('SERVICE_CONTEXT.md')) {
|
||||
return '# Auth Service Context\n\nThis is the auth service...';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// Setup default mock returns
|
||||
vi.mocked(path.isAbsolute).mockReturnValue(false);
|
||||
vi.mocked(path.join).mockImplementation((...args) => {
|
||||
// Actually join the paths for realistic behavior
|
||||
return args.join('/');
|
||||
});
|
||||
vi.mocked(suggestServices).mockReturnValue(['auth-service', 'user-service']);
|
||||
vi.mocked(extractKeywords).mockReturnValue(['auth', 'user', 'login', 'api']);
|
||||
vi.mocked(searchService).mockReturnValue([createMockFileMatch()]);
|
||||
vi.mocked(categorizeMatches).mockReturnValue({
|
||||
toModify: [createMockFileMatch({ path: '/test/project/src/auth.ts' })],
|
||||
toReference: [createMockFileMatch({ path: '/test/project/src/user.ts' })],
|
||||
});
|
||||
vi.mocked(discoverPatterns).mockReturnValue({
|
||||
authentication_pattern: 'export function authenticate()',
|
||||
});
|
||||
vi.mocked(isMemoryEnabled).mockReturnValue(true);
|
||||
vi.mocked(fetchGraphHints).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// buildContext
|
||||
// ============================================
|
||||
|
||||
describe('buildContext', () => {
|
||||
it('should build context with default configuration', async () => {
|
||||
const config = createMockConfig();
|
||||
|
||||
const result = await buildContext(config);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.files).toBeDefined();
|
||||
expect(Array.isArray(result.files)).toBe(true);
|
||||
expect(result.services).toBeDefined();
|
||||
expect(Array.isArray(result.services)).toBe(true);
|
||||
expect(result.patterns).toBeDefined();
|
||||
expect(Array.isArray(result.patterns)).toBe(true);
|
||||
expect(result.keywords).toEqual(['auth', 'user', 'login', 'api']);
|
||||
});
|
||||
|
||||
it('should use provided services when available', async () => {
|
||||
const config = createMockConfig({ services: ['auth-service'] });
|
||||
|
||||
await buildContext(config);
|
||||
|
||||
expect(suggestServices).not.toHaveBeenCalled();
|
||||
expect(searchService).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'auth-service',
|
||||
['auth', 'user', 'login', 'api'],
|
||||
'/test/project'
|
||||
);
|
||||
});
|
||||
|
||||
it('should use provided keywords when available', async () => {
|
||||
const config = createMockConfig({ keywords: ['custom', 'keyword'] });
|
||||
|
||||
await buildContext(config);
|
||||
|
||||
expect(extractKeywords).not.toHaveBeenCalled();
|
||||
expect(searchService).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
['custom', 'keyword'],
|
||||
'/test/project'
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip graph hints when includeGraphHints is false', async () => {
|
||||
const config = createMockConfig({ includeGraphHints: false });
|
||||
|
||||
await buildContext(config);
|
||||
|
||||
expect(fetchGraphHints).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip graph hints when memory is disabled', async () => {
|
||||
vi.mocked(isMemoryEnabled).mockReturnValue(false);
|
||||
const config = createMockConfig({ includeGraphHints: true });
|
||||
|
||||
await buildContext(config);
|
||||
|
||||
expect(fetchGraphHints).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fetch graph hints when memory is enabled', async () => {
|
||||
vi.mocked(fetchGraphHints).mockResolvedValue([
|
||||
{ type: 'entity', data: 'User' },
|
||||
]);
|
||||
const config = createMockConfig({ includeGraphHints: true });
|
||||
|
||||
await buildContext(config);
|
||||
|
||||
expect(fetchGraphHints).toHaveBeenCalledWith(
|
||||
'Add user authentication to the API',
|
||||
'/test/project'
|
||||
);
|
||||
});
|
||||
|
||||
it('should categorize files into modify and reference', async () => {
|
||||
const mockModifyFile = createMockFileMatch({ path: '/test/project/src/auth.ts' });
|
||||
const mockReferenceFile = createMockFileMatch({ path: '/test/project/src/user.ts' });
|
||||
|
||||
vi.mocked(categorizeMatches).mockReturnValue({
|
||||
toModify: [mockModifyFile],
|
||||
toReference: [mockReferenceFile],
|
||||
});
|
||||
|
||||
const config = createMockConfig();
|
||||
const result = await buildContext(config);
|
||||
|
||||
expect(categorizeMatches).toHaveBeenCalled();
|
||||
expect(result.files).toHaveLength(2);
|
||||
expect(result.files[0].role).toBe('modify');
|
||||
expect(result.files[1].role).toBe('reference');
|
||||
});
|
||||
|
||||
it('should discover patterns from reference files', async () => {
|
||||
vi.mocked(discoverPatterns).mockReturnValue({
|
||||
auth_pattern: 'export function authenticate()',
|
||||
});
|
||||
|
||||
const config = createMockConfig();
|
||||
const result = await buildContext(config);
|
||||
|
||||
expect(discoverPatterns).toHaveBeenCalled();
|
||||
expect(result.patterns).toHaveLength(1);
|
||||
expect(result.patterns[0].name).toBe('auth_pattern');
|
||||
expect(result.patterns[0].description).toContain('auth');
|
||||
expect(result.patterns[0].example).toBe('export function authenticate()');
|
||||
});
|
||||
|
||||
it('should build service matches from file matches', async () => {
|
||||
const config = createMockConfig();
|
||||
const result = await buildContext(config);
|
||||
|
||||
expect(result.services).toBeDefined();
|
||||
expect(Array.isArray(result.services)).toBe(true);
|
||||
expect(result.services[0]).toMatchObject({
|
||||
name: expect.any(String),
|
||||
type: expect.any(String),
|
||||
relatedFiles: expect.any(Array),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// buildTaskContext
|
||||
// ============================================
|
||||
|
||||
describe('buildTaskContext', () => {
|
||||
it('should build task context with full internal representation', async () => {
|
||||
const config = createMockConfig();
|
||||
|
||||
const result = await buildTaskContext(config);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.taskDescription).toBe('Add user authentication to the API');
|
||||
expect(result.scopedServices).toBeDefined();
|
||||
expect(Array.isArray(result.filesToModify)).toBe(true);
|
||||
expect(Array.isArray(result.filesToReference)).toBe(true);
|
||||
expect(result.patternsDiscovered).toBeDefined();
|
||||
expect(result.serviceContexts).toBeDefined();
|
||||
expect(result.graphHints).toEqual([]);
|
||||
});
|
||||
|
||||
it('should include graph hints in task context when enabled', async () => {
|
||||
const mockGraphHints = [{ type: 'entity', data: 'User' }];
|
||||
vi.mocked(fetchGraphHints).mockResolvedValue(mockGraphHints);
|
||||
|
||||
const config = createMockConfig({ includeGraphHints: true });
|
||||
const result = await buildTaskContext(config);
|
||||
|
||||
expect(result.graphHints).toEqual(mockGraphHints);
|
||||
});
|
||||
|
||||
it('should build service contexts for each discovered service', async () => {
|
||||
const config = createMockConfig();
|
||||
const result = await buildTaskContext(config);
|
||||
|
||||
expect(result.serviceContexts).toBeDefined();
|
||||
expect(Object.keys(result.serviceContexts).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Error Handling
|
||||
// ============================================
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle missing project index gracefully', async () => {
|
||||
vi.mocked(fs.existsSync).mockImplementation((filePath) => {
|
||||
return !String(filePath).includes('project_index.json');
|
||||
});
|
||||
|
||||
const config = createMockConfig();
|
||||
|
||||
const result = await buildContext(config);
|
||||
|
||||
// Should still work with empty project index
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle corrupted project index gracefully', async () => {
|
||||
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
||||
if (String(filePath).includes('project_index.json')) {
|
||||
return 'invalid json{{{';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const config = createMockConfig();
|
||||
|
||||
const result = await buildContext(config);
|
||||
|
||||
// Should fall back to empty index
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle missing service info gracefully', async () => {
|
||||
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
||||
if (String(filePath).includes('project_index.json')) {
|
||||
return JSON.stringify({
|
||||
services: {
|
||||
'auth-service': createMockServiceInfo(),
|
||||
'missing-service': null, // Missing service info
|
||||
},
|
||||
});
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const config = createMockConfig();
|
||||
|
||||
const result = await buildContext(config);
|
||||
|
||||
// Should skip services with missing info
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle searchService errors gracefully', async () => {
|
||||
vi.mocked(searchService).mockImplementation(() => {
|
||||
throw new Error('Search failed');
|
||||
});
|
||||
|
||||
const config = createMockConfig();
|
||||
|
||||
// Current implementation propagates errors from searchService
|
||||
await expect(buildContext(config)).rejects.toThrow('Search failed');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Service Context
|
||||
// ============================================
|
||||
|
||||
describe('service context', () => {
|
||||
it('should read SERVICE_CONTEXT.md when available', async () => {
|
||||
vi.mocked(fs.existsSync).mockImplementation((filePath) => {
|
||||
const path = String(filePath);
|
||||
// Project index must exist
|
||||
if (path.endsWith('project_index.json')) return true;
|
||||
// SERVICE_CONTEXT.md exists
|
||||
return path.includes('SERVICE_CONTEXT.md');
|
||||
});
|
||||
|
||||
const config = createMockConfig();
|
||||
const result = await buildTaskContext(config);
|
||||
|
||||
const authContext = result.serviceContexts['auth-service'];
|
||||
expect(authContext).toBeDefined();
|
||||
expect(authContext?.source).toBe('SERVICE_CONTEXT.md');
|
||||
expect((authContext as { content: string }).content).toBe('# Auth Service Context\n\nThis is the auth service...');
|
||||
});
|
||||
|
||||
it('should generate context from service info when SERVICE_CONTEXT.md missing', async () => {
|
||||
vi.mocked(fs.existsSync).mockImplementation((filePath) => {
|
||||
const path = String(filePath);
|
||||
// Project index must exist
|
||||
if (path.endsWith('project_index.json')) return true;
|
||||
// SERVICE_CONTEXT.md does not exist
|
||||
return false;
|
||||
});
|
||||
|
||||
const config = createMockConfig();
|
||||
const result = await buildTaskContext(config);
|
||||
|
||||
const authContext = result.serviceContexts['auth-service'];
|
||||
expect(authContext).toBeDefined();
|
||||
expect(authContext?.source).toBe('generated');
|
||||
expect(authContext?.language).toBe('typescript');
|
||||
expect(authContext?.entry_point).toBe('index.ts');
|
||||
});
|
||||
|
||||
it('should truncate SERVICE_CONTEXT.md content to 2000 characters', async () => {
|
||||
const longContent = '#'.repeat(3000); // Longer than 2000 chars
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
||||
if (String(filePath).includes('SERVICE_CONTEXT.md')) {
|
||||
return longContent;
|
||||
}
|
||||
// Preserve project index mock
|
||||
if (String(filePath).endsWith('project_index.json')) {
|
||||
return JSON.stringify({
|
||||
services: {
|
||||
'auth-service': createMockServiceInfo(),
|
||||
'user-service': createMockServiceInfo({ path: 'services/user' }),
|
||||
},
|
||||
});
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const config = createMockConfig();
|
||||
const result = await buildTaskContext(config);
|
||||
|
||||
const authContext = result.serviceContexts['auth-service'];
|
||||
expect(authContext?.source).toBe('SERVICE_CONTEXT.md');
|
||||
expect((authContext as { content: string }).content?.length).toBeLessThanOrEqual(2000);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Pattern Discovery
|
||||
// ============================================
|
||||
|
||||
describe('pattern discovery', () => {
|
||||
it('should convert discovered patterns to CodePattern format', async () => {
|
||||
vi.mocked(discoverPatterns).mockReturnValue({
|
||||
user_auth_pattern: 'export function authenticateUser()',
|
||||
session_pattern: 'export class SessionManager',
|
||||
});
|
||||
|
||||
const config = createMockConfig();
|
||||
const result = await buildContext(config);
|
||||
|
||||
expect(result.patterns).toHaveLength(2);
|
||||
expect(result.patterns[0]).toMatchObject({
|
||||
name: 'user_auth_pattern',
|
||||
description: expect.stringContaining('user_auth'),
|
||||
example: 'export function authenticateUser()',
|
||||
files: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty pattern discovery results', async () => {
|
||||
vi.mocked(discoverPatterns).mockReturnValue({});
|
||||
|
||||
const config = createMockConfig();
|
||||
const result = await buildContext(config);
|
||||
|
||||
expect(result.patterns).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Keyword Extraction
|
||||
// ============================================
|
||||
|
||||
describe('keyword extraction', () => {
|
||||
it('should extract keywords from task description', async () => {
|
||||
vi.mocked(extractKeywords).mockReturnValue(['auth', 'user']);
|
||||
|
||||
const config = createMockConfig();
|
||||
await buildContext(config);
|
||||
|
||||
expect(extractKeywords).toHaveBeenCalledWith('Add user authentication to the API');
|
||||
const result = await buildContext(config);
|
||||
expect(result.keywords).toEqual(['auth', 'user']);
|
||||
});
|
||||
|
||||
it('should use provided keywords when available', async () => {
|
||||
const config = createMockConfig({ keywords: ['custom', 'keyword'] });
|
||||
await buildContext(config);
|
||||
|
||||
expect(extractKeywords).not.toHaveBeenCalled();
|
||||
const result = await buildContext(config);
|
||||
expect(result.keywords).toEqual(['custom', 'keyword']);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Service Suggestion
|
||||
// ============================================
|
||||
|
||||
describe('service suggestion', () => {
|
||||
it('should suggest services when not explicitly provided', async () => {
|
||||
const config = createMockConfig();
|
||||
await buildContext(config);
|
||||
|
||||
expect(suggestServices).toHaveBeenCalledWith(
|
||||
'Add user authentication to the API',
|
||||
expect.objectContaining({
|
||||
services: expect.any(Object),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use provided services when available', async () => {
|
||||
const config = createMockConfig({ services: ['auth-service'] });
|
||||
await buildContext(config);
|
||||
|
||||
expect(suggestServices).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// File Categorization
|
||||
// ============================================
|
||||
|
||||
describe('file categorization', () => {
|
||||
it('should categorize files based on task description', async () => {
|
||||
const config = createMockConfig();
|
||||
await buildContext(config);
|
||||
|
||||
expect(categorizeMatches).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
'Add user authentication to the API'
|
||||
);
|
||||
});
|
||||
|
||||
it('should convert FileMatch to ContextFile with correct role', async () => {
|
||||
const mockModifyFile = createMockFileMatch({ path: '/test/project/src/auth.ts' });
|
||||
const mockReferenceFile = createMockFileMatch({ path: '/test/project/src/user.ts' });
|
||||
|
||||
vi.mocked(categorizeMatches).mockReturnValue({
|
||||
toModify: [mockModifyFile],
|
||||
toReference: [mockReferenceFile],
|
||||
});
|
||||
|
||||
const config = createMockConfig();
|
||||
const result = await buildContext(config);
|
||||
|
||||
expect(result.files[0]).toMatchObject({
|
||||
path: '/test/project/src/auth.ts',
|
||||
role: 'modify',
|
||||
});
|
||||
expect(result.files[1]).toMatchObject({
|
||||
path: '/test/project/src/user.ts',
|
||||
role: 'reference',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include snippets for files with matching lines', async () => {
|
||||
const mockFileWithSnippet = createMockFileMatch({
|
||||
path: '/test/project/src/auth.ts',
|
||||
relevanceScore: 0.9,
|
||||
matchingLines: [[1, 'export function authenticate()'], [2, ' return true;']],
|
||||
});
|
||||
|
||||
vi.mocked(categorizeMatches).mockReturnValue({
|
||||
toModify: [mockFileWithSnippet],
|
||||
toReference: [],
|
||||
});
|
||||
|
||||
const config = createMockConfig();
|
||||
const result = await buildContext(config);
|
||||
|
||||
expect(result.files[0].snippet).toBeDefined();
|
||||
expect(result.files[0].snippet).toContain('export function authenticate()');
|
||||
});
|
||||
|
||||
it('should not include snippets for files without matching lines', async () => {
|
||||
const mockFileWithoutSnippet = createMockFileMatch({
|
||||
path: '/test/project/src/auth.ts',
|
||||
relevanceScore: 0.9,
|
||||
matchingLines: [],
|
||||
});
|
||||
|
||||
vi.mocked(categorizeMatches).mockReturnValue({
|
||||
toModify: [mockFileWithoutSnippet],
|
||||
toReference: [],
|
||||
});
|
||||
|
||||
const config = createMockConfig();
|
||||
const result = await buildContext(config);
|
||||
|
||||
expect(result.files[0].snippet).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Service Matching
|
||||
// ============================================
|
||||
|
||||
describe('service matching', () => {
|
||||
it('should match services with correct type', async () => {
|
||||
const config = createMockConfig();
|
||||
const result = await buildContext(config);
|
||||
|
||||
expect(result.services[0].type).toMatch(/api|database|queue|cache|storage/);
|
||||
});
|
||||
|
||||
it('should include related files for each service', async () => {
|
||||
const config = createMockConfig();
|
||||
const result = await buildContext(config);
|
||||
|
||||
expect(result.services[0].relatedFiles).toBeDefined();
|
||||
expect(Array.isArray(result.services[0].relatedFiles)).toBe(true);
|
||||
});
|
||||
|
||||
it('should default unknown service types to api', async () => {
|
||||
// Service info with unknown type
|
||||
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
||||
if (String(filePath).includes('project_index.json')) {
|
||||
return JSON.stringify({
|
||||
services: {
|
||||
'auth-service': createMockServiceInfo({ type: 'unknown-type' }),
|
||||
},
|
||||
});
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const config = createMockConfig();
|
||||
const result = await buildContext(config);
|
||||
|
||||
// Unknown types should default to 'api'
|
||||
expect(result.services[0].type).toBe('api');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Subtask Context
|
||||
// ============================================
|
||||
|
||||
describe('SubtaskContext structure', () => {
|
||||
it('should return SubtaskContext with all required fields', async () => {
|
||||
const config = createMockConfig();
|
||||
const result = await buildContext(config) as SubtaskContext;
|
||||
|
||||
expect(result.files).toBeDefined();
|
||||
expect(result.services).toBeDefined();
|
||||
expect(result.patterns).toBeDefined();
|
||||
expect(result.keywords).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include correct file metadata in context files', async () => {
|
||||
const mockFile = createMockFileMatch({
|
||||
path: '/test/project/src/auth.ts',
|
||||
relevanceScore: 0.85,
|
||||
});
|
||||
|
||||
vi.mocked(categorizeMatches).mockReturnValue({
|
||||
toModify: [mockFile],
|
||||
toReference: [],
|
||||
});
|
||||
|
||||
const config = createMockConfig();
|
||||
const result = await buildContext(config);
|
||||
|
||||
expect(result.files[0]).toMatchObject({
|
||||
path: '/test/project/src/auth.ts',
|
||||
relevance: 0.85,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Task Context
|
||||
// ============================================
|
||||
|
||||
describe('TaskContext structure', () => {
|
||||
it('should return TaskContext with all required fields', async () => {
|
||||
const config = createMockConfig();
|
||||
const result = await buildTaskContext(config) as TaskContext;
|
||||
|
||||
expect(result.taskDescription).toBe('Add user authentication to the API');
|
||||
expect(result.scopedServices).toBeDefined();
|
||||
expect(result.filesToModify).toBeDefined();
|
||||
expect(result.filesToReference).toBeDefined();
|
||||
expect(result.patternsDiscovered).toBeDefined();
|
||||
expect(result.serviceContexts).toBeDefined();
|
||||
expect(result.graphHints).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user