diff --git a/apps/desktop/src/main/ai/auth/__tests__/resolver.test.ts b/apps/desktop/src/main/ai/auth/__tests__/resolver.test.ts new file mode 100644 index 00000000..afeac218 --- /dev/null +++ b/apps/desktop/src/main/ai/auth/__tests__/resolver.test.ts @@ -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 => ({ + provider, + profileId: 'test-profile', + configDir: '/test/config', + ...overrides, +}); + +const createMockProviderAccount = ( + overrides?: Partial, +): 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'); + }); + }); +}); diff --git a/apps/desktop/src/main/ai/client/__tests__/factory.test.ts b/apps/desktop/src/main/ai/client/__tests__/factory.test.ts new file mode 100644 index 00000000..a8f15b90 --- /dev/null +++ b/apps/desktop/src/main/ai/client/__tests__/factory.test.ts @@ -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 => ({ + agentType: 'coder', + systemPrompt: 'You are a coder agent.', + toolContext: createMockToolContext(), + phase: 'coding', + ...overrides, +}); + +const createMockSimpleClientConfig = ( + overrides?: Partial, +): SimpleClientConfig => ({ + systemPrompt: 'Generate a commit message.', + ...overrides, +}); + +const createMockProviderAccount = ( + overrides?: Partial, +): 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'); + }); + }); +}); diff --git a/apps/desktop/src/main/ai/config/__tests__/phase-config.test.ts b/apps/desktop/src/main/ai/config/__tests__/phase-config.test.ts index 1989e834..a43509af 100644 --- a/apps/desktop/src/main/ai/config/__tests__/phase-config.test.ts +++ b/apps/desktop/src/main/ai/config/__tests__/phase-config.test.ts @@ -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(() => { diff --git a/apps/desktop/src/main/ai/context/__tests__/builder.test.ts b/apps/desktop/src/main/ai/context/__tests__/builder.test.ts new file mode 100644 index 00000000..02f01f18 --- /dev/null +++ b/apps/desktop/src/main/ai/context/__tests__/builder.test.ts @@ -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 => ({ + 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(); + }); + }); +});