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:
StillKnotKnown
2026-03-13 13:58:52 +02:00
parent bec3fc88a2
commit a1a79ce121
4 changed files with 1627 additions and 0 deletions
@@ -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();
});
});
});