From 070413bd42af40dbaf96542945b7352c4fa7b278 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Mon, 23 Feb 2026 18:58:22 +0000 Subject: [PATCH 1/2] test: add 121 unit tests for highest-impact coverage gaps --- .../unit/agents/shared/builderFactory.test.ts | 257 +++++++++ .../unit/agents/shared/syntheticCalls.test.ts | 296 +++++++++++ tests/unit/agents/utils/agentLoop.test.ts | 486 ++++++++++++++++++ tests/unit/agents/utils/logging.test.ts | 88 ++++ tests/unit/agents/utils/setup.test.ts | 294 +++++++++++ tests/unit/pm/webhook-handler.test.ts | 306 +++++++++++ tests/unit/triggers/builtins.test.ts | 168 ++++++ tests/unit/utils/llmEnv.test.ts | 85 +++ 8 files changed, 1980 insertions(+) create mode 100644 tests/unit/agents/shared/builderFactory.test.ts create mode 100644 tests/unit/agents/shared/syntheticCalls.test.ts create mode 100644 tests/unit/agents/utils/agentLoop.test.ts create mode 100644 tests/unit/agents/utils/logging.test.ts create mode 100644 tests/unit/agents/utils/setup.test.ts create mode 100644 tests/unit/pm/webhook-handler.test.ts create mode 100644 tests/unit/triggers/builtins.test.ts create mode 100644 tests/unit/utils/llmEnv.test.ts diff --git a/tests/unit/agents/shared/builderFactory.test.ts b/tests/unit/agents/shared/builderFactory.test.ts new file mode 100644 index 00000000..7ea267c9 --- /dev/null +++ b/tests/unit/agents/shared/builderFactory.test.ts @@ -0,0 +1,257 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/utils/squintDb.js', () => ({ + resolveSquintDbPath: vi.fn().mockReturnValue(null), +})); + +vi.mock('../../../../src/config/compactionConfig.js', () => ({ + getCompactionConfig: vi.fn().mockReturnValue({ maxTokens: 100000, strategy: 'hybrid' }), +})); + +vi.mock('../../../../src/config/hintConfig.js', () => ({ + getIterationTrailingMessage: vi.fn().mockReturnValue(null), +})); + +vi.mock('../../../../src/config/rateLimits.js', () => ({ + getRateLimitForModel: vi.fn().mockReturnValue({ rpm: 60, tpm: 100000 }), +})); + +vi.mock('../../../../src/config/retryConfig.js', () => ({ + getRetryConfig: vi.fn().mockReturnValue({ maxRetries: 3 }), +})); + +vi.mock('../../../../src/gadgets/sessionState.js', () => ({ + initSessionState: vi.fn(), +})); + +vi.mock('../../../../src/agents/utils/hooks.js', () => ({ + createObserverHooks: vi.fn().mockReturnValue({ onIteration: vi.fn() }), +})); + +// Mock llmist +const mockBuilderInstance = { + withModel: vi.fn(), + withTemperature: vi.fn(), + withSystem: vi.fn(), + withMaxIterations: vi.fn(), + withLogger: vi.fn(), + withRateLimits: vi.fn(), + withRetry: vi.fn(), + withCompaction: vi.fn(), + withTrailingMessage: vi.fn(), + withTextOnlyHandler: vi.fn(), + withHooks: vi.fn(), + withGadgets: vi.fn(), + withMaxGadgetsPerResponse: vi.fn(), + withBudget: vi.fn(), +}; + +// Each method returns the builder for chaining +for (const key of Object.keys(mockBuilderInstance)) { + (mockBuilderInstance as Record)[key] = vi + .fn() + .mockReturnValue(mockBuilderInstance); +} + +vi.mock('llmist', () => ({ + AgentBuilder: vi.fn().mockImplementation(() => mockBuilderInstance), + BudgetPricingUnavailableError: class BudgetPricingUnavailableError extends Error {}, +})); + +import { AgentBuilder, BudgetPricingUnavailableError } from 'llmist'; +import { + createConfiguredBuilder, + isSquintEnabled, +} from '../../../../src/agents/shared/builderFactory.js'; +import { initSessionState } from '../../../../src/gadgets/sessionState.js'; +import { resolveSquintDbPath } from '../../../../src/utils/squintDb.js'; + +const mockResolveSquintDbPath = vi.mocked(resolveSquintDbPath); +const mockInitSessionState = vi.mocked(initSessionState); +const MockAgentBuilder = vi.mocked(AgentBuilder); + +function createBaseOptions(overrides?: object) { + return { + client: {} as never, + agentType: 'implementation', + model: 'claude-sonnet-4', + systemPrompt: 'You are a helpful assistant', + maxIterations: 20, + llmistLogger: {} as never, + trackingContext: { + metrics: { llmIterations: 0, gadgetCalls: 0 }, + syntheticInvocationIds: new Set(), + loopDetection: { + previousIterationCalls: [], + currentIterationCalls: [], + repeatCount: 1, + repeatedPattern: null, + pendingWarning: null, + nameOnlyRepeatCount: 1, + pendingAction: null, + }, + } as never, + logWriter: vi.fn(), + llmCallLogger: {} as never, + repoDir: '/repo', + gadgets: [] as never, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockResolveSquintDbPath.mockReturnValue(null); + + // Reset all mock builder methods to return the builder instance + for (const key of Object.keys(mockBuilderInstance)) { + (mockBuilderInstance as Record>)[key].mockReturnValue( + mockBuilderInstance, + ); + } +}); + +// ============================================================================ +// isSquintEnabled +// ============================================================================ + +describe('isSquintEnabled', () => { + it('returns false when resolveSquintDbPath returns null', () => { + mockResolveSquintDbPath.mockReturnValue(null); + expect(isSquintEnabled('/repo')).toBe(false); + }); + + it('returns true when resolveSquintDbPath returns a path', () => { + mockResolveSquintDbPath.mockReturnValue('/repo/.squint.db'); + expect(isSquintEnabled('/repo')).toBe(true); + }); +}); + +// ============================================================================ +// createConfiguredBuilder +// ============================================================================ + +describe('createConfiguredBuilder', () => { + it('creates an AgentBuilder with the given client', () => { + const options = createBaseOptions(); + createConfiguredBuilder(options); + expect(MockAgentBuilder).toHaveBeenCalledWith(options.client); + }); + + it('configures the model', () => { + const options = createBaseOptions(); + createConfiguredBuilder(options); + expect(mockBuilderInstance.withModel).toHaveBeenCalledWith('claude-sonnet-4'); + }); + + it('configures the system prompt', () => { + const options = createBaseOptions(); + createConfiguredBuilder(options); + expect(mockBuilderInstance.withSystem).toHaveBeenCalledWith('You are a helpful assistant'); + }); + + it('configures max iterations', () => { + const options = createBaseOptions(); + createConfiguredBuilder(options); + expect(mockBuilderInstance.withMaxIterations).toHaveBeenCalledWith(20); + }); + + it('sets temperature to 0', () => { + const options = createBaseOptions(); + createConfiguredBuilder(options); + expect(mockBuilderInstance.withTemperature).toHaveBeenCalledWith(0); + }); + + it('calls initSessionState when skipSessionState is not set', () => { + const options = createBaseOptions(); + createConfiguredBuilder(options); + expect(mockInitSessionState).toHaveBeenCalledWith( + 'implementation', + undefined, + undefined, + undefined, + ); + }); + + it('skips initSessionState when skipSessionState is true', () => { + const options = createBaseOptions({ skipSessionState: true }); + createConfiguredBuilder(options); + expect(mockInitSessionState).not.toHaveBeenCalled(); + }); + + it('passes baseBranch, projectId, cardId to initSessionState', () => { + const options = createBaseOptions({ + baseBranch: 'main', + projectId: 'project-1', + cardId: 'card-123', + }); + createConfiguredBuilder(options); + expect(mockInitSessionState).toHaveBeenCalledWith( + 'implementation', + 'main', + 'project-1', + 'card-123', + ); + }); + + it('calls withBudget when remainingBudgetUsd is positive', () => { + const options = createBaseOptions({ remainingBudgetUsd: 5.0 }); + createConfiguredBuilder(options); + expect(mockBuilderInstance.withBudget).toHaveBeenCalledWith(5.0); + }); + + it('does not call withBudget when remainingBudgetUsd is undefined', () => { + const options = createBaseOptions({ remainingBudgetUsd: undefined }); + createConfiguredBuilder(options); + expect(mockBuilderInstance.withBudget).not.toHaveBeenCalled(); + }); + + it('does not call withBudget when remainingBudgetUsd is 0', () => { + const options = createBaseOptions({ remainingBudgetUsd: 0 }); + createConfiguredBuilder(options); + expect(mockBuilderInstance.withBudget).not.toHaveBeenCalled(); + }); + + it('handles BudgetPricingUnavailableError gracefully', () => { + mockBuilderInstance.withBudget.mockImplementationOnce(() => { + throw new BudgetPricingUnavailableError('Budget unavailable'); + }); + const options = createBaseOptions({ remainingBudgetUsd: 5.0 }); + + // Should not throw + expect(() => createConfiguredBuilder(options)).not.toThrow(); + }); + + it('rethrows non-BudgetPricingUnavailableError errors from withBudget', () => { + mockBuilderInstance.withBudget.mockImplementationOnce(() => { + throw new Error('Unexpected budget error'); + }); + const options = createBaseOptions({ remainingBudgetUsd: 5.0 }); + + expect(() => createConfiguredBuilder(options)).toThrow('Unexpected budget error'); + }); + + it('calls postConfigure callback when provided', () => { + const customBuilder = { ...mockBuilderInstance, custom: true }; + const postConfigure = vi.fn().mockReturnValue(customBuilder); + const options = createBaseOptions({ postConfigure }); + + const result = createConfiguredBuilder(options); + + expect(postConfigure).toHaveBeenCalled(); + expect(result).toBe(customBuilder); + }); + + it('does not call postConfigure when not provided', () => { + const options = createBaseOptions({ postConfigure: undefined }); + + // Should not throw and returns builder + expect(() => createConfiguredBuilder(options)).not.toThrow(); + }); + + it('returns a builder with max gadgets per response set', () => { + const options = createBaseOptions(); + createConfiguredBuilder(options); + expect(mockBuilderInstance.withMaxGadgetsPerResponse).toHaveBeenCalledWith(25); + }); +}); diff --git a/tests/unit/agents/shared/syntheticCalls.test.ts b/tests/unit/agents/shared/syntheticCalls.test.ts new file mode 100644 index 00000000..80a2ab13 --- /dev/null +++ b/tests/unit/agents/shared/syntheticCalls.test.ts @@ -0,0 +1,296 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/utils/squintDb.js', () => ({ + resolveSquintDbPath: vi.fn().mockReturnValue(null), +})); + +vi.mock('../../../../src/agents/utils/tracking.js', () => ({ + recordSyntheticInvocationId: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(), +})); + +// Mock ListDirectory gadget +vi.mock('../../../../src/gadgets/ListDirectory.js', () => ({ + ListDirectory: vi.fn().mockImplementation(() => ({ + execute: vi.fn().mockReturnValue('mocked directory listing output'), + })), +})); + +import { execFileSync } from 'node:child_process'; +import { + injectContextFiles, + injectDirectoryListing, + injectSquintContext, + injectSyntheticCall, +} from '../../../../src/agents/shared/syntheticCalls.js'; +import { recordSyntheticInvocationId } from '../../../../src/agents/utils/tracking.js'; +import { resolveSquintDbPath } from '../../../../src/utils/squintDb.js'; + +const mockResolveSquintDbPath = vi.mocked(resolveSquintDbPath); +const mockExecFileSync = vi.mocked(execFileSync); +const mockRecordSyntheticInvocationId = vi.mocked(recordSyntheticInvocationId); + +function createMockBuilder() { + const builder = { + withSyntheticGadgetCall: vi.fn(), + }; + builder.withSyntheticGadgetCall.mockReturnValue(builder); + return builder; +} + +function createTrackingContext() { + return { + metrics: { llmIterations: 0, gadgetCalls: 0 }, + syntheticInvocationIds: new Set(), + loopDetection: { + previousIterationCalls: [], + currentIterationCalls: [], + repeatCount: 1, + repeatedPattern: null, + pendingWarning: null, + nameOnlyRepeatCount: 1, + pendingAction: null, + }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockResolveSquintDbPath.mockReturnValue(null); +}); + +describe('injectSyntheticCall', () => { + it('records the invocation ID for tracking', () => { + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + + injectSyntheticCall( + builder as never, + ctx as never, + 'ReadFile', + { filePath: '/foo.ts' }, + 'content', + 'gc_test', + ); + + expect(mockRecordSyntheticInvocationId).toHaveBeenCalledWith(ctx, 'gc_test'); + }); + + it('calls withSyntheticGadgetCall on builder with correct params', () => { + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + + injectSyntheticCall( + builder as never, + ctx as never, + 'ReadFile', + { filePath: '/foo.ts' }, + 'file content', + 'gc_1', + ); + + expect(builder.withSyntheticGadgetCall).toHaveBeenCalledWith( + 'ReadFile', + { filePath: '/foo.ts' }, + 'file content', + 'gc_1', + ); + }); + + it('returns the result of withSyntheticGadgetCall', () => { + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + + const result = injectSyntheticCall( + builder as never, + ctx as never, + 'ReadFile', + {}, + 'result', + 'gc_2', + ); + + expect(result).toBe(builder); + }); +}); + +describe('injectDirectoryListing', () => { + it('calls injectSyntheticCall with ListDirectory gadget name', () => { + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + + injectDirectoryListing(builder as never, ctx as never); + + expect(builder.withSyntheticGadgetCall).toHaveBeenCalledWith( + 'ListDirectory', + expect.objectContaining({ directoryPath: '.', maxDepth: 3 }), + 'mocked directory listing output', + 'gc_dir', + ); + }); + + it('uses custom maxDepth when provided', () => { + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + + injectDirectoryListing(builder as never, ctx as never, 5); + + expect(builder.withSyntheticGadgetCall).toHaveBeenCalledWith( + 'ListDirectory', + expect.objectContaining({ maxDepth: 5 }), + expect.any(String), + 'gc_dir', + ); + }); + + it('records the invocation ID gc_dir', () => { + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + + injectDirectoryListing(builder as never, ctx as never); + + expect(mockRecordSyntheticInvocationId).toHaveBeenCalledWith(ctx, 'gc_dir'); + }); +}); + +describe('injectContextFiles', () => { + it('injects multiple context files with sequential IDs', () => { + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + const files = [ + { path: 'CLAUDE.md', content: '# Project docs' }, + { path: 'AGENTS.md', content: '# Agent docs' }, + ]; + + injectContextFiles(builder as never, ctx as never, files); + + expect(builder.withSyntheticGadgetCall).toHaveBeenCalledTimes(2); + expect(builder.withSyntheticGadgetCall).toHaveBeenCalledWith( + 'ReadFile', + expect.objectContaining({ filePath: 'CLAUDE.md' }), + '# Project docs', + 'gc_init_1', + ); + expect(builder.withSyntheticGadgetCall).toHaveBeenCalledWith( + 'ReadFile', + expect.objectContaining({ filePath: 'AGENTS.md' }), + '# Agent docs', + 'gc_init_2', + ); + }); + + it('returns builder unchanged when contextFiles is empty', () => { + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + + const result = injectContextFiles(builder as never, ctx as never, []); + + expect(builder.withSyntheticGadgetCall).not.toHaveBeenCalled(); + expect(result).toBe(builder); + }); + + it('records synthetic invocation ID for each file', () => { + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + const files = [ + { path: 'CLAUDE.md', content: 'docs' }, + { path: 'AGENTS.md', content: 'agents' }, + ]; + + injectContextFiles(builder as never, ctx as never, files); + + expect(mockRecordSyntheticInvocationId).toHaveBeenCalledWith(ctx, 'gc_init_1'); + expect(mockRecordSyntheticInvocationId).toHaveBeenCalledWith(ctx, 'gc_init_2'); + }); + + it('includes comment describing the file in ReadFile params', () => { + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + const files = [{ path: 'CLAUDE.md', content: 'docs' }]; + + injectContextFiles(builder as never, ctx as never, files); + + expect(builder.withSyntheticGadgetCall).toHaveBeenCalledWith( + 'ReadFile', + expect.objectContaining({ comment: expect.stringContaining('CLAUDE.md') }), + 'docs', + 'gc_init_1', + ); + }); +}); + +describe('injectSquintContext', () => { + it('returns builder unchanged when squint DB not found', () => { + mockResolveSquintDbPath.mockReturnValue(null); + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + + const result = injectSquintContext(builder as never, ctx as never, '/repo'); + + expect(result).toBe(builder); + expect(builder.withSyntheticGadgetCall).not.toHaveBeenCalled(); + }); + + it('calls squint overview command when DB is found', () => { + mockResolveSquintDbPath.mockReturnValue('/repo/.squint.db'); + mockExecFileSync.mockReturnValue('squint overview output' as never); + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + + injectSquintContext(builder as never, ctx as never, '/repo'); + + expect(mockExecFileSync).toHaveBeenCalledWith( + 'squint', + ['overview', '-d', '/repo/.squint.db'], + { + encoding: 'utf-8', + timeout: 30_000, + }, + ); + }); + + it('injects squint overview as synthetic SquintOverview call', () => { + mockResolveSquintDbPath.mockReturnValue('/repo/.squint.db'); + mockExecFileSync.mockReturnValue('# Squint Overview\n- modules: 5' as never); + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + + injectSquintContext(builder as never, ctx as never, '/repo'); + + expect(builder.withSyntheticGadgetCall).toHaveBeenCalledWith( + 'SquintOverview', + expect.objectContaining({ database: '/repo/.squint.db' }), + '# Squint Overview\n- modules: 5', + 'gc_squint_overview', + ); + }); + + it('returns builder unchanged when squint output is empty', () => { + mockResolveSquintDbPath.mockReturnValue('/repo/.squint.db'); + mockExecFileSync.mockReturnValue('' as never); + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + + const result = injectSquintContext(builder as never, ctx as never, '/repo'); + + expect(result).toBe(builder); + expect(builder.withSyntheticGadgetCall).not.toHaveBeenCalled(); + }); + + it('returns builder unchanged when squint command throws', () => { + mockResolveSquintDbPath.mockReturnValue('/repo/.squint.db'); + mockExecFileSync.mockImplementation(() => { + throw new Error('squint not found'); + }); + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + + const result = injectSquintContext(builder as never, ctx as never, '/repo'); + + expect(result).toBe(builder); + expect(builder.withSyntheticGadgetCall).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/agents/utils/agentLoop.test.ts b/tests/unit/agents/utils/agentLoop.test.ts new file mode 100644 index 00000000..dd06b6c9 --- /dev/null +++ b/tests/unit/agents/utils/agentLoop.test.ts @@ -0,0 +1,486 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock external dependencies +vi.mock('../../../../src/gadgets/tmux.js', () => ({ + consumePendingSessionNotices: vi.fn().mockReturnValue(new Map()), +})); + +vi.mock('../../../../src/utils/interactive.js', () => ({ + displayGadgetCall: vi.fn(), + displayGadgetResult: vi.fn(), + displayLLMText: vi.fn(), + waitForEnter: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../../../src/agents/utils/logging.js', () => ({ + createAgentLogger: vi.fn(), +})); + +vi.mock('../../../../src/agents/utils/tracking.js', () => ({ + consumeLoopAction: vi.fn().mockReturnValue(null), + consumeLoopWarning: vi.fn().mockReturnValue(null), + incrementGadgetCall: vi.fn(), + isSyntheticCall: vi.fn().mockReturnValue(false), + recordGadgetCallForLoop: vi.fn(), +})); + +import { runAgentLoop, truncateContent } from '../../../../src/agents/utils/agentLoop.js'; +import { + consumeLoopAction, + consumeLoopWarning, + incrementGadgetCall, + isSyntheticCall, + recordGadgetCallForLoop, +} from '../../../../src/agents/utils/tracking.js'; +import { consumePendingSessionNotices } from '../../../../src/gadgets/tmux.js'; +import { + displayGadgetCall, + displayGadgetResult, + displayLLMText, + waitForEnter, +} from '../../../../src/utils/interactive.js'; + +const mockConsumePendingSessionNotices = vi.mocked(consumePendingSessionNotices); +const mockDisplayGadgetCall = vi.mocked(displayGadgetCall); +const mockDisplayGadgetResult = vi.mocked(displayGadgetResult); +const mockDisplayLLMText = vi.mocked(displayLLMText); +const mockWaitForEnter = vi.mocked(waitForEnter); +const mockConsumeLoopAction = vi.mocked(consumeLoopAction); +const mockConsumeLoopWarning = vi.mocked(consumeLoopWarning); +const mockIncrementGadgetCall = vi.mocked(incrementGadgetCall); +const mockIsSyntheticCall = vi.mocked(isSyntheticCall); +const mockRecordGadgetCallForLoop = vi.mocked(recordGadgetCallForLoop); + +function createMockLog() { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +function createTrackingContext(overrides?: object) { + return { + metrics: { llmIterations: 0, gadgetCalls: 0 }, + syntheticInvocationIds: new Set(), + loopDetection: { + previousIterationCalls: [], + currentIterationCalls: [], + repeatCount: 1, + repeatedPattern: null, + pendingWarning: null, + nameOnlyRepeatCount: 1, + pendingAction: null, + }, + ...overrides, + }; +} + +function createMockAgent(events: object[]) { + return { + run: async function* () { + for (const event of events) { + yield event; + } + }, + getTree: vi.fn().mockReturnValue({ getTotalCost: vi.fn().mockReturnValue(1.5) }), + injectUserMessage: vi.fn(), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockConsumePendingSessionNotices.mockReturnValue(new Map()); + mockConsumeLoopWarning.mockReturnValue(null); + mockConsumeLoopAction.mockReturnValue(null); + mockIsSyntheticCall.mockReturnValue(false); +}); + +// ============================================================================ +// truncateContent +// ============================================================================ + +describe('truncateContent', () => { + it('returns content unchanged if within maxLen', () => { + const content = 'hello world'; + expect(truncateContent(content, 400)).toBe('hello world'); + }); + + it('truncates content that exceeds maxLen', () => { + const content = 'a'.repeat(500); + const result = truncateContent(content, 400); + expect(result).toContain('[100 truncated]'); + expect(result.length).toBeLessThan(500); + }); + + it('uses default maxLen of 400', () => { + const content = 'x'.repeat(500); + const result = truncateContent(content); + expect(result).toContain('truncated'); + }); + + it('preserves first and last half of content', () => { + const content = `FIRST${'x'.repeat(500)}LAST`; + const result = truncateContent(content, 400); + expect(result.startsWith('FIRST')).toBe(true); + expect(result.endsWith('LAST')).toBe(true); + }); + + it('returns content exactly at maxLen unchanged', () => { + const content = 'a'.repeat(400); + expect(truncateContent(content, 400)).toBe(content); + }); +}); + +// ============================================================================ +// runAgentLoop +// ============================================================================ + +describe('runAgentLoop', () => { + it('processes text events and accumulates output', async () => { + const agent = createMockAgent([ + { type: 'text', content: 'Hello' }, + { type: 'text', content: 'World' }, + ]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + const result = await runAgentLoop(agent as never, log as never, ctx as never); + + expect(result.output).toBe('Hello\nWorld'); + }); + + it('returns cost from getTree().getTotalCost()', async () => { + const agent = createMockAgent([]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + const result = await runAgentLoop(agent as never, log as never, ctx as never); + + expect(result.cost).toBe(1.5); + }); + + it('returns zero cost if getTree() returns null', async () => { + const agent = createMockAgent([]); + agent.getTree.mockReturnValue(null); + const log = createMockLog(); + const ctx = createTrackingContext(); + + const result = await runAgentLoop(agent as never, log as never, ctx as never); + + expect(result.cost).toBe(0); + }); + + it('tracks gadget calls via incrementGadgetCall for non-synthetic calls', async () => { + const agent = createMockAgent([ + { + type: 'gadget_call', + call: { gadgetName: 'ReadFile', invocationId: 'gc_1', parameters: { filePath: '/foo.ts' } }, + }, + ]); + mockIsSyntheticCall.mockReturnValue(false); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never); + + expect(mockIncrementGadgetCall).toHaveBeenCalled(); + expect(mockRecordGadgetCallForLoop).toHaveBeenCalled(); + }); + + it('does not call incrementGadgetCall for synthetic calls', async () => { + const agent = createMockAgent([ + { + type: 'gadget_call', + call: { gadgetName: 'ReadFile', invocationId: 'gc_init_1', parameters: {} }, + }, + ]); + mockIsSyntheticCall.mockReturnValue(true); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never); + + expect(mockIncrementGadgetCall).not.toHaveBeenCalled(); + }); + + it('handles gadget_result events and logs them', async () => { + const agent = createMockAgent([ + { + type: 'gadget_result', + result: { gadgetName: 'ReadFile', executionTimeMs: 50, result: 'file contents' }, + }, + ]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never); + + expect(log.info).toHaveBeenCalledWith( + '[Gadget result]', + expect.objectContaining({ name: 'ReadFile', ms: 50 }), + ); + }); + + it('logs error for gadget_result with error field', async () => { + const agent = createMockAgent([ + { + type: 'gadget_result', + result: { gadgetName: 'WriteFile', executionTimeMs: 10, error: 'Permission denied' }, + }, + ]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never); + + expect(log.error).toHaveBeenCalledWith( + '[Gadget result]', + expect.objectContaining({ error: 'Permission denied' }), + ); + }); + + it('handles stream_complete event by logging info', async () => { + const agent = createMockAgent([{ type: 'stream_complete' }]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never); + + expect(log.info).toHaveBeenCalledWith('Stream complete', expect.any(Object)); + }); + + it('calls injectUserMessage when session completions are pending', async () => { + const notice = { exitCode: 0, tailOutput: 'output' }; + mockConsumePendingSessionNotices.mockReturnValue(new Map([['session1', notice]])); + + const agent = createMockAgent([{ type: 'text', content: 'Hello' }]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never); + + expect(agent.injectUserMessage).toHaveBeenCalledWith(expect.stringContaining('session1')); + expect(agent.injectUserMessage).toHaveBeenCalledWith(expect.stringContaining('exit code 0')); + }); + + it('injects loop warning messages when pending', async () => { + mockConsumeLoopWarning.mockReturnValue('⚠️ LOOP DETECTED — please change approach'); + + const agent = createMockAgent([{ type: 'text', content: 'Hello' }]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never); + + expect(agent.injectUserMessage).toHaveBeenCalledWith( + '⚠️ LOOP DETECTED — please change approach', + ); + }); + + it('terminates with hard_stop when loop action says hard_stop', async () => { + mockConsumeLoopAction.mockReturnValue({ + type: 'hard_stop', + message: '[System] 🛑 SEMANTIC LOOP — FORCED TERMINATION', + }); + + // Create a generator that yields multiple events + const events = [ + { type: 'text', content: 'First' }, + { type: 'text', content: 'Second' }, + ]; + const agent = createMockAgent(events); + const log = createMockLog(); + const ctx = createTrackingContext(); + + const result = await runAgentLoop(agent as never, log as never, ctx as never); + + expect(result.loopTerminated).toBe(true); + expect(log.error).toHaveBeenCalledWith( + '[Loop Hard Stop] Agent terminated due to persistent semantic loop', + expect.any(Object), + ); + }); + + it('does not set loopTerminated when normal completion', async () => { + const agent = createMockAgent([{ type: 'text', content: 'Done' }]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + const result = await runAgentLoop(agent as never, log as never, ctx as never); + + expect(result.loopTerminated).toBe(false); + }); + + it('displays text in interactive mode', async () => { + const agent = createMockAgent([{ type: 'text', content: 'Interactive output' }]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never, true); + + expect(mockDisplayLLMText).toHaveBeenCalledWith('Interactive output'); + }); + + it('does not display text in non-interactive mode', async () => { + const agent = createMockAgent([{ type: 'text', content: 'Silent output' }]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never, false); + + expect(mockDisplayLLMText).not.toHaveBeenCalled(); + }); + + it('displays gadget calls in interactive mode', async () => { + const agent = createMockAgent([ + { + type: 'gadget_call', + call: { gadgetName: 'ReadFile', invocationId: 'gc_1', parameters: { filePath: '/foo.ts' } }, + }, + ]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never, true); + + expect(mockDisplayGadgetCall).toHaveBeenCalledWith('ReadFile', { filePath: '/foo.ts' }, false); + }); + + it('waits for enter on non-synthetic gadget call in interactive non-autoAccept mode', async () => { + mockIsSyntheticCall.mockReturnValue(false); + const agent = createMockAgent([ + { + type: 'gadget_call', + call: { gadgetName: 'WriteFile', invocationId: 'gc_2', parameters: {} }, + }, + ]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never, true, false); + + expect(mockWaitForEnter).toHaveBeenCalled(); + }); + + it('skips waitForEnter for synthetic calls even in interactive mode', async () => { + mockIsSyntheticCall.mockReturnValue(true); + const agent = createMockAgent([ + { + type: 'gadget_call', + call: { gadgetName: 'ReadFile', invocationId: 'gc_init_1', parameters: {} }, + }, + ]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never, true, false); + + expect(mockWaitForEnter).not.toHaveBeenCalled(); + }); + + it('skips waitForEnter in autoAccept mode', async () => { + mockIsSyntheticCall.mockReturnValue(false); + const agent = createMockAgent([ + { + type: 'gadget_call', + call: { gadgetName: 'WriteFile', invocationId: 'gc_2', parameters: {} }, + }, + ]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never, true, true); + + expect(mockWaitForEnter).not.toHaveBeenCalled(); + }); + + it('returns gadgetCalls and iterations from tracking context', async () => { + const ctx = createTrackingContext({ metrics: { llmIterations: 5, gadgetCalls: 12 } }); + const agent = createMockAgent([]); + const log = createMockLog(); + + const result = await runAgentLoop(agent as never, log as never, ctx as never); + + expect(result.iterations).toBe(5); + expect(result.gadgetCalls).toBe(12); + }); + + it('adds comment to log context when present in parameters', async () => { + const agent = createMockAgent([ + { + type: 'gadget_call', + call: { + gadgetName: 'Bash', + invocationId: 'gc_1', + parameters: { comment: 'Running tests', command: 'npm test' }, + }, + }, + ]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never); + + expect(log.info).toHaveBeenCalledWith( + '[Gadget]', + expect.objectContaining({ comment: 'Running tests' }), + ); + }); + + it('adds path to log context for gadgets with filePath parameter', async () => { + const agent = createMockAgent([ + { + type: 'gadget_call', + call: { + gadgetName: 'ReadFile', + invocationId: 'gc_1', + parameters: { filePath: '/src/foo.ts' }, + }, + }, + ]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never); + + expect(log.info).toHaveBeenCalledWith( + '[Gadget]', + expect.objectContaining({ path: '/src/foo.ts' }), + ); + }); + + it('adds params to log context for Tmux gadget', async () => { + const agent = createMockAgent([ + { + type: 'gadget_call', + call: { + gadgetName: 'Tmux', + invocationId: 'gc_1', + parameters: { session: 'test', command: 'npm test' }, + }, + }, + ]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never); + + expect(log.info).toHaveBeenCalledWith( + '[Gadget]', + expect.objectContaining({ params: { session: 'test', command: 'npm test' } }), + ); + }); + + it('handles empty events gracefully', async () => { + const agent = createMockAgent([]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + const result = await runAgentLoop(agent as never, log as never, ctx as never); + + expect(result.output).toBe(''); + expect(result.loopTerminated).toBe(false); + }); +}); diff --git a/tests/unit/agents/utils/logging.test.ts b/tests/unit/agents/utils/logging.test.ts new file mode 100644 index 00000000..35cb1c00 --- /dev/null +++ b/tests/unit/agents/utils/logging.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { createAgentLogger } from '../../../../src/agents/utils/logging.js'; +import { logger } from '../../../../src/utils/logging.js'; + +const mockLogger = vi.mocked(logger); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('createAgentLogger', () => { + it('debug writes to both console logger and file logger', () => { + const fileLogger = { write: vi.fn() }; + const agentLogger = createAgentLogger(fileLogger as never); + + agentLogger.debug('test debug', { key: 'value' }); + + expect(mockLogger.debug).toHaveBeenCalledWith('test debug', { key: 'value' }); + expect(fileLogger.write).toHaveBeenCalledWith('DEBUG', 'test debug', { key: 'value' }); + }); + + it('info writes to both console logger and file logger', () => { + const fileLogger = { write: vi.fn() }; + const agentLogger = createAgentLogger(fileLogger as never); + + agentLogger.info('test info', { foo: 'bar' }); + + expect(mockLogger.info).toHaveBeenCalledWith('test info', { foo: 'bar' }); + expect(fileLogger.write).toHaveBeenCalledWith('INFO', 'test info', { foo: 'bar' }); + }); + + it('warn writes to both console logger and file logger', () => { + const fileLogger = { write: vi.fn() }; + const agentLogger = createAgentLogger(fileLogger as never); + + agentLogger.warn('test warn'); + + expect(mockLogger.warn).toHaveBeenCalledWith('test warn', undefined); + expect(fileLogger.write).toHaveBeenCalledWith('WARN', 'test warn', undefined); + }); + + it('error writes to both console logger and file logger', () => { + const fileLogger = { write: vi.fn() }; + const agentLogger = createAgentLogger(fileLogger as never); + + agentLogger.error('test error', { errCode: 42 }); + + expect(mockLogger.error).toHaveBeenCalledWith('test error', { errCode: 42 }); + expect(fileLogger.write).toHaveBeenCalledWith('ERROR', 'test error', { errCode: 42 }); + }); + + it('works with null fileLogger — only writes to console logger', () => { + const agentLogger = createAgentLogger(null); + + agentLogger.info('no file logger', { x: 1 }); + + expect(mockLogger.info).toHaveBeenCalledWith('no file logger', { x: 1 }); + }); + + it('does not throw when fileLogger is null for all log levels', () => { + const agentLogger = createAgentLogger(null); + + expect(() => agentLogger.debug('d')).not.toThrow(); + expect(() => agentLogger.info('i')).not.toThrow(); + expect(() => agentLogger.warn('w')).not.toThrow(); + expect(() => agentLogger.error('e')).not.toThrow(); + }); + + it('works with no context argument', () => { + const fileLogger = { write: vi.fn() }; + const agentLogger = createAgentLogger(fileLogger as never); + + agentLogger.info('no context'); + + expect(mockLogger.info).toHaveBeenCalledWith('no context', undefined); + expect(fileLogger.write).toHaveBeenCalledWith('INFO', 'no context', undefined); + }); +}); diff --git a/tests/unit/agents/utils/setup.test.ts b/tests/unit/agents/utils/setup.test.ts new file mode 100644 index 00000000..8d55d9b7 --- /dev/null +++ b/tests/unit/agents/utils/setup.test.ts @@ -0,0 +1,294 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), +})); + +vi.mock('../../src/utils/repo.js', () => ({ + runCommand: vi.fn(), +})); + +// We need to mock at the path the module actually imports from +vi.mock('../../../../src/utils/repo.js', () => ({ + runCommand: vi.fn(), +})); + +import { existsSync, readFileSync } from 'node:fs'; +import { + LOG_LEVELS, + getLogLevel, + installDependencies, + readContextFiles, + warmTypeScriptCache, +} from '../../../../src/agents/utils/setup.js'; +import { runCommand } from '../../../../src/utils/repo.js'; + +const mockExistsSync = vi.mocked(existsSync); +const mockReadFileSync = vi.mocked(readFileSync); +const mockRunCommand = vi.mocked(runCommand); + +beforeEach(() => { + vi.clearAllMocks(); + Reflect.deleteProperty(process.env, 'LLMIST_LOG_LEVEL'); + Reflect.deleteProperty(process.env, 'LOG_LEVEL'); +}); + +afterEach(() => { + Reflect.deleteProperty(process.env, 'LLMIST_LOG_LEVEL'); + Reflect.deleteProperty(process.env, 'LOG_LEVEL'); +}); + +// ============================================================================ +// getLogLevel +// ============================================================================ + +describe('getLogLevel', () => { + it('returns debug level (2) by default when no env vars set', () => { + expect(getLogLevel()).toBe(LOG_LEVELS.debug); + }); + + it('reads from LLMIST_LOG_LEVEL env var first', () => { + process.env.LLMIST_LOG_LEVEL = 'info'; + expect(getLogLevel()).toBe(LOG_LEVELS.info); + }); + + it('reads from LOG_LEVEL env var when LLMIST_LOG_LEVEL is not set', () => { + process.env.LOG_LEVEL = 'warn'; + expect(getLogLevel()).toBe(LOG_LEVELS.warn); + }); + + it('LLMIST_LOG_LEVEL takes precedence over LOG_LEVEL', () => { + process.env.LLMIST_LOG_LEVEL = 'error'; + process.env.LOG_LEVEL = 'info'; + expect(getLogLevel()).toBe(LOG_LEVELS.error); + }); + + it('is case-insensitive', () => { + process.env.LOG_LEVEL = 'DEBUG'; + expect(getLogLevel()).toBe(LOG_LEVELS.debug); + }); + + it('returns debug level for unknown log level strings', () => { + process.env.LOG_LEVEL = 'unknown-level'; + expect(getLogLevel()).toBe(LOG_LEVELS.debug); + }); + + it('has correct numeric values for standard log levels', () => { + expect(LOG_LEVELS.silly).toBe(0); + expect(LOG_LEVELS.trace).toBe(1); + expect(LOG_LEVELS.debug).toBe(2); + expect(LOG_LEVELS.info).toBe(3); + expect(LOG_LEVELS.warn).toBe(4); + expect(LOG_LEVELS.error).toBe(5); + expect(LOG_LEVELS.fatal).toBe(6); + }); +}); + +// ============================================================================ +// readContextFiles +// ============================================================================ + +describe('readContextFiles', () => { + it('returns CLAUDE.md and AGENTS.md content when both exist', async () => { + mockRunCommand + .mockResolvedValueOnce({ stdout: '# Claude docs', stderr: '' }) + .mockResolvedValueOnce({ stdout: '# Agents docs', stderr: '' }); + + const result = await readContextFiles('/repo'); + + expect(result).toEqual([ + { path: 'CLAUDE.md', content: '# Claude docs' }, + { path: 'AGENTS.md', content: '# Agents docs' }, + ]); + }); + + it('skips files that produce empty stdout', async () => { + mockRunCommand + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: '# Agents docs', stderr: '' }); + + const result = await readContextFiles('/repo'); + + expect(result).toEqual([{ path: 'AGENTS.md', content: '# Agents docs' }]); + }); + + it('skips files that throw (file not found)', async () => { + mockRunCommand + .mockRejectedValueOnce(new Error('ENOENT')) + .mockResolvedValueOnce({ stdout: '# Agents docs', stderr: '' }); + + const result = await readContextFiles('/repo'); + + expect(result).toEqual([{ path: 'AGENTS.md', content: '# Agents docs' }]); + }); + + it('returns empty array when all files are missing', async () => { + mockRunCommand.mockRejectedValue(new Error('ENOENT')); + + const result = await readContextFiles('/repo'); + + expect(result).toEqual([]); + }); + + it('trims whitespace from file content', async () => { + mockRunCommand + .mockResolvedValueOnce({ stdout: ' # Claude docs \n', stderr: '' }) + .mockRejectedValueOnce(new Error('ENOENT')); + + const result = await readContextFiles('/repo'); + + expect(result[0].content).toBe('# Claude docs'); + }); +}); + +// ============================================================================ +// installDependencies +// ============================================================================ + +describe('installDependencies', () => { + it('returns null when package.json does not exist', async () => { + mockExistsSync.mockReturnValue(false); + + const result = await installDependencies('/repo'); + + expect(result).toBeNull(); + }); + + it('uses npm by default when no lockfile found', async () => { + // package.json exists + mockExistsSync.mockImplementation((path) => { + return String(path).endsWith('package.json'); + }); + mockReadFileSync.mockReturnValue('{}' as never); + mockRunCommand.mockResolvedValue({ stdout: 'installed', stderr: '' }); + + const result = await installDependencies('/repo'); + + expect(result?.packageManager).toBe('npm'); + expect(mockRunCommand).toHaveBeenCalledWith('npm', ['install'], '/repo', expect.any(Object)); + }); + + it('detects pnpm from pnpm-lock.yaml', async () => { + mockExistsSync.mockImplementation((path) => { + const p = String(path); + return p.endsWith('package.json') || p.endsWith('pnpm-lock.yaml'); + }); + mockRunCommand.mockResolvedValue({ stdout: '', stderr: '' }); + + const result = await installDependencies('/repo'); + + expect(result?.packageManager).toBe('pnpm'); + }); + + it('detects yarn from yarn.lock', async () => { + mockExistsSync.mockImplementation((path) => { + const p = String(path); + return p.endsWith('package.json') || p.endsWith('yarn.lock'); + }); + // pnpm-lock.yaml should not exist (checked first) + mockRunCommand.mockResolvedValue({ stdout: '', stderr: '' }); + + const result = await installDependencies('/repo'); + + expect(result?.packageManager).toBe('yarn'); + }); + + it('returns success=true when install succeeds', async () => { + mockExistsSync.mockImplementation((path) => String(path).endsWith('package.json')); + mockReadFileSync.mockReturnValue('{}' as never); + mockRunCommand.mockResolvedValue({ stdout: 'ok', stderr: '' }); + + const result = await installDependencies('/repo'); + + expect(result?.success).toBe(true); + }); + + it('returns success=false when install throws', async () => { + mockExistsSync.mockImplementation((path) => String(path).endsWith('package.json')); + mockReadFileSync.mockReturnValue('{}' as never); + mockRunCommand.mockRejectedValue(new Error('install failed')); + + const result = await installDependencies('/repo'); + + expect(result?.success).toBe(false); + expect(result?.error).toContain('install failed'); + }); + + it('passes CI=true environment variable to install', async () => { + mockExistsSync.mockImplementation((path) => String(path).endsWith('package.json')); + mockReadFileSync.mockReturnValue('{}' as never); + mockRunCommand.mockResolvedValue({ stdout: '', stderr: '' }); + + await installDependencies('/repo'); + + expect(mockRunCommand).toHaveBeenCalledWith( + expect.any(String), + ['install'], + '/repo', + expect.objectContaining({ CI: 'true' }), + ); + }); + + it('reads packageManager field from package.json as fallback', async () => { + mockExistsSync.mockImplementation((path) => String(path).endsWith('package.json')); + mockReadFileSync.mockReturnValue(JSON.stringify({ packageManager: 'pnpm@8.0.0' }) as never); + mockRunCommand.mockResolvedValue({ stdout: '', stderr: '' }); + + const result = await installDependencies('/repo'); + + expect(result?.packageManager).toBe('pnpm'); + }); +}); + +// ============================================================================ +// warmTypeScriptCache +// ============================================================================ + +describe('warmTypeScriptCache', () => { + it('returns null when tsconfig.json does not exist', async () => { + mockExistsSync.mockReturnValue(false); + + const result = await warmTypeScriptCache('/repo'); + + expect(result).toBeNull(); + }); + + it('runs tsc --noEmit when tsconfig.json exists', async () => { + mockExistsSync.mockReturnValue(true); + mockRunCommand.mockResolvedValue({ stdout: '', stderr: '' }); + + await warmTypeScriptCache('/repo'); + + expect(mockRunCommand).toHaveBeenCalledWith('npx', ['tsc', '--noEmit'], '/repo'); + }); + + it('returns success=true when tsc succeeds', async () => { + mockExistsSync.mockReturnValue(true); + mockRunCommand.mockResolvedValue({ stdout: '', stderr: '' }); + + const result = await warmTypeScriptCache('/repo'); + + expect(result?.success).toBe(true); + expect(result?.durationMs).toBeGreaterThanOrEqual(0); + }); + + it('returns success=true even when tsc fails (type errors expected)', async () => { + mockExistsSync.mockReturnValue(true); + mockRunCommand.mockRejectedValue(new Error('Type error in foo.ts')); + + const result = await warmTypeScriptCache('/repo'); + + expect(result?.success).toBe(true); + expect(result?.error).toContain('Type error in foo.ts'); + }); + + it('includes durationMs in the result', async () => { + mockExistsSync.mockReturnValue(true); + mockRunCommand.mockResolvedValue({ stdout: '', stderr: '' }); + + const result = await warmTypeScriptCache('/repo'); + + expect(typeof result?.durationMs).toBe('number'); + }); +}); diff --git a/tests/unit/pm/webhook-handler.test.ts b/tests/unit/pm/webhook-handler.test.ts new file mode 100644 index 00000000..26a67ae1 --- /dev/null +++ b/tests/unit/pm/webhook-handler.test.ts @@ -0,0 +1,306 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/github/client.js', () => ({ + withGitHubToken: vi.fn().mockImplementation((_token, fn) => fn()), +})); + +vi.mock('../../../src/github/personas.js', () => ({ + getPersonaToken: vi.fn().mockResolvedValue('gh-token-xxx'), +})); + +vi.mock('../../../src/triggers/shared/agent-execution.js', () => ({ + runAgentExecutionPipeline: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../../src/triggers/shared/webhook-queue.js', () => ({ + processNextQueuedWebhook: vi.fn(), +})); + +vi.mock('../../../src/utils/index.js', () => ({ + clearCardActive: vi.fn(), + enqueueWebhook: vi.fn().mockReturnValue(true), + getQueueLength: vi.fn().mockReturnValue(0), + isCardActive: vi.fn().mockReturnValue(false), + isCurrentlyProcessing: vi.fn().mockReturnValue(false), + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + setCardActive: vi.fn(), + setProcessing: vi.fn(), + startWatchdog: vi.fn(), +})); + +vi.mock('../../../src/utils/llmEnv.js', () => ({ + injectLlmApiKeys: vi.fn().mockResolvedValue(vi.fn()), +})); + +vi.mock('../../../src/pm/context.js', () => ({ + getPMProvider: vi.fn().mockReturnValue({}), + withPMProvider: vi.fn().mockImplementation((_provider, fn) => fn()), +})); + +vi.mock('../../../src/pm/lifecycle.js', () => ({ + PMLifecycleManager: vi.fn().mockImplementation(() => ({ + handleError: vi.fn().mockResolvedValue(undefined), + })), + resolveProjectPMConfig: vi.fn().mockReturnValue({ type: 'trello' }), +})); + +vi.mock('../../../src/pm/registry.js', () => ({ + pmRegistry: { + createProvider: vi.fn().mockReturnValue({}), + }, +})); + +import { processPMWebhook } from '../../../src/pm/webhook-handler.js'; +import { runAgentExecutionPipeline } from '../../../src/triggers/shared/agent-execution.js'; +import { + clearCardActive, + enqueueWebhook, + isCardActive, + isCurrentlyProcessing, + setCardActive, + setProcessing, + startWatchdog, +} from '../../../src/utils/index.js'; + +const mockIsCurrentlyProcessing = vi.mocked(isCurrentlyProcessing); +const mockIsCardActive = vi.mocked(isCardActive); +const mockEnqueueWebhook = vi.mocked(enqueueWebhook); +const mockSetProcessing = vi.mocked(setProcessing); +const mockStartWatchdog = vi.mocked(startWatchdog); +const mockSetCardActive = vi.mocked(setCardActive); +const mockClearCardActive = vi.mocked(clearCardActive); +const mockRunAgentExecutionPipeline = vi.mocked(runAgentExecutionPipeline); + +// ============================================================================ +// PMIntegration factory +// ============================================================================ + +function createMockIntegration( + overrides?: Partial<{ + parseWebhookPayload: () => object | null; + lookupProject: () => object | null; + withCredentials: (projectId: string, fn: () => Promise) => Promise; + deleteAckComment: () => Promise; + type: string; + }>, +) { + const mockEvent = { + projectIdentifier: 'BOARD_123', + workItemId: 'card-abc', + eventType: 'card_moved', + }; + const mockProject = { + id: 'project-1', + name: 'Test Project', + repo: 'owner/repo', + baseBranch: 'main', + }; + const mockConfig = { + defaults: { watchdogTimeoutMs: 120000 }, + }; + + return { + type: 'trello', + parseWebhookPayload: vi.fn().mockReturnValue(mockEvent), + lookupProject: vi.fn().mockResolvedValue({ project: mockProject, config: mockConfig }), + withCredentials: vi + .fn() + .mockImplementation((_projectId: string, fn: () => Promise) => fn()), + deleteAckComment: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function createMockRegistry(result?: object | null) { + return { + dispatch: vi.fn().mockResolvedValue( + result === undefined + ? { + agentType: 'implementation', + workItemId: 'card-abc', + agentInput: { cardId: 'card-abc' }, + } + : result, + ), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockIsCurrentlyProcessing.mockReturnValue(false); + mockIsCardActive.mockReturnValue(false); + mockEnqueueWebhook.mockReturnValue(true); + mockRunAgentExecutionPipeline.mockResolvedValue(undefined); +}); + +// ============================================================================ +// processPMWebhook +// ============================================================================ + +describe('processPMWebhook', () => { + it('returns early when payload is invalid', async () => { + const integration = createMockIntegration({ + parseWebhookPayload: vi.fn().mockReturnValue(null), + }); + const registry = createMockRegistry(); + + await processPMWebhook(integration as never, { invalid: true }, registry as never); + + expect(registry.dispatch).not.toHaveBeenCalled(); + }); + + it('enqueues webhook when currently processing', async () => { + mockIsCurrentlyProcessing.mockReturnValue(true); + const integration = createMockIntegration(); + const registry = createMockRegistry(); + + await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); + + expect(mockEnqueueWebhook).toHaveBeenCalled(); + expect(registry.dispatch).not.toHaveBeenCalled(); + }); + + it('returns early when no project found for identifier', async () => { + const integration = createMockIntegration({ + lookupProject: vi.fn().mockResolvedValue(null), + }); + const registry = createMockRegistry(); + + await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); + + expect(registry.dispatch).not.toHaveBeenCalled(); + }); + + it('dispatches to trigger registry when project found', async () => { + const integration = createMockIntegration(); + const registry = createMockRegistry(); + + await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); + + expect(registry.dispatch).toHaveBeenCalled(); + }); + + it('runs agent when trigger matches', async () => { + const integration = createMockIntegration(); + const registry = createMockRegistry(); + + await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); + + expect(mockRunAgentExecutionPipeline).toHaveBeenCalled(); + }); + + it('sets card active and clears it after execution', async () => { + const integration = createMockIntegration(); + const registry = createMockRegistry(); + + await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); + + expect(mockSetCardActive).toHaveBeenCalledWith('card-abc'); + expect(mockClearCardActive).toHaveBeenCalledWith('card-abc'); + }); + + it('starts watchdog on trigger match', async () => { + const integration = createMockIntegration(); + const registry = createMockRegistry(); + + await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); + + expect(mockStartWatchdog).toHaveBeenCalledWith(120000); + }); + + it('sets processing to true on start and false when done', async () => { + const integration = createMockIntegration(); + const registry = createMockRegistry(); + + await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); + + expect(mockSetProcessing).toHaveBeenCalledWith(true); + expect(mockSetProcessing).toHaveBeenCalledWith(false); + }); + + it('skips agent execution when work item is already active', async () => { + mockIsCardActive.mockReturnValue(true); + const integration = createMockIntegration(); + const registry = createMockRegistry(); + + await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); + + expect(mockRunAgentExecutionPipeline).not.toHaveBeenCalled(); + }); + + it('still clears processing flag when agent throws', async () => { + mockRunAgentExecutionPipeline.mockRejectedValue(new Error('Agent failed')); + const integration = createMockIntegration(); + const registry = createMockRegistry(); + + await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); + + expect(mockSetProcessing).toHaveBeenCalledWith(false); + }); + + it('uses pre-resolved trigger result when provided', async () => { + const integration = createMockIntegration(); + const registry = createMockRegistry(null); // registry would return null + const preResolvedResult = { + agentType: 'briefing', + workItemId: 'card-pre', + agentInput: { cardId: 'card-pre' }, + }; + + await processPMWebhook( + integration as never, + { type: 'card_moved' }, + registry as never, + undefined, + preResolvedResult, + ); + + // Should use the pre-resolved result, not dispatch to registry + expect(registry.dispatch).not.toHaveBeenCalled(); + expect(mockRunAgentExecutionPipeline).toHaveBeenCalled(); + }); + + it('passes ackCommentId into agentInput when provided', async () => { + const integration = createMockIntegration(); + const registry = createMockRegistry(); + + await processPMWebhook( + integration as never, + { type: 'card_moved' }, + registry as never, + 'ack-comment-123', + ); + + // Verify ackCommentId was injected — the agent pipeline was called + expect(mockRunAgentExecutionPipeline).toHaveBeenCalled(); + }); + + it('does not set card active when workItemId is undefined', async () => { + const integration = createMockIntegration(); + const registry = { + dispatch: vi.fn().mockResolvedValue({ + agentType: 'implementation', + workItemId: undefined, // no workItemId + agentInput: {}, + }), + }; + + await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); + + expect(mockSetCardActive).not.toHaveBeenCalled(); + }); + + it('calls withCredentials on integration during execution', async () => { + const integration = createMockIntegration(); + const registry = createMockRegistry(); + + await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); + + expect(integration.withCredentials).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/triggers/builtins.test.ts b/tests/unit/triggers/builtins.test.ts new file mode 100644 index 00000000..9fa58775 --- /dev/null +++ b/tests/unit/triggers/builtins.test.ts @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock all trigger imports +vi.mock('../../../src/triggers/github/check-suite-failure.js', () => ({ + CheckSuiteFailureTrigger: vi.fn().mockImplementation(() => ({ name: 'check-suite-failure' })), +})); +vi.mock('../../../src/triggers/github/check-suite-success.js', () => ({ + CheckSuiteSuccessTrigger: vi.fn().mockImplementation(() => ({ name: 'check-suite-success' })), +})); +vi.mock('../../../src/triggers/github/pr-comment-mention.js', () => ({ + PRCommentMentionTrigger: vi.fn().mockImplementation(() => ({ name: 'pr-comment-mention' })), +})); +vi.mock('../../../src/triggers/github/pr-merged.js', () => ({ + PRMergedTrigger: vi.fn().mockImplementation(() => ({ name: 'pr-merged' })), +})); +vi.mock('../../../src/triggers/github/pr-opened.js', () => ({ + PROpenedTrigger: vi.fn().mockImplementation(() => ({ name: 'pr-opened' })), +})); +vi.mock('../../../src/triggers/github/pr-ready-to-merge.js', () => ({ + PRReadyToMergeTrigger: vi.fn().mockImplementation(() => ({ name: 'pr-ready-to-merge' })), +})); +vi.mock('../../../src/triggers/github/pr-review-submitted.js', () => ({ + PRReviewSubmittedTrigger: vi.fn().mockImplementation(() => ({ name: 'pr-review-submitted' })), +})); +vi.mock('../../../src/triggers/github/review-requested.js', () => ({ + ReviewRequestedTrigger: vi.fn().mockImplementation(() => ({ name: 'review-requested' })), +})); +vi.mock('../../../src/triggers/jira/comment-mention.js', () => ({ + JiraCommentMentionTrigger: vi.fn().mockImplementation(() => ({ name: 'jira-comment-mention' })), +})); +vi.mock('../../../src/triggers/jira/issue-transitioned.js', () => ({ + JiraIssueTransitionedTrigger: vi + .fn() + .mockImplementation(() => ({ name: 'jira-issue-transitioned' })), +})); +vi.mock('../../../src/triggers/jira/label-added.js', () => ({ + JiraReadyToProcessLabelTrigger: vi.fn().mockImplementation(() => ({ name: 'jira-label-added' })), +})); +vi.mock('../../../src/triggers/trello/card-moved.js', () => ({ + CardMovedToBriefingTrigger: { name: 'card-moved-to-briefing' }, + CardMovedToPlanningTrigger: { name: 'card-moved-to-planning' }, + CardMovedToTodoTrigger: { name: 'card-moved-to-todo' }, +})); +vi.mock('../../../src/triggers/trello/comment-mention.js', () => ({ + TrelloCommentMentionTrigger: vi + .fn() + .mockImplementation(() => ({ name: 'trello-comment-mention' })), +})); +vi.mock('../../../src/triggers/trello/label-added.js', () => ({ + ReadyToProcessLabelTrigger: vi + .fn() + .mockImplementation(() => ({ name: 'ready-to-process-label' })), +})); + +vi.mock('../../../src/utils/logging.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { registerBuiltInTriggers } from '../../../src/triggers/builtins.js'; +import type { TriggerRegistry } from '../../../src/triggers/registry.js'; + +function createMockRegistry(): { register: ReturnType; handlers: object[] } { + const handlers: object[] = []; + return { + register: vi.fn((handler) => handlers.push(handler)), + handlers, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('registerBuiltInTriggers', () => { + it('registers all expected trigger handlers', () => { + const registry = createMockRegistry(); + + registerBuiltInTriggers(registry as unknown as TriggerRegistry); + + // Should have registered all 16 built-in triggers + expect(registry.register).toHaveBeenCalledTimes(16); + }); + + it('registers TrelloCommentMentionTrigger first', () => { + const registry = createMockRegistry(); + + registerBuiltInTriggers(registry as unknown as TriggerRegistry); + + const firstCall = registry.register.mock.calls[0][0]; + expect(firstCall.name).toBe('trello-comment-mention'); + }); + + it('registers all three card-moved triggers', () => { + const registry = createMockRegistry(); + + registerBuiltInTriggers(registry as unknown as TriggerRegistry); + + const registeredNames = registry.handlers.map((h: object) => (h as { name: string }).name); + expect(registeredNames).toContain('card-moved-to-briefing'); + expect(registeredNames).toContain('card-moved-to-planning'); + expect(registeredNames).toContain('card-moved-to-todo'); + }); + + it('registers GitHub triggers', () => { + const registry = createMockRegistry(); + + registerBuiltInTriggers(registry as unknown as TriggerRegistry); + + const registeredNames = registry.handlers.map((h: object) => (h as { name: string }).name); + expect(registeredNames).toContain('check-suite-failure'); + expect(registeredNames).toContain('check-suite-success'); + expect(registeredNames).toContain('pr-comment-mention'); + expect(registeredNames).toContain('pr-merged'); + expect(registeredNames).toContain('pr-opened'); + expect(registeredNames).toContain('pr-ready-to-merge'); + expect(registeredNames).toContain('pr-review-submitted'); + expect(registeredNames).toContain('review-requested'); + }); + + it('registers JIRA triggers', () => { + const registry = createMockRegistry(); + + registerBuiltInTriggers(registry as unknown as TriggerRegistry); + + const registeredNames = registry.handlers.map((h: object) => (h as { name: string }).name); + expect(registeredNames).toContain('jira-comment-mention'); + expect(registeredNames).toContain('jira-issue-transitioned'); + expect(registeredNames).toContain('jira-label-added'); + }); + + it('registers TrelloCommentMentionTrigger before card-moved triggers', () => { + const registry = createMockRegistry(); + + registerBuiltInTriggers(registry as unknown as TriggerRegistry); + + const names = registry.handlers.map((h: object) => (h as { name: string }).name); + const commentMentionIdx = names.indexOf('trello-comment-mention'); + const cardMovedIdx = names.indexOf('card-moved-to-briefing'); + expect(commentMentionIdx).toBeLessThan(cardMovedIdx); + }); + + it('registers JiraCommentMentionTrigger before JiraIssueTransitionedTrigger', () => { + const registry = createMockRegistry(); + + registerBuiltInTriggers(registry as unknown as TriggerRegistry); + + const names = registry.handlers.map((h: object) => (h as { name: string }).name); + const jiraCommentIdx = names.indexOf('jira-comment-mention'); + const jiraTransitionIdx = names.indexOf('jira-issue-transitioned'); + expect(jiraCommentIdx).toBeLessThan(jiraTransitionIdx); + }); + + it('registers PRCommentMentionTrigger before other GitHub triggers', () => { + const registry = createMockRegistry(); + + registerBuiltInTriggers(registry as unknown as TriggerRegistry); + + const names = registry.handlers.map((h: object) => (h as { name: string }).name); + const prCommentIdx = names.indexOf('pr-comment-mention'); + const prReviewIdx = names.indexOf('pr-review-submitted'); + expect(prCommentIdx).toBeLessThan(prReviewIdx); + }); +}); diff --git a/tests/unit/utils/llmEnv.test.ts b/tests/unit/utils/llmEnv.test.ts new file mode 100644 index 00000000..9126a45e --- /dev/null +++ b/tests/unit/utils/llmEnv.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/config/provider.js', () => ({ + getOrgCredential: vi.fn(), +})); + +vi.mock('../../../src/utils/logging.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { getOrgCredential } from '../../../src/config/provider.js'; +import { injectLlmApiKeys } from '../../../src/utils/llmEnv.js'; + +const mockGetOrgCredential = vi.mocked(getOrgCredential); + +beforeEach(() => { + vi.clearAllMocks(); + // Clean up the env var before each test + Reflect.deleteProperty(process.env, 'OPENROUTER_API_KEY'); +}); + +afterEach(() => { + Reflect.deleteProperty(process.env, 'OPENROUTER_API_KEY'); +}); + +describe('injectLlmApiKeys', () => { + it('injects OPENROUTER_API_KEY from DB into process.env', async () => { + mockGetOrgCredential.mockResolvedValue('sk-or-test-key'); + + await injectLlmApiKeys('project-1'); + + expect(process.env.OPENROUTER_API_KEY).toBe('sk-or-test-key'); + }); + + it('returns a restore function that removes injected key', async () => { + mockGetOrgCredential.mockResolvedValue('sk-or-test-key'); + + const restore = await injectLlmApiKeys('project-1'); + + expect(process.env.OPENROUTER_API_KEY).toBe('sk-or-test-key'); + restore(); + expect(process.env.OPENROUTER_API_KEY).toBeUndefined(); + }); + + it('restores previously set env var value on restore', async () => { + process.env.OPENROUTER_API_KEY = 'original-key'; + mockGetOrgCredential.mockResolvedValue('new-key-from-db'); + + const restore = await injectLlmApiKeys('project-1'); + + expect(process.env.OPENROUTER_API_KEY).toBe('new-key-from-db'); + restore(); + expect(process.env.OPENROUTER_API_KEY).toBe('original-key'); + }); + + it('does not set env var when DB returns null', async () => { + mockGetOrgCredential.mockResolvedValue(null); + + await injectLlmApiKeys('project-1'); + + expect(process.env.OPENROUTER_API_KEY).toBeUndefined(); + }); + + it('restores original undefined when DB returns null', async () => { + mockGetOrgCredential.mockResolvedValue(null); + + const restore = await injectLlmApiKeys('project-1'); + restore(); + + expect(process.env.OPENROUTER_API_KEY).toBeUndefined(); + }); + + it('calls getOrgCredential with the given projectId and key name', async () => { + mockGetOrgCredential.mockResolvedValue(null); + + await injectLlmApiKeys('my-project'); + + expect(mockGetOrgCredential).toHaveBeenCalledWith('my-project', 'OPENROUTER_API_KEY'); + }); +}); From fbc309db63966b47346b5ae9b3a153b18cca5971 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Mon, 23 Feb 2026 19:07:10 +0000 Subject: [PATCH 2/2] fix(tests): remove dead mock in setup.test.ts Remove unused vi.mock at incorrect path '../../src/utils/repo.js' that doesn't match any actual module resolution path. The correct mock at '../../../../src/utils/repo.js' on the next line is the one that works. Co-Authored-By: Claude Opus 4.6 --- tests/unit/agents/utils/setup.test.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/unit/agents/utils/setup.test.ts b/tests/unit/agents/utils/setup.test.ts index 8d55d9b7..576eff93 100644 --- a/tests/unit/agents/utils/setup.test.ts +++ b/tests/unit/agents/utils/setup.test.ts @@ -5,11 +5,6 @@ vi.mock('node:fs', () => ({ readFileSync: vi.fn(), })); -vi.mock('../../src/utils/repo.js', () => ({ - runCommand: vi.fn(), -})); - -// We need to mock at the path the module actually imports from vi.mock('../../../../src/utils/repo.js', () => ({ runCommand: vi.fn(), }));