diff --git a/CLAUDE.md b/CLAUDE.md index bf2a5337..90fe9d3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -278,6 +278,8 @@ cascade runs llm-call cascade runs debug # View debug analysis cascade runs debug --analyze # Trigger new debug analysis cascade runs debug --analyze --wait # Trigger and wait for completion +cascade runs trigger --project --agent-type [--card-id ID] [--model MODEL] +cascade runs retry [--model MODEL] # Projects cascade projects list diff --git a/src/api/routers/runs.ts b/src/api/routers/runs.ts index a3fdebfd..60805fde 100644 --- a/src/api/routers/runs.ts +++ b/src/api/routers/runs.ts @@ -15,6 +15,7 @@ import { import { projects } from '../../db/schema/index.js'; import { triggerDebugAnalysis } from '../../triggers/shared/debug-runner.js'; import { isAnalysisRunning } from '../../triggers/shared/debug-status.js'; +import { triggerManualRun, triggerRetryRun } from '../../triggers/shared/manual-runner.js'; import { logger } from '../../utils/logging.js'; import { protectedProcedure, router } from '../trpc.js'; @@ -170,6 +171,120 @@ export const runsRouter = router({ }); }); + return { triggered: true }; + }), + + trigger: protectedProcedure + .input( + z.object({ + projectId: z.string(), + agentType: z.string(), + cardId: z.string().optional(), + prNumber: z.number().optional(), + prBranch: z.string().optional(), + repoFullName: z.string().optional(), + headSha: z.string().optional(), + model: z.string().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + // Verify org ownership of project + const db = getDb(); + const [project] = await db + .select({ orgId: projects.orgId }) + .from(projects) + .where(eq(projects.id, input.projectId)); + + if (!project || project.orgId !== ctx.user.orgId) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Project not found', + }); + } + + const projectConfig = await findProjectById(input.projectId); + if (!projectConfig) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Project configuration not found', + }); + } + + const config = await loadConfig(); + + // Fire-and-forget + triggerManualRun( + { + projectId: input.projectId, + agentType: input.agentType, + cardId: input.cardId, + prNumber: input.prNumber, + prBranch: input.prBranch, + repoFullName: input.repoFullName, + headSha: input.headSha, + modelOverride: input.model, + }, + projectConfig, + config, + ).catch((err) => { + logger.error('Manual trigger failed', { + projectId: input.projectId, + agentType: input.agentType, + error: String(err), + }); + }); + + return { triggered: true }; + }), + + retry: protectedProcedure + .input( + z.object({ + runId: z.string().uuid(), + model: z.string().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const run = await getRunById(input.runId); + if (!run) throw new TRPCError({ code: 'NOT_FOUND' }); + + // Verify org access + if (run.projectId) { + const db = getDb(); + const [project] = await db + .select({ orgId: projects.orgId }) + .from(projects) + .where(eq(projects.id, run.projectId)); + if (!project || project.orgId !== ctx.user.orgId) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } + } + + if (!run.projectId) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Run has no associated project', + }); + } + + const projectConfig = await findProjectById(run.projectId); + if (!projectConfig) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Project configuration not found', + }); + } + + const config = await loadConfig(); + + // Fire-and-forget + triggerRetryRun(input.runId, projectConfig, config, input.model).catch((err) => { + logger.error('Retry run failed', { + runId: input.runId, + error: String(err), + }); + }); + return { triggered: true }; }), }); diff --git a/src/cli/dashboard/runs/retry.ts b/src/cli/dashboard/runs/retry.ts new file mode 100644 index 00000000..10ac5ac2 --- /dev/null +++ b/src/cli/dashboard/runs/retry.ts @@ -0,0 +1,34 @@ +import { Args, Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class RunsRetry extends DashboardCommand { + static override description = 'Retry a previous agent run.'; + + static override args = { + id: Args.string({ description: 'Run ID (UUID)', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + model: Flags.string({ description: 'Override model (optional)' }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(RunsRetry); + + try { + const result = await this.client.runs.retry.mutate({ + runId: args.id, + model: flags.model, + }); + + if (flags.json) { + this.outputJson(result); + } else { + this.log('Run retry triggered successfully.'); + } + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/runs/trigger.ts b/src/cli/dashboard/runs/trigger.ts new file mode 100644 index 00000000..cdd71d98 --- /dev/null +++ b/src/cli/dashboard/runs/trigger.ts @@ -0,0 +1,43 @@ +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class RunsTrigger extends DashboardCommand { + static override description = 'Manually trigger an agent run.'; + + static override flags = { + ...DashboardCommand.baseFlags, + project: Flags.string({ description: 'Project ID', required: true }), + 'agent-type': Flags.string({ description: 'Agent type to run', required: true }), + 'card-id': Flags.string({ description: 'Card ID (optional)' }), + 'pr-number': Flags.integer({ description: 'PR number (optional)' }), + 'pr-branch': Flags.string({ description: 'PR branch (optional)' }), + 'repo-full-name': Flags.string({ description: 'Repository full name (optional)' }), + 'head-sha': Flags.string({ description: 'Git SHA (optional)' }), + model: Flags.string({ description: 'Override model (optional)' }), + }; + + async run(): Promise { + const { flags } = await this.parse(RunsTrigger); + + try { + const result = await this.client.runs.trigger.mutate({ + projectId: flags.project, + agentType: flags['agent-type'], + cardId: flags['card-id'], + prNumber: flags['pr-number'], + prBranch: flags['pr-branch'], + repoFullName: flags['repo-full-name'], + headSha: flags['head-sha'], + model: flags.model, + }); + + if (flags.json) { + this.outputJson(result); + } else { + this.log('Agent run triggered successfully.'); + } + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/router/index.ts b/src/router/index.ts index 4e593e2c..712110df 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -124,7 +124,8 @@ function parseTrelloWebhook(payload: unknown): TrelloWebhookResult { const shouldProcess = isCardInTriggerList(actionType, data, project) || isReadyToProcessLabelAdded(actionType, data, project) || - isAgentLogAttachmentUploaded(actionType, data, project); + isAgentLogAttachmentUploaded(actionType, data, project) || + actionType === 'commentCard'; return { shouldProcess, project, actionType, cardId }; } diff --git a/src/triggers/shared/manual-runner.ts b/src/triggers/shared/manual-runner.ts new file mode 100644 index 00000000..df33708a --- /dev/null +++ b/src/triggers/shared/manual-runner.ts @@ -0,0 +1,161 @@ +import { runAgent } from '../../agents/registry.js'; +import { getRunById } from '../../db/repositories/runsRepository.js'; +import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; + +/** + * In-memory tracking to prevent duplicate concurrent manual triggers. + */ +const runningTriggers = new Map(); + +function generateTriggerKey( + projectId: string, + agentType: string, + cardId?: string, + prNumber?: number, +): string { + return `${projectId}:${agentType}:${cardId ?? 'no-card'}:${prNumber ?? 'no-pr'}`; +} + +function markTriggerRunning(key: string): void { + runningTriggers.set(key, true); +} + +function markTriggerComplete(key: string): void { + runningTriggers.delete(key); +} + +export function isTriggerRunning(key: string): boolean { + return runningTriggers.has(key); +} + +/** + * Clear all trigger tracking (test utility). + */ +export function clearTriggerTracking(): void { + runningTriggers.clear(); +} + +/** + * Input for manual agent triggers. + */ +export interface ManualTriggerInput { + projectId: string; + agentType: string; + cardId?: string; + prNumber?: number; + prBranch?: string; + repoFullName?: string; + headSha?: string; + modelOverride?: string; +} + +/** + * Trigger a manual agent run. + * + * This runs fire-and-forget (does not await runAgent completion). + * Status tracking is handled via in-memory map to prevent duplicates. + */ +export async function triggerManualRun( + input: ManualTriggerInput, + project: ProjectConfig, + config: CascadeConfig, +): Promise { + const triggerKey = generateTriggerKey( + input.projectId, + input.agentType, + input.cardId, + input.prNumber, + ); + + if (isTriggerRunning(triggerKey)) { + throw new Error( + `Manual trigger already running for project=${input.projectId}, agent=${input.agentType}, card=${input.cardId ?? 'N/A'}, pr=${input.prNumber ?? 'N/A'}`, + ); + } + + logger.info('Triggering manual agent run', { + projectId: input.projectId, + agentType: input.agentType, + cardId: input.cardId, + prNumber: input.prNumber, + modelOverride: input.modelOverride, + }); + + markTriggerRunning(triggerKey); + + const agentInput: AgentInput & { project: ProjectConfig; config: CascadeConfig } = { + cardId: input.cardId, + prNumber: input.prNumber, + prBranch: input.prBranch, + repoFullName: input.repoFullName, + headSha: input.headSha, + modelOverride: input.modelOverride, + triggerType: 'manual', + project, + config, + }; + + // Fire-and-forget execution + runAgent(input.agentType, agentInput) + .then((result: AgentResult) => { + logger.info('Manual agent run completed', { + projectId: input.projectId, + agentType: input.agentType, + success: result.success, + runId: result.runId, + }); + }) + .catch((err) => { + logger.error('Manual agent run failed', { + projectId: input.projectId, + agentType: input.agentType, + error: String(err), + }); + }) + .finally(() => { + markTriggerComplete(triggerKey); + }); +} + +/** + * Retry a previous agent run. + * + * Reads the original run from DB, extracts parameters, and triggers a new manual run. + */ +export async function triggerRetryRun( + runId: string, + project: ProjectConfig, + config: CascadeConfig, + modelOverride?: string, +): Promise { + const run = await getRunById(runId); + if (!run) { + throw new Error(`Run not found: ${runId}`); + } + + if (!run.projectId) { + throw new Error(`Run ${runId} has no associated project`); + } + + logger.info('Retrying agent run', { + originalRunId: runId, + agentType: run.agentType, + projectId: run.projectId, + modelOverride, + }); + + // Extract params from original run + const triggerInput: ManualTriggerInput = { + projectId: run.projectId, + agentType: run.agentType, + cardId: run.cardId ?? undefined, + prNumber: run.prNumber ?? undefined, + modelOverride: modelOverride ?? run.model ?? undefined, + }; + + // For PR-based agents, we don't store branch/SHA in DB, so we can't restore them. + // The retry will fetch fresh data from GitHub if needed. + + await triggerManualRun(triggerInput, project, config); +} diff --git a/src/types/index.ts b/src/types/index.ts index bd5b5dfd..752956e7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -13,7 +13,7 @@ export interface AgentInput { prBranch?: string; repoFullName?: string; headSha?: string; - triggerType?: 'check-failure' | 'feature-implementation' | 'ci-success'; + triggerType?: 'check-failure' | 'feature-implementation' | 'ci-success' | 'manual'; // Debug agent fields logDir?: string; diff --git a/tests/unit/config/compactionConfig.test.ts b/tests/unit/config/compactionConfig.test.ts new file mode 100644 index 00000000..32ceee2d --- /dev/null +++ b/tests/unit/config/compactionConfig.test.ts @@ -0,0 +1,245 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { getCompactionConfig } from '../../../src/config/compactionConfig.js'; + +// Mock the dependencies +vi.mock('../../../src/gadgets/readTracking.js', () => ({ + clearReadTracking: vi.fn(), +})); + +vi.mock('../../../src/utils/logging.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +import { clearReadTracking } from '../../../src/gadgets/readTracking.js'; +import { logger } from '../../../src/utils/logging.js'; + +describe('config/compactionConfig', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('getCompactionConfig', () => { + it('returns implementation agent config with lower threshold', () => { + const config = getCompactionConfig('implementation'); + + expect(config.enabled).toBe(true); + expect(config.strategy).toBe('hybrid'); + expect(config.triggerThresholdPercent).toBe(70); + expect(config.targetPercent).toBe(40); + expect(config.preserveRecentTurns).toBe(8); + expect(config.summarizationPrompt).toContain('Summarize this conversation history'); + expect(config.onCompaction).toBeTypeOf('function'); + }); + + it('returns default config for other agents with higher threshold', () => { + const agentTypes = ['briefing', 'planning', 'debug', 'respond-to-review', 'review']; + + for (const agentType of agentTypes) { + const config = getCompactionConfig(agentType); + + expect(config.enabled).toBe(true); + expect(config.strategy).toBe('hybrid'); + expect(config.triggerThresholdPercent).toBe(80); + expect(config.targetPercent).toBe(50); + expect(config.preserveRecentTurns).toBe(5); + } + }); + + it('implementation agent has more aggressive reduction targets', () => { + const implConfig = getCompactionConfig('implementation'); + const otherConfig = getCompactionConfig('briefing'); + + expect(implConfig.triggerThresholdPercent).toBeLessThan(otherConfig.triggerThresholdPercent); + expect(implConfig.targetPercent).toBeLessThan(otherConfig.targetPercent); + expect(implConfig.preserveRecentTurns).toBeGreaterThan(otherConfig.preserveRecentTurns); + }); + + it('implementation agent preserves more recent turns', () => { + const implConfig = getCompactionConfig('implementation'); + const otherConfig = getCompactionConfig('planning'); + + expect(implConfig.preserveRecentTurns).toBe(8); + expect(otherConfig.preserveRecentTurns).toBe(5); + }); + + it('includes failed approaches section in summarization prompt', () => { + const config = getCompactionConfig('implementation'); + + expect(config.summarizationPrompt).toContain('Failed Approaches'); + expect(config.summarizationPrompt).toContain('tried and FAILED'); + }); + + it('implementation prompt preserves task goals and files', () => { + const config = getCompactionConfig('implementation'); + + expect(config.summarizationPrompt).toContain('task goals and acceptance criteria'); + expect(config.summarizationPrompt).toContain('files that were created or modified'); + expect(config.summarizationPrompt).toContain('todo list status'); + }); + + it('default prompt preserves key decisions and progress', () => { + const config = getCompactionConfig('briefing'); + + expect(config.summarizationPrompt).toContain('Key decisions made'); + expect(config.summarizationPrompt).toContain('Current progress'); + expect(config.summarizationPrompt).toContain('Failed Approaches'); + }); + }); + + describe('onCompaction callback', () => { + it('logs compaction event with token savings', () => { + const config = getCompactionConfig('implementation'); + + const event = { + strategy: 'hybrid' as const, + iteration: 10, + tokensBefore: 50000, + tokensAfter: 20000, + messagesBefore: 40, + messagesAfter: 15, + }; + + config.onCompaction?.(event); + + expect(logger.info).toHaveBeenCalledWith('Context compaction performed', { + strategy: 'hybrid', + iteration: 10, + tokensBefore: 50000, + tokensAfter: 20000, + tokensSaved: 30000, + reductionPercent: 60, + messagesRemoved: 25, + }); + }); + + it('calculates reduction percentage correctly', () => { + const config = getCompactionConfig('planning'); + + const event = { + strategy: 'hybrid' as const, + iteration: 5, + tokensBefore: 100000, + tokensAfter: 40000, + messagesBefore: 30, + messagesAfter: 10, + }; + + config.onCompaction?.(event); + + const logCall = vi.mocked(logger.info).mock.calls[0]; + expect(logCall[1]).toMatchObject({ + tokensSaved: 60000, + reductionPercent: 60, // (60000 / 100000) * 100 + }); + }); + + it('clears read tracking after compaction', () => { + const config = getCompactionConfig('implementation'); + + const event = { + strategy: 'hybrid' as const, + iteration: 8, + tokensBefore: 80000, + tokensAfter: 30000, + messagesBefore: 50, + messagesAfter: 20, + }; + + config.onCompaction?.(event); + + expect(clearReadTracking).toHaveBeenCalledTimes(1); + }); + + it('logs messages removed count', () => { + const config = getCompactionConfig('debug'); + + const event = { + strategy: 'hybrid' as const, + iteration: 3, + tokensBefore: 60000, + tokensAfter: 25000, + messagesBefore: 35, + messagesAfter: 12, + }; + + config.onCompaction?.(event); + + const logCall = vi.mocked(logger.info).mock.calls[0]; + expect(logCall[1]).toMatchObject({ + messagesRemoved: 23, // 35 - 12 + }); + }); + + it('rounds reduction percentage to integer', () => { + const config = getCompactionConfig('implementation'); + + const event = { + strategy: 'hybrid' as const, + iteration: 7, + tokensBefore: 33333, + tokensAfter: 11111, + messagesBefore: 25, + messagesAfter: 10, + }; + + config.onCompaction?.(event); + + const logCall = vi.mocked(logger.info).mock.calls[0]; + // (22222 / 33333) * 100 = 66.666... -> should be rounded + expect(logCall[1].reductionPercent).toBe(67); + }); + }); + + describe('config consistency', () => { + it('all agent types return valid config structure', () => { + const agentTypes = [ + 'implementation', + 'briefing', + 'planning', + 'debug', + 'review', + 'respond-to-review', + 'respond-to-ci', + ]; + + for (const agentType of agentTypes) { + const config = getCompactionConfig(agentType); + + expect(config.enabled).toBe(true); + expect(config.strategy).toBe('hybrid'); + expect(config.triggerThresholdPercent).toBeGreaterThan(0); + expect(config.targetPercent).toBeGreaterThan(0); + expect(config.preserveRecentTurns).toBeGreaterThan(0); + expect(config.summarizationPrompt).toBeTruthy(); + expect(config.onCompaction).toBeTypeOf('function'); + } + }); + + it('target percent is less than trigger threshold', () => { + const agentTypes = ['implementation', 'briefing', 'planning']; + + for (const agentType of agentTypes) { + const config = getCompactionConfig(agentType); + + expect(config.targetPercent).toBeLessThan(config.triggerThresholdPercent); + } + }); + + it('thresholds are reasonable percentages', () => { + const implConfig = getCompactionConfig('implementation'); + const otherConfig = getCompactionConfig('briefing'); + + expect(implConfig.triggerThresholdPercent).toBeGreaterThanOrEqual(50); + expect(implConfig.triggerThresholdPercent).toBeLessThanOrEqual(100); + + expect(otherConfig.triggerThresholdPercent).toBeGreaterThanOrEqual(50); + expect(otherConfig.triggerThresholdPercent).toBeLessThanOrEqual(100); + }); + }); +}); diff --git a/tests/unit/config/customModels.test.ts b/tests/unit/config/customModels.test.ts new file mode 100644 index 00000000..0c4fddb9 --- /dev/null +++ b/tests/unit/config/customModels.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from 'vitest'; + +import { CUSTOM_MODELS } from '../../../src/config/customModels.js'; + +describe('config/customModels', () => { + describe('CUSTOM_MODELS array', () => { + it('is defined and is an array', () => { + expect(Array.isArray(CUSTOM_MODELS)).toBe(true); + expect(CUSTOM_MODELS.length).toBeGreaterThan(0); + }); + + it('contains expected model count', () => { + // As of current implementation: Gemini 3 Flash/Pro, Grok, DeepSeek variants, MiniMax, Gemini 2.5 Flash Lite + expect(CUSTOM_MODELS.length).toBeGreaterThanOrEqual(7); + }); + + it('all models use openrouter provider', () => { + for (const model of CUSTOM_MODELS) { + expect(model.provider).toBe('openrouter'); + } + }); + }); + + describe('model specifications', () => { + it('all models have required fields', () => { + const requiredFields = [ + 'provider', + 'modelId', + 'displayName', + 'contextWindow', + 'maxOutputTokens', + 'pricing', + 'features', + ]; + + for (const model of CUSTOM_MODELS) { + for (const field of requiredFields) { + expect(model).toHaveProperty(field); + } + } + }); + + it('all models have valid context windows', () => { + for (const model of CUSTOM_MODELS) { + expect(model.contextWindow).toBeGreaterThan(0); + expect(model.contextWindow).toBeGreaterThan(model.maxOutputTokens); + } + }); + + it('all models have valid max output tokens', () => { + for (const model of CUSTOM_MODELS) { + expect(model.maxOutputTokens).toBeGreaterThan(0); + expect(model.maxOutputTokens).toBeLessThanOrEqual(model.contextWindow); + } + }); + + it('all models have pricing information', () => { + for (const model of CUSTOM_MODELS) { + expect(model.pricing).toBeDefined(); + expect(model.pricing.input).toBeGreaterThanOrEqual(0); + expect(model.pricing.output).toBeGreaterThanOrEqual(0); + expect(model.pricing.output).toBeGreaterThanOrEqual(model.pricing.input); + } + }); + + it('all models have knowledge cutoff date', () => { + for (const model of CUSTOM_MODELS) { + if (model.knowledgeCutoff) { + expect(model.knowledgeCutoff).toMatch(/^\d{4}-\d{2}$/); + } + } + }); + + it('all models have feature flags', () => { + for (const model of CUSTOM_MODELS) { + expect(model.features).toBeDefined(); + expect(typeof model.features.streaming).toBe('boolean'); + expect(typeof model.features.functionCalling).toBe('boolean'); + } + }); + }); + + describe('specific models', () => { + it('includes Gemini 3 Flash Preview', () => { + const model = CUSTOM_MODELS.find((m) => m.modelId === 'google/gemini-3-flash-preview'); + + expect(model).toBeDefined(); + expect(model?.displayName).toBe('Gemini 3 Flash Preview'); + expect(model?.contextWindow).toBe(1_048_576); + expect(model?.features.streaming).toBe(true); + expect(model?.features.functionCalling).toBe(true); + expect(model?.features.vision).toBe(true); + }); + + it('includes Gemini 3 Pro Preview', () => { + const model = CUSTOM_MODELS.find((m) => m.modelId === 'google/gemini-3-pro-preview'); + + expect(model).toBeDefined(); + expect(model?.displayName).toBe('Gemini 3 Pro Preview'); + expect(model?.contextWindow).toBe(1_048_576); + expect(model?.pricing.input).toBeGreaterThan(0); + expect(model?.features.reasoning).toBe(true); + }); + + it('includes Grok Code Fast 1', () => { + const model = CUSTOM_MODELS.find((m) => m.modelId === 'x-ai/grok-code-fast-1'); + + expect(model).toBeDefined(); + expect(model?.displayName).toBe('Grok Code Fast 1'); + expect(model?.contextWindow).toBe(256_000); + expect(model?.maxOutputTokens).toBe(32_768); + expect(model?.features.vision).toBe(false); + }); + + it('includes DeepSeek V3 0324', () => { + const model = CUSTOM_MODELS.find((m) => m.modelId === 'deepseek/deepseek-chat-v3-0324'); + + expect(model).toBeDefined(); + expect(model?.displayName).toBe('DeepSeek V3 0324'); + expect(model?.contextWindow).toBe(163_840); + expect(model?.features.functionCalling).toBe(true); + }); + + it('includes DeepSeek V3.2', () => { + const model = CUSTOM_MODELS.find((m) => m.modelId === 'deepseek/deepseek-v3.2'); + + expect(model).toBeDefined(); + expect(model?.displayName).toBe('DeepSeek V3.2'); + expect(model?.features.reasoning).toBe(true); + }); + + it('includes DeepSeek V3.2 Speciale', () => { + const model = CUSTOM_MODELS.find((m) => m.modelId === 'deepseek/deepseek-v3.2-speciale'); + + expect(model).toBeDefined(); + expect(model?.displayName).toBe('DeepSeek V3.2 Speciale'); + }); + + it('includes MiniMax M2.1', () => { + const model = CUSTOM_MODELS.find((m) => m.modelId === 'minimax/minimax-m2.1'); + + expect(model).toBeDefined(); + expect(model?.displayName).toBe('MiniMax M2.1'); + expect(model?.contextWindow).toBe(196_608); + expect(model?.maxOutputTokens).toBe(65_536); + }); + + it('includes Gemini 2.5 Flash Lite', () => { + const model = CUSTOM_MODELS.find((m) => m.modelId === 'google/gemini-2.5-flash-lite'); + + expect(model).toBeDefined(); + expect(model?.displayName).toBe('Gemini 2.5 Flash Lite'); + expect(model?.contextWindow).toBe(1_048_576); + expect(model?.maxOutputTokens).toBe(8_192); + expect(model?.features.functionCalling).toBe(false); + expect(model?.features.vision).toBe(false); + }); + }); + + describe('model characteristics', () => { + it('Gemini models have large context windows', () => { + const geminiModels = CUSTOM_MODELS.filter((m) => m.modelId.includes('gemini')); + + for (const model of geminiModels) { + expect(model.contextWindow).toBeGreaterThanOrEqual(1_000_000); + } + }); + + it('vision models are correctly flagged', () => { + const visionModels = CUSTOM_MODELS.filter((m) => m.features.vision); + + // Gemini 3 Flash and Pro have vision + expect(visionModels.length).toBeGreaterThan(0); + + for (const model of visionModels) { + expect(model.modelId).toContain('gemini-3'); + } + }); + + it('reasoning models are correctly flagged', () => { + const reasoningModels = CUSTOM_MODELS.filter((m) => m.features.reasoning); + + expect(reasoningModels.length).toBeGreaterThan(0); + + // Gemini 3, Grok, DeepSeek V3.2, MiniMax should have reasoning + for (const model of reasoningModels) { + const isReasoningModel = + model.modelId.includes('gemini-3') || + model.modelId.includes('grok') || + model.modelId.includes('deepseek-v3') || + model.modelId.includes('minimax'); + expect(isReasoningModel).toBe(true); + } + }); + + it('all models support streaming', () => { + for (const model of CUSTOM_MODELS) { + expect(model.features.streaming).toBe(true); + } + }); + + it('most models support function calling', () => { + const withFunctionCalling = CUSTOM_MODELS.filter((m) => m.features.functionCalling); + + // All except Gemini 2.5 Flash Lite + expect(withFunctionCalling.length).toBe(CUSTOM_MODELS.length - 1); + }); + }); + + describe('pricing structure', () => { + it('output pricing is higher than input pricing', () => { + for (const model of CUSTOM_MODELS) { + expect(model.pricing.output).toBeGreaterThanOrEqual(model.pricing.input); + } + }); + + it('pricing is in reasonable range (per million tokens)', () => { + for (const model of CUSTOM_MODELS) { + // Input should be between $0 and $10 per million tokens + expect(model.pricing.input).toBeGreaterThanOrEqual(0); + expect(model.pricing.input).toBeLessThanOrEqual(10); + + // Output should be between $0 and $20 per million tokens + expect(model.pricing.output).toBeGreaterThanOrEqual(0); + expect(model.pricing.output).toBeLessThanOrEqual(20); + } + }); + + it('lite/flash models are cheaper than pro models', () => { + const flashLite = CUSTOM_MODELS.find((m) => m.modelId === 'google/gemini-2.5-flash-lite'); + const pro = CUSTOM_MODELS.find((m) => m.modelId === 'google/gemini-3-pro-preview'); + + expect(flashLite?.pricing.input).toBeLessThan(pro?.pricing.input || Number.POSITIVE_INFINITY); + }); + }); + + describe('model IDs', () => { + it('all model IDs are unique', () => { + const modelIds = CUSTOM_MODELS.map((m) => m.modelId); + const uniqueIds = new Set(modelIds); + + expect(uniqueIds.size).toBe(modelIds.length); + }); + + it('all display names are unique', () => { + const displayNames = CUSTOM_MODELS.map((m) => m.displayName); + const uniqueNames = new Set(displayNames); + + expect(uniqueNames.size).toBe(displayNames.length); + }); + + it('model IDs follow expected format', () => { + for (const model of CUSTOM_MODELS) { + // Should be in format: provider/model-name + expect(model.modelId).toMatch(/^[a-z0-9-]+\/[a-z0-9.-]+$/); + } + }); + }); +}); diff --git a/tests/unit/config/rateLimits.test.ts b/tests/unit/config/rateLimits.test.ts new file mode 100644 index 00000000..ead87e5b --- /dev/null +++ b/tests/unit/config/rateLimits.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest'; + +import { MODEL_RATE_LIMITS, getRateLimitForModel } from '../../../src/config/rateLimits.js'; + +describe('config/rateLimits', () => { + describe('getRateLimitForModel', () => { + it('returns exact match for known models', () => { + const result = getRateLimitForModel('gemini:gemini-2.5-flash'); + + expect(result).toEqual({ + requestsPerMinute: 15, + tokensPerMinute: 1_000_000, + tokensPerDay: 1_500_000, + safetyMargin: 0.8, + }); + }); + + it('returns exact match for Claude Sonnet 4.5', () => { + const result = getRateLimitForModel('anthropic:claude-sonnet-4-5'); + + expect(result).toEqual({ + requestsPerMinute: 50, + tokensPerMinute: 40_000, + safetyMargin: 0.9, + }); + }); + + it('returns exact match for Claude Opus 4.5', () => { + const result = getRateLimitForModel('anthropic:claude-opus-4-5'); + + expect(result).toEqual({ + requestsPerMinute: 50, + tokensPerMinute: 10_000, + safetyMargin: 0.85, + }); + }); + + it('returns prefix match for models with version suffix', () => { + // anthropic:claude-sonnet-4-5-20250929 should match anthropic:claude-sonnet-4-5 + const result = getRateLimitForModel('anthropic:claude-sonnet-4-5-20250929'); + + expect(result).toEqual({ + requestsPerMinute: 50, + tokensPerMinute: 40_000, + safetyMargin: 0.9, + }); + }); + + it('returns disabled config for unknown models', () => { + const result = getRateLimitForModel('unknown-provider:unknown-model'); + + expect(result).toEqual({ enabled: false }); + }); + + it('returns disabled config for empty string', () => { + const result = getRateLimitForModel(''); + + expect(result).toEqual({ enabled: false }); + }); + + it('returns exact match priority over prefix match', () => { + // Verify exact match takes precedence + const exactKey = 'openrouter:google/gemini-3-flash-preview'; + const exactMatch = getRateLimitForModel(exactKey); + + // Should match the exact config, not a generic openrouter prefix + expect(exactMatch).toEqual(MODEL_RATE_LIMITS[exactKey]); + }); + + it('returns prefix match for OpenRouter models with version suffix', () => { + // Test that prefix matching works for OpenRouter models + const result = getRateLimitForModel('openrouter:google/gemini-3-flash-preview-2025'); + + // Should match openrouter:google/gemini-3-flash-preview prefix + expect(result).toEqual({ + requestsPerMinute: 100, + tokensPerMinute: 500_000, + safetyMargin: 0.9, + }); + }); + + it('includes all expected config fields', () => { + const result = getRateLimitForModel('gemini:gemini-2.5-flash'); + + expect(result).toHaveProperty('requestsPerMinute'); + expect(result).toHaveProperty('tokensPerMinute'); + expect(result).toHaveProperty('safetyMargin'); + expect(typeof result.requestsPerMinute).toBe('number'); + expect(typeof result.tokensPerMinute).toBe('number'); + expect(typeof result.safetyMargin).toBe('number'); + }); + + it('safety margin is between 0 and 1', () => { + for (const [modelId, config] of Object.entries(MODEL_RATE_LIMITS)) { + expect(config.safetyMargin).toBeGreaterThan(0); + expect(config.safetyMargin).toBeLessThanOrEqual(1); + } + }); + }); + + describe('MODEL_RATE_LIMITS constants', () => { + it('includes Gemini free tier config', () => { + expect(MODEL_RATE_LIMITS['gemini:gemini-2.5-flash']).toBeDefined(); + expect(MODEL_RATE_LIMITS['gemini:gemini-2.5-flash'].tokensPerDay).toBe(1_500_000); + }); + + it('includes Claude Sonnet and Opus configs', () => { + expect(MODEL_RATE_LIMITS['anthropic:claude-sonnet-4-5']).toBeDefined(); + expect(MODEL_RATE_LIMITS['anthropic:claude-opus-4-5']).toBeDefined(); + }); + + it('includes OpenRouter models', () => { + expect(MODEL_RATE_LIMITS['openrouter:google/gemini-3-flash-preview']).toBeDefined(); + expect(MODEL_RATE_LIMITS['openrouter:deepseek/deepseek-chat-v3-0324']).toBeDefined(); + expect(MODEL_RATE_LIMITS['openrouter:x-ai/grok-code-fast-1']).toBeDefined(); + }); + + it('all configs have required fields', () => { + for (const [modelId, config] of Object.entries(MODEL_RATE_LIMITS)) { + expect(config.requestsPerMinute, `${modelId} missing RPM`).toBeDefined(); + expect(config.tokensPerMinute, `${modelId} missing TPM`).toBeDefined(); + expect(config.safetyMargin, `${modelId} missing safety margin`).toBeDefined(); + } + }); + }); +}); diff --git a/tests/unit/config/retryConfig.test.ts b/tests/unit/config/retryConfig.test.ts new file mode 100644 index 00000000..33fa48a4 --- /dev/null +++ b/tests/unit/config/retryConfig.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { getRetryConfig } from '../../../src/config/retryConfig.js'; + +// Create a mock logger +const createMockLogger = () => ({ + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), +}); + +describe('config/retryConfig', () => { + describe('getRetryConfig', () => { + it('returns retry configuration with correct structure', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + expect(config).toEqual({ + enabled: true, + retries: 5, + minTimeout: 1000, + maxTimeout: 60000, + factor: 2, + randomize: true, + respectRetryAfter: true, + maxRetryAfterMs: 120000, + shouldRetry: expect.any(Function), + onRetry: expect.any(Function), + onRetriesExhausted: expect.any(Function), + }); + }); + + it('has aggressive retry settings for long-running agents', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + expect(config.retries).toBe(5); + expect(config.minTimeout).toBe(1000); + expect(config.maxTimeout).toBe(60000); + expect(config.factor).toBe(2); // Exponential backoff + }); + + it('enables jitter to prevent thundering herd', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + expect(config.randomize).toBe(true); + }); + + it('respects Retry-After headers with cap', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + expect(config.respectRetryAfter).toBe(true); + expect(config.maxRetryAfterMs).toBe(120000); // 2 minutes cap + }); + }); + + describe('shouldRetry', () => { + it('returns true for rate limit errors (429)', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const rateLimitError = new Error('Rate limit exceeded'); + Object.assign(rateLimitError, { status: 429 }); + + expect(config.shouldRetry?.(rateLimitError)).toBe(true); + }); + + it('returns true for 5xx server errors', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const serverError = new Error('Internal server error'); + Object.assign(serverError, { status: 500 }); + + expect(config.shouldRetry?.(serverError)).toBe(true); + }); + + it('returns true for stream termination errors', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const terminatedError = new Error('stream terminated'); + expect(config.shouldRetry?.(terminatedError)).toBe(true); + + const abortedError = new Error('request aborted'); + expect(config.shouldRetry?.(abortedError)).toBe(true); + + const hangUpError = new Error('socket hang up'); + expect(config.shouldRetry?.(hangUpError)).toBe(true); + + const fetchFailedError = new Error('fetch failed due to network error'); + expect(config.shouldRetry?.(fetchFailedError)).toBe(true); + }); + + it('returns true for stream errors case-insensitive', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const upperCaseError = new Error('STREAM TERMINATED'); + expect(config.shouldRetry?.(upperCaseError)).toBe(true); + + const mixedCaseError = new Error('Fetch Failed'); + expect(config.shouldRetry?.(mixedCaseError)).toBe(true); + }); + + it('returns false for non-retryable errors (4xx except 429)', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const badRequestError = new Error('Bad request'); + Object.assign(badRequestError, { status: 400 }); + + expect(config.shouldRetry?.(badRequestError)).toBe(false); + }); + + it('returns false for authentication errors (401)', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const authError = new Error('Unauthorized'); + Object.assign(authError, { status: 401 }); + + expect(config.shouldRetry?.(authError)).toBe(false); + }); + + it('returns false for non-stream generic errors', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const genericError = new Error('Something went wrong'); + expect(config.shouldRetry?.(genericError)).toBe(false); + }); + }); + + describe('onRetry callback', () => { + it('logs retry attempts with attempt number', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const error = new Error('Rate limit exceeded'); + config.onRetry?.(error, 2); + + expect(logger.warn).toHaveBeenCalledWith('LLM call retry', { + attempt: 2, + maxAttempts: 5, + error: 'Rate limit exceeded', + isStreamError: false, + nextRetryDelayMs: expect.any(Number), + }); + }); + + it('calculates exponential backoff delay correctly', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const error = new Error('Timeout'); + config.onRetry?.(error, 1); + + // Attempt 1: 1000 * 2^0 = 1000ms + expect(logger.warn).toHaveBeenCalledWith( + 'LLM call retry', + expect.objectContaining({ nextRetryDelayMs: 1000 }), + ); + + logger.warn.mockClear(); + config.onRetry?.(error, 2); + + // Attempt 2: 1000 * 2^1 = 2000ms + expect(logger.warn).toHaveBeenCalledWith( + 'LLM call retry', + expect.objectContaining({ nextRetryDelayMs: 2000 }), + ); + + logger.warn.mockClear(); + config.onRetry?.(error, 3); + + // Attempt 3: 1000 * 2^2 = 4000ms + expect(logger.warn).toHaveBeenCalledWith( + 'LLM call retry', + expect.objectContaining({ nextRetryDelayMs: 4000 }), + ); + }); + + it('caps delay at maxTimeout (60s)', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const error = new Error('Timeout'); + config.onRetry?.(error, 10); // Very high attempt + + // Should be capped at 60000ms + expect(logger.warn).toHaveBeenCalledWith( + 'LLM call retry', + expect.objectContaining({ nextRetryDelayMs: 60000 }), + ); + }); + + it('flags stream termination errors correctly', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const streamError = new Error('stream terminated'); + config.onRetry?.(streamError, 1); + + expect(logger.warn).toHaveBeenCalledWith( + 'LLM call retry', + expect.objectContaining({ isStreamError: true }), + ); + + logger.warn.mockClear(); + + const normalError = new Error('Rate limit'); + config.onRetry?.(normalError, 1); + + expect(logger.warn).toHaveBeenCalledWith( + 'LLM call retry', + expect.objectContaining({ isStreamError: false }), + ); + }); + }); + + describe('onRetriesExhausted callback', () => { + it('logs failure after all retries exhausted', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const error = new Error('Persistent failure'); + config.onRetriesExhausted?.(error, 5); + + expect(logger.error).toHaveBeenCalledWith('LLM call failed after all retries exhausted', { + attempts: 5, + error: 'Persistent failure', + totalWaitTimeMs: '~31000', // 1s + 2s + 4s + 8s + 16s + }); + }); + + it('includes total approximate wait time', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const error = new Error('Failed'); + config.onRetriesExhausted?.(error, 5); + + const call = logger.error.mock.calls[0]; + expect(call[1]).toHaveProperty('totalWaitTimeMs'); + expect(call[1].totalWaitTimeMs).toBe('~31000'); + }); + }); +}); diff --git a/tests/unit/config/reviewConfig.test.ts b/tests/unit/config/reviewConfig.test.ts new file mode 100644 index 00000000..3bb4ef2b --- /dev/null +++ b/tests/unit/config/reviewConfig.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest'; + +import { + REVIEW_FILE_CONTENT_TOKEN_LIMIT, + estimateTokens, +} from '../../../src/config/reviewConfig.js'; + +describe('config/reviewConfig', () => { + describe('REVIEW_FILE_CONTENT_TOKEN_LIMIT', () => { + it('is defined as a number', () => { + expect(typeof REVIEW_FILE_CONTENT_TOKEN_LIMIT).toBe('number'); + }); + + it('is set to 25000 tokens', () => { + expect(REVIEW_FILE_CONTENT_TOKEN_LIMIT).toBe(25_000); + }); + + it('is a positive value', () => { + expect(REVIEW_FILE_CONTENT_TOKEN_LIMIT).toBeGreaterThan(0); + }); + }); + + describe('estimateTokens', () => { + it('estimates roughly 4 characters per token', () => { + const text = 'a'.repeat(400); + const tokens = estimateTokens(text); + + // 400 chars / 4 = 100 tokens + expect(tokens).toBe(100); + }); + + it('returns correct estimate for short text', () => { + const text = 'hello world'; // 11 chars + const tokens = estimateTokens(text); + + // 11 / 4 = 2.75 -> ceil = 3 + expect(tokens).toBe(3); + }); + + it('returns correct estimate for longer text', () => { + const text = 'a'.repeat(1000); + const tokens = estimateTokens(text); + + // 1000 / 4 = 250 + expect(tokens).toBe(250); + }); + + it('rounds up using ceil', () => { + const text = 'abc'; // 3 chars + const tokens = estimateTokens(text); + + // 3 / 4 = 0.75 -> ceil = 1 + expect(tokens).toBe(1); + }); + + it('handles empty string', () => { + const tokens = estimateTokens(''); + + // 0 / 4 = 0 -> ceil = 0 + expect(tokens).toBe(0); + }); + + it('handles single character', () => { + const tokens = estimateTokens('x'); + + // 1 / 4 = 0.25 -> ceil = 1 + expect(tokens).toBe(1); + }); + + it('handles exact multiples of 4', () => { + const text = 'a'.repeat(40); + const tokens = estimateTokens(text); + + // 40 / 4 = 10 (exact) + expect(tokens).toBe(10); + }); + + it('estimates tokens for realistic code snippet', () => { + const codeSnippet = ` +function greet(name: string): string { + return \`Hello, \${name}!\`; +} +`.trim(); + + const tokens = estimateTokens(codeSnippet); + + // Length is ~64 chars -> 64/4 = 16 tokens + expect(tokens).toBeGreaterThan(10); + expect(tokens).toBeLessThan(25); + }); + + it('estimates tokens for multiline text', () => { + const text = `This is line 1 +This is line 2 +This is line 3`; + + const tokens = estimateTokens(text); + + // ~42 chars (including newlines) -> 42/4 = 10.5 -> ceil = 11 + expect(tokens).toBeGreaterThan(8); + expect(tokens).toBeLessThan(15); + }); + + it('handles unicode characters as character length', () => { + const text = '๐Ÿ”ฅ'.repeat(100); // 100 emoji (each is 2 chars in JS) + const tokens = estimateTokens(text); + + // In JS, emoji are typically 2 chars each -> 200 / 4 = 50 tokens + expect(tokens).toBe(50); + }); + + it('returns consistent results for same input', () => { + const text = 'The quick brown fox jumps over the lazy dog'; + + const tokens1 = estimateTokens(text); + const tokens2 = estimateTokens(text); + + expect(tokens1).toBe(tokens2); + }); + + it('larger text has proportionally more tokens', () => { + const shortText = 'a'.repeat(100); + const longText = 'a'.repeat(1000); + + const shortTokens = estimateTokens(shortText); + const longTokens = estimateTokens(longText); + + expect(longTokens).toBe(shortTokens * 10); + }); + + it('approximates typical file within limit', () => { + // A file with ~100k characters should be ~25k tokens + const largeFile = 'x'.repeat(100_000); + const tokens = estimateTokens(largeFile); + + expect(tokens).toBe(25_000); + expect(tokens).toBe(REVIEW_FILE_CONTENT_TOKEN_LIMIT); + }); + }); + + describe('integration', () => { + it('can use estimateTokens to check against limit', () => { + const smallFile = 'a'.repeat(50_000); // ~12.5k tokens + const largeFile = 'a'.repeat(150_000); // ~37.5k tokens + + expect(estimateTokens(smallFile)).toBeLessThan(REVIEW_FILE_CONTENT_TOKEN_LIMIT); + expect(estimateTokens(largeFile)).toBeGreaterThan(REVIEW_FILE_CONTENT_TOKEN_LIMIT); + }); + + it('limit allows for reasonable amount of file content', () => { + // 25k tokens * 4 chars = 100k characters + // This is enough for ~3-5 medium TypeScript files + const estimatedChars = REVIEW_FILE_CONTENT_TOKEN_LIMIT * 4; + + expect(estimatedChars).toBe(100_000); + expect(estimatedChars).toBeGreaterThan(50_000); // Minimum reasonable + expect(estimatedChars).toBeLessThan(200_000); // Maximum to avoid context overflow + }); + }); +}); diff --git a/tests/unit/config/statusUpdateConfig.test.ts b/tests/unit/config/statusUpdateConfig.test.ts new file mode 100644 index 00000000..eb22ad66 --- /dev/null +++ b/tests/unit/config/statusUpdateConfig.test.ts @@ -0,0 +1,275 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + formatGitHubProgressComment, + formatStatusMessage, + getStatusUpdateConfig, +} from '../../../src/config/statusUpdateConfig.js'; + +// Mock todo storage +vi.mock('../../../src/gadgets/todo/storage.js', () => ({ + loadTodos: vi.fn(() => []), + formatTodoList: vi.fn(() => '- [ ] Task 1\n- [x] Task 2'), +})); + +import { formatTodoList, loadTodos } from '../../../src/gadgets/todo/storage.js'; + +describe('config/statusUpdateConfig', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('getStatusUpdateConfig', () => { + it('returns enabled config for non-debug agents', () => { + const agentTypes = ['implementation', 'briefing', 'planning', 'review']; + + for (const agentType of agentTypes) { + const config = getStatusUpdateConfig(agentType); + + expect(config.enabled).toBe(true); + expect(config.intervalMinutes).toBe(5); + expect(config.progressModel).toBe('openrouter:google/gemini-2.5-flash-lite'); + } + }); + + it('returns disabled config for debug agent', () => { + const config = getStatusUpdateConfig('debug'); + + expect(config.enabled).toBe(false); + expect(config.intervalMinutes).toBe(5); + expect(config.progressModel).toBe('openrouter:google/gemini-2.5-flash-lite'); + }); + + it('uses fast, cheap model for progress summaries', () => { + const config = getStatusUpdateConfig('implementation'); + + expect(config.progressModel).toBe('openrouter:google/gemini-2.5-flash-lite'); + }); + + it('has reasonable update interval', () => { + const config = getStatusUpdateConfig('implementation'); + + expect(config.intervalMinutes).toBeGreaterThan(0); + expect(config.intervalMinutes).toBeLessThanOrEqual(10); + }); + }); + + describe('formatStatusMessage', () => { + it('includes agent type and progress bar', () => { + vi.mocked(loadTodos).mockReturnValue([]); + + const message = formatStatusMessage(5, 20, 'implementation'); + + expect(message).toContain('**implementation agent progress**'); + expect(message).toContain('25%'); // (5/20) * 100 + expect(message).toContain('iteration 5/20'); + }); + + it('renders progress bar correctly at 0%', () => { + vi.mocked(loadTodos).mockReturnValue([]); + + const message = formatStatusMessage(0, 20, 'planning'); + + expect(message).toContain('[โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]'); + expect(message).toContain('0%'); + }); + + it('renders progress bar correctly at 50%', () => { + vi.mocked(loadTodos).mockReturnValue([]); + + const message = formatStatusMessage(10, 20, 'implementation'); + + expect(message).toContain('[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘]'); + expect(message).toContain('50%'); + }); + + it('renders progress bar correctly at 100%', () => { + vi.mocked(loadTodos).mockReturnValue([]); + + const message = formatStatusMessage(20, 20, 'implementation'); + + expect(message).toContain('[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ]'); + expect(message).toContain('100%'); + }); + + it('rounds progress percentage', () => { + vi.mocked(loadTodos).mockReturnValue([]); + + const message = formatStatusMessage(7, 20, 'planning'); + + expect(message).toContain('35%'); // 7/20 = 0.35 -> 35% + }); + + it('includes task counts when todos exist', () => { + vi.mocked(loadTodos).mockReturnValue([ + { id: '1', content: 'Task 1', status: 'done' }, + { id: '2', content: 'Task 2', status: 'done' }, + { id: '3', content: 'Task 3', status: 'pending' }, + ]); + + const message = formatStatusMessage(10, 20, 'implementation'); + + expect(message).toContain('**Tasks:** 2/3 complete'); + }); + + it('includes current in-progress task', () => { + vi.mocked(loadTodos).mockReturnValue([ + { id: '1', content: 'Write tests', status: 'in_progress' }, + { id: '2', content: 'Fix linting', status: 'pending' }, + ]); + + const message = formatStatusMessage(5, 20, 'implementation'); + + expect(message).toContain('**Working on:** Write tests'); + }); + + it('does not include task section when no todos', () => { + vi.mocked(loadTodos).mockReturnValue([]); + + const message = formatStatusMessage(5, 20, 'implementation'); + + expect(message).not.toContain('**Tasks:**'); + expect(message).not.toContain('**Working on:**'); + }); + + it('does not include "Working on" when no in-progress todo', () => { + vi.mocked(loadTodos).mockReturnValue([ + { id: '1', content: 'Task 1', status: 'done' }, + { id: '2', content: 'Task 2', status: 'pending' }, + ]); + + const message = formatStatusMessage(8, 20, 'planning'); + + expect(message).toContain('**Tasks:**'); + expect(message).not.toContain('**Working on:**'); + }); + + it('formats message with proper markdown structure', () => { + vi.mocked(loadTodos).mockReturnValue([]); + + const message = formatStatusMessage(10, 20, 'implementation'); + + const lines = message.split('\n'); + expect(lines[0]).toBe('**implementation agent progress**'); + expect(lines[1]).toBe(''); + expect(lines[2]).toContain('[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘]'); + }); + }); + + describe('formatGitHubProgressComment', () => { + it('includes header message and progress bar', () => { + vi.mocked(loadTodos).mockReturnValue([]); + vi.mocked(formatTodoList).mockReturnValue('- [ ] Task 1'); + + const comment = formatGitHubProgressComment('๐Ÿ” Reviewing PR...', 8, 20, 'review'); + + expect(comment).toContain('๐Ÿ” Reviewing PR...'); + expect(comment).toContain('**Progress:**'); + expect(comment).toContain('40%'); // (8/20) * 100 + expect(comment).toContain('iteration 8/20'); + }); + + it('includes formatted todo list', () => { + vi.mocked(loadTodos).mockReturnValue([{ id: '1', content: 'Task 1', status: 'pending' }]); + vi.mocked(formatTodoList).mockReturnValue('- [ ] Task 1\n- [x] Task 2'); + + const comment = formatGitHubProgressComment('๐Ÿ” Reviewing PR...', 5, 20, 'review'); + + expect(comment).toContain('- [ ] Task 1'); + expect(comment).toContain('- [x] Task 2'); + }); + + it('includes metadata footer with iteration and agent type', () => { + vi.mocked(loadTodos).mockReturnValue([]); + vi.mocked(formatTodoList).mockReturnValue(''); + + const comment = formatGitHubProgressComment( + '๐Ÿš€ Implementing feature...', + 12, + 25, + 'implementation', + ); + + expect(comment).toContain('Last updated: iteration 12 ยท implementation agent'); + }); + + it('separates sections with horizontal rule', () => { + vi.mocked(loadTodos).mockReturnValue([]); + vi.mocked(formatTodoList).mockReturnValue(''); + + const comment = formatGitHubProgressComment('Header text', 5, 20, 'review'); + + const lines = comment.split('\n'); + expect(lines).toContain('---'); + }); + + it('preserves header message exactly as provided', () => { + vi.mocked(loadTodos).mockReturnValue([]); + vi.mocked(formatTodoList).mockReturnValue(''); + + const headerWithMarkdown = '๐Ÿ” **Reviewing PR** #123\n\nThis is a test.'; + const comment = formatGitHubProgressComment(headerWithMarkdown, 5, 20, 'review'); + + expect(comment.startsWith(headerWithMarkdown)).toBe(true); + }); + + it('loads todos and formats them via formatTodoList', () => { + const todos = [ + { id: '1', content: 'Test 1', status: 'done' as const }, + { id: '2', content: 'Test 2', status: 'pending' as const }, + ]; + vi.mocked(loadTodos).mockReturnValue(todos); + vi.mocked(formatTodoList).mockReturnValue('formatted todos'); + + const comment = formatGitHubProgressComment('Header', 10, 20, 'implementation'); + + expect(loadTodos).toHaveBeenCalled(); + expect(formatTodoList).toHaveBeenCalledWith(todos); + expect(comment).toContain('formatted todos'); + }); + + it('renders progress bar at different percentages', () => { + vi.mocked(loadTodos).mockReturnValue([]); + vi.mocked(formatTodoList).mockReturnValue(''); + + const comment25 = formatGitHubProgressComment('Header', 5, 20, 'review'); + expect(comment25).toContain('[โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]'); // 25% -> rounds to 3 blocks + + const comment75 = formatGitHubProgressComment('Header', 15, 20, 'review'); + expect(comment75).toContain('[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘]'); // 75% -> rounds to 8 blocks + }); + }); + + describe('progress bar rendering', () => { + it('progress bar has exactly 10 blocks', () => { + vi.mocked(loadTodos).mockReturnValue([]); + + const message = formatStatusMessage(7, 20, 'implementation'); + + const progressBarMatch = message.match(/\[([โ–ˆโ–‘]+)\]/); + expect(progressBarMatch).toBeTruthy(); + expect(progressBarMatch?.[1].length).toBe(10); + }); + + it('progress bar uses filled and empty blocks', () => { + vi.mocked(loadTodos).mockReturnValue([]); + + const message = formatStatusMessage(6, 20, 'planning'); // 30% + + // 30% -> 3 filled, 7 empty + expect(message).toContain('[โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]'); + }); + + it('handles edge case percentages', () => { + vi.mocked(loadTodos).mockReturnValue([]); + + // 1/20 = 5% -> 0.5 rounds to 1 + const message1 = formatStatusMessage(1, 20, 'planning'); + expect(message1).toContain('[โ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]'); + + // 19/20 = 95% -> 9.5 rounds to 10 + const message19 = formatStatusMessage(19, 20, 'planning'); + expect(message19).toContain('[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ]'); + }); + }); +}); diff --git a/tests/unit/pm/context.test.ts b/tests/unit/pm/context.test.ts new file mode 100644 index 00000000..fd9b95d5 --- /dev/null +++ b/tests/unit/pm/context.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getPMProvider, getPMProviderOrNull, withPMProvider } from '../../../src/pm/context.js'; +import type { PMProvider } from '../../../src/pm/types.js'; + +describe('pm/context', () => { + // Create a minimal mock provider for testing + const createMockProvider = (): PMProvider => ({ + type: 'trello', + addLabel: vi.fn(), + removeLabel: vi.fn(), + moveWorkItem: vi.fn(), + addComment: vi.fn(), + getWorkItem: vi.fn(), + getWorkItemComments: vi.fn(), + updateWorkItem: vi.fn(), + createWorkItem: vi.fn(), + listWorkItems: vi.fn(), + getChecklists: vi.fn(), + createChecklist: vi.fn(), + addChecklistItem: vi.fn(), + updateChecklistItem: vi.fn(), + getAttachments: vi.fn(), + addAttachment: vi.fn(), + addAttachmentFile: vi.fn(), + getCustomFieldNumber: vi.fn(), + updateCustomFieldNumber: vi.fn(), + getWorkItemUrl: vi.fn(), + getAuthenticatedUser: vi.fn(), + }); + + describe('withPMProvider', () => { + it('makes provider available within the async context', async () => { + const provider = createMockProvider(); + + await withPMProvider(provider, async () => { + const retrieved = getPMProvider(); + expect(retrieved).toBe(provider); + }); + }); + + it('isolates provider scope between concurrent calls', async () => { + const provider1 = createMockProvider(); + const provider2 = createMockProvider(); + + // Run two contexts concurrently + const [result1, result2] = await Promise.all([ + withPMProvider(provider1, async () => { + // Simulate async work + await new Promise((resolve) => setTimeout(resolve, 10)); + return getPMProvider(); + }), + withPMProvider(provider2, async () => { + // Simulate async work + await new Promise((resolve) => setTimeout(resolve, 5)); + return getPMProvider(); + }), + ]); + + // Each context should see its own provider + expect(result1).toBe(provider1); + expect(result2).toBe(provider2); + }); + + it('removes provider from context after callback completes', async () => { + const provider = createMockProvider(); + + await withPMProvider(provider, async () => { + expect(getPMProvider()).toBe(provider); + }); + + // Provider should not be available outside the context + expect(() => getPMProvider()).toThrow(); + }); + + it('propagates errors from callback', async () => { + const provider = createMockProvider(); + const error = new Error('Callback failed'); + + await expect( + withPMProvider(provider, async () => { + throw error; + }), + ).rejects.toThrow('Callback failed'); + }); + + it('returns the callback result', async () => { + const provider = createMockProvider(); + + const result = await withPMProvider(provider, async () => { + return { success: true, data: 'test' }; + }); + + expect(result).toEqual({ success: true, data: 'test' }); + }); + }); + + describe('getPMProvider', () => { + it('returns provider when in context', async () => { + const provider = createMockProvider(); + + await withPMProvider(provider, async () => { + const retrieved = getPMProvider(); + expect(retrieved).toBe(provider); + }); + }); + + it('throws error when not in context', () => { + expect(() => getPMProvider()).toThrow( + 'No PMProvider in scope. Wrap the call with withPMProvider() or ensure the webhook handler has established a PM context.', + ); + }); + + it('throws error with helpful message', () => { + try { + getPMProvider(); + // Should not reach here + expect(true).toBe(false); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('withPMProvider()'); + expect((error as Error).message).toContain('webhook handler'); + } + }); + }); + + describe('getPMProviderOrNull', () => { + it('returns provider when in context', async () => { + const provider = createMockProvider(); + + await withPMProvider(provider, async () => { + const retrieved = getPMProviderOrNull(); + expect(retrieved).toBe(provider); + }); + }); + + it('returns null when not in context', () => { + const result = getPMProviderOrNull(); + expect(result).toBeNull(); + }); + + it('does not throw error when not in context', () => { + expect(() => getPMProviderOrNull()).not.toThrow(); + }); + }); + + describe('nested contexts', () => { + it('inner context overrides outer context', async () => { + const outerProvider = createMockProvider(); + const innerProvider = createMockProvider(); + + await withPMProvider(outerProvider, async () => { + expect(getPMProvider()).toBe(outerProvider); + + await withPMProvider(innerProvider, async () => { + expect(getPMProvider()).toBe(innerProvider); + }); + + // After inner context, outer provider is restored + expect(getPMProvider()).toBe(outerProvider); + }); + }); + + it('handles errors in nested contexts without affecting outer context', async () => { + const outerProvider = createMockProvider(); + const innerProvider = createMockProvider(); + + await withPMProvider(outerProvider, async () => { + expect(getPMProvider()).toBe(outerProvider); + + try { + await withPMProvider(innerProvider, async () => { + throw new Error('Inner error'); + }); + } catch (error) { + // Expected error + } + + // Outer context should still be valid + expect(getPMProvider()).toBe(outerProvider); + }); + }); + }); +}); diff --git a/tests/unit/pm/factory.test.ts b/tests/unit/pm/factory.test.ts new file mode 100644 index 00000000..09e625d1 --- /dev/null +++ b/tests/unit/pm/factory.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createPMProvider } from '../../../src/pm/factory.js'; +import type { ProjectConfig } from '../../../src/types/index.js'; + +// Mock the adapters +vi.mock('../../../src/pm/trello/adapter.js', () => ({ + TrelloPMProvider: vi.fn().mockImplementation(() => ({ + type: 'trello', + addLabel: vi.fn(), + removeLabel: vi.fn(), + })), +})); + +vi.mock('../../../src/pm/jira/adapter.js', () => ({ + JiraPMProvider: vi.fn().mockImplementation((config) => ({ + type: 'jira', + config, + addLabel: vi.fn(), + removeLabel: vi.fn(), + })), +})); + +import { JiraPMProvider } from '../../../src/pm/jira/adapter.js'; +import { TrelloPMProvider } from '../../../src/pm/trello/adapter.js'; + +describe('pm/factory', () => { + describe('createPMProvider', () => { + it('returns TrelloPMProvider when pm.type is trello', () => { + const project: ProjectConfig = { + id: 'proj1', + orgId: 'org1', + name: 'Trello Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'trello' }, + trello: { + boardId: 'board123', + labels: { processing: 'label-id' }, + lists: { todo: 'list-id' }, + }, + }; + + const provider = createPMProvider(project); + + expect(TrelloPMProvider).toHaveBeenCalled(); + expect(provider.type).toBe('trello'); + }); + + it('returns TrelloPMProvider when pm.type is undefined (defaults to trello)', () => { + const project: ProjectConfig = { + id: 'proj1', + orgId: 'org1', + name: 'Default Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + trello: { + boardId: 'board123', + labels: { processing: 'label-id' }, + lists: { todo: 'list-id' }, + }, + }; + + const provider = createPMProvider(project); + + expect(TrelloPMProvider).toHaveBeenCalled(); + expect(provider.type).toBe('trello'); + }); + + it('returns JiraPMProvider when pm.type is jira', () => { + const project: ProjectConfig = { + id: 'proj1', + orgId: 'org1', + name: 'JIRA Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'jira' }, + jira: { + projectKey: 'PROJ', + statuses: { + inProgress: 'In Progress', + inReview: 'Code Review', + done: 'Done', + merged: 'Merged', + }, + }, + }; + + const provider = createPMProvider(project); + + expect(JiraPMProvider).toHaveBeenCalledWith(project.jira); + expect(provider.type).toBe('jira'); + }); + + it('throws error when pm.type is jira but jira config is missing', () => { + const project: ProjectConfig = { + id: 'proj1', + orgId: 'org1', + name: 'Invalid JIRA Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'jira' }, + // No jira config + }; + + expect(() => createPMProvider(project)).toThrow( + "Project 'proj1' has pm.type=jira but no jira config", + ); + }); + + it('throws error for unknown pm.type', () => { + const project = { + id: 'proj1', + orgId: 'org1', + name: 'Unknown PM Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'unknown' }, + } as ProjectConfig; + + expect(() => createPMProvider(project)).toThrow('Unknown PM type: unknown'); + }); + }); +}); diff --git a/tests/unit/pm/lifecycle.test.ts b/tests/unit/pm/lifecycle.test.ts new file mode 100644 index 00000000..f9391a9f --- /dev/null +++ b/tests/unit/pm/lifecycle.test.ts @@ -0,0 +1,355 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + PMLifecycleManager, + type ProjectPMConfig, + resolveProjectPMConfig, +} from '../../../src/pm/lifecycle.js'; +import type { PMProvider } from '../../../src/pm/types.js'; +import type { ProjectConfig } from '../../../src/types/index.js'; + +// Mock safeOperation utilities +vi.mock('../../../src/utils/safeOperation.js', () => ({ + safeOperation: vi.fn((fn) => fn()), + silentOperation: vi.fn((fn) => fn()), +})); + +describe('pm/lifecycle', () => { + describe('resolveProjectPMConfig', () => { + it('returns JIRA config when project type is jira', () => { + const project: ProjectConfig = { + id: 'proj1', + orgId: 'org1', + name: 'JIRA Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'jira' }, + jira: { + projectKey: 'PROJ', + statuses: { + inProgress: 'In Progress', + inReview: 'Code Review', + done: 'Done', + merged: 'Merged', + }, + }, + }; + + const config = resolveProjectPMConfig(project); + + expect(config).toEqual({ + labels: { + processing: 'cascade-processing', + processed: 'cascade-processed', + error: 'cascade-error', + readyToProcess: 'cascade-ready', + }, + statuses: { + inProgress: 'In Progress', + inReview: 'Code Review', + done: 'Done', + merged: 'Merged', + }, + }); + }); + + it('returns Trello config when project type is trello', () => { + const project: ProjectConfig = { + id: 'proj1', + orgId: 'org1', + name: 'Trello Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'trello' }, + trello: { + boardId: 'board123', + labels: { + processing: 'label-proc-id', + processed: 'label-done-id', + error: 'label-err-id', + readyToProcess: 'label-ready-id', + }, + lists: { + todo: 'list-todo-id', + inProgress: 'list-progress-id', + inReview: 'list-review-id', + done: 'list-done-id', + merged: 'list-merged-id', + }, + }, + }; + + const config = resolveProjectPMConfig(project); + + expect(config).toEqual({ + labels: { + processing: 'label-proc-id', + processed: 'label-done-id', + error: 'label-err-id', + readyToProcess: 'label-ready-id', + }, + statuses: { + inProgress: 'list-progress-id', + inReview: 'list-review-id', + done: 'list-done-id', + merged: 'list-merged-id', + }, + }); + }); + + it('defaults to Trello config when pm.type is undefined', () => { + const project: ProjectConfig = { + id: 'proj1', + orgId: 'org1', + name: 'Default Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + trello: { + boardId: 'board123', + labels: { processing: 'label-id' }, + lists: { todo: 'list-id' }, + }, + }; + + const config = resolveProjectPMConfig(project); + + expect(config.labels.processing).toBe('label-id'); + }); + + it('handles missing optional Trello labels and lists', () => { + const project: ProjectConfig = { + id: 'proj1', + orgId: 'org1', + name: 'Partial Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + trello: { + boardId: 'board123', + labels: {}, + lists: { todo: 'list-id' }, + }, + }; + + const config = resolveProjectPMConfig(project); + + expect(config).toEqual({ + labels: { + processing: undefined, + processed: undefined, + error: undefined, + readyToProcess: undefined, + }, + statuses: { + inProgress: undefined, + inReview: undefined, + done: undefined, + merged: undefined, + }, + }); + }); + }); + + describe('PMLifecycleManager', () => { + let mockProvider: PMProvider; + let pmConfig: ProjectPMConfig; + let manager: PMLifecycleManager; + + beforeEach(() => { + // Create mock provider with all required methods + mockProvider = { + type: 'trello', + addLabel: vi.fn().mockResolvedValue(undefined), + removeLabel: vi.fn().mockResolvedValue(undefined), + moveWorkItem: vi.fn().mockResolvedValue(undefined), + addComment: vi.fn().mockResolvedValue(undefined), + // Other PMProvider methods (not used by lifecycle manager) + getWorkItem: vi.fn(), + getWorkItemComments: vi.fn(), + updateWorkItem: vi.fn(), + createWorkItem: vi.fn(), + listWorkItems: vi.fn(), + getChecklists: vi.fn(), + createChecklist: vi.fn(), + addChecklistItem: vi.fn(), + updateChecklistItem: vi.fn(), + getAttachments: vi.fn(), + addAttachment: vi.fn(), + addAttachmentFile: vi.fn(), + getCustomFieldNumber: vi.fn(), + updateCustomFieldNumber: vi.fn(), + getWorkItemUrl: vi.fn(), + getAuthenticatedUser: vi.fn(), + }; + + pmConfig = { + labels: { + processing: 'label-proc', + processed: 'label-done', + error: 'label-error', + readyToProcess: 'label-ready', + }, + statuses: { + inProgress: 'list-progress', + inReview: 'list-review', + done: 'list-done', + merged: 'list-merged', + }, + }; + + manager = new PMLifecycleManager(mockProvider, pmConfig); + }); + + describe('prepareForAgent', () => { + it('adds processing label and removes ready/processed labels', async () => { + await manager.prepareForAgent('work-item-1', 'briefing'); + + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-proc'); + expect(mockProvider.removeLabel).toHaveBeenCalledWith('work-item-1', 'label-ready'); + expect(mockProvider.removeLabel).toHaveBeenCalledWith('work-item-1', 'label-done'); + }); + + it('moves to inProgress status when agentType is implementation', async () => { + await manager.prepareForAgent('work-item-1', 'implementation'); + + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-proc'); + expect(mockProvider.moveWorkItem).toHaveBeenCalledWith('work-item-1', 'list-progress'); + }); + + it('does not move work item for non-implementation agents', async () => { + await manager.prepareForAgent('work-item-1', 'briefing'); + + expect(mockProvider.moveWorkItem).not.toHaveBeenCalled(); + }); + + it('skips operations when labels are undefined', async () => { + const managerNoLabels = new PMLifecycleManager(mockProvider, { + labels: {}, + statuses: {}, + }); + + await managerNoLabels.prepareForAgent('work-item-1', 'briefing'); + + expect(mockProvider.addLabel).not.toHaveBeenCalled(); + expect(mockProvider.removeLabel).not.toHaveBeenCalled(); + }); + }); + + describe('handleSuccess', () => { + it('adds processed label', async () => { + await manager.handleSuccess('work-item-1', 'briefing'); + + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-done'); + }); + + it('moves to inReview status when agentType is implementation', async () => { + await manager.handleSuccess('work-item-1', 'implementation'); + + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-done'); + expect(mockProvider.moveWorkItem).toHaveBeenCalledWith('work-item-1', 'list-review'); + }); + + it('adds PR comment when prUrl is provided for implementation agent', async () => { + await manager.handleSuccess('work-item-1', 'implementation', 'https://github.com/pr/123'); + + expect(mockProvider.addComment).toHaveBeenCalledWith( + 'work-item-1', + 'PR created: https://github.com/pr/123', + ); + }); + + it('does not add PR comment when prUrl is not provided', async () => { + await manager.handleSuccess('work-item-1', 'implementation'); + + expect(mockProvider.addComment).not.toHaveBeenCalled(); + }); + + it('does not move work item for non-implementation agents', async () => { + await manager.handleSuccess('work-item-1', 'briefing'); + + expect(mockProvider.moveWorkItem).not.toHaveBeenCalled(); + }); + }); + + describe('handleFailure', () => { + it('adds error label', async () => { + await manager.handleFailure('work-item-1'); + + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-error'); + }); + + it('adds error comment when error message is provided', async () => { + await manager.handleFailure('work-item-1', 'Something went wrong'); + + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-error'); + expect(mockProvider.addComment).toHaveBeenCalledWith( + 'work-item-1', + 'โŒ Agent failed: Something went wrong', + ); + }); + + it('does not add comment when error message is not provided', async () => { + await manager.handleFailure('work-item-1'); + + expect(mockProvider.addComment).not.toHaveBeenCalled(); + }); + }); + + describe('handleBudgetExceeded', () => { + it('removes processing label and adds error label', async () => { + await manager.handleBudgetExceeded('work-item-1', 5.5, 5.0); + + expect(mockProvider.removeLabel).toHaveBeenCalledWith('work-item-1', 'label-proc'); + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-error'); + }); + + it('adds budget exceeded comment with formatted amounts', async () => { + await manager.handleBudgetExceeded('work-item-1', 5.678, 5.0); + + expect(mockProvider.addComment).toHaveBeenCalledWith( + 'work-item-1', + 'โ›” Budget exceeded: cost $5.68 >= limit $5.00. Agent not started.', + ); + }); + }); + + describe('handleBudgetWarning', () => { + it('adds error label', async () => { + await manager.handleBudgetWarning('work-item-1', 4.95, 5.0); + + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-error'); + }); + + it('adds budget warning comment with formatted amounts', async () => { + await manager.handleBudgetWarning('work-item-1', 5.123, 5.0); + + expect(mockProvider.addComment).toHaveBeenCalledWith( + 'work-item-1', + 'โš ๏ธ Budget limit reached: cost $5.12 >= limit $5.00. Further agent runs will be blocked.', + ); + }); + }); + + describe('cleanupProcessing', () => { + it('removes processing label', async () => { + await manager.cleanupProcessing('work-item-1'); + + expect(mockProvider.removeLabel).toHaveBeenCalledWith('work-item-1', 'label-proc'); + }); + }); + + describe('handleError', () => { + it('adds error label and error comment', async () => { + await manager.handleError('work-item-1', 'Database connection failed'); + + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-error'); + expect(mockProvider.addComment).toHaveBeenCalledWith( + 'work-item-1', + 'โŒ Error: Database connection failed', + ); + }); + }); + }); +}); diff --git a/tests/unit/triggers/agent-result-handler.test.ts b/tests/unit/triggers/agent-result-handler.test.ts new file mode 100644 index 00000000..9abda72e --- /dev/null +++ b/tests/unit/triggers/agent-result-handler.test.ts @@ -0,0 +1,238 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/pm/index.js', () => ({ + getPMProvider: vi.fn(), +})); + +vi.mock('../../../src/utils/safeOperation.js', () => ({ + safeOperation: vi.fn((fn) => fn()), +})); + +import type { PMProvider } from '../../../src/pm/index.js'; +import { getPMProvider } from '../../../src/pm/index.js'; +import { handleAgentResultArtifacts } from '../../../src/triggers/shared/agent-result-handler.js'; +import type { AgentResult, ProjectConfig } from '../../../src/types/index.js'; + +const mockPMProvider = { + getCustomFieldNumber: vi.fn(), + updateCustomFieldNumber: vi.fn(), +}; + +vi.mocked(getPMProvider).mockReturnValue(mockPMProvider as unknown as PMProvider); + +const mockTrelloProject: ProjectConfig = { + id: 'test', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + trello: { + boardId: 'board123', + lists: {}, + labels: {}, + customFields: { cost: 'cf-cost-123' }, + }, +}; + +const mockJiraProject: ProjectConfig = { + id: 'test', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'jira' }, + jira: { + host: 'example.atlassian.net', + projectKey: 'TEST', + customFields: { cost: 'cf-jira-cost-456' }, + }, +}; + +describe('handleAgentResultArtifacts', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('updates cost custom field with accumulation', async () => { + mockPMProvider.getCustomFieldNumber.mockResolvedValue(2.5); + + const agentResult: AgentResult = { + success: true, + cost: 1.75, + sessionId: 'session-123', + }; + + await handleAgentResultArtifacts('card123', 'implementation', agentResult, mockTrelloProject); + + expect(mockPMProvider.getCustomFieldNumber).toHaveBeenCalledWith('card123', 'cf-cost-123'); + expect(mockPMProvider.updateCustomFieldNumber).toHaveBeenCalledWith( + 'card123', + 'cf-cost-123', + 4.25, + ); + }); + + it('handles zero current cost', async () => { + mockPMProvider.getCustomFieldNumber.mockResolvedValue(0); + + const agentResult: AgentResult = { + success: true, + cost: 3.5, + sessionId: 'session-456', + }; + + await handleAgentResultArtifacts('card456', 'review', agentResult, mockTrelloProject); + + expect(mockPMProvider.updateCustomFieldNumber).toHaveBeenCalledWith( + 'card456', + 'cf-cost-123', + 3.5, + ); + }); + + it('rounds accumulated cost to 4 decimal places', async () => { + mockPMProvider.getCustomFieldNumber.mockResolvedValue(1.123456); + + const agentResult: AgentResult = { + success: true, + cost: 0.987654, + sessionId: 'session-789', + }; + + await handleAgentResultArtifacts('card789', 'implementation', agentResult, mockTrelloProject); + + // 1.123456 + 0.987654 = 2.11111, rounded to 4 decimals = 2.1111 + expect(mockPMProvider.updateCustomFieldNumber).toHaveBeenCalledWith( + 'card789', + 'cf-cost-123', + 2.1111, + ); + }); + + it('skips when no cost field configured (Trello)', async () => { + const projectNoCostField: ProjectConfig = { + ...mockTrelloProject, + trello: mockTrelloProject.trello + ? { + ...mockTrelloProject.trello, + customFields: {}, + } + : undefined, + }; + + const agentResult: AgentResult = { + success: true, + cost: 1.5, + sessionId: 'session-abc', + }; + + await handleAgentResultArtifacts( + 'card-no-field', + 'implementation', + agentResult, + projectNoCostField, + ); + + expect(mockPMProvider.getCustomFieldNumber).not.toHaveBeenCalled(); + expect(mockPMProvider.updateCustomFieldNumber).not.toHaveBeenCalled(); + }); + + it('skips when no cost field configured (JIRA)', async () => { + const projectNoCostField: ProjectConfig = { + ...mockJiraProject, + jira: mockJiraProject.jira + ? { + ...mockJiraProject.jira, + customFields: {}, + } + : undefined, + }; + + const agentResult: AgentResult = { + success: true, + cost: 2.0, + sessionId: 'session-def', + }; + + await handleAgentResultArtifacts( + 'issue-no-field', + 'implementation', + agentResult, + projectNoCostField, + ); + + expect(mockPMProvider.getCustomFieldNumber).not.toHaveBeenCalled(); + expect(mockPMProvider.updateCustomFieldNumber).not.toHaveBeenCalled(); + }); + + it('skips when cost is zero', async () => { + const agentResult: AgentResult = { + success: true, + cost: 0, + sessionId: 'session-zero', + }; + + await handleAgentResultArtifacts('card-zero', 'implementation', agentResult, mockTrelloProject); + + expect(mockPMProvider.getCustomFieldNumber).not.toHaveBeenCalled(); + expect(mockPMProvider.updateCustomFieldNumber).not.toHaveBeenCalled(); + }); + + it('skips when cost is undefined', async () => { + const agentResult: AgentResult = { + success: true, + sessionId: 'session-undef', + }; + + await handleAgentResultArtifacts( + 'card-undef', + 'implementation', + agentResult, + mockTrelloProject, + ); + + expect(mockPMProvider.getCustomFieldNumber).not.toHaveBeenCalled(); + expect(mockPMProvider.updateCustomFieldNumber).not.toHaveBeenCalled(); + }); + + it('uses JIRA cost field for JIRA projects', async () => { + mockPMProvider.getCustomFieldNumber.mockResolvedValue(1.0); + + const agentResult: AgentResult = { + success: true, + cost: 0.5, + sessionId: 'session-jira', + }; + + await handleAgentResultArtifacts('PROJ-123', 'implementation', agentResult, mockJiraProject); + + expect(mockPMProvider.getCustomFieldNumber).toHaveBeenCalledWith( + 'PROJ-123', + 'cf-jira-cost-456', + ); + expect(mockPMProvider.updateCustomFieldNumber).toHaveBeenCalledWith( + 'PROJ-123', + 'cf-jira-cost-456', + 1.5, + ); + }); + + it('handles failed agent results with cost', async () => { + mockPMProvider.getCustomFieldNumber.mockResolvedValue(0.5); + + const agentResult: AgentResult = { + success: false, + error: 'Something went wrong', + cost: 0.25, + sessionId: 'session-failed', + }; + + await handleAgentResultArtifacts('card-fail', 'implementation', agentResult, mockTrelloProject); + + expect(mockPMProvider.updateCustomFieldNumber).toHaveBeenCalledWith( + 'card-fail', + 'cf-cost-123', + 0.75, + ); + }); +}); diff --git a/tests/unit/triggers/github-utils.test.ts b/tests/unit/triggers/github-utils.test.ts new file mode 100644 index 00000000..5d46b4b3 --- /dev/null +++ b/tests/unit/triggers/github-utils.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; +import { + extractJiraIssueKey, + extractTrelloCardId, + extractWorkItemId, + hasTrelloCardUrl, + requireWorkItemId, +} from '../../../src/triggers/github/utils.js'; +import type { ProjectConfig } from '../../../src/types/index.js'; + +const mockTrelloProject: ProjectConfig = { + id: 'test', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + trello: { + boardId: 'board123', + lists: {}, + labels: {}, + }, +}; + +const mockJiraProject: ProjectConfig = { + id: 'test', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'jira' }, + jira: { + host: 'example.atlassian.net', + projectKey: 'TEST', + }, +}; + +describe('extractTrelloCardId', () => { + it('returns null for null input', () => { + expect(extractTrelloCardId(null)).toBeNull(); + }); + + it('returns null for text with no URL', () => { + expect(extractTrelloCardId('Just some regular text')).toBeNull(); + }); + + it('extracts card ID from valid Trello URL', () => { + const text = 'Implements https://trello.com/c/abc123/card-name'; + expect(extractTrelloCardId(text)).toBe('abc123'); + }); + + it('extracts card ID from URL without slug', () => { + const text = 'See https://trello.com/c/xyz789'; + expect(extractTrelloCardId(text)).toBe('xyz789'); + }); + + it('returns first card ID when multiple URLs present', () => { + const text = + 'https://trello.com/c/first123/card-one and https://trello.com/c/second456/card-two'; + expect(extractTrelloCardId(text)).toBe('first123'); + }); + + it('handles URLs with alphanumeric IDs', () => { + const text = 'https://trello.com/c/AbC123DeF/my-card'; + expect(extractTrelloCardId(text)).toBe('AbC123DeF'); + }); +}); + +describe('hasTrelloCardUrl', () => { + it('returns false for null input', () => { + expect(hasTrelloCardUrl(null)).toBe(false); + }); + + it('returns false for text without URL', () => { + expect(hasTrelloCardUrl('No URL here')).toBe(false); + }); + + it('returns true for text with Trello URL', () => { + expect(hasTrelloCardUrl('https://trello.com/c/abc123/card')).toBe(true); + }); + + it('returns true for partial match in longer text', () => { + expect(hasTrelloCardUrl('Check out this card: https://trello.com/c/xyz789')).toBe(true); + }); +}); + +describe('extractJiraIssueKey', () => { + it('returns null for null input', () => { + expect(extractJiraIssueKey(null)).toBeNull(); + }); + + it('returns null when no key found', () => { + expect(extractJiraIssueKey('Just some text without a key')).toBeNull(); + }); + + it('extracts valid JIRA key', () => { + expect(extractJiraIssueKey('PROJ-123')).toBe('PROJ-123'); + }); + + it('extracts key embedded in longer text', () => { + const text = 'This fixes PROJ-456 by updating the logic'; + expect(extractJiraIssueKey(text)).toBe('PROJ-456'); + }); + + it('extracts key with multiple characters in project code', () => { + expect(extractJiraIssueKey('TEST-999')).toBe('TEST-999'); + }); + + it('extracts key with alphanumeric project code', () => { + expect(extractJiraIssueKey('AB12-345')).toBe('AB12-345'); + }); + + it('requires word boundaries around key', () => { + // Should not match partial strings + expect(extractJiraIssueKey('NOTAKEY-123-MORE')).toBe('NOTAKEY-123'); + }); + + it('returns first key when multiple present', () => { + const text = 'Relates to PROJ-111 and PROJ-222'; + expect(extractJiraIssueKey(text)).toBe('PROJ-111'); + }); +}); + +describe('extractWorkItemId', () => { + it('returns null for null input', () => { + expect(extractWorkItemId(null, mockTrelloProject)).toBeNull(); + }); + + it('delegates to Trello extraction for Trello projects', () => { + const text = 'https://trello.com/c/abc123/card'; + expect(extractWorkItemId(text, mockTrelloProject)).toBe('abc123'); + }); + + it('delegates to JIRA extraction for JIRA projects', () => { + const text = 'Fixes PROJ-456'; + expect(extractWorkItemId(text, mockJiraProject)).toBe('PROJ-456'); + }); + + it('returns null for Trello project without Trello URL', () => { + const text = 'Just regular text'; + expect(extractWorkItemId(text, mockTrelloProject)).toBeNull(); + }); + + it('returns null for JIRA project without JIRA key', () => { + const text = 'Just regular text'; + expect(extractWorkItemId(text, mockJiraProject)).toBeNull(); + }); +}); + +describe('requireWorkItemId', () => { + const context = { prNumber: 42, triggerName: 'test-trigger' }; + + it('returns null when no ID found', () => { + const result = requireWorkItemId('No work item reference', mockTrelloProject, context); + expect(result).toBeNull(); + }); + + it('returns ID when present in Trello project', () => { + const text = 'Implements https://trello.com/c/abc123/card'; + const result = requireWorkItemId(text, mockTrelloProject, context); + expect(result).toBe('abc123'); + }); + + it('returns ID when present in JIRA project', () => { + const text = 'Fixes PROJ-789'; + const result = requireWorkItemId(text, mockJiraProject, context); + expect(result).toBe('PROJ-789'); + }); + + it('returns null for null input', () => { + const result = requireWorkItemId(null, mockTrelloProject, context); + expect(result).toBeNull(); + }); +}); diff --git a/tests/unit/triggers/manual-runner.test.ts b/tests/unit/triggers/manual-runner.test.ts new file mode 100644 index 00000000..f9c75e9c --- /dev/null +++ b/tests/unit/triggers/manual-runner.test.ts @@ -0,0 +1,276 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/agents/registry.js', () => ({ + runAgent: vi.fn(), +})); + +vi.mock('../../../src/db/repositories/runsRepository.js', () => ({ + getRunById: vi.fn(), +})); + +vi.mock('../../../src/utils/logging.js', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + }, +})); + +import { runAgent } from '../../../src/agents/registry.js'; +import { getRunById } from '../../../src/db/repositories/runsRepository.js'; +import { + clearTriggerTracking, + isTriggerRunning, + triggerManualRun, + triggerRetryRun, +} from '../../../src/triggers/shared/manual-runner.js'; +import type { CascadeConfig, ProjectConfig } from '../../../src/types/index.js'; + +const mockProject: ProjectConfig = { + id: 'test-project', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + trello: { + boardId: 'board-1', + lists: { briefing: 'l1', planning: 'l2', todo: 'l3' }, + labels: {}, + }, +} as unknown as ProjectConfig; + +const mockConfig = {} as CascadeConfig; + +describe('triggerManualRun', () => { + beforeEach(() => { + vi.clearAllMocks(); + clearTriggerTracking(); + }); + + it('throws when trigger is already running for same project+agent+card', async () => { + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {})); // Never resolves + + // Start first trigger (fire-and-forget, so no await) + triggerManualRun( + { + projectId: 'test-project', + agentType: 'implementation', + cardId: 'card-1', + }, + mockProject, + mockConfig, + ); + + // Wait a tick for the trigger to mark itself as running + await new Promise((resolve) => setImmediate(resolve)); + + // Try to trigger again + await expect( + triggerManualRun( + { + projectId: 'test-project', + agentType: 'implementation', + cardId: 'card-1', + }, + mockProject, + mockConfig, + ), + ).rejects.toThrow('Manual trigger already running'); + }); + + it('calls runAgent with correct input including triggerType: manual', async () => { + vi.mocked(runAgent).mockResolvedValue({ + success: true, + output: 'Done', + runId: 'run-1', + }); + + await triggerManualRun( + { + projectId: 'test-project', + agentType: 'implementation', + cardId: 'card-1', + modelOverride: 'claude-3-5-sonnet-20241022', + }, + mockProject, + mockConfig, + ); + + // Wait for async execution + await new Promise((resolve) => setImmediate(resolve)); + + expect(runAgent).toHaveBeenCalledWith( + 'implementation', + expect.objectContaining({ + cardId: 'card-1', + modelOverride: 'claude-3-5-sonnet-20241022', + triggerType: 'manual', + project: mockProject, + config: mockConfig, + }), + ); + }); + + it('calls runAgent with PR fields when provided', async () => { + vi.mocked(runAgent).mockResolvedValue({ + success: true, + output: 'Done', + runId: 'run-2', + }); + + await triggerManualRun( + { + projectId: 'test-project', + agentType: 'review', + prNumber: 42, + prBranch: 'feature/test', + repoFullName: 'owner/repo', + headSha: 'abc123', + }, + mockProject, + mockConfig, + ); + + await new Promise((resolve) => setImmediate(resolve)); + + expect(runAgent).toHaveBeenCalledWith( + 'review', + expect.objectContaining({ + prNumber: 42, + prBranch: 'feature/test', + repoFullName: 'owner/repo', + headSha: 'abc123', + triggerType: 'manual', + }), + ); + }); + + it('marks trigger as complete after runAgent finishes', async () => { + const projectId = 'test-project'; + const agentType = 'implementation'; + const cardId = 'card-complete'; + + vi.mocked(runAgent).mockResolvedValue({ + success: true, + output: 'Done', + runId: 'run-complete', + }); + + await triggerManualRun({ projectId, agentType, cardId }, mockProject, mockConfig); + + // Immediately after triggering, should be running + const key = `${projectId}:${agentType}:${cardId}:no-pr`; + expect(isTriggerRunning(key)).toBe(true); + + // Wait for completion + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Should be marked complete + expect(isTriggerRunning(key)).toBe(false); + }); + + it('marks trigger as complete even when runAgent fails', async () => { + const projectId = 'test-project'; + const agentType = 'implementation'; + const cardId = 'card-fail'; + + vi.mocked(runAgent).mockRejectedValue(new Error('Agent error')); + + await triggerManualRun({ projectId, agentType, cardId }, mockProject, mockConfig); + + // Should be running + const key = `${projectId}:${agentType}:${cardId}:no-pr`; + expect(isTriggerRunning(key)).toBe(true); + + // Wait for failure + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Should be marked complete + expect(isTriggerRunning(key)).toBe(false); + }); +}); + +describe('triggerRetryRun', () => { + beforeEach(() => { + vi.clearAllMocks(); + clearTriggerTracking(); + }); + + it('throws when run is not found', async () => { + vi.mocked(getRunById).mockResolvedValue(null); + + await expect(triggerRetryRun('run-1', mockProject, mockConfig)).rejects.toThrow( + 'Run not found: run-1', + ); + }); + + it('throws when run has no projectId', async () => { + vi.mocked(getRunById).mockResolvedValue({ + id: 'run-1', + agentType: 'implementation', + projectId: null, + } as ReturnType extends Promise ? NonNullable : never); + + await expect(triggerRetryRun('run-1', mockProject, mockConfig)).rejects.toThrow( + 'Run run-1 has no associated project', + ); + }); + + it('extracts params from original run and calls triggerManualRun', async () => { + vi.mocked(getRunById).mockResolvedValue({ + id: 'run-1', + agentType: 'implementation', + projectId: 'test-project', + cardId: 'card-1', + prNumber: null, + model: 'claude-sonnet-4-5-20250929', + } as ReturnType extends Promise ? NonNullable : never); + + vi.mocked(runAgent).mockResolvedValue({ + success: true, + output: 'Retried', + runId: 'run-2', + }); + + await triggerRetryRun('run-1', mockProject, mockConfig); + + // Wait for async execution + await new Promise((resolve) => setImmediate(resolve)); + + expect(runAgent).toHaveBeenCalledWith( + 'implementation', + expect.objectContaining({ + cardId: 'card-1', + modelOverride: 'claude-sonnet-4-5-20250929', + triggerType: 'manual', + }), + ); + }); + + it('uses modelOverride param if provided, otherwise falls back to original run model', async () => { + vi.mocked(getRunById).mockResolvedValue({ + id: 'run-1', + agentType: 'review', + projectId: 'test-project', + cardId: null, + prNumber: 10, + model: 'claude-sonnet-4-5-20250929', + } as ReturnType extends Promise ? NonNullable : never); + + vi.mocked(runAgent).mockResolvedValue({ + success: true, + output: 'Retried', + runId: 'run-3', + }); + + await triggerRetryRun('run-1', mockProject, mockConfig, 'claude-3-5-sonnet-20241022'); + + await new Promise((resolve) => setImmediate(resolve)); + + expect(runAgent).toHaveBeenCalledWith( + 'review', + expect.objectContaining({ + modelOverride: 'claude-3-5-sonnet-20241022', + }), + ); + }); +});