From 1335bb3d6ee7d291e69f850ec6efadb93473d72a Mon Sep 17 00:00:00 2001 From: aaight Date: Tue, 24 Feb 2026 17:55:46 +0100 Subject: [PATCH 01/13] test(coverage): add unit tests for agent-profiles context fetching and hintConfig trailing messages (#537) Co-authored-by: Cascade Bot --- tests/unit/backends/agent-profiles.test.ts | 495 +++++++++++++++++++++ tests/unit/config/hintConfig.test.ts | 247 +++++++++- 2 files changed, 741 insertions(+), 1 deletion(-) diff --git a/tests/unit/backends/agent-profiles.test.ts b/tests/unit/backends/agent-profiles.test.ts index 36acc6f8..c6fcb7a9 100644 --- a/tests/unit/backends/agent-profiles.test.ts +++ b/tests/unit/backends/agent-profiles.test.ts @@ -7,6 +7,7 @@ vi.mock('../../../src/agents/shared/prFormatting.js', () => ({ formatPRComments: vi.fn(() => 'formatted-pr-comments'), formatPRReviews: vi.fn(() => 'formatted-pr-reviews'), formatPRIssueComments: vi.fn(() => 'formatted-pr-issue-comments'), + readPRFileContents: vi.fn(() => Promise.resolve({ included: [], skipped: [] })), })); vi.mock('../../../src/config/reviewConfig.js', () => ({ @@ -108,8 +109,31 @@ vi.mock('../../../src/github/client.js', () => ({ vi.mock('../../../src/agents/utils/setup.js', () => ({})); +vi.mock('../../../src/utils/squintDb.js', () => ({ + resolveSquintDbPath: vi.fn(() => null), +})); + +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(() => 'squint overview output'), +})); + +import { execFileSync } from 'node:child_process'; +import { + formatPRComments, + formatPRDetails, + formatPRDiff, + formatPRIssueComments, + formatPRReviews, + readPRFileContents, +} from '../../../src/agents/shared/prFormatting.js'; import { type AgentProfile, getAgentProfile } from '../../../src/backends/agent-profiles.js'; +import { readWorkItem } from '../../../src/gadgets/pm/core/readWorkItem.js'; import { githubClient } from '../../../src/github/client.js'; +import { resolveSquintDbPath } from '../../../src/utils/squintDb.js'; + +const mockExecFileSync = vi.mocked(execFileSync); +const mockResolveSquintDbPath = vi.mocked(resolveSquintDbPath); +const mockReadWorkItem = vi.mocked(readWorkItem); const mockGithub = vi.mocked(githubClient); @@ -575,3 +599,474 @@ describe('AgentProfile.getLlmistGadgets', () => { expect(names).toContain('Finish'); }); }); + +// ============================================================================ +// Context Fetching Tests +// ============================================================================ + +/** + * Helper params for fetchContext calls. + */ +function makeContextParams(overrides: { + cardId?: string; + repoFullName?: string; + prNumber?: number; + contextFiles?: Array<{ path: string; content: string }>; +}): { + input: Record; + repoDir: string; + contextFiles: Array<{ path: string; content: string }>; + logWriter: ReturnType; +} { + return { + input: { + cardId: overrides.cardId, + repoFullName: overrides.repoFullName ?? 'acme/widgets', + prNumber: overrides.prNumber ?? 42, + ...overrides, + }, + repoDir: '/repo', + contextFiles: overrides.contextFiles ?? [], + logWriter: vi.fn(), + }; +} + +describe('fetchDirectoryListing', () => { + it('briefing fetchContext returns a ListDirectory injection with maxDepth:3', async () => { + mockResolveSquintDbPath.mockReturnValue(null); + const profile = getAgentProfile('briefing'); + const params = makeContextParams({ cardId: undefined }); + + const injections = await profile.fetchContext( + params as Parameters[0], + ); + + const dirInjection = injections.find((i) => i.toolName === 'ListDirectory'); + expect(dirInjection).toBeDefined(); + expect(dirInjection?.params).toMatchObject({ + directoryPath: '/repo', + maxDepth: 3, + includeGitIgnored: false, + }); + expect(dirInjection?.result).toBe('directory listing'); + }); +}); + +describe('fetchContextFileInjections', () => { + it('returns ReadFile injections for each context file', async () => { + mockResolveSquintDbPath.mockReturnValue(null); + const profile = getAgentProfile('briefing'); + const params = makeContextParams({ + contextFiles: [ + { path: 'CLAUDE.md', content: 'project guidelines' }, + { path: 'README.md', content: 'readme text' }, + ], + }); + + const injections = await profile.fetchContext( + params as Parameters[0], + ); + + const readFileInjections = injections.filter((i) => i.toolName === 'ReadFile'); + expect(readFileInjections).toHaveLength(2); + expect(readFileInjections[0].params).toMatchObject({ filePath: 'CLAUDE.md' }); + expect(readFileInjections[0].result).toBe('project guidelines'); + expect(readFileInjections[1].params).toMatchObject({ filePath: 'README.md' }); + expect(readFileInjections[1].result).toBe('readme text'); + }); + + it('returns no ReadFile injections when contextFiles is empty', async () => { + mockResolveSquintDbPath.mockReturnValue(null); + const profile = getAgentProfile('briefing'); + const params = makeContextParams({ contextFiles: [] }); + + const injections = await profile.fetchContext( + params as Parameters[0], + ); + + const readFileInjections = injections.filter((i) => i.toolName === 'ReadFile'); + expect(readFileInjections).toHaveLength(0); + }); +}); + +describe('fetchSquintOverview', () => { + it('returns SquintOverview injection when squint db is present', async () => { + mockResolveSquintDbPath.mockReturnValue('/repo/.squint.db'); + mockExecFileSync.mockReturnValue('squint overview output\n'); + const profile = getAgentProfile('briefing'); + const params = makeContextParams({}); + + const injections = await profile.fetchContext( + params as Parameters[0], + ); + + const squintInjection = injections.find((i) => i.toolName === 'SquintOverview'); + expect(squintInjection).toBeDefined(); + expect(squintInjection?.result).toBe('squint overview output\n'); + expect(squintInjection?.params).toMatchObject({ database: '/repo/.squint.db' }); + }); + + it('returns no SquintOverview injection when squint db is absent', async () => { + mockResolveSquintDbPath.mockReturnValue(null); + const profile = getAgentProfile('briefing'); + const params = makeContextParams({}); + + const injections = await profile.fetchContext( + params as Parameters[0], + ); + + const squintInjection = injections.find((i) => i.toolName === 'SquintOverview'); + expect(squintInjection).toBeUndefined(); + }); + + it('returns no SquintOverview injection when squint command throws', async () => { + mockResolveSquintDbPath.mockReturnValue('/repo/.squint.db'); + mockExecFileSync.mockImplementation(() => { + throw new Error('squint not found'); + }); + const profile = getAgentProfile('briefing'); + const params = makeContextParams({}); + + const injections = await profile.fetchContext( + params as Parameters[0], + ); + + const squintInjection = injections.find((i) => i.toolName === 'SquintOverview'); + expect(squintInjection).toBeUndefined(); + }); + + it('returns no SquintOverview injection when squint output is empty', async () => { + mockResolveSquintDbPath.mockReturnValue('/repo/.squint.db'); + mockExecFileSync.mockReturnValue(' '); + const profile = getAgentProfile('briefing'); + const params = makeContextParams({}); + + const injections = await profile.fetchContext( + params as Parameters[0], + ); + + const squintInjection = injections.find((i) => i.toolName === 'SquintOverview'); + expect(squintInjection).toBeUndefined(); + }); +}); + +describe('fetchWorkItemInjection', () => { + it('returns ReadWorkItem injection when readWorkItem resolves', async () => { + mockResolveSquintDbPath.mockReturnValue(null); + mockReadWorkItem.mockResolvedValue('# card title\n\ncard body'); + const profile = getAgentProfile('briefing'); + const params = makeContextParams({ cardId: 'card-123' }); + + const injections = await profile.fetchContext( + params as Parameters[0], + ); + + const workItemInjection = injections.find((i) => i.toolName === 'ReadWorkItem'); + expect(workItemInjection).toBeDefined(); + expect(workItemInjection?.result).toBe('# card title\n\ncard body'); + expect(workItemInjection?.params).toMatchObject({ + workItemId: 'card-123', + includeComments: true, + }); + expect(mockReadWorkItem).toHaveBeenCalledWith('card-123', true); + }); + + it('skips injection when readWorkItem throws', async () => { + mockResolveSquintDbPath.mockReturnValue(null); + mockReadWorkItem.mockRejectedValue(new Error('card not found')); + const profile = getAgentProfile('briefing'); + const params = makeContextParams({ cardId: 'missing-card' }); + + const injections = await profile.fetchContext( + params as Parameters[0], + ); + + const workItemInjection = injections.find((i) => i.toolName === 'ReadWorkItem'); + expect(workItemInjection).toBeUndefined(); + }); + + it('never calls readWorkItem when cardId is absent', async () => { + mockResolveSquintDbPath.mockReturnValue(null); + const profile = getAgentProfile('briefing'); + const params = makeContextParams({ cardId: undefined }); + + await profile.fetchContext(params as Parameters[0]); + + expect(mockReadWorkItem).not.toHaveBeenCalled(); + }); +}); + +describe('fetchWorkItemContext orchestration', () => { + it('includes dirListing, contextFiles, squint, and workItem in order', async () => { + mockResolveSquintDbPath.mockReturnValue('/repo/.squint.db'); + mockExecFileSync.mockReturnValue('squint output\n'); + mockReadWorkItem.mockResolvedValue('card content'); + const profile = getAgentProfile('briefing'); + const params = makeContextParams({ + cardId: 'card-abc', + contextFiles: [{ path: 'CLAUDE.md', content: 'guidelines' }], + }); + + const injections = await profile.fetchContext( + params as Parameters[0], + ); + + const toolNames = injections.map((i) => i.toolName); + expect(toolNames).toContain('ListDirectory'); + expect(toolNames).toContain('ReadFile'); + expect(toolNames).toContain('SquintOverview'); + expect(toolNames).toContain('ReadWorkItem'); + + // Ordering: dirListing first + const dirIdx = toolNames.indexOf('ListDirectory'); + const readFileIdx = toolNames.indexOf('ReadFile'); + const squintIdx = toolNames.indexOf('SquintOverview'); + const workItemIdx = toolNames.indexOf('ReadWorkItem'); + expect(dirIdx).toBeLessThan(readFileIdx); + expect(readFileIdx).toBeLessThan(squintIdx); + expect(squintIdx).toBeLessThan(workItemIdx); + }); + + it('gracefully omits squint and workItem when unavailable', async () => { + mockResolveSquintDbPath.mockReturnValue(null); + mockReadWorkItem.mockRejectedValue(new Error('unavailable')); + const profile = getAgentProfile('briefing'); + const params = makeContextParams({ cardId: 'card-xyz' }); + + const injections = await profile.fetchContext( + params as Parameters[0], + ); + + expect(injections.some((i) => i.toolName === 'SquintOverview')).toBe(false); + expect(injections.some((i) => i.toolName === 'ReadWorkItem')).toBe(false); + expect(injections.some((i) => i.toolName === 'ListDirectory')).toBe(true); + }); +}); + +describe('fetchReviewContext', () => { + beforeEach(() => { + mockGithub.getPR.mockResolvedValue({ headSha: 'sha123' } as never); + mockGithub.getPRDiff.mockResolvedValue([]); + mockGithub.getCheckSuiteStatus.mockResolvedValue({ checks: [] } as never); + vi.mocked(readPRFileContents).mockResolvedValue({ included: [], skipped: [] }); + }); + + it('includes PR injections (Details, Diff, Checks)', async () => { + mockResolveSquintDbPath.mockReturnValue(null); + const profile = getAgentProfile('review'); + const params = makeContextParams({ repoFullName: 'acme/widgets', prNumber: 42 }); + + const injections = await profile.fetchContext( + params as Parameters[0], + ); + + const toolNames = injections.map((i) => i.toolName); + expect(toolNames).toContain('GetPRDetails'); + expect(toolNames).toContain('GetPRDiff'); + expect(toolNames).toContain('GetPRChecks'); + }); + + it('includes context file injections', async () => { + mockResolveSquintDbPath.mockReturnValue(null); + const profile = getAgentProfile('review'); + const params = makeContextParams({ + repoFullName: 'acme/widgets', + prNumber: 42, + contextFiles: [{ path: 'CLAUDE.md', content: 'project info' }], + }); + + const injections = await profile.fetchContext( + params as Parameters[0], + ); + + const readFileInjections = injections.filter((i) => i.toolName === 'ReadFile'); + expect(readFileInjections).toHaveLength(1); + expect(readFileInjections[0].params).toMatchObject({ filePath: 'CLAUDE.md' }); + }); + + it('includes squint injection when squint db is present', async () => { + mockResolveSquintDbPath.mockReturnValue('/repo/.squint.db'); + mockExecFileSync.mockReturnValue('squint content\n'); + const profile = getAgentProfile('review'); + const params = makeContextParams({ repoFullName: 'acme/widgets', prNumber: 42 }); + + const injections = await profile.fetchContext( + params as Parameters[0], + ); + + expect(injections.some((i) => i.toolName === 'SquintOverview')).toBe(true); + }); + + it('does NOT include a work item injection (review has no cardId)', async () => { + mockResolveSquintDbPath.mockReturnValue(null); + const profile = getAgentProfile('review'); + const params = makeContextParams({ repoFullName: 'acme/widgets', prNumber: 42 }); + + const injections = await profile.fetchContext( + params as Parameters[0], + ); + + expect(injections.some((i) => i.toolName === 'ReadWorkItem')).toBe(false); + expect(mockReadWorkItem).not.toHaveBeenCalled(); + }); + + it('includes file content injections for included PR files', async () => { + mockResolveSquintDbPath.mockReturnValue(null); + vi.mocked(readPRFileContents).mockResolvedValue({ + included: [{ path: 'src/index.ts', content: 'file content' }], + skipped: [], + }); + const profile = getAgentProfile('review'); + const params = makeContextParams({ repoFullName: 'acme/widgets', prNumber: 42 }); + + const injections = await profile.fetchContext( + params as Parameters[0], + ); + + const fileInjections = injections.filter( + (i) => + i.toolName === 'ReadFile' && + typeof i.result === 'string' && + i.result.includes('src/index.ts'), + ); + expect(fileInjections).toHaveLength(1); + }); + + it('calls formatting functions', async () => { + mockResolveSquintDbPath.mockReturnValue(null); + const profile = getAgentProfile('review'); + const params = makeContextParams({ repoFullName: 'acme/widgets', prNumber: 42 }); + + await profile.fetchContext(params as Parameters[0]); + + expect(vi.mocked(formatPRDetails)).toHaveBeenCalled(); + expect(vi.mocked(formatPRDiff)).toHaveBeenCalled(); + }); +}); + +describe('fetchCIContext', () => { + beforeEach(() => { + mockGithub.getPR.mockResolvedValue({ headSha: 'sha456' } as never); + mockGithub.getPRDiff.mockResolvedValue([]); + mockGithub.getCheckSuiteStatus.mockResolvedValue({ checks: [] } as never); + vi.mocked(readPRFileContents).mockResolvedValue({ included: [], skipped: [] }); + }); + + it('includes PR injections, dirListing, contextFiles, squint, and workItem', async () => { + mockResolveSquintDbPath.mockReturnValue('/repo/.squint.db'); + mockExecFileSync.mockReturnValue('squint ci output\n'); + mockReadWorkItem.mockResolvedValue('ci card content'); + const profile = getAgentProfile('respond-to-ci'); + const params = makeContextParams({ + repoFullName: 'acme/widgets', + prNumber: 5, + cardId: 'ci-card', + contextFiles: [{ path: 'CLAUDE.md', content: 'info' }], + }); + + const injections = await profile.fetchContext( + params as Parameters[0], + ); + + const toolNames = injections.map((i) => i.toolName); + expect(toolNames).toContain('GetPRDetails'); + expect(toolNames).toContain('GetPRDiff'); + expect(toolNames).toContain('GetPRChecks'); + expect(toolNames).toContain('ListDirectory'); + expect(toolNames).toContain('ReadFile'); + expect(toolNames).toContain('SquintOverview'); + expect(toolNames).toContain('ReadWorkItem'); + }); + + it('skips workItem injection when cardId is absent', async () => { + mockResolveSquintDbPath.mockReturnValue(null); + const profile = getAgentProfile('respond-to-ci'); + const params = makeContextParams({ + repoFullName: 'acme/widgets', + prNumber: 5, + cardId: undefined, + }); + + const injections = await profile.fetchContext( + params as Parameters[0], + ); + + expect(injections.some((i) => i.toolName === 'ReadWorkItem')).toBe(false); + expect(mockReadWorkItem).not.toHaveBeenCalled(); + }); +}); + +describe('fetchPRCommentResponseContext', () => { + beforeEach(() => { + mockGithub.getPR.mockResolvedValue({ headSha: 'sha789' } as never); + mockGithub.getPRDiff.mockResolvedValue([]); + mockGithub.getCheckSuiteStatus.mockResolvedValue({ checks: [] } as never); + mockGithub.getPRReviewComments.mockResolvedValue([] as never); + mockGithub.getPRReviews.mockResolvedValue([] as never); + mockGithub.getPRIssueComments.mockResolvedValue([] as never); + vi.mocked(readPRFileContents).mockResolvedValue({ included: [], skipped: [] }); + }); + + it('includes PR injections and 3 conversation injections', async () => { + mockResolveSquintDbPath.mockReturnValue(null); + const profile = getAgentProfile('respond-to-pr-comment'); + const params = makeContextParams({ repoFullName: 'acme/widgets', prNumber: 7 }); + + const injections = await profile.fetchContext( + params as Parameters[0], + ); + + const toolNames = injections.map((i) => i.toolName); + expect(toolNames).toContain('GetPRDetails'); + expect(toolNames).toContain('GetPRDiff'); + expect(toolNames).toContain('GetPRChecks'); + + // 3 conversation injections (all tagged as GetPRComments) + const conversationInjections = injections.filter((i) => i.toolName === 'GetPRComments'); + expect(conversationInjections).toHaveLength(3); + }); + + it('includes dirListing, contextFiles, and squint', async () => { + mockResolveSquintDbPath.mockReturnValue('/repo/.squint.db'); + mockExecFileSync.mockReturnValue('squint pr comment output\n'); + const profile = getAgentProfile('respond-to-pr-comment'); + const params = makeContextParams({ + repoFullName: 'acme/widgets', + prNumber: 7, + contextFiles: [{ path: 'AGENTS.md', content: 'agents doc' }], + }); + + const injections = await profile.fetchContext( + params as Parameters[0], + ); + + const toolNames = injections.map((i) => i.toolName); + expect(toolNames).toContain('ListDirectory'); + expect(toolNames).toContain('ReadFile'); + expect(toolNames).toContain('SquintOverview'); + }); + + it('calls all 3 formatting functions for conversation context', async () => { + mockResolveSquintDbPath.mockReturnValue(null); + const profile = getAgentProfile('respond-to-pr-comment'); + const params = makeContextParams({ repoFullName: 'acme/widgets', prNumber: 7 }); + + await profile.fetchContext(params as Parameters[0]); + + expect(vi.mocked(formatPRComments)).toHaveBeenCalled(); + expect(vi.mocked(formatPRReviews)).toHaveBeenCalled(); + expect(vi.mocked(formatPRIssueComments)).toHaveBeenCalled(); + }); + + it('calls getPRReviewComments, getPRReviews, getPRIssueComments', async () => { + mockResolveSquintDbPath.mockReturnValue(null); + const profile = getAgentProfile('respond-to-pr-comment'); + const params = makeContextParams({ repoFullName: 'acme/widgets', prNumber: 7 }); + + await profile.fetchContext(params as Parameters[0]); + + expect(mockGithub.getPRReviewComments).toHaveBeenCalledWith('acme', 'widgets', 7); + expect(mockGithub.getPRReviews).toHaveBeenCalledWith('acme', 'widgets', 7); + expect(mockGithub.getPRIssueComments).toHaveBeenCalledWith('acme', 'widgets', 7); + }); +}); diff --git a/tests/unit/config/hintConfig.test.ts b/tests/unit/config/hintConfig.test.ts index 523f8e81..00f71074 100644 --- a/tests/unit/config/hintConfig.test.ts +++ b/tests/unit/config/hintConfig.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getIterationTrailingMessage } from '../../../src/config/hintConfig.js'; import { @@ -17,11 +17,30 @@ vi.mock('../../../src/gadgets/todo/storage.js', () => ({ formatTodoList: vi.fn(() => ''), })); +import { execSync } from 'node:child_process'; +import { formatTodoList, loadTodos } from '../../../src/gadgets/todo/storage.js'; + +const mockExecSync = vi.mocked(execSync); +const mockLoadTodos = vi.mocked(loadTodos); +const mockFormatTodoList = vi.mocked(formatTodoList); + const ctx = { iteration: 3, maxIterations: 20 }; +/** Helper to invoke the trailing message function */ +function getMessage(agentType: string | undefined, iteration = 3, maxIterations = 20): string { + const trailingFn = getIterationTrailingMessage(agentType); + return typeof trailingFn === 'function' + ? (trailingFn({ iteration, maxIterations }) as string) + : (trailingFn as string); +} + describe('getIterationTrailingMessage', () => { afterEach(() => { clearDiagnosticState(); + vi.clearAllMocks(); + mockLoadTodos.mockReturnValue([]); + mockFormatTodoList.mockReturnValue(''); + mockExecSync.mockReturnValue(''); }); describe('respond-to-ci agent', () => { @@ -114,4 +133,230 @@ describe('getIterationTrailingMessage', () => { expect(message).not.toContain('Diagnostic Status'); }); }); + + // ============================================================================ + // Implementation trailing message (Steps 8-10) + // ============================================================================ + + describe('implementation agent trailing message', () => { + it('includes todos section when todos are present', () => { + mockLoadTodos.mockReturnValue([ + { id: '1', content: 'Write tests', status: 'in_progress', createdAt: '', updatedAt: '' }, + ]); + mockFormatTodoList.mockReturnValue('πŸ”„ #1 [in_progress]: Write tests'); + + const message = getMessage('implementation'); + + expect(message).toContain('Current Progress'); + expect(message).toContain('Write tests'); + }); + + it('omits todos section when todos list is empty', () => { + mockLoadTodos.mockReturnValue([]); + + const message = getMessage('implementation'); + + expect(message).not.toContain('Current Progress'); + }); + + it('shows git status section with content when git status returns output', () => { + mockExecSync.mockImplementation((cmd: string) => { + if ((cmd as string).includes('git status')) return 'M src/index.ts'; + return ''; + }); + + const message = getMessage('implementation'); + + expect(message).toContain('## Git Status'); + expect(message).toContain('M src/index.ts'); + }); + + it('shows "No uncommitted changes" when git status is empty', () => { + mockExecSync.mockReturnValue(''); + + const message = getMessage('implementation'); + + expect(message).toContain('## Git Status'); + expect(message).toContain('No uncommitted changes'); + }); + + it('shows PR status with content when gh pr view returns output', () => { + mockExecSync.mockImplementation((cmd: string) => { + if ((cmd as string).includes('gh pr view')) return 'title: My PR\nurl: http://...'; + return ''; + }); + + const message = getMessage('implementation'); + + expect(message).toContain('## PR Status'); + expect(message).toContain('My PR'); + }); + + it('shows "No PR exists" when gh pr view returns empty', () => { + mockExecSync.mockReturnValue(''); + + const message = getMessage('implementation'); + + expect(message).toContain('## PR Status'); + expect(message).toContain('No PR exists for current branch'); + }); + + it('always includes reminder section', () => { + const message = getMessage('implementation'); + + expect(message).toContain('## Reminder'); + }); + + it('includes diagnostic status when implementation has errors', () => { + updateDiagnosticState('src/broken.ts', { + output: '', + hasTypeErrors: true, + hasParseErrors: false, + hasLintErrors: false, + }); + + const message = getMessage('implementation'); + + expect(message).toContain('Diagnostic Status'); + expect(message).toContain('broken.ts'); + }); + + it('does not include diagnostic status when implementation has no errors', () => { + const message = getMessage('implementation'); + + expect(message).not.toContain('Diagnostic Status'); + }); + }); + + // ============================================================================ + // formatIterationStatus urgency levels (Step 9) + // ============================================================================ + + describe('formatIterationStatus urgency levels', () => { + it('uses no emoji at < 50% usage', () => { + // iteration=3, maxIterations=20 β†’ 15% β€” no emoji + const message = getMessage('review', 3, 20); + expect(message).not.toContain('🚨'); + expect(message).not.toContain('⚠️'); + expect(message).toContain('Iteration 3/20'); + }); + + it('uses ⚠️ at 50-79% usage', () => { + // iteration=12, maxIterations=20 β†’ 60% + const message = getMessage('review', 12, 20); + expect(message).toContain('⚠️'); + expect(message).not.toContain('🚨'); + }); + + it('uses 🚨 at >= 80% usage', () => { + // iteration=16, maxIterations=20 β†’ 80% + const message = getMessage('review', 16, 20); + expect(message).toContain('🚨'); + }); + + it('uses 🚨 above 80% usage', () => { + // iteration=19, maxIterations=20 β†’ 95% + const message = getMessage('review', 19, 20); + expect(message).toContain('🚨'); + }); + + it('includes correct remaining count in message', () => { + const message = getMessage('review', 12, 20); + expect(message).toContain('8 remaining'); + }); + + it('includes correct percentage in message', () => { + const message = getMessage('review', 10, 20); + expect(message).toContain('50% used'); + }); + + it('uses agent-specific hint for implementation', () => { + const message = getMessage('implementation'); + expect(message).toContain('Batch related edits'); + }); + + it('uses agent-specific hint for review', () => { + const message = getMessage('review'); + expect(message).toContain('Focus on the current aspect'); + }); + + it('uses default hint for unknown agent type', () => { + const message = getMessage('some-unknown-agent'); + expect(message).toContain('Complete the current task efficiently'); + }); + + it('uses default hint when agentType is undefined', () => { + const message = getMessage(undefined); + expect(message).toContain('Complete the current task efficiently'); + }); + }); + + // ============================================================================ + // formatDiagnosticLoopWarning (Step 10) + // ============================================================================ + + describe('formatDiagnosticLoopWarning via implementation', () => { + it('no warning when no loops', () => { + updateDiagnosticState('src/file.ts', { + output: '', + hasTypeErrors: true, + hasParseErrors: false, + hasLintErrors: false, + }); + // No recordDiagnosticLoop calls + + const message = getMessage('implementation'); + + expect(message).not.toContain('Diagnostic Loop Detected'); + }); + + it('no warning when loop count is 1 (below threshold of 2)', () => { + updateDiagnosticState('src/file.ts', { + output: '', + hasTypeErrors: true, + hasParseErrors: false, + hasLintErrors: false, + }); + recordDiagnosticLoop('src/file.ts'); // count = 1 + + const message = getMessage('implementation'); + + expect(message).not.toContain('Diagnostic Loop Detected'); + }); + + it('includes warning with file path and count when loop count is 2', () => { + updateDiagnosticState('src/file.ts', { + output: '', + hasTypeErrors: true, + hasParseErrors: false, + hasLintErrors: false, + }); + recordDiagnosticLoop('src/file.ts'); // count = 1 + recordDiagnosticLoop('src/file.ts'); // count = 2 + + const message = getMessage('implementation'); + + expect(message).toContain('Diagnostic Loop Detected'); + expect(message).toContain('src/file.ts'); + expect(message).toContain('edited 2 times'); + }); + + it('includes warning with correct count when loop count is 3', () => { + updateDiagnosticState('src/utils.ts', { + output: '', + hasTypeErrors: true, + hasParseErrors: false, + hasLintErrors: false, + }); + recordDiagnosticLoop('src/utils.ts'); + recordDiagnosticLoop('src/utils.ts'); + recordDiagnosticLoop('src/utils.ts'); + + const message = getMessage('implementation'); + + expect(message).toContain('Diagnostic Loop Detected'); + expect(message).toContain('src/utils.ts'); + expect(message).toContain('edited 3 times'); + }); + }); }); From 0b581e38ee87e242297a52f181aeba0dc69ec2a9 Mon Sep 17 00:00:00 2001 From: aaight Date: Tue, 24 Feb 2026 18:25:43 +0100 Subject: [PATCH 02/13] refactor(progress-monitor): extract 4 single-responsibility modules from god class (#538) --- src/backends/progressMonitor.ts | 346 +++++---------------- src/backends/progressState/accumulator.ts | 103 ++++++ src/backends/progressState/githubPoster.ts | 51 +++ src/backends/progressState/pmPoster.ts | 113 +++++++ src/backends/progressState/scheduler.ts | 55 ++++ tests/unit/backends/accumulator.test.ts | 200 ++++++++++++ tests/unit/backends/githubPoster.test.ts | 135 ++++++++ tests/unit/backends/pmPoster.test.ts | 181 +++++++++++ tests/unit/backends/scheduler.test.ts | 159 ++++++++++ 9 files changed, 1080 insertions(+), 263 deletions(-) create mode 100644 src/backends/progressState/accumulator.ts create mode 100644 src/backends/progressState/githubPoster.ts create mode 100644 src/backends/progressState/pmPoster.ts create mode 100644 src/backends/progressState/scheduler.ts create mode 100644 tests/unit/backends/accumulator.test.ts create mode 100644 tests/unit/backends/githubPoster.test.ts create mode 100644 tests/unit/backends/pmPoster.test.ts create mode 100644 tests/unit/backends/scheduler.test.ts diff --git a/src/backends/progressMonitor.ts b/src/backends/progressMonitor.ts index dfe81755..32928df8 100644 --- a/src/backends/progressMonitor.ts +++ b/src/backends/progressMonitor.ts @@ -8,24 +8,25 @@ * * Falls back to the existing template-based formatStatusMessage() if * the progress model call fails. + * + * This class is a thin orchestrator that delegates to: + * - ProgressAccumulator β€” ring buffers for tool calls, text, tasks + * - ProgressScheduler β€” progressive timer scheduling + * - PMProgressPoster β€” PM comment create/update/fallback lifecycle + * - GitHubProgressPoster β€” GitHub PR comment updates */ import type { ModelSpec } from 'llmist'; import { syncCompletedTodosToChecklist } from '../agents/utils/checklistSync.js'; -import { INITIAL_MESSAGES } from '../config/agentMessages.js'; -import { formatGitHubProgressComment, formatStatusMessage } from '../config/statusUpdateConfig.js'; -import { getSessionState } from '../gadgets/sessionState.js'; -import { loadTodos } from '../gadgets/todo/storage.js'; -import { githubClient } from '../github/client.js'; -import { getPMProviderOrNull } from '../pm/index.js'; +import { formatStatusMessage } from '../config/statusUpdateConfig.js'; import { captureException } from '../sentry.js'; -import { type ProgressContext, callProgressModel } from './progressModel.js'; -import { - clearProgressCommentId, - readProgressCommentId, - writeProgressCommentId, -} from './progressState.js'; +import { callProgressModel } from './progressModel.js'; +import { clearProgressCommentId, writeProgressCommentId } from './progressState.js'; +import { ProgressAccumulator } from './progressState/accumulator.js'; +import { GitHubProgressPoster } from './progressState/githubPoster.js'; +import { PMProgressPoster } from './progressState/pmPoster.js'; +import { DEFAULT_SCHEDULE_MINUTES, ProgressScheduler } from './progressState/scheduler.js'; import type { LogWriter, ProgressReporter } from './types.js'; export interface ProgressMonitorConfig { @@ -50,100 +51,65 @@ export interface ProgressMonitorConfig { scheduleMinutes?: number[]; } -/** Default progressive schedule: 1min, 3min, 5min, then every intervalMinutes */ -const DEFAULT_SCHEDULE_MINUTES = [1, 3, 5]; - const PROGRESS_MODEL_TIMEOUT_MS = 20_000; -const RING_BUFFER_MAX = 20; -const TEXT_SNIPPETS_MAX = 10; -const COMPLETED_TASKS_MAX = 5; - -/** - * Extract a meaningful detail string from tool call params. - * Returns file paths, commands, or search patterns β€” the most useful - * context for progress reporting. - */ -function summarizeToolParams(_toolName: string, params?: Record): string { - if (!params) return ''; - if (params.file_path) return String(params.file_path); - if (params.filePath) return String(params.filePath); - if (params.command) return String(params.command).slice(0, 100); - if (params.pattern) { - const detail = String(params.pattern); - return params.path ? `${detail} in ${params.path}` : detail; - } - return ''; -} export class ProgressMonitor implements ProgressReporter { - private recentToolCalls: { name: string; detail?: string; timestamp: number }[] = []; - private recentTextSnippets: { text: string; timestamp: number }[] = []; - private completedTasks: { subject: string; summary: string; timestamp: number }[] = []; - private currentIteration = 0; - private maxIterations = 0; - private startTime = Date.now(); - private timer: ReturnType | null = null; + private readonly accumulator: ProgressAccumulator; + private readonly scheduler: ProgressScheduler; + private readonly pmPoster: PMProgressPoster | null; + private readonly githubPoster: GitHubProgressPoster | null; + private isGenerating = false; - private progressCommentId: string | null = null; private initialCommentPromise: Promise | null = null; - private tickIndex = 0; - private stopped = false; private started = false; - private readonly schedule: number[]; constructor(private readonly config: ProgressMonitorConfig) { - this.schedule = config.scheduleMinutes ?? DEFAULT_SCHEDULE_MINUTES; + const schedule = config.scheduleMinutes ?? DEFAULT_SCHEDULE_MINUTES; + + this.accumulator = new ProgressAccumulator(config.logWriter); + this.scheduler = new ProgressScheduler(schedule, config.intervalMinutes); + + this.pmPoster = config.trello + ? new PMProgressPoster({ + agentType: config.agentType, + cardId: config.trello.cardId, + repoDir: config.repoDir, + logWriter: config.logWriter, + }) + : null; + + this.githubPoster = config.github + ? new GitHubProgressPoster({ + owner: config.github.owner, + repo: config.github.repo, + headerMessage: config.github.headerMessage, + logWriter: config.logWriter, + }) + : null; } // ── Public accessors ── getProgressCommentId(): string | null { - return this.progressCommentId; + return this.pmPoster?.getCommentId() ?? null; } // ── ProgressReporter interface (accumulate only, no posting) ── async onIteration(iteration: number, maxIterations: number): Promise { - this.currentIteration = iteration; - this.maxIterations = maxIterations; + this.accumulator.onIteration(iteration, maxIterations); } onToolCall(toolName: string, params?: Record): void { - const detail = summarizeToolParams(toolName, params); - this.recentToolCalls.push({ - name: toolName, - detail: detail || undefined, - timestamp: Date.now(), - }); - if (this.recentToolCalls.length > RING_BUFFER_MAX) { - this.recentToolCalls.shift(); - } - this.config.logWriter('INFO', 'Tool call', { toolName, params }); + this.accumulator.onToolCall(toolName, params); } onText(content: string): void { - if (content.trim()) { - this.recentTextSnippets.push({ - text: content.slice(0, 200), - timestamp: Date.now(), - }); - if (this.recentTextSnippets.length > TEXT_SNIPPETS_MAX) { - this.recentTextSnippets.shift(); - } - } - this.config.logWriter('INFO', 'Agent text output', { length: content.length }); + this.accumulator.onText(content); } onTaskCompleted(taskId: string, subject: string, summary: string): void { - this.completedTasks.push({ - subject, - summary: summary.slice(0, 300), - timestamp: Date.now(), - }); - if (this.completedTasks.length > COMPLETED_TASKS_MAX) { - this.completedTasks.shift(); - } - this.config.logWriter('INFO', 'Task completed', { taskId, subject }); + this.accumulator.onTaskCompleted(taskId, subject, summary); } // ── Lifecycle ── @@ -151,13 +117,12 @@ export class ProgressMonitor implements ProgressReporter { start(): void { if (this.started) return; this.started = true; - this.startTime = Date.now(); if (this.config.preSeededCommentId) { // Router already posted the ack comment β€” reuse its ID - this.progressCommentId = this.config.preSeededCommentId; + this.pmPoster?.setCommentId(this.config.preSeededCommentId); this.config.logWriter('INFO', 'Using pre-seeded ack comment ID from router', { - commentId: this.progressCommentId, + commentId: this.config.preSeededCommentId, }); // Write state file so PostComment gadget can find it @@ -165,12 +130,12 @@ export class ProgressMonitor implements ProgressReporter { writeProgressCommentId( this.config.repoDir, this.config.trello.cardId, - this.progressCommentId, + this.config.preSeededCommentId, ); } - } else { + } else if (this.pmPoster) { // Post initial comment immediately (fire-and-forget) - this.initialCommentPromise = this.postInitialComment().catch((err) => { + this.initialCommentPromise = this.pmPoster.postInitial().catch((err) => { this.config.logWriter('WARN', 'Failed to post initial progress comment', { error: String(err), }); @@ -178,15 +143,11 @@ export class ProgressMonitor implements ProgressReporter { } // Start the progressive tick chain - this.scheduleNextTick(); + this.scheduler.start(() => this.tick()); } stop(): void { - this.stopped = true; - if (this.timer) { - clearTimeout(this.timer); - this.timer = null; - } + this.scheduler.stop(); // Clean up state file on stop (best-effort β€” stop() is called from finally // blocks, so an rmSync failure must not mask the actual agent result) try { @@ -198,61 +159,6 @@ export class ProgressMonitor implements ProgressReporter { // ── Internal ── - /** - * Schedules the next tick using the progressive schedule. - * Uses schedule[tickIndex] if available, otherwise falls back to intervalMinutes. - */ - private scheduleNextTick(): void { - const delayMinutes = - this.tickIndex < this.schedule.length - ? this.schedule[this.tickIndex] - : this.config.intervalMinutes; - const delayMs = delayMinutes * 60 * 1000; - this.timer = setTimeout(() => { - void this.tickAndScheduleNext(); - }, delayMs); - } - - /** Fires a tick, increments the counter, then schedules the next one. */ - private async tickAndScheduleNext(): Promise { - await this.tick(); - this.tickIndex++; - // Only schedule next tick if stop() hasn't been called - if (!this.stopped) { - this.scheduleNextTick(); - } - } - - private formatInitialMessage(): string { - return ( - INITIAL_MESSAGES[this.config.agentType] ?? - `**πŸš€ Starting** (${this.config.agentType})\n\nWorking on this now. Progress updates will follow...` - ); - } - - private async postInitialComment(): Promise { - if (!this.config.trello) return; - - const provider = getPMProviderOrNull(); - if (!provider) return; - - const message = this.formatInitialMessage(); - this.progressCommentId = await provider.addComment(this.config.trello.cardId, message); - this.config.logWriter('INFO', 'Posted initial progress comment to work item', { - cardId: this.config.trello.cardId, - commentId: this.progressCommentId, - }); - - // Write state file so PostComment gadget can update this comment - if (this.config.repoDir && this.progressCommentId) { - writeProgressCommentId( - this.config.repoDir, - this.config.trello.cardId, - this.progressCommentId, - ); - } - } - private async tick(): Promise { // Wait for initial comment to complete before proceeding so the first // tick updates the same comment instead of creating a duplicate @@ -263,20 +169,10 @@ export class ProgressMonitor implements ProgressReporter { this.isGenerating = true; try { - const todos = loadTodos(); - const elapsedMinutes = (Date.now() - this.startTime) / 60_000; - - const progressContext: ProgressContext = { - agentType: this.config.agentType, - taskDescription: this.config.taskDescription, - elapsedMinutes, - iteration: this.currentIteration, - maxIterations: this.maxIterations, - todos, - recentToolCalls: [...this.recentToolCalls], - recentTextSnippets: [...this.recentTextSnippets], - completedTasks: [...this.completedTasks], - }; + const progressContext = this.accumulator.getSnapshot( + this.config.agentType, + this.config.taskDescription, + ); let summary: string; try { @@ -290,7 +186,7 @@ export class ProgressMonitor implements ProgressReporter { ), ]); this.config.logWriter('INFO', 'Progress model generated summary', { - elapsedMinutes: Math.round(elapsedMinutes), + elapsedMinutes: Math.round(progressContext.elapsedMinutes), summaryLength: summary.length, }); } catch (err) { @@ -301,123 +197,47 @@ export class ProgressMonitor implements ProgressReporter { tags: { source: 'progress_model', agentType: this.config.agentType }, }); summary = formatStatusMessage( - this.currentIteration, - this.maxIterations, + progressContext.iteration, + progressContext.maxIterations, this.config.agentType, ); } - await this.postProgress(summary); - - // Sync checklist items for implementation agents - if (this.config.agentType === 'implementation' && this.config.trello) { - await syncCompletedTodosToChecklist(this.config.trello.cardId); - } - } catch (err) { - this.config.logWriter('WARN', 'Progress tick failed', { error: String(err) }); - } finally { - this.isGenerating = false; - } - } - - private maybeWriteStateFile(cardId: string, commentId: string | null): void { - if (this.config.repoDir && commentId) { - writeProgressCommentId(this.config.repoDir, cardId, commentId); - } - } - - private async postProgressToPM(summary: string, cardId: string): Promise { - const provider = getPMProviderOrNull(); - if (!provider) return; - - if (this.progressCommentId) { - // If the PostComment gadget (subprocess) cleared the state file, - // the agent has posted its final comment to this ID β€” do not overwrite. - const stateFile = readProgressCommentId(this.config.repoDir); - if (!stateFile) { - this.config.logWriter('DEBUG', 'State file cleared by agent β€” skipping progress update', { - commentId: this.progressCommentId, - }); - this.progressCommentId = null; - return; - } - - // Subsequent ticks: update the existing comment. - // On success, the state file written by postInitialComment() remains - // valid (same comment ID), so no need to rewrite it here. - try { - await provider.updateComment(cardId, this.progressCommentId, summary); - this.config.logWriter('INFO', 'Updated progress comment on work item', { - cardId, - commentId: this.progressCommentId, - }); - } catch (updateErr) { - // Comment may have been deleted β€” fall back to creating a new one - this.config.logWriter('WARN', 'Failed to update progress comment, creating new one', { - error: String(updateErr), - }); - this.progressCommentId = await provider.addComment(cardId, summary); - this.config.logWriter('INFO', 'Posted new progress comment to work item', { - cardId, - commentId: this.progressCommentId, - }); - // Update state file with new comment ID - this.maybeWriteStateFile(cardId, this.progressCommentId); - } - } else { - // First tick: create the comment and store its ID. - // This branch is reached when postInitialComment() failed (transient API error) - // and the first tick creates the comment instead. - this.progressCommentId = await provider.addComment(cardId, summary); - this.config.logWriter('INFO', 'Posted progress update to work item', { - cardId, - commentId: this.progressCommentId, - }); - // Write state file so PostComment gadget can find this comment - this.maybeWriteStateFile(cardId, this.progressCommentId); - } - } - - private async postProgress(summary: string): Promise { - // Post to PM provider (Trello/JIRA) β€” create once, update in place - if (this.config.trello) { - try { - await this.postProgressToPM(summary, this.config.trello.cardId); - } catch (err) { - this.config.logWriter('WARN', 'Failed to post progress to work item', { - error: String(err), - }); + // Post to PM provider (Trello/JIRA) + if (this.pmPoster) { + try { + await this.pmPoster.update(summary); + } catch (err) { + this.config.logWriter('WARN', 'Failed to post progress to work item', { + error: String(err), + }); + } } - } - // Post to GitHub (update the initial PR comment) - if (this.config.github) { - const { initialCommentId } = getSessionState(); - if (initialCommentId) { + // Post to GitHub + if (this.githubPoster) { try { - const body = formatGitHubProgressComment( - this.config.github.headerMessage, - this.currentIteration, - this.maxIterations, + await this.githubPoster.update( + summary, + progressContext.iteration, + progressContext.maxIterations, this.config.agentType, ); - // Replace the todo section with the AI-generated summary - const bodyWithSummary = body.replace(/\n\nπŸ“‹[\s\S]*?\n\n/, `\n\n${summary}\n\n`); - await githubClient.updatePRComment( - this.config.github.owner, - this.config.github.repo, - initialCommentId, - bodyWithSummary, - ); - this.config.logWriter('INFO', 'Updated GitHub PR comment with progress', { - commentId: initialCommentId, - }); } catch (err) { this.config.logWriter('WARN', 'Failed to update GitHub PR comment', { error: String(err), }); } } + + // Sync checklist items for implementation agents + if (this.config.agentType === 'implementation' && this.config.trello) { + await syncCompletedTodosToChecklist(this.config.trello.cardId); + } + } catch (err) { + this.config.logWriter('WARN', 'Progress tick failed', { error: String(err) }); + } finally { + this.isGenerating = false; } } } diff --git a/src/backends/progressState/accumulator.ts b/src/backends/progressState/accumulator.ts new file mode 100644 index 00000000..dc63cc30 --- /dev/null +++ b/src/backends/progressState/accumulator.ts @@ -0,0 +1,103 @@ +/** + * Progress state accumulator for CASCADE agents. + * + * Accumulates tool calls, text snippets, completed tasks, and iteration + * counts using ring buffers. Provides a snapshot of current progress + * context for use by the progress model and posting layers. + */ + +import { loadTodos } from '../../gadgets/todo/storage.js'; +import type { ProgressContext } from '../progressModel.js'; +import type { LogWriter } from '../types.js'; + +export const RING_BUFFER_MAX = 20; +export const TEXT_SNIPPETS_MAX = 10; +export const COMPLETED_TASKS_MAX = 5; + +/** + * Extract a meaningful detail string from tool call params. + * Returns file paths, commands, or search patterns β€” the most useful + * context for progress reporting. + */ +export function summarizeToolParams(_toolName: string, params?: Record): string { + if (!params) return ''; + if (params.file_path) return String(params.file_path); + if (params.filePath) return String(params.filePath); + if (params.command) return String(params.command).slice(0, 100); + if (params.pattern) { + const detail = String(params.pattern); + return params.path ? `${detail} in ${params.path}` : detail; + } + return ''; +} + +export class ProgressAccumulator { + private recentToolCalls: { name: string; detail?: string; timestamp: number }[] = []; + private recentTextSnippets: { text: string; timestamp: number }[] = []; + private completedTasks: { subject: string; summary: string; timestamp: number }[] = []; + private currentIteration = 0; + private maxIterations = 0; + private readonly startTime = Date.now(); + + constructor(private readonly logWriter: LogWriter) {} + + onIteration(iteration: number, maxIterations: number): void { + this.currentIteration = iteration; + this.maxIterations = maxIterations; + } + + onToolCall(toolName: string, params?: Record): void { + const detail = summarizeToolParams(toolName, params); + this.recentToolCalls.push({ + name: toolName, + detail: detail || undefined, + timestamp: Date.now(), + }); + if (this.recentToolCalls.length > RING_BUFFER_MAX) { + this.recentToolCalls.shift(); + } + this.logWriter('INFO', 'Tool call', { toolName, params }); + } + + onText(content: string): void { + if (content.trim()) { + this.recentTextSnippets.push({ + text: content.slice(0, 200), + timestamp: Date.now(), + }); + if (this.recentTextSnippets.length > TEXT_SNIPPETS_MAX) { + this.recentTextSnippets.shift(); + } + } + this.logWriter('INFO', 'Agent text output', { length: content.length }); + } + + onTaskCompleted(taskId: string, subject: string, summary: string): void { + this.completedTasks.push({ + subject, + summary: summary.slice(0, 300), + timestamp: Date.now(), + }); + if (this.completedTasks.length > COMPLETED_TASKS_MAX) { + this.completedTasks.shift(); + } + this.logWriter('INFO', 'Task completed', { taskId, subject }); + } + + getSnapshot(agentType: string, taskDescription: string): ProgressContext { + const todos = loadTodos(); + const elapsedMinutes = (Date.now() - this.startTime) / 60_000; + + return { + agentType, + taskDescription, + elapsedMinutes, + iteration: this.currentIteration, + maxIterations: this.maxIterations, + todos, + recentToolCalls: [...this.recentToolCalls], + recentTextSnippets: [...this.recentTextSnippets], + completedTasks: [...this.completedTasks], + }; + } +} diff --git a/src/backends/progressState/githubPoster.ts b/src/backends/progressState/githubPoster.ts new file mode 100644 index 00000000..52a26be9 --- /dev/null +++ b/src/backends/progressState/githubPoster.ts @@ -0,0 +1,51 @@ +/** + * GitHub PR progress comment poster. + * + * Updates the initial PR comment with AI-generated progress summaries. + * Reads the session state to find the initial comment ID, formats the + * GitHub progress comment, and updates it via the GitHub client. + */ + +import { formatGitHubProgressComment } from '../../config/statusUpdateConfig.js'; +import { getSessionState } from '../../gadgets/sessionState.js'; +import { githubClient } from '../../github/client.js'; +import type { LogWriter } from '../types.js'; + +export interface GitHubProgressPosterConfig { + owner: string; + repo: string; + headerMessage: string; + logWriter: LogWriter; +} + +export class GitHubProgressPoster { + constructor(private readonly config: GitHubProgressPosterConfig) {} + + async update( + summary: string, + iteration: number, + maxIterations: number, + agentType: string, + ): Promise { + const { initialCommentId } = getSessionState(); + if (!initialCommentId) return; + + const body = formatGitHubProgressComment( + this.config.headerMessage, + iteration, + maxIterations, + agentType, + ); + // Replace the todo section with the AI-generated summary + const bodyWithSummary = body.replace(/\n\nπŸ“‹[\s\S]*?\n\n/, `\n\n${summary}\n\n`); + await githubClient.updatePRComment( + this.config.owner, + this.config.repo, + initialCommentId, + bodyWithSummary, + ); + this.config.logWriter('INFO', 'Updated GitHub PR comment with progress', { + commentId: initialCommentId, + }); + } +} diff --git a/src/backends/progressState/pmPoster.ts b/src/backends/progressState/pmPoster.ts new file mode 100644 index 00000000..07b6a48a --- /dev/null +++ b/src/backends/progressState/pmPoster.ts @@ -0,0 +1,113 @@ +/** + * PM (Project Management) progress comment poster. + * + * Manages the create-once/update-in-place/fallback-to-new lifecycle + * for progress comments on Trello/JIRA work items. Handles state file + * coordination with the PostComment gadget subprocess. + */ + +import { INITIAL_MESSAGES } from '../../config/agentMessages.js'; +import { getPMProviderOrNull } from '../../pm/index.js'; +import { readProgressCommentId, writeProgressCommentId } from '../progressState.js'; +import type { LogWriter } from '../types.js'; + +export interface PMProgressPosterConfig { + agentType: string; + cardId: string; + repoDir?: string; + logWriter: LogWriter; +} + +export class PMProgressPoster { + private progressCommentId: string | null = null; + + constructor(private readonly config: PMProgressPosterConfig) {} + + getCommentId(): string | null { + return this.progressCommentId; + } + + setCommentId(commentId: string): void { + this.progressCommentId = commentId; + } + + private formatInitialMessage(): string { + return ( + INITIAL_MESSAGES[this.config.agentType] ?? + `**πŸš€ Starting** (${this.config.agentType})\n\nWorking on this now. Progress updates will follow...` + ); + } + + private maybeWriteStateFile(commentId: string | null): void { + if (this.config.repoDir && commentId) { + writeProgressCommentId(this.config.repoDir, this.config.cardId, commentId); + } + } + + async postInitial(): Promise { + const provider = getPMProviderOrNull(); + if (!provider) return; + + const message = this.formatInitialMessage(); + this.progressCommentId = await provider.addComment(this.config.cardId, message); + this.config.logWriter('INFO', 'Posted initial progress comment to work item', { + cardId: this.config.cardId, + commentId: this.progressCommentId, + }); + + // Write state file so PostComment gadget can update this comment + this.maybeWriteStateFile(this.progressCommentId); + } + + async update(summary: string): Promise { + const provider = getPMProviderOrNull(); + if (!provider) return; + + const { cardId } = this.config; + + if (this.progressCommentId) { + // If the PostComment gadget (subprocess) cleared the state file, + // the agent has posted its final comment to this ID β€” do not overwrite. + const stateFile = readProgressCommentId(this.config.repoDir); + if (!stateFile) { + this.config.logWriter('DEBUG', 'State file cleared by agent β€” skipping progress update', { + commentId: this.progressCommentId, + }); + this.progressCommentId = null; + return; + } + + // Subsequent ticks: update the existing comment. + try { + await provider.updateComment(cardId, this.progressCommentId, summary); + this.config.logWriter('INFO', 'Updated progress comment on work item', { + cardId, + commentId: this.progressCommentId, + }); + } catch (updateErr) { + // Comment may have been deleted β€” fall back to creating a new one + this.config.logWriter('WARN', 'Failed to update progress comment, creating new one', { + error: String(updateErr), + }); + this.progressCommentId = await provider.addComment(cardId, summary); + this.config.logWriter('INFO', 'Posted new progress comment to work item', { + cardId, + commentId: this.progressCommentId, + }); + // Update state file with new comment ID + this.maybeWriteStateFile(this.progressCommentId); + } + } else { + // First tick: create the comment and store its ID. + // This branch is reached when postInitial() failed (transient API error) + // and the first tick creates the comment instead. + this.progressCommentId = await provider.addComment(cardId, summary); + this.config.logWriter('INFO', 'Posted progress update to work item', { + cardId, + commentId: this.progressCommentId, + }); + // Write state file so PostComment gadget can find this comment + this.maybeWriteStateFile(this.progressCommentId); + } + } +} diff --git a/src/backends/progressState/scheduler.ts b/src/backends/progressState/scheduler.ts new file mode 100644 index 00000000..aa038efd --- /dev/null +++ b/src/backends/progressState/scheduler.ts @@ -0,0 +1,55 @@ +/** + * Progressive timer scheduler for CASCADE progress monitor. + * + * Fires a tick callback according to a progressive schedule (e.g., 1min, + * 3min, 5min) before falling back to a steady-state interval. Pure + * scheduling β€” no business logic. + */ + +/** Default progressive schedule: 1min, 3min, 5min, then every intervalMinutes */ +export const DEFAULT_SCHEDULE_MINUTES = [1, 3, 5]; + +export class ProgressScheduler { + private timer: ReturnType | null = null; + private tickIndex = 0; + private stopped = false; + + constructor( + private readonly schedule: number[], + private readonly intervalMinutes: number, + ) {} + + /** + * Start the scheduler, calling `tickFn` on each tick. + * `tickFn` is awaited before the next tick is scheduled. + */ + start(tickFn: () => Promise): void { + this.scheduleNextTick(tickFn); + } + + /** Stop the scheduler. No more ticks will fire after this call. */ + stop(): void { + this.stopped = true; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + } + + private scheduleNextTick(tickFn: () => Promise): void { + const delayMinutes = + this.tickIndex < this.schedule.length ? this.schedule[this.tickIndex] : this.intervalMinutes; + const delayMs = delayMinutes * 60 * 1000; + this.timer = setTimeout(() => { + void this.tickAndScheduleNext(tickFn); + }, delayMs); + } + + private async tickAndScheduleNext(tickFn: () => Promise): Promise { + await tickFn(); + this.tickIndex++; + if (!this.stopped) { + this.scheduleNextTick(tickFn); + } + } +} diff --git a/tests/unit/backends/accumulator.test.ts b/tests/unit/backends/accumulator.test.ts new file mode 100644 index 00000000..6786d01b --- /dev/null +++ b/tests/unit/backends/accumulator.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/gadgets/todo/storage.js', () => ({ + loadTodos: vi.fn(), +})); + +import { + COMPLETED_TASKS_MAX, + ProgressAccumulator, + RING_BUFFER_MAX, + TEXT_SNIPPETS_MAX, + summarizeToolParams, +} from '../../../src/backends/progressState/accumulator.js'; +import { loadTodos } from '../../../src/gadgets/todo/storage.js'; + +const mockLoadTodos = vi.mocked(loadTodos); + +beforeEach(() => { + vi.clearAllMocks(); + mockLoadTodos.mockReturnValue([]); +}); + +describe('summarizeToolParams', () => { + it('returns empty string when no params provided', () => { + expect(summarizeToolParams('Bash')).toBe(''); + }); + + it('returns file_path when present', () => { + expect(summarizeToolParams('Read', { file_path: '/src/foo.ts' })).toBe('/src/foo.ts'); + }); + + it('returns filePath (camelCase) when present', () => { + expect(summarizeToolParams('ReadFile', { filePath: '/src/bar.ts' })).toBe('/src/bar.ts'); + }); + + it('returns truncated command (max 100 chars) when present', () => { + const longCmd = 'npm run test:coverage -- --reporter verbose'.padEnd(120, ' extra'); + const result = summarizeToolParams('Bash', { command: longCmd }); + expect(result.length).toBeLessThanOrEqual(100); + }); + + it('returns pattern when present without path', () => { + expect(summarizeToolParams('Grep', { pattern: 'class.*Foo' })).toBe('class.*Foo'); + }); + + it('returns pattern with path when both present', () => { + expect(summarizeToolParams('Grep', { pattern: 'class.*Foo', path: 'src/' })).toBe( + 'class.*Foo in src/', + ); + }); + + it('returns empty string when params exist but have no recognized keys', () => { + expect(summarizeToolParams('Unknown', { randomKey: 'value' })).toBe(''); + }); +}); + +describe('ProgressAccumulator', () => { + function makeAccumulator() { + return new ProgressAccumulator(vi.fn()); + } + + describe('onToolCall', () => { + it('logs each tool call via logWriter', () => { + const logWriter = vi.fn(); + const acc = new ProgressAccumulator(logWriter); + acc.onToolCall('Bash', { command: 'npm test' }); + expect(logWriter).toHaveBeenCalledWith('INFO', 'Tool call', { + toolName: 'Bash', + params: { command: 'npm test' }, + }); + }); + + it('enforces ring buffer max (RING_BUFFER_MAX)', () => { + const logWriter = vi.fn(); + const acc = new ProgressAccumulator(logWriter); + for (let i = 0; i < RING_BUFFER_MAX + 5; i++) { + acc.onToolCall(`Tool${i}`); + } + // Logged all calls + expect(logWriter).toHaveBeenCalledTimes(RING_BUFFER_MAX + 5); + // Snapshot should only have RING_BUFFER_MAX entries + const snap = acc.getSnapshot('impl', 'task'); + expect(snap.recentToolCalls).toHaveLength(RING_BUFFER_MAX); + // First entries should be the most recent ones + expect(snap.recentToolCalls[0].name).toBe('Tool5'); + expect(snap.recentToolCalls[RING_BUFFER_MAX - 1].name).toBe(`Tool${RING_BUFFER_MAX + 4}`); + }); + }); + + describe('onText', () => { + it('logs text output via logWriter', () => { + const logWriter = vi.fn(); + const acc = new ProgressAccumulator(logWriter); + acc.onText('Hello world'); + expect(logWriter).toHaveBeenCalledWith('INFO', 'Agent text output', { length: 11 }); + }); + + it('ignores whitespace-only text', () => { + const logWriter = vi.fn(); + const acc = new ProgressAccumulator(logWriter); + acc.onText(' '); + // Still logged but nothing added to snippets + const snap = acc.getSnapshot('impl', 'task'); + expect(snap.recentTextSnippets).toHaveLength(0); + }); + + it('truncates text to 200 chars in snippet', () => { + const acc = makeAccumulator(); + const longText = 'x'.repeat(300); + acc.onText(longText); + const snap = acc.getSnapshot('impl', 'task'); + expect(snap.recentTextSnippets[0].text).toHaveLength(200); + }); + + it('enforces ring buffer max (TEXT_SNIPPETS_MAX)', () => { + const acc = makeAccumulator(); + for (let i = 0; i < TEXT_SNIPPETS_MAX + 3; i++) { + acc.onText(`Snippet ${i}`); + } + const snap = acc.getSnapshot('impl', 'task'); + expect(snap.recentTextSnippets).toHaveLength(TEXT_SNIPPETS_MAX); + }); + }); + + describe('onTaskCompleted', () => { + it('logs completed task via logWriter', () => { + const logWriter = vi.fn(); + const acc = new ProgressAccumulator(logWriter); + acc.onTaskCompleted('t1', 'My Task', 'Did the thing'); + expect(logWriter).toHaveBeenCalledWith('INFO', 'Task completed', { + taskId: 't1', + subject: 'My Task', + }); + }); + + it('truncates summary to 300 chars', () => { + const acc = makeAccumulator(); + const longSummary = 'y'.repeat(400); + acc.onTaskCompleted('t1', 'Task', longSummary); + const snap = acc.getSnapshot('impl', 'task'); + expect(snap.completedTasks[0].summary).toHaveLength(300); + }); + + it('enforces ring buffer max (COMPLETED_TASKS_MAX)', () => { + const acc = makeAccumulator(); + for (let i = 0; i < COMPLETED_TASKS_MAX + 2; i++) { + acc.onTaskCompleted(`t${i}`, `Task ${i}`, 'summary'); + } + const snap = acc.getSnapshot('impl', 'task'); + expect(snap.completedTasks).toHaveLength(COMPLETED_TASKS_MAX); + }); + }); + + describe('onIteration', () => { + it('records current and max iterations in snapshot', () => { + const acc = makeAccumulator(); + acc.onIteration(7, 20); + const snap = acc.getSnapshot('impl', 'task'); + expect(snap.iteration).toBe(7); + expect(snap.maxIterations).toBe(20); + }); + }); + + describe('getSnapshot', () => { + it('returns snapshot with correct agentType and taskDescription', () => { + const acc = makeAccumulator(); + const snap = acc.getSnapshot('review', 'Review the PR'); + expect(snap.agentType).toBe('review'); + expect(snap.taskDescription).toBe('Review the PR'); + }); + + it('returns todos from loadTodos()', () => { + mockLoadTodos.mockReturnValue([ + { id: '1', content: 'Do thing', status: 'todo' }, + { id: '2', content: 'Other thing', status: 'done' }, + ]); + const acc = makeAccumulator(); + const snap = acc.getSnapshot('impl', 'task'); + expect(snap.todos).toHaveLength(2); + expect(snap.todos[0].content).toBe('Do thing'); + }); + + it('returns elapsed time > 0', () => { + const acc = makeAccumulator(); + const snap = acc.getSnapshot('impl', 'task'); + expect(snap.elapsedMinutes).toBeGreaterThanOrEqual(0); + }); + + it('returns copies of arrays (not references)', () => { + const acc = makeAccumulator(); + acc.onToolCall('Bash'); + const snap1 = acc.getSnapshot('impl', 'task'); + acc.onToolCall('Read'); + const snap2 = acc.getSnapshot('impl', 'task'); + // snap1 should still have only 1 entry + expect(snap1.recentToolCalls).toHaveLength(1); + expect(snap2.recentToolCalls).toHaveLength(2); + }); + }); +}); diff --git a/tests/unit/backends/githubPoster.test.ts b/tests/unit/backends/githubPoster.test.ts new file mode 100644 index 00000000..90ed6dcb --- /dev/null +++ b/tests/unit/backends/githubPoster.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/github/client.js', () => ({ + githubClient: { + updatePRComment: vi.fn(), + }, +})); + +vi.mock('../../../src/gadgets/sessionState.js', () => ({ + getSessionState: vi.fn(), +})); + +vi.mock('../../../src/config/statusUpdateConfig.js', () => ({ + formatGitHubProgressComment: vi.fn(), +})); + +import { GitHubProgressPoster } from '../../../src/backends/progressState/githubPoster.js'; +import { formatGitHubProgressComment } from '../../../src/config/statusUpdateConfig.js'; +import { getSessionState } from '../../../src/gadgets/sessionState.js'; +import { githubClient } from '../../../src/github/client.js'; + +const mockGithubClient = vi.mocked(githubClient); +const mockGetSessionState = vi.mocked(getSessionState); +const mockFormatGitHubProgressComment = vi.mocked(formatGitHubProgressComment); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function makePoster() { + return new GitHubProgressPoster({ + owner: 'myorg', + repo: 'myrepo', + headerMessage: '**πŸ§‘β€πŸ’» Implementation Update**', + logWriter: vi.fn(), + }); +} + +describe('GitHubProgressPoster β€” update()', () => { + it('does nothing when there is no initialCommentId in session state', async () => { + mockGetSessionState.mockReturnValue({ + agentType: 'implementation', + prCreated: false, + prUrl: null, + reviewSubmitted: false, + reviewUrl: null, + initialCommentId: null, + }); + + const poster = makePoster(); + await poster.update('summary', 3, 20, 'implementation'); + + expect(mockGithubClient.updatePRComment).not.toHaveBeenCalled(); + }); + + it('formats and updates PR comment when initialCommentId exists', async () => { + mockGetSessionState.mockReturnValue({ + agentType: 'implementation', + prCreated: false, + prUrl: null, + reviewSubmitted: false, + reviewUrl: null, + initialCommentId: 99, + }); + mockFormatGitHubProgressComment.mockReturnValue('Header\n\nπŸ“‹ Old todo section\n\nFooter'); + mockGithubClient.updatePRComment.mockResolvedValue(undefined as never); + + const poster = makePoster(); + await poster.update('AI-generated summary', 5, 20, 'implementation'); + + expect(mockFormatGitHubProgressComment).toHaveBeenCalledWith( + '**πŸ§‘β€πŸ’» Implementation Update**', + 5, + 20, + 'implementation', + ); + expect(mockGithubClient.updatePRComment).toHaveBeenCalledWith( + 'myorg', + 'myrepo', + 99, + expect.stringContaining('AI-generated summary'), + ); + }); + + it('replaces the todo section with the AI summary', async () => { + mockGetSessionState.mockReturnValue({ + agentType: 'implementation', + prCreated: false, + prUrl: null, + reviewSubmitted: false, + reviewUrl: null, + initialCommentId: 42, + }); + // The format includes a todo section matching \n\nπŸ“‹[\s\S]*?\n\n + mockFormatGitHubProgressComment.mockReturnValue( + 'Header text\n\nπŸ“‹ Todo item 1\nTodo item 2\n\nFooter text', + ); + mockGithubClient.updatePRComment.mockResolvedValue(undefined as never); + + const poster = makePoster(); + await poster.update('My AI summary', 2, 10, 'review'); + + const callArg = mockGithubClient.updatePRComment.mock.calls[0][3]; + expect(callArg).toContain('My AI summary'); + expect(callArg).not.toContain('πŸ“‹ Todo item'); + }); + + it('logs success after updating comment', async () => { + const logWriter = vi.fn(); + mockGetSessionState.mockReturnValue({ + agentType: 'review', + prCreated: false, + prUrl: null, + reviewSubmitted: false, + reviewUrl: null, + initialCommentId: 7, + }); + mockFormatGitHubProgressComment.mockReturnValue('body'); + mockGithubClient.updatePRComment.mockResolvedValue(undefined as never); + + const poster = new GitHubProgressPoster({ + owner: 'o', + repo: 'r', + headerMessage: 'Header', + logWriter, + }); + await poster.update('summary', 1, 5, 'review'); + + expect(logWriter).toHaveBeenCalledWith( + 'INFO', + 'Updated GitHub PR comment with progress', + expect.objectContaining({ commentId: 7 }), + ); + }); +}); diff --git a/tests/unit/backends/pmPoster.test.ts b/tests/unit/backends/pmPoster.test.ts new file mode 100644 index 00000000..86f880f8 --- /dev/null +++ b/tests/unit/backends/pmPoster.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/pm/index.js', () => ({ + getPMProviderOrNull: vi.fn(), +})); + +vi.mock('../../../src/backends/progressState.js', () => ({ + writeProgressCommentId: vi.fn(), + readProgressCommentId: vi.fn(), + clearProgressCommentId: vi.fn(), +})); + +import { + readProgressCommentId, + writeProgressCommentId, +} from '../../../src/backends/progressState.js'; +import { PMProgressPoster } from '../../../src/backends/progressState/pmPoster.js'; +import type { PMProvider } from '../../../src/pm/index.js'; +import { getPMProviderOrNull } from '../../../src/pm/index.js'; + +const mockGetPMProvider = vi.mocked(getPMProviderOrNull); +const mockWriteProgressCommentId = vi.mocked(writeProgressCommentId); +const mockReadProgressCommentId = vi.mocked(readProgressCommentId); +const mockPMProvider = { + addComment: vi.fn<[string, string], Promise>(), + updateComment: vi.fn<[string, string, string], Promise>(), +}; + +beforeEach(() => { + vi.clearAllMocks(); + // Default: state file exists + mockReadProgressCommentId.mockReturnValue({ workItemId: 'card1', commentId: 'comment1' }); +}); + +function makePoster(overrides?: Partial[0]>) { + return new PMProgressPoster({ + agentType: 'implementation', + cardId: 'card1', + logWriter: vi.fn(), + ...overrides, + }); +} + +describe('PMProgressPoster β€” getCommentId / setCommentId', () => { + it('returns null initially', () => { + const poster = makePoster(); + expect(poster.getCommentId()).toBeNull(); + }); + + it('returns the ID set via setCommentId', () => { + const poster = makePoster(); + poster.setCommentId('preset-id'); + expect(poster.getCommentId()).toBe('preset-id'); + }); +}); + +describe('PMProgressPoster β€” postInitial()', () => { + it('does nothing when PM provider is null', async () => { + mockGetPMProvider.mockReturnValue(null); + const poster = makePoster(); + await poster.postInitial(); + expect(mockPMProvider.addComment).not.toHaveBeenCalled(); + expect(poster.getCommentId()).toBeNull(); + }); + + it('posts the initial message and stores the comment ID', async () => { + mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider); + mockPMProvider.addComment.mockResolvedValue('initial-id'); + const poster = makePoster({ agentType: 'implementation' }); + + await poster.postInitial(); + + expect(mockPMProvider.addComment).toHaveBeenCalledWith( + 'card1', + '**πŸš€ Implementing changes** β€” Writing code, running tests, and preparing a PR...', + ); + expect(poster.getCommentId()).toBe('initial-id'); + }); + + it('uses fallback message for unknown agent types', async () => { + mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider); + mockPMProvider.addComment.mockResolvedValue('new-id'); + const poster = makePoster({ agentType: 'future-agent' }); + + await poster.postInitial(); + + expect(mockPMProvider.addComment).toHaveBeenCalledWith( + 'card1', + '**πŸš€ Starting** (future-agent)\n\nWorking on this now. Progress updates will follow...', + ); + }); + + it('writes state file when repoDir is provided', async () => { + mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider); + mockPMProvider.addComment.mockResolvedValue('initial-id'); + const poster = makePoster({ repoDir: '/tmp/repo' }); + + await poster.postInitial(); + + expect(mockWriteProgressCommentId).toHaveBeenCalledWith('/tmp/repo', 'card1', 'initial-id'); + }); + + it('does not write state file when repoDir is absent', async () => { + mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider); + mockPMProvider.addComment.mockResolvedValue('initial-id'); + const poster = makePoster(); // no repoDir + + await poster.postInitial(); + + expect(mockWriteProgressCommentId).not.toHaveBeenCalled(); + }); +}); + +describe('PMProgressPoster β€” update()', () => { + it('does nothing when PM provider is null', async () => { + mockGetPMProvider.mockReturnValue(null); + const poster = makePoster(); + await poster.update('summary'); + expect(mockPMProvider.addComment).not.toHaveBeenCalled(); + }); + + it('creates new comment when no existing comment ID (fallback branch)', async () => { + mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider); + mockPMProvider.addComment.mockResolvedValue('tick-id'); + const poster = makePoster({ repoDir: '/tmp/repo' }); + // No initial comment was posted + + await poster.update('First progress update'); + + expect(mockPMProvider.addComment).toHaveBeenCalledWith('card1', 'First progress update'); + expect(poster.getCommentId()).toBe('tick-id'); + expect(mockWriteProgressCommentId).toHaveBeenCalledWith('/tmp/repo', 'card1', 'tick-id'); + }); + + it('updates existing comment when comment ID is set', async () => { + mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider); + mockPMProvider.updateComment.mockResolvedValue(undefined); + const poster = makePoster(); + poster.setCommentId('existing-id'); + + await poster.update('Updated progress'); + + expect(mockPMProvider.updateComment).toHaveBeenCalledWith( + 'card1', + 'existing-id', + 'Updated progress', + ); + expect(mockPMProvider.addComment).not.toHaveBeenCalled(); + }); + + it('skips update when state file has been cleared by agent subprocess', async () => { + mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider); + mockReadProgressCommentId.mockReturnValue(null); // state file cleared + const poster = makePoster(); + poster.setCommentId('existing-id'); + + await poster.update('Should be skipped'); + + expect(mockPMProvider.updateComment).not.toHaveBeenCalled(); + expect(poster.getCommentId()).toBeNull(); + }); + + it('falls back to new comment when updateComment throws', async () => { + mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider); + mockPMProvider.updateComment.mockRejectedValue(new Error('Comment not found')); + mockPMProvider.addComment.mockResolvedValue('fallback-id'); + const poster = makePoster({ repoDir: '/tmp/repo' }); + poster.setCommentId('deleted-id'); + + await poster.update('Fallback summary'); + + expect(mockPMProvider.updateComment).toHaveBeenCalledWith( + 'card1', + 'deleted-id', + 'Fallback summary', + ); + expect(mockPMProvider.addComment).toHaveBeenCalledWith('card1', 'Fallback summary'); + expect(poster.getCommentId()).toBe('fallback-id'); + expect(mockWriteProgressCommentId).toHaveBeenCalledWith('/tmp/repo', 'card1', 'fallback-id'); + }); +}); diff --git a/tests/unit/backends/scheduler.test.ts b/tests/unit/backends/scheduler.test.ts new file mode 100644 index 00000000..a7df7f64 --- /dev/null +++ b/tests/unit/backends/scheduler.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + DEFAULT_SCHEDULE_MINUTES, + ProgressScheduler, +} from '../../../src/backends/progressState/scheduler.js'; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('DEFAULT_SCHEDULE_MINUTES', () => { + it('is [1, 3, 5]', () => { + expect(DEFAULT_SCHEDULE_MINUTES).toEqual([1, 3, 5]); + }); +}); + +describe('ProgressScheduler', () => { + describe('start / progressive schedule', () => { + it('fires first tick at schedule[0] minutes', async () => { + const tickFn = vi.fn().mockResolvedValue(undefined); + const scheduler = new ProgressScheduler([1, 3, 5], 10); + scheduler.start(tickFn); + + await vi.advanceTimersByTimeAsync(59_999); + expect(tickFn).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(tickFn).toHaveBeenCalledTimes(1); + + scheduler.stop(); + }); + + it('fires second tick at schedule[1] minutes after first tick', async () => { + const tickFn = vi.fn().mockResolvedValue(undefined); + const scheduler = new ProgressScheduler([1, 3], 10); + scheduler.start(tickFn); + + // First tick at 1min + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(1); + + // Second tick at 3 more minutes + await vi.advanceTimersByTimeAsync(3 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(2); + + scheduler.stop(); + }); + + it('falls back to intervalMinutes after schedule exhausted', async () => { + const tickFn = vi.fn().mockResolvedValue(undefined); + const scheduler = new ProgressScheduler([1], 5); + scheduler.start(tickFn); + + // First tick (from schedule) + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(1); + + // Second tick (steady-state: 5min) + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(2); + + // Third tick (steady-state: another 5min) + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(3); + + scheduler.stop(); + }); + + it('fires ticks at full progressive schedule then steady state', async () => { + const tickFn = vi.fn().mockResolvedValue(undefined); + const scheduler = new ProgressScheduler([1, 3, 5], 5); + scheduler.start(tickFn); + + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(3 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(3); + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(4); + + scheduler.stop(); + }); + }); + + describe('stop()', () => { + it('prevents further ticks from firing', async () => { + const tickFn = vi.fn().mockResolvedValue(undefined); + const scheduler = new ProgressScheduler([1, 3], 10); + scheduler.start(tickFn); + + // Fire first tick + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(1); + + // Stop the scheduler + scheduler.stop(); + + // Advance well past the next scheduled tick + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(1); + }); + + it('is safe to call multiple times', () => { + const tickFn = vi.fn().mockResolvedValue(undefined); + const scheduler = new ProgressScheduler([1], 5); + scheduler.start(tickFn); + scheduler.stop(); + expect(() => scheduler.stop()).not.toThrow(); + }); + + it('prevents next tick from scheduling even if stop called during tick', async () => { + let resolveTickFn!: () => void; + const tickPromise = new Promise((resolve) => { + resolveTickFn = resolve; + }); + const tickFn = vi.fn().mockReturnValue(tickPromise); + const scheduler = new ProgressScheduler([1], 5); + scheduler.start(tickFn); + + // Trigger first tick β€” it will not resolve yet + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(1); + + // Stop while tick is "running" + scheduler.stop(); + + // Resolve the tick + resolveTickFn(); + await vi.advanceTimersByTimeAsync(0); + + // No further tick should be scheduled + await vi.advanceTimersByTimeAsync(10 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(1); + }); + }); + + describe('edge cases', () => { + it('handles empty schedule by immediately using intervalMinutes', async () => { + const tickFn = vi.fn().mockResolvedValue(undefined); + const scheduler = new ProgressScheduler([], 3); + scheduler.start(tickFn); + + await vi.advanceTimersByTimeAsync(3 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(1); + + scheduler.stop(); + }); + }); +}); From 72440d0cfb8fe7e43cba4175093c8798a7a48579 Mon Sep 17 00:00:00 2001 From: aaight Date: Tue, 24 Feb 2026 19:20:06 +0100 Subject: [PATCH 03/13] refactor(config): extract configMapper, unify build paths, DRY gadget escalation (#539) --- src/db/repositories/configMapper.ts | 296 +++++++++++++ src/db/repositories/configRepository.ts | 289 ++++--------- src/gadgets/FileMultiEdit.ts | 12 +- src/gadgets/FileSearchAndReplace.ts | 12 +- src/gadgets/shared/editEscalation.ts | 19 + .../unit/db/repositories/configMapper.test.ts | 397 ++++++++++++++++++ .../gadgets/shared/editEscalation.test.ts | 60 +++ 7 files changed, 848 insertions(+), 237 deletions(-) create mode 100644 src/db/repositories/configMapper.ts create mode 100644 src/gadgets/shared/editEscalation.ts create mode 100644 tests/unit/db/repositories/configMapper.test.ts create mode 100644 tests/unit/gadgets/shared/editEscalation.test.ts diff --git a/src/db/repositories/configMapper.ts b/src/db/repositories/configMapper.ts new file mode 100644 index 00000000..b85ef3d6 --- /dev/null +++ b/src/db/repositories/configMapper.ts @@ -0,0 +1,296 @@ +/** + * Config mapper β€” pure transformation functions for converting DB rows into + * raw config objects consumed by `validateConfig`. + * + * Extracted from configRepository.ts to separate query concerns from mapping + * concerns and to enable isolated unit testing of the transformation logic. + */ + +// --------------------------------------------------------------------------- +// Integration config interfaces +// --------------------------------------------------------------------------- + +export interface TrelloIntegrationConfig { + boardId: string; + lists: Record; + labels: Record; + customFields?: { cost?: string }; +} + +export interface JiraIntegrationConfig { + projectKey: string; + baseUrl: string; + statuses: Record; + issueTypes?: Record; + customFields?: { cost?: string }; + labels?: Record; +} + +// biome-ignore lint/complexity/noBannedTypes: GitHub config has no fields (credentials are in integration_credentials) +export type GitHubIntegrationConfig = {}; + +// --------------------------------------------------------------------------- +// Row interfaces (mirrors DB select shapes) +// --------------------------------------------------------------------------- + +export interface DefaultsRow { + model: string | null; + maxIterations: number | null; + watchdogTimeoutMs: number | null; + cardBudgetUsd: string | null; + agentBackend: string | null; + progressModel: string | null; + progressIntervalMinutes: string | null; +} + +export interface AgentConfigRow { + orgId: string | null; + projectId: string | null; + agentType: string; + model: string | null; + maxIterations: number | null; + agentBackend: string | null; + prompt: string | null; +} + +export interface IntegrationRow { + projectId: string; + category: string; + provider: string; + config: unknown; + triggers: unknown; +} + +// --------------------------------------------------------------------------- +// Structured input for mapProjectRow (replaces 8 positional params) +// --------------------------------------------------------------------------- + +export interface MapProjectInput { + row: ProjectRow; + projectAgentConfigs: AgentConfigRow[]; + trelloConfig?: TrelloIntegrationConfig; + trelloTriggers?: Record; + jiraConfig?: JiraIntegrationConfig; + jiraTriggers?: Record; + githubConfig?: GitHubIntegrationConfig; + githubTriggers?: Record; +} + +// --------------------------------------------------------------------------- +// Typed return interface for mapProjectRow +// --------------------------------------------------------------------------- + +export interface ProjectConfigRaw { + id: string; + orgId: string; + name: string; + repo: string; + baseBranch: string; + branchPrefix: string; + pm: { type: string }; + prompts?: Record; + model?: string; + agentModels?: Record; + cardBudgetUsd?: number; + squintDbUrl?: string; + trello?: { + boardId: string; + lists: Record; + labels: Record; + customFields?: { cost?: string }; + triggers?: Record; + }; + jira?: { + projectKey: string; + baseUrl: string; + statuses: Record; + issueTypes?: Record; + customFields?: { cost?: string }; + labels?: Record; + triggers?: Record; + }; + github?: { triggers: Record }; + agentBackend?: { + default?: string; + overrides: Record; + subscriptionCostZero: boolean; + }; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +type ProjectRow = { + id: string; + orgId: string; + name: string; + repo: string; + baseBranch: string | null; + branchPrefix: string | null; + model: string | null; + cardBudgetUsd: string | null; + squintDbUrl: string | null; + agentBackend: string | null; + subscriptionCostZero: boolean | null; +}; + +export function buildAgentMaps(configs: AgentConfigRow[]): { + models: Record; + iterations: Record; + prompts: Record; + backends: Record; +} { + const models: Record = {}; + const iterations: Record = {}; + const prompts: Record = {}; + const backends: Record = {}; + for (const ac of configs) { + if (ac.model) models[ac.agentType] = ac.model; + if (ac.maxIterations != null) iterations[ac.agentType] = ac.maxIterations; + if (ac.prompt) prompts[ac.agentType] = ac.prompt; + if (ac.agentBackend) backends[ac.agentType] = ac.agentBackend; + } + return { models, iterations, prompts, backends }; +} + +export function orUndefined>(obj: T): T | undefined { + return Object.keys(obj).length > 0 ? obj : undefined; +} + +function buildTrelloConfig( + config: TrelloIntegrationConfig, + triggers?: Record, +): ProjectConfigRaw['trello'] { + return { + boardId: config.boardId, + lists: config.lists, + labels: config.labels, + customFields: config.customFields, + ...(triggers && Object.keys(triggers).length > 0 ? { triggers } : {}), + }; +} + +function buildJiraConfig( + config: JiraIntegrationConfig, + triggers?: Record, +): ProjectConfigRaw['jira'] { + return { + projectKey: config.projectKey, + baseUrl: config.baseUrl, + statuses: config.statuses, + issueTypes: config.issueTypes, + customFields: config.customFields, + labels: config.labels, + ...(triggers && Object.keys(triggers).length > 0 ? { triggers } : {}), + }; +} + +function buildAgentBackendConfig( + row: ProjectRow, + backends: Record, +): ProjectConfigRaw['agentBackend'] | undefined { + if (!row.agentBackend && Object.keys(backends).length === 0) return undefined; + return { + default: row.agentBackend ?? undefined, + overrides: backends, + subscriptionCostZero: row.subscriptionCostZero ?? false, + }; +} + +// --------------------------------------------------------------------------- +// Public mapping functions +// --------------------------------------------------------------------------- + +export function mapDefaultsRow( + row: DefaultsRow | undefined, + globalAgentConfigs: AgentConfigRow[], +): Record { + const { models, iterations, prompts } = buildAgentMaps(globalAgentConfigs); + + return { + model: row?.model ?? undefined, + agentModels: orUndefined(models), + maxIterations: row?.maxIterations ?? undefined, + agentIterations: orUndefined(iterations), + watchdogTimeoutMs: row?.watchdogTimeoutMs ?? undefined, + cardBudgetUsd: row?.cardBudgetUsd ? Number(row.cardBudgetUsd) : undefined, + agentBackend: row?.agentBackend ?? undefined, + progressModel: row?.progressModel ?? undefined, + progressIntervalMinutes: row?.progressIntervalMinutes + ? Number(row.progressIntervalMinutes) + : undefined, + prompts: orUndefined(prompts), + }; +} + +export function extractIntegrationConfigs(integrations: IntegrationRow[]): { + trelloConfig?: TrelloIntegrationConfig; + trelloTriggers?: Record; + jiraConfig?: JiraIntegrationConfig; + jiraTriggers?: Record; + githubConfig?: GitHubIntegrationConfig; + githubTriggers?: Record; +} { + const trelloRow = integrations.find((i) => i.provider === 'trello'); + const jiraRow = integrations.find((i) => i.provider === 'jira'); + const githubRow = integrations.find((i) => i.provider === 'github'); + + return { + trelloConfig: trelloRow?.config as TrelloIntegrationConfig | undefined, + trelloTriggers: (trelloRow?.triggers ?? undefined) as Record | undefined, + jiraConfig: jiraRow?.config as JiraIntegrationConfig | undefined, + jiraTriggers: (jiraRow?.triggers ?? undefined) as Record | undefined, + githubConfig: githubRow?.config as GitHubIntegrationConfig | undefined, + githubTriggers: (githubRow?.triggers ?? undefined) as Record | undefined, + }; +} + +export function mapProjectRow({ + row, + projectAgentConfigs, + trelloConfig, + trelloTriggers, + jiraConfig, + jiraTriggers, + githubTriggers, +}: MapProjectInput): ProjectConfigRaw { + const { models, prompts, backends } = buildAgentMaps(projectAgentConfigs); + + // Derive PM type from integration config + const pmType = jiraConfig ? 'jira' : 'trello'; + + const project: ProjectConfigRaw = { + id: row.id, + orgId: row.orgId, + name: row.name, + repo: row.repo, + baseBranch: row.baseBranch ?? 'main', + branchPrefix: row.branchPrefix ?? 'feature/', + pm: { type: pmType }, + prompts: orUndefined(prompts), + model: row.model ?? undefined, + agentModels: orUndefined(models), + cardBudgetUsd: row.cardBudgetUsd ? Number(row.cardBudgetUsd) : undefined, + squintDbUrl: row.squintDbUrl ?? undefined, + }; + + if (trelloConfig) { + project.trello = buildTrelloConfig(trelloConfig, trelloTriggers); + } + + if (jiraConfig) { + project.jira = buildJiraConfig(jiraConfig, jiraTriggers); + } + + if (githubTriggers && Object.keys(githubTriggers).length > 0) { + project.github = { triggers: githubTriggers }; + } + + const agentBackend = buildAgentBackendConfig(row, backends); + if (agentBackend) { + project.agentBackend = agentBackend; + } + + return project; +} diff --git a/src/db/repositories/configRepository.ts b/src/db/repositories/configRepository.ts index 7575c2d3..30af354b 100644 --- a/src/db/repositories/configRepository.ts +++ b/src/db/repositories/configRepository.ts @@ -3,174 +3,67 @@ import { validateConfig } from '../../config/schema.js'; import type { CascadeConfig, ProjectConfig } from '../../types/index.js'; import { getDb } from '../client.js'; import { agentConfigs, cascadeDefaults, projectIntegrations, projects } from '../schema/index.js'; - -interface TrelloIntegrationConfig { - boardId: string; - lists: Record; - labels: Record; - customFields?: { cost?: string }; -} - -interface JiraIntegrationConfig { - projectKey: string; - baseUrl: string; - statuses: Record; - issueTypes?: Record; - customFields?: { cost?: string }; - labels?: Record; -} - -// biome-ignore lint/complexity/noBannedTypes: GitHub config has no fields (credentials are in integration_credentials) -type GitHubIntegrationConfig = {}; - -interface DefaultsRow { - model: string | null; - maxIterations: number | null; - watchdogTimeoutMs: number | null; - cardBudgetUsd: string | null; - agentBackend: string | null; - progressModel: string | null; - progressIntervalMinutes: string | null; -} - -interface AgentConfigRow { - orgId: string | null; - projectId: string | null; - agentType: string; - model: string | null; - maxIterations: number | null; - agentBackend: string | null; - prompt: string | null; -} - -function buildAgentMaps(configs: AgentConfigRow[]) { - const models: Record = {}; - const iterations: Record = {}; - const prompts: Record = {}; - const backends: Record = {}; - for (const ac of configs) { - if (ac.model) models[ac.agentType] = ac.model; - if (ac.maxIterations != null) iterations[ac.agentType] = ac.maxIterations; - if (ac.prompt) prompts[ac.agentType] = ac.prompt; - if (ac.agentBackend) backends[ac.agentType] = ac.agentBackend; - } - return { models, iterations, prompts, backends }; -} - -function orUndefined>(obj: T): T | undefined { - return Object.keys(obj).length > 0 ? obj : undefined; -} - -function mapDefaultsRow(row: DefaultsRow | undefined, globalAgentConfigs: AgentConfigRow[]) { - const { models, iterations, prompts } = buildAgentMaps(globalAgentConfigs); - - return { - model: row?.model ?? undefined, - agentModels: orUndefined(models), - maxIterations: row?.maxIterations ?? undefined, - agentIterations: orUndefined(iterations), - watchdogTimeoutMs: row?.watchdogTimeoutMs ?? undefined, - cardBudgetUsd: row?.cardBudgetUsd ? Number(row.cardBudgetUsd) : undefined, - agentBackend: row?.agentBackend ?? undefined, - progressModel: row?.progressModel ?? undefined, - progressIntervalMinutes: row?.progressIntervalMinutes - ? Number(row.progressIntervalMinutes) - : undefined, - prompts: orUndefined(prompts), - }; -} - -type ProjectRow = typeof projects.$inferSelect; - -interface IntegrationRow { - category: string; - provider: string; - config: unknown; - triggers: unknown; -} - -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: inherently maps multiple integration types -function mapProjectRow( - row: ProjectRow, - projectAgentConfigs: AgentConfigRow[], - trelloConfig?: TrelloIntegrationConfig, - trelloTriggers?: Record, - jiraConfig?: JiraIntegrationConfig, - jiraTriggers?: Record, - _githubConfig?: GitHubIntegrationConfig, - githubTriggers?: Record, -): Record { - const { models, prompts, backends } = buildAgentMaps(projectAgentConfigs); - - // Derive PM type from integration config - const pmType = jiraConfig ? 'jira' : 'trello'; - - const project: Record = { - id: row.id, - orgId: row.orgId, - name: row.name, - repo: row.repo, - baseBranch: row.baseBranch ?? 'main', - branchPrefix: row.branchPrefix ?? 'feature/', - pm: { type: pmType }, - prompts: orUndefined(prompts), - model: row.model ?? undefined, - agentModels: orUndefined(models), - cardBudgetUsd: row.cardBudgetUsd ? Number(row.cardBudgetUsd) : undefined, - squintDbUrl: row.squintDbUrl ?? undefined, - }; - - if (trelloConfig) { - project.trello = { - boardId: trelloConfig.boardId, - lists: trelloConfig.lists, - labels: trelloConfig.labels, - customFields: trelloConfig.customFields, - ...(trelloTriggers && Object.keys(trelloTriggers).length > 0 - ? { triggers: trelloTriggers } - : {}), - }; - } - - if (jiraConfig) { - project.jira = { - projectKey: jiraConfig.projectKey, - baseUrl: jiraConfig.baseUrl, - statuses: jiraConfig.statuses, - issueTypes: jiraConfig.issueTypes, - customFields: jiraConfig.customFields, - labels: jiraConfig.labels, - ...(jiraTriggers && Object.keys(jiraTriggers).length > 0 ? { triggers: jiraTriggers } : {}), - }; - } - - if (githubTriggers && Object.keys(githubTriggers).length > 0) { - project.github = { triggers: githubTriggers }; - } - - if (row.agentBackend || Object.keys(backends).length > 0) { - project.agentBackend = { - default: row.agentBackend ?? undefined, - overrides: backends, - subscriptionCostZero: row.subscriptionCostZero ?? false, - }; +import { + type AgentConfigRow, + type DefaultsRow, + type IntegrationRow, + extractIntegrationConfigs, + mapDefaultsRow, + mapProjectRow, +} from './configMapper.js'; + +// --------------------------------------------------------------------------- +// Shared config builder β€” eliminates duplicated extractβ†’splitβ†’mapβ†’validate +// --------------------------------------------------------------------------- + +interface BuildRawConfigOpts { + defaultsRow: DefaultsRow | undefined; + globalAgentConfigs: AgentConfigRow[]; + projectRows: Array; + /** All integration rows for all projects in projectRows */ + integrationRows: IntegrationRow[]; + /** Per-project agent configs, keyed by project ID */ + projectAgentConfigsMap: Map; +} + +function buildRawConfig({ + defaultsRow, + globalAgentConfigs, + projectRows, + integrationRows, + projectAgentConfigsMap, +}: BuildRawConfigOpts) { + // Index integrations by project ID + const integrationsByProject = new Map(); + for (const row of integrationRows) { + const existing = integrationsByProject.get(row.projectId as string) ?? []; + existing.push(row); + integrationsByProject.set(row.projectId as string, existing); } - return project; -} - -function extractIntegrationConfigs(integrations: IntegrationRow[]) { - const trelloRow = integrations.find((i) => i.provider === 'trello'); - const jiraRow = integrations.find((i) => i.provider === 'jira'); - const githubRow = integrations.find((i) => i.provider === 'github'); - return { - trelloConfig: trelloRow?.config as TrelloIntegrationConfig | undefined, - trelloTriggers: (trelloRow?.triggers ?? undefined) as Record | undefined, - jiraConfig: jiraRow?.config as JiraIntegrationConfig | undefined, - jiraTriggers: (jiraRow?.triggers ?? undefined) as Record | undefined, - githubConfig: githubRow?.config as GitHubIntegrationConfig | undefined, - githubTriggers: (githubRow?.triggers ?? undefined) as Record | undefined, + defaults: mapDefaultsRow(defaultsRow, globalAgentConfigs), + projects: projectRows.map((row) => { + const integrations = integrationsByProject.get(row.id) ?? []; + const { + trelloConfig, + trelloTriggers, + jiraConfig, + jiraTriggers, + githubConfig, + githubTriggers, + } = extractIntegrationConfigs(integrations); + return mapProjectRow({ + row, + projectAgentConfigs: projectAgentConfigsMap.get(row.id) ?? [], + trelloConfig, + trelloTriggers, + jiraConfig, + jiraTriggers, + githubConfig, + githubTriggers, + }); + }), }; } @@ -193,14 +86,6 @@ export async function loadConfigFromDb(): Promise { db.select().from(projectIntegrations), ]); - // Index integrations by project ID - const integrationsByProject = new Map(); - for (const row of integrationRows) { - const existing = integrationsByProject.get(row.projectId) ?? []; - existing.push(row); - integrationsByProject.set(row.projectId, existing); - } - // Split agent configs: global (project_id IS NULL, org_id IS NULL) and per-project // Also collect org-level configs (org_id set, project_id IS NULL) as fallback globals const globalAgentConfigs = allAgentConfigs.filter( @@ -226,30 +111,13 @@ export async function loadConfigFromDb(): Promise { ...(defaultsRow ? (orgAgentConfigsMap.get(defaultsRow.orgId) ?? []) : []), ]; - const rawConfig = { - defaults: mapDefaultsRow(defaultsRow, mergedGlobalConfigs), - projects: projectRows.map((row) => { - const integrations = (integrationsByProject.get(row.id) ?? []) as IntegrationRow[]; - const { - trelloConfig, - trelloTriggers, - jiraConfig, - jiraTriggers, - githubConfig, - githubTriggers, - } = extractIntegrationConfigs(integrations); - return mapProjectRow( - row, - projectAgentConfigsMap.get(row.id) ?? [], - trelloConfig, - trelloTriggers, - jiraConfig, - jiraTriggers, - githubConfig, - githubTriggers, - ); - }), - }; + const rawConfig = buildRawConfig({ + defaultsRow, + globalAgentConfigs: mergedGlobalConfigs, + projectRows, + integrationRows: integrationRows as IntegrationRow[], + projectAgentConfigsMap, + }); return validateConfig(rawConfig); } @@ -279,25 +147,16 @@ async function findProjectConfigFromDb( db.select().from(projectIntegrations).where(eq(projectIntegrations.projectId, row.id)), ]); - const integrationRows = integrations as IntegrationRow[]; - const { trelloConfig, trelloTriggers, jiraConfig, jiraTriggers, githubConfig, githubTriggers } = - extractIntegrationConfigs(integrationRows); + const projectAgentConfigsMap = new Map([[row.id, projectAcs]]); + + const rawConfig = buildRawConfig({ + defaultsRow, + globalAgentConfigs: [...globalAcs, ...orgAcs], + projectRows: [row], + integrationRows: integrations as IntegrationRow[], + projectAgentConfigsMap, + }); - const rawConfig = { - defaults: mapDefaultsRow(defaultsRow, [...globalAcs, ...orgAcs]), - projects: [ - mapProjectRow( - row, - projectAcs, - trelloConfig, - trelloTriggers, - jiraConfig, - jiraTriggers, - githubConfig, - githubTriggers, - ), - ], - }; const config = validateConfig(rawConfig); return { project: config.projects[0], config }; } diff --git a/src/gadgets/FileMultiEdit.ts b/src/gadgets/FileMultiEdit.ts index 38075a08..f401e150 100644 --- a/src/gadgets/FileMultiEdit.ts +++ b/src/gadgets/FileMultiEdit.ts @@ -11,6 +11,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { Gadget, z } from 'llmist'; import { assertFileRead, markFileRead } from './readTracking.js'; +import { withEscalationHint } from './shared/editEscalation.js'; import { adjustIndentation, applyReplacement, @@ -18,21 +19,10 @@ import { findAllMatches, formatContext, getMatchFailure, - recordEditFailure, runPostEditChecks, validatePath, } from './shared/index.js'; -const ESCALATION_HINT = - '\n\nTIP: This file has failed multiple edit attempts. For files with repetitive structure ' + - '(CRUD methods, similar function signatures), use ReadFile to get the current content, ' + - 'then WriteFile to rewrite the entire file or section.'; - -function withEscalationHint(message: string, filePath: string): string { - const failCount = recordEditFailure(filePath); - return failCount >= 2 ? message + ESCALATION_HINT : message; -} - export class FileMultiEdit extends Gadget({ name: 'FileMultiEdit', description: `Apply multiple search/replace edits to a single file atomically. diff --git a/src/gadgets/FileSearchAndReplace.ts b/src/gadgets/FileSearchAndReplace.ts index d9837d67..72d6d53e 100644 --- a/src/gadgets/FileSearchAndReplace.ts +++ b/src/gadgets/FileSearchAndReplace.ts @@ -10,6 +10,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { Gadget, z } from 'llmist'; import { assertFileRead, markFileRead } from './readTracking.js'; +import { withEscalationHint } from './shared/editEscalation.js'; import { adjustIndentation, applyReplacement, @@ -17,22 +18,11 @@ import { findAllMatches, formatContext, getMatchFailure, - recordEditFailure, runPostEditChecks, validatePath, } from './shared/index.js'; import type { MatchResult } from './shared/types.js'; -const ESCALATION_HINT = - '\n\nTIP: This file has failed multiple edit attempts. For files with repetitive structure ' + - '(CRUD methods, similar function signatures), use ReadFile to get the current content, ' + - 'then WriteFile to rewrite the entire file or section.'; - -function withEscalationHint(message: string, filePath: string): string { - const failCount = recordEditFailure(filePath); - return failCount >= 2 ? message + ESCALATION_HINT : message; -} - export class FileSearchAndReplace extends Gadget({ name: 'FileSearchAndReplace', description: `Search for content in a file and replace it. diff --git a/src/gadgets/shared/editEscalation.ts b/src/gadgets/shared/editEscalation.ts new file mode 100644 index 00000000..51c4fbfe --- /dev/null +++ b/src/gadgets/shared/editEscalation.ts @@ -0,0 +1,19 @@ +/** + * Shared escalation hint utilities for file-editing gadgets. + * + * Extracted from FileSearchAndReplace and FileMultiEdit to eliminate + * byte-for-byte duplication of the ESCALATION_HINT constant and the + * withEscalationHint function. + */ + +import { recordEditFailure } from './diagnosticState.js'; + +export const ESCALATION_HINT = + '\n\nTIP: This file has failed multiple edit attempts. For files with repetitive structure ' + + '(CRUD methods, similar function signatures), use ReadFile to get the current content, ' + + 'then WriteFile to rewrite the entire file or section.'; + +export function withEscalationHint(message: string, filePath: string): string { + const failCount = recordEditFailure(filePath); + return failCount >= 2 ? message + ESCALATION_HINT : message; +} diff --git a/tests/unit/db/repositories/configMapper.test.ts b/tests/unit/db/repositories/configMapper.test.ts new file mode 100644 index 00000000..d21909d8 --- /dev/null +++ b/tests/unit/db/repositories/configMapper.test.ts @@ -0,0 +1,397 @@ +import { describe, expect, it } from 'vitest'; + +import { + type AgentConfigRow, + type DefaultsRow, + type IntegrationRow, + type MapProjectInput, + buildAgentMaps, + extractIntegrationConfigs, + mapDefaultsRow, + mapProjectRow, + orUndefined, +} from '../../../../src/db/repositories/configMapper.js'; + +// --------------------------------------------------------------------------- +// Shared fixtures +// --------------------------------------------------------------------------- + +const baseProjectRow = { + id: 'proj1', + orgId: 'org1', + name: 'Test Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + model: null, + cardBudgetUsd: null, + squintDbUrl: null, + agentBackend: null, + subscriptionCostZero: false, +}; + +const trelloConfig = { + boardId: 'board123', + lists: { todo: 'list-todo', done: 'list-done' }, + labels: { processing: 'label-proc' }, +}; + +const jiraConfig = { + projectKey: 'PROJ', + baseUrl: 'https://test.atlassian.net', + statuses: { briefing: 'Briefing', todo: 'To Do' }, +}; + +const trelloIntegrationRow: IntegrationRow = { + projectId: 'proj1', + category: 'pm', + provider: 'trello', + config: trelloConfig, + triggers: {}, +}; + +const jiraIntegrationRow: IntegrationRow = { + projectId: 'proj1', + category: 'pm', + provider: 'jira', + config: jiraConfig, + triggers: {}, +}; + +const githubIntegrationRow: IntegrationRow = { + projectId: 'proj1', + category: 'scm', + provider: 'github', + config: {}, + triggers: { ownPrsOnly: true }, +}; + +// --------------------------------------------------------------------------- +// orUndefined +// --------------------------------------------------------------------------- + +describe('orUndefined', () => { + it('returns the object when it has keys', () => { + expect(orUndefined({ a: '1' })).toEqual({ a: '1' }); + }); + + it('returns undefined for an empty object', () => { + expect(orUndefined({})).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// buildAgentMaps +// --------------------------------------------------------------------------- + +describe('buildAgentMaps', () => { + it('returns empty maps for empty input', () => { + const result = buildAgentMaps([]); + expect(result.models).toEqual({}); + expect(result.iterations).toEqual({}); + expect(result.prompts).toEqual({}); + expect(result.backends).toEqual({}); + }); + + it('maps model, iterations, prompt, and backend for each agent type', () => { + const configs: AgentConfigRow[] = [ + { + orgId: null, + projectId: 'proj1', + agentType: 'implementation', + model: 'claude-3-7-sonnet', + maxIterations: 30, + agentBackend: 'claude-code', + prompt: 'Write clean code', + }, + { + orgId: null, + projectId: 'proj1', + agentType: 'review', + model: 'claude-3-opus', + maxIterations: null, + agentBackend: null, + prompt: null, + }, + ]; + + const result = buildAgentMaps(configs); + expect(result.models).toEqual({ implementation: 'claude-3-7-sonnet', review: 'claude-3-opus' }); + expect(result.iterations).toEqual({ implementation: 30 }); + expect(result.prompts).toEqual({ implementation: 'Write clean code' }); + expect(result.backends).toEqual({ implementation: 'claude-code' }); + }); + + it('skips null values', () => { + const configs: AgentConfigRow[] = [ + { + orgId: null, + projectId: null, + agentType: 'briefing', + model: null, + maxIterations: null, + agentBackend: null, + prompt: null, + }, + ]; + + const result = buildAgentMaps(configs); + expect(Object.keys(result.models)).toHaveLength(0); + expect(Object.keys(result.iterations)).toHaveLength(0); + expect(Object.keys(result.prompts)).toHaveLength(0); + expect(Object.keys(result.backends)).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// mapDefaultsRow +// --------------------------------------------------------------------------- + +describe('mapDefaultsRow', () => { + const defaultsRow: DefaultsRow = { + model: 'test-model', + maxIterations: 50, + watchdogTimeoutMs: 1800000, + cardBudgetUsd: '5.00', + agentBackend: 'llmist', + progressModel: 'progress-model', + progressIntervalMinutes: '5', + }; + + it('maps all fields from row', () => { + const result = mapDefaultsRow(defaultsRow, []); + expect(result.model).toBe('test-model'); + expect(result.maxIterations).toBe(50); + expect(result.watchdogTimeoutMs).toBe(1800000); + expect(result.cardBudgetUsd).toBe(5); + expect(result.agentBackend).toBe('llmist'); + expect(result.progressModel).toBe('progress-model'); + expect(result.progressIntervalMinutes).toBe(5); + }); + + it('converts cardBudgetUsd string to number', () => { + const result = mapDefaultsRow({ ...defaultsRow, cardBudgetUsd: '10.50' }, []); + expect(result.cardBudgetUsd).toBe(10.5); + }); + + it('converts progressIntervalMinutes string to number', () => { + const result = mapDefaultsRow({ ...defaultsRow, progressIntervalMinutes: '15' }, []); + expect(result.progressIntervalMinutes).toBe(15); + }); + + it('handles undefined defaults row gracefully', () => { + const result = mapDefaultsRow(undefined, []); + expect(result.model).toBeUndefined(); + expect(result.cardBudgetUsd).toBeUndefined(); + }); + + it('builds agentModels and agentIterations from agent configs', () => { + const agentConfigs: AgentConfigRow[] = [ + { + orgId: null, + projectId: null, + agentType: 'review', + model: 'review-model', + maxIterations: 20, + agentBackend: null, + prompt: null, + }, + ]; + const result = mapDefaultsRow(defaultsRow, agentConfigs); + expect(result.agentModels).toEqual({ review: 'review-model' }); + expect(result.agentIterations).toEqual({ review: 20 }); + }); +}); + +// --------------------------------------------------------------------------- +// extractIntegrationConfigs +// --------------------------------------------------------------------------- + +describe('extractIntegrationConfigs', () => { + it('extracts trello config from integration rows', () => { + const result = extractIntegrationConfigs([trelloIntegrationRow]); + expect(result.trelloConfig).toEqual(trelloConfig); + expect(result.jiraConfig).toBeUndefined(); + expect(result.githubConfig).toBeUndefined(); + }); + + it('extracts jira config from integration rows', () => { + const result = extractIntegrationConfigs([jiraIntegrationRow]); + expect(result.jiraConfig).toEqual(jiraConfig); + expect(result.trelloConfig).toBeUndefined(); + }); + + it('extracts github triggers from integration rows', () => { + const result = extractIntegrationConfigs([githubIntegrationRow]); + expect(result.githubTriggers).toEqual({ ownPrsOnly: true }); + }); + + it('extracts trello triggers', () => { + const withTriggers: IntegrationRow = { + ...trelloIntegrationRow, + triggers: { cardMovedToTodo: true }, + }; + const result = extractIntegrationConfigs([withTriggers]); + expect(result.trelloTriggers).toEqual({ cardMovedToTodo: true }); + }); + + it('handles empty integration list', () => { + const result = extractIntegrationConfigs([]); + expect(result.trelloConfig).toBeUndefined(); + expect(result.jiraConfig).toBeUndefined(); + expect(result.githubConfig).toBeUndefined(); + }); + + it('extracts all providers from mixed integration list', () => { + const rows = [trelloIntegrationRow, githubIntegrationRow]; + const result = extractIntegrationConfigs(rows); + expect(result.trelloConfig).toEqual(trelloConfig); + expect(result.githubTriggers).toEqual({ ownPrsOnly: true }); + expect(result.jiraConfig).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// mapProjectRow +// --------------------------------------------------------------------------- + +describe('mapProjectRow', () => { + function makeInput(overrides: Partial = {}): MapProjectInput { + return { + row: baseProjectRow, + projectAgentConfigs: [], + trelloConfig, + ...overrides, + }; + } + + it('maps base project fields', () => { + const result = mapProjectRow(makeInput()); + expect(result.id).toBe('proj1'); + expect(result.orgId).toBe('org1'); + expect(result.name).toBe('Test Project'); + expect(result.repo).toBe('owner/repo'); + expect(result.baseBranch).toBe('main'); + expect(result.branchPrefix).toBe('feature/'); + }); + + it('defaults baseBranch to main when null', () => { + const result = mapProjectRow(makeInput({ row: { ...baseProjectRow, baseBranch: null } })); + expect(result.baseBranch).toBe('main'); + }); + + it('defaults branchPrefix to feature/ when null', () => { + const result = mapProjectRow(makeInput({ row: { ...baseProjectRow, branchPrefix: null } })); + expect(result.branchPrefix).toBe('feature/'); + }); + + it('sets pm.type to trello when trelloConfig is provided', () => { + const result = mapProjectRow(makeInput({ trelloConfig })); + expect(result.pm.type).toBe('trello'); + }); + + it('sets pm.type to jira when jiraConfig is provided', () => { + const result = mapProjectRow(makeInput({ trelloConfig: undefined, jiraConfig })); + expect(result.pm.type).toBe('jira'); + }); + + it('builds trello config with boardId, lists, labels', () => { + const result = mapProjectRow(makeInput()); + expect(result.trello?.boardId).toBe('board123'); + expect(result.trello?.lists).toEqual({ todo: 'list-todo', done: 'list-done' }); + expect(result.trello?.labels).toEqual({ processing: 'label-proc' }); + }); + + it('includes trello triggers when non-empty', () => { + const result = mapProjectRow(makeInput({ trelloTriggers: { cardMovedToTodo: true } })); + expect(result.trello?.triggers).toEqual({ cardMovedToTodo: true }); + }); + + it('omits trello triggers when empty object', () => { + const result = mapProjectRow(makeInput({ trelloTriggers: {} })); + expect(result.trello?.triggers).toBeUndefined(); + }); + + it('builds jira config', () => { + const result = mapProjectRow(makeInput({ trelloConfig: undefined, jiraConfig })); + expect(result.jira?.projectKey).toBe('PROJ'); + expect(result.jira?.baseUrl).toBe('https://test.atlassian.net'); + expect(result.jira?.statuses).toEqual({ briefing: 'Briefing', todo: 'To Do' }); + }); + + it('includes jira triggers when non-empty', () => { + const result = mapProjectRow( + makeInput({ trelloConfig: undefined, jiraConfig, jiraTriggers: { issueTransitioned: true } }), + ); + expect(result.jira?.triggers).toEqual({ issueTransitioned: true }); + }); + + it('builds github section when githubTriggers is non-empty', () => { + const result = mapProjectRow(makeInput({ githubTriggers: { ownPrsOnly: true } })); + expect(result.github?.triggers).toEqual({ ownPrsOnly: true }); + }); + + it('omits github section when githubTriggers is empty', () => { + const result = mapProjectRow(makeInput({ githubTriggers: {} })); + expect(result.github).toBeUndefined(); + }); + + it('omits agentBackend when neither row.agentBackend nor agent overrides are set', () => { + const result = mapProjectRow(makeInput()); + expect(result.agentBackend).toBeUndefined(); + }); + + it('builds agentBackend from project row', () => { + const result = mapProjectRow( + makeInput({ + row: { ...baseProjectRow, agentBackend: 'claude-code', subscriptionCostZero: true }, + }), + ); + expect(result.agentBackend?.default).toBe('claude-code'); + expect(result.agentBackend?.subscriptionCostZero).toBe(true); + }); + + it('builds agentBackend overrides from project agent configs', () => { + const agentConfigs: AgentConfigRow[] = [ + { + orgId: null, + projectId: 'proj1', + agentType: 'implementation', + model: 'impl-model', + maxIterations: null, + agentBackend: 'claude-code', + prompt: null, + }, + ]; + const result = mapProjectRow(makeInput({ projectAgentConfigs: agentConfigs })); + expect(result.agentBackend?.overrides).toEqual({ implementation: 'claude-code' }); + }); + + it('converts cardBudgetUsd from string to number', () => { + const result = mapProjectRow(makeInput({ row: { ...baseProjectRow, cardBudgetUsd: '7.50' } })); + expect(result.cardBudgetUsd).toBe(7.5); + }); + + it('includes squintDbUrl when set', () => { + const result = mapProjectRow( + makeInput({ row: { ...baseProjectRow, squintDbUrl: 'file://.squint.db' } }), + ); + expect(result.squintDbUrl).toBe('file://.squint.db'); + }); + + it('includes prompts from agent configs', () => { + const agentConfigs: AgentConfigRow[] = [ + { + orgId: null, + projectId: 'proj1', + agentType: 'implementation', + model: null, + maxIterations: null, + agentBackend: null, + prompt: 'Write clean code', + }, + ]; + const result = mapProjectRow(makeInput({ projectAgentConfigs: agentConfigs })); + expect(result.prompts).toEqual({ implementation: 'Write clean code' }); + }); +}); diff --git a/tests/unit/gadgets/shared/editEscalation.test.ts b/tests/unit/gadgets/shared/editEscalation.test.ts new file mode 100644 index 00000000..fd44a500 --- /dev/null +++ b/tests/unit/gadgets/shared/editEscalation.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { clearDiagnosticState } from '../../../../src/gadgets/shared/diagnosticState.js'; +import { + ESCALATION_HINT, + withEscalationHint, +} from '../../../../src/gadgets/shared/editEscalation.js'; + +describe('editEscalation', () => { + afterEach(() => { + clearDiagnosticState(); + }); + + describe('ESCALATION_HINT', () => { + it('is a non-empty string', () => { + expect(typeof ESCALATION_HINT).toBe('string'); + expect(ESCALATION_HINT.length).toBeGreaterThan(0); + }); + + it('contains guidance about ReadFile/WriteFile', () => { + expect(ESCALATION_HINT).toContain('ReadFile'); + expect(ESCALATION_HINT).toContain('WriteFile'); + }); + }); + + describe('withEscalationHint', () => { + it('returns message unchanged on first failure', () => { + const result = withEscalationHint('Some error', '/path/to/file.ts'); + expect(result).toBe('Some error'); + }); + + it('returns message unchanged on second failure', () => { + withEscalationHint('first failure', '/path/to/file.ts'); + // The second call records failure count 2 β€” hint kicks in at >= 2 + const result = withEscalationHint('second failure', '/path/to/file.ts'); + expect(result).toBe(`second failure${ESCALATION_HINT}`); + }); + + it('appends escalation hint from the second failure onward', () => { + withEscalationHint('msg', '/path/to/file.ts'); // count = 1 + const second = withEscalationHint('msg', '/path/to/file.ts'); // count = 2 + const third = withEscalationHint('msg', '/path/to/file.ts'); // count = 3 + + expect(second).toContain(ESCALATION_HINT); + expect(third).toContain(ESCALATION_HINT); + }); + + it('tracks failure counts per file independently', () => { + withEscalationHint('msg', '/path/to/file1.ts'); // file1: count = 1 + const resultFile2 = withEscalationHint('msg', '/path/to/file2.ts'); // file2: count = 1 + + // file2 count is only 1, so no hint + expect(resultFile2).toBe('msg'); + + // file1 second failure triggers hint + const resultFile1 = withEscalationHint('msg', '/path/to/file1.ts'); // file1: count = 2 + expect(resultFile1).toContain(ESCALATION_HINT); + }); + }); +}); From ef6207eba58c37217e174a2314c9ec840907c655 Mon Sep 17 00:00:00 2001 From: aaight Date: Tue, 24 Feb 2026 20:00:42 +0100 Subject: [PATCH 04/13] refactor(llmist): unify llmist backend onto shared executeWithBackend adapter path (#540) --- src/agents/registry.ts | 36 +--- src/backends/adapter.ts | 1 + src/backends/llmist/index.ts | 189 +++++++++++++---- src/backends/types.ts | 2 + tests/unit/agents/registry.test.ts | 26 +-- tests/unit/backends/llmist.test.ts | 326 ++++++++++++++++++++++------- 6 files changed, 423 insertions(+), 157 deletions(-) diff --git a/src/agents/registry.ts b/src/agents/registry.ts index 3e72dc75..60187375 100644 --- a/src/agents/registry.ts +++ b/src/agents/registry.ts @@ -22,6 +22,10 @@ registerBackend(new ClaudeCodeBackend()); * 2. Project-level default backend * 3. Cascade-level default backend * 4. Fallback: 'llmist' + * + * All backends β€” including llmist β€” go through the shared adapter + * (executeWithBackend), which handles repo setup, lifecycle, progress + * monitoring, run tracking, and log finalization in one place. */ export async function runAgent( agentType: string, @@ -48,34 +52,10 @@ export async function runAgent( logger.info('Running agent via backend', { agentType, backend: backendName }); - // For the llmist backend, delegate directly (it wraps existing executors) - // For other backends, use the shared adapter which handles lifecycle - if (backendName === 'llmist') { - // The llmist backend needs the full AgentBackendInput, but since it - // delegates to the existing executors which handle their own lifecycle, - // we pass a minimal input and let it reconstruct what it needs. - return backend.execute({ - agentType, - project: input.project, - config: input.config, - repoDir: '', - systemPrompt: '', - taskPrompt: '', - cliToolsDir: '', - availableTools: [], - contextInjections: [], - maxIterations: 0, - model: '', - progressReporter: { - onIteration: async () => {}, - onToolCall: () => {}, - onText: () => {}, - }, - logWriter: () => {}, - agentInput: input, - }); - } - + // All backends (including llmist) use the shared adapter which handles: + // - Repo setup, CWD change/restore, env var loading + // - Run record creation, log finalization + // - Progress monitor, watchdog return executeWithBackend(backend, agentType, input); } diff --git a/src/backends/adapter.ts b/src/backends/adapter.ts index d7e13435..b35d50c5 100644 --- a/src/backends/adapter.ts +++ b/src/backends/adapter.ts @@ -238,6 +238,7 @@ export async function executeWithBackend( onText: () => {}, }, runId, + llmistLogPath: fileLogger.llmistLogPath, }; monitor?.start(); diff --git a/src/backends/llmist/index.ts b/src/backends/llmist/index.ts index 8d3035e0..2bfa5cdb 100644 --- a/src/backends/llmist/index.ts +++ b/src/backends/llmist/index.ts @@ -1,37 +1,36 @@ -import { executeAgent } from '../../agents/base.js'; -import { executeRespondToCIAgent } from '../../agents/respond-to-ci.js'; -import { executeRespondToPRCommentAgent } from '../../agents/respond-to-pr-comment.js'; -import { executeRespondToReviewAgent } from '../../agents/respond-to-review.js'; -import { executeReviewAgent } from '../../agents/review.js'; -import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../../types/index.js'; -import type { AgentBackend, AgentBackendInput, AgentBackendResult } from '../types.js'; +import os from 'node:os'; -/** - * Mapping from agent type to its specialized executor function. - * Agents not listed here fall through to the base `executeAgent()`. - */ -const specializedExecutors: Record< - string, - (input: AgentInput & { project: ProjectConfig; config: CascadeConfig }) => Promise -> = { - 'respond-to-review': (input) => - executeRespondToReviewAgent(input as Parameters[0]), - 'respond-to-ci': (input) => - executeRespondToCIAgent(input as Parameters[0]), - 'respond-to-pr-comment': (input) => - executeRespondToPRCommentAgent(input as Parameters[0]), - review: (input) => executeReviewAgent(input as Parameters[0]), -}; +import { LLMist, type ModelSpec, createLogger } from 'llmist'; + +import { type BuilderType, createConfiguredBuilder } from '../../agents/shared/builderFactory.js'; +import { injectSyntheticCall } from '../../agents/shared/syntheticCalls.js'; +import { runAgentLoop } from '../../agents/utils/agentLoop.js'; +import type { AccumulatedLlmCall } from '../../agents/utils/hooks.js'; +import { getLogLevel } from '../../agents/utils/index.js'; +import { createAgentLogger } from '../../agents/utils/logging.js'; +import { createTrackingContext } from '../../agents/utils/tracking.js'; +import { CUSTOM_MODELS } from '../../config/customModels.js'; +import { createLLMCallLogger } from '../../utils/llmLogging.js'; +import { extractPRUrl } from '../../utils/prUrl.js'; +import { getAgentProfile } from '../agent-profiles.js'; +import type { AgentBackend, AgentBackendInput, AgentBackendResult } from '../types.js'; /** - * llmist backend - wraps the existing llmist-based agent execution. + * llmist backend β€” executes agents using the llmist SDK. * - * This is the "Option A" approach: the llmist backend delegates to the existing - * executeAgent()/executeGitHubAgent() functions as-is. The shared adapter from - * adapter.ts handles lifecycle only for non-llmist backends. + * Receives a fully pre-resolved AgentBackendInput from the shared adapter + * (adapter.ts β†’ executeWithBackend β†’ buildBackendInput), which provides: + * - systemPrompt, taskPrompt, model, maxIterations + * - contextInjections (pre-fetched PR/work-item/directory data) + * - repoDir (already set up by the outer executeAgentPipeline) + * - logWriter (shared file logger from the outer pipeline) * - * In a follow-up, the llmist code can be refactored to also use the shared adapter, - * but that's not needed for this PR. + * Llmist-specific features preserved: + * - AccumulatedLlmCall metrics (via createObserverHooks inside createConfiguredBuilder) + * - Loop detection and hard-stop (via createObserverHooks + runAgentLoop) + * - Iteration hints / trailing messages (via createConfiguredBuilder) + * - Context compaction (via createConfiguredBuilder) + * - Synthetic gadget call injection from ContextInjection[] */ export class LlmistBackend implements AgentBackend { readonly name = 'llmist'; @@ -41,25 +40,129 @@ export class LlmistBackend implements AgentBackend { } async execute(input: AgentBackendInput): Promise { - const fullInput: AgentInput & { project: ProjectConfig; config: CascadeConfig } = { - ...input.agentInput, - project: input.project, - config: input.config, - }; + const { + agentType, + systemPrompt, + taskPrompt, + model, + maxIterations, + contextInjections, + budgetUsd, + repoDir, + logWriter, + runId, + agentInput, + llmistLogPath, + progressReporter, + } = input; + + const profile = getAgentProfile(agentType); + + // Create LLMist client with custom model definitions + const client = new LLMist({ customModels: CUSTOM_MODELS as ModelSpec[] }); + + // Create per-execution llmist logger and tracking state + const llmistLogger = createLogger({ minLevel: getLogLevel() }); + const trackingContext = createTrackingContext(); + const llmCallAccumulator: AccumulatedLlmCall[] = []; - const executor = specializedExecutors[input.agentType]; - const result = executor - ? await executor(fullInput) - : await executeAgent(input.agentType, fullInput); + // Create a LLM call logger for raw request/response file logging. + // Lives in the system tmp dir, independent from the outer fileLogger + // (which handles cascade.log / llmist.log). + const llmCallLogger = createLLMCallLogger(os.tmpdir(), `llmist-${agentType}-${Date.now()}`); + + // Point llmist SDK at the workspace directory llmist log path (provided by the outer + // pipeline's fileLogger). This ensures the structured llmist log is included in run + // records and log bundles (read from fileLogger.llmistLogPath during finalization). + if (llmistLogPath) { + process.env.LLMIST_LOG_FILE = llmistLogPath; + process.env.LLMIST_LOG_TEE = 'true'; + } + + // Get gadget instances from the agent profile (single source of truth for tool sets) + const gadgets = profile.getLlmistGadgets(agentType); + + // Build the configured agent builder with all llmist-specific features: + // rate limiting, retry, compaction, iteration hints, observer hooks + let builder: BuilderType = createConfiguredBuilder({ + client, + agentType, + model, + systemPrompt, + maxIterations, + llmistLogger, + trackingContext, + logWriter, + llmCallLogger, + repoDir, + gadgets: gadgets as Parameters[0]['gadgets'], + remainingBudgetUsd: budgetUsd, + llmCallAccumulator, + runId, + baseBranch: input.project.baseBranch, + projectId: input.project.id, + cardId: agentInput.cardId, + // Pass the progress monitor from the adapter so createObserverHooks can call + // onIteration/onToolCall/onText β€” enables progress updates to Trello/GitHub + progressMonitor: progressReporter as Parameters< + typeof createConfiguredBuilder + >[0]['progressMonitor'], + // Implementation agent uses sequential execution to ensure file operations + // are properly ordered (e.g., FileSearchAndReplace then ReadFile on same file) + postConfigure: + agentType === 'implementation' ? (b) => b.withGadgetExecutionMode('sequential') : undefined, + }); + + // Convert ContextInjection[] from the unified adapter into synthetic gadget calls. + // This is the llmist-native way to inject pre-fetched context: each injection + // appears in the conversation as if the agent called the gadget itself. + for (let idx = 0; idx < contextInjections.length; idx++) { + const injection = contextInjections[idx]; + const invocationId = `gc_${injection.toolName.toLowerCase()}_${idx}`; + builder = injectSyntheticCall( + builder, + trackingContext, + injection.toolName, + injection.params, + injection.result, + invocationId, + ); + } + + // Create agent logger that writes to the shared logWriter from the outer pipeline + const log = createAgentLogger({ write: logWriter } as Parameters[0]); + + log.info('Starting llmist agent', { + model, + maxIterations, + promptLength: taskPrompt.length, + contextInjections: contextInjections.length, + runId, + }); + + // Run the agent event loop (includes loop detection, session notices, etc.) + const agent = builder.ask(taskPrompt); + const result = await runAgentLoop( + agent, + log, + trackingContext, + agentInput.interactive === true, + agentInput.autoAccept === true, + ); + + log.info('Agent completed', { + iterations: result.iterations, + gadgetCalls: result.gadgetCalls, + cost: result.cost, + loopTerminated: result.loopTerminated ?? false, + }); return { - success: result.success, + success: !result.loopTerminated, output: result.output, - prUrl: result.prUrl, - error: result.error, + prUrl: extractPRUrl(result.output) ?? undefined, + error: result.loopTerminated ? 'Agent terminated due to persistent loop' : undefined, cost: result.cost, - logBuffer: result.logBuffer, - runId: result.runId, }; } } diff --git a/src/backends/types.ts b/src/backends/types.ts index c2e1f240..cd5f2934 100644 --- a/src/backends/types.ts +++ b/src/backends/types.ts @@ -69,6 +69,8 @@ export interface AgentBackendInput { enableStopHooks?: boolean; /** Whether to block git push in hooks (defaults to true) */ blockGitPush?: boolean; + /** Path where the llmist SDK should write its structured log (workspace dir, not temp) */ + llmistLogPath?: string; } export type LogWriter = (level: string, message: string, context?: Record) => void; diff --git a/tests/unit/agents/registry.test.ts b/tests/unit/agents/registry.test.ts index 69414e41..bf34f19f 100644 --- a/tests/unit/agents/registry.test.ts +++ b/tests/unit/agents/registry.test.ts @@ -87,6 +87,7 @@ describe('runAgent', () => { const backend = makeMockBackend('llmist'); mockResolveBackendName.mockReturnValue('llmist'); mockGetBackend.mockReturnValue(backend); + mockExecuteWithBackend.mockResolvedValue({ success: true, output: 'Done' }); await runAgent('implementation', makeInput()); @@ -120,26 +121,25 @@ describe('runAgent', () => { expect(result.error).toContain('does not support agent type "implementation"'); }); - it('for llmist: calls backend.execute with minimal input + agentInput', async () => { + it('for llmist: calls executeWithBackend (unified adapter path)', async () => { const backend = makeMockBackend('llmist'); mockResolveBackendName.mockReturnValue('llmist'); mockGetBackend.mockReturnValue(backend); + mockExecuteWithBackend.mockResolvedValue({ + success: true, + output: 'Done via adapter', + }); - await runAgent('implementation', makeInput()); + const input = makeInput(); + const result = await runAgent('implementation', input); - expect(backend.execute).toHaveBeenCalledWith( - expect.objectContaining({ - agentType: 'implementation', - repoDir: '', - systemPrompt: '', - availableTools: [], - contextInjections: [], - }), - ); - expect(mockExecuteWithBackend).not.toHaveBeenCalled(); + // llmist now goes through executeWithBackend like all other backends + expect(mockExecuteWithBackend).toHaveBeenCalledWith(backend, 'implementation', input); + expect(backend.execute).not.toHaveBeenCalled(); + expect(result.output).toBe('Done via adapter'); }); - it('for non-llmist: calls executeWithBackend with full lifecycle', async () => { + it('for claude-code: calls executeWithBackend with full lifecycle', async () => { const backend = makeMockBackend('claude-code'); mockResolveBackendName.mockReturnValue('claude-code'); mockGetBackend.mockReturnValue(backend); diff --git a/tests/unit/backends/llmist.test.ts b/tests/unit/backends/llmist.test.ts index dc0fede7..d5d35c6f 100644 --- a/tests/unit/backends/llmist.test.ts +++ b/tests/unit/backends/llmist.test.ts @@ -1,67 +1,115 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('../../../src/agents/base.js', () => ({ - executeAgent: vi.fn(), +// Mock all llmist SDK and internal dependencies +vi.mock('llmist', () => ({ + LLMist: vi.fn().mockImplementation(() => ({})), + createLogger: vi.fn(() => ({})), + type: undefined, })); -vi.mock('../../../src/agents/respond-to-review.js', () => ({ - executeRespondToReviewAgent: vi.fn(), +vi.mock('../../../src/backends/agent-profiles.js', () => ({ + getAgentProfile: vi.fn(() => ({ + getLlmistGadgets: vi.fn(() => []), + })), })); -vi.mock('../../../src/agents/respond-to-ci.js', () => ({ - executeRespondToCIAgent: vi.fn(), +vi.mock('../../../src/agents/shared/builderFactory.js', () => ({ + createConfiguredBuilder: vi.fn(() => ({ + ask: vi.fn(() => ({ + run: vi.fn(async function* () {}), + getTree: vi.fn(() => null), + injectUserMessage: vi.fn(), + })), + })), })); -vi.mock('../../../src/agents/respond-to-pr-comment.js', () => ({ - executeRespondToPRCommentAgent: vi.fn(), +vi.mock('../../../src/agents/shared/syntheticCalls.js', () => ({ + injectSyntheticCall: vi.fn((builder) => builder), })); -vi.mock('../../../src/agents/review.js', () => ({ - executeReviewAgent: vi.fn(), +vi.mock('../../../src/agents/utils/agentLoop.js', () => ({ + runAgentLoop: vi.fn().mockResolvedValue({ + output: 'Agent completed', + iterations: 3, + gadgetCalls: 5, + cost: 0.05, + loopTerminated: false, + }), })); -import { executeAgent } from '../../../src/agents/base.js'; -import { executeRespondToCIAgent } from '../../../src/agents/respond-to-ci.js'; -import { executeRespondToPRCommentAgent } from '../../../src/agents/respond-to-pr-comment.js'; -import { executeRespondToReviewAgent } from '../../../src/agents/respond-to-review.js'; -import { executeReviewAgent } from '../../../src/agents/review.js'; +vi.mock('../../../src/agents/utils/index.js', () => ({ + getLogLevel: vi.fn(() => 'info'), +})); + +vi.mock('../../../src/agents/utils/logging.js', () => ({ + createAgentLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), +})); + +vi.mock('../../../src/agents/utils/tracking.js', () => ({ + createTrackingContext: vi.fn(() => ({ + metrics: { llmIterations: 0, gadgetCalls: 0 }, + loopDetection: { repeatCount: 0, nameOnlyRepeatCount: 0 }, + syntheticInvocationIds: new Set(), + })), +})); + +vi.mock('../../../src/config/customModels.js', () => ({ + CUSTOM_MODELS: [], +})); + +vi.mock('../../../src/utils/llmLogging.js', () => ({ + createLLMCallLogger: vi.fn(() => ({ + logDir: '/tmp', + logRequest: vi.fn(), + logResponse: vi.fn(), + getLogFiles: vi.fn(() => []), + })), +})); + +vi.mock('../../../src/utils/prUrl.js', () => ({ + extractPRUrl: vi.fn((output: string) => { + const m = output.match(/https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/); + return m ? m[0] : undefined; + }), +})); + +import { runAgentLoop } from '../../../src/agents/utils/agentLoop.js'; import { LlmistBackend } from '../../../src/backends/llmist/index.js'; import type { AgentBackendInput } from '../../../src/backends/types.js'; -const mockExecuteAgent = vi.mocked(executeAgent); -const mockRespondToReview = vi.mocked(executeRespondToReviewAgent); -const mockRespondToCI = vi.mocked(executeRespondToCIAgent); -const mockRespondToPRComment = vi.mocked(executeRespondToPRCommentAgent); -const mockReviewAgent = vi.mocked(executeReviewAgent); +const mockRunAgentLoop = vi.mocked(runAgentLoop); -function makeInput(agentType: string): AgentBackendInput { +function makeInput(agentType = 'implementation'): AgentBackendInput { return { agentType, - project: { id: 'test', name: 'Test', repo: 'o/r' } as AgentBackendInput['project'], + project: { + id: 'p1', + name: 'P', + repo: 'o/r', + baseBranch: 'main', + } as AgentBackendInput['project'], config: { defaults: {} } as AgentBackendInput['config'], - repoDir: '', - systemPrompt: '', - taskPrompt: '', - cliToolsDir: '', + repoDir: '/repo', + systemPrompt: 'You are an agent.', + taskPrompt: 'Implement feature X.', + cliToolsDir: '/cli', availableTools: [], contextInjections: [], - maxIterations: 0, - model: '', + maxIterations: 10, + model: 'claude-sonnet-4', progressReporter: { onIteration: async () => {}, onToolCall: () => {}, onText: () => {} }, logWriter: () => {}, agentInput: { cardId: 'c1' } as AgentBackendInput['agentInput'], + runId: 'run-123', + llmistLogPath: '/workspace/llmist-implementation-12345.log', }; } -const agentResult = { - success: true, - output: 'Done', - prUrl: 'https://github.com/o/r/pull/1', - error: undefined, - cost: 0.05, - logBuffer: Buffer.from('log'), -}; - beforeEach(() => { vi.clearAllMocks(); }); @@ -80,77 +128,209 @@ describe('LlmistBackend', () => { }); }); -describe('execute', () => { - it('delegates to executeAgent for generic types', async () => { - mockExecuteAgent.mockResolvedValue(agentResult); +describe('LlmistBackend.execute', () => { + it('returns success when runAgentLoop completes normally', async () => { + mockRunAgentLoop.mockResolvedValue({ + output: 'Done', + iterations: 5, + gadgetCalls: 8, + cost: 0.1, + loopTerminated: false, + }); const backend = new LlmistBackend(); - const result = await backend.execute(makeInput('implementation')); + const result = await backend.execute(makeInput()); - expect(mockExecuteAgent).toHaveBeenCalledWith('implementation', expect.any(Object)); expect(result.success).toBe(true); expect(result.output).toBe('Done'); + expect(result.cost).toBe(0.1); + expect(result.error).toBeUndefined(); }); - it('delegates to executeAgent for briefing', async () => { - mockExecuteAgent.mockResolvedValue(agentResult); + it('returns failure when loop is terminated due to persistent loop', async () => { + mockRunAgentLoop.mockResolvedValue({ + output: 'partial output', + iterations: 10, + gadgetCalls: 20, + cost: 0.3, + loopTerminated: true, + }); const backend = new LlmistBackend(); - await backend.execute(makeInput('briefing')); + const result = await backend.execute(makeInput()); - expect(mockExecuteAgent).toHaveBeenCalledWith('briefing', expect.any(Object)); + expect(result.success).toBe(false); + expect(result.error).toContain('loop'); }); - it('delegates to specialized executor for respond-to-review', async () => { - mockRespondToReview.mockResolvedValue(agentResult); + it('extracts PR URL from output when present', async () => { + mockRunAgentLoop.mockResolvedValue({ + output: 'Created PR: https://github.com/owner/repo/pull/42', + iterations: 5, + gadgetCalls: 8, + cost: 0.1, + loopTerminated: false, + }); const backend = new LlmistBackend(); - await backend.execute(makeInput('respond-to-review')); + const result = await backend.execute(makeInput()); - expect(mockRespondToReview).toHaveBeenCalled(); - expect(mockExecuteAgent).not.toHaveBeenCalled(); + expect(result.prUrl).toBe('https://github.com/owner/repo/pull/42'); }); - it('delegates to specialized executor for respond-to-ci', async () => { - mockRespondToCI.mockResolvedValue(agentResult); + it('injects context injections as synthetic calls', async () => { + mockRunAgentLoop.mockResolvedValue({ + output: 'Done', + iterations: 5, + gadgetCalls: 8, + cost: 0.1, + loopTerminated: false, + }); - const backend = new LlmistBackend(); - await backend.execute(makeInput('respond-to-ci')); + const { injectSyntheticCall } = await import('../../../src/agents/shared/syntheticCalls.js'); + const mockInjectSyntheticCall = vi.mocked(injectSyntheticCall); + + const input = makeInput(); + input.contextInjections = [ + { + toolName: 'ReadWorkItem', + params: { workItemId: 'c1' }, + result: 'card content', + description: 'Work item', + }, + { + toolName: 'ListDirectory', + params: { directoryPath: '.' }, + result: 'dir listing', + description: 'Dir', + }, + ]; - expect(mockRespondToCI).toHaveBeenCalled(); + const backend = new LlmistBackend(); + await backend.execute(input); + + expect(mockInjectSyntheticCall).toHaveBeenCalledTimes(2); + expect(mockInjectSyntheticCall).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.anything(), + 'ReadWorkItem', + { workItemId: 'c1' }, + 'card content', + 'gc_readworkitem_0', + ); + expect(mockInjectSyntheticCall).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.anything(), + 'ListDirectory', + { directoryPath: '.' }, + 'dir listing', + 'gc_listdirectory_1', + ); }); - it('delegates to specialized executor for respond-to-pr-comment', async () => { - mockRespondToPRComment.mockResolvedValue(agentResult); + it('passes model and maxIterations from input to createConfiguredBuilder', async () => { + mockRunAgentLoop.mockResolvedValue({ + output: 'Done', + iterations: 5, + gadgetCalls: 8, + cost: 0.1, + loopTerminated: false, + }); - const backend = new LlmistBackend(); - await backend.execute(makeInput('respond-to-pr-comment')); + const { createConfiguredBuilder } = await import( + '../../../src/agents/shared/builderFactory.js' + ); + const mockCreateConfiguredBuilder = vi.mocked(createConfiguredBuilder); + + const input = makeInput(); + input.model = 'claude-3-5-sonnet-20241022'; + input.maxIterations = 25; - expect(mockRespondToPRComment).toHaveBeenCalled(); + const backend = new LlmistBackend(); + await backend.execute(input); + + expect(mockCreateConfiguredBuilder).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'claude-3-5-sonnet-20241022', + maxIterations: 25, + }), + ); }); - it('delegates to specialized executor for review', async () => { - mockReviewAgent.mockResolvedValue(agentResult); + it('gets gadgets from the agent profile', async () => { + mockRunAgentLoop.mockResolvedValue({ + output: 'Done', + iterations: 1, + gadgetCalls: 0, + cost: 0.01, + loopTerminated: false, + }); + + const { getAgentProfile } = await import('../../../src/backends/agent-profiles.js'); + const mockGetAgentProfile = vi.mocked(getAgentProfile); + const mockGetLlmistGadgets = vi.fn().mockReturnValue([]); + mockGetAgentProfile.mockReturnValue({ + getLlmistGadgets: mockGetLlmistGadgets, + } as ReturnType); const backend = new LlmistBackend(); await backend.execute(makeInput('review')); - expect(mockReviewAgent).toHaveBeenCalled(); + expect(mockGetAgentProfile).toHaveBeenCalledWith('review'); + expect(mockGetLlmistGadgets).toHaveBeenCalledWith('review'); }); - it('maps AgentResult fields to AgentBackendResult', async () => { - mockExecuteAgent.mockResolvedValue(agentResult); + it('sets LLMIST_LOG_FILE to the provided llmistLogPath', async () => { + mockRunAgentLoop.mockResolvedValue({ + output: 'Done', + iterations: 1, + gadgetCalls: 0, + cost: 0.01, + loopTerminated: false, + }); + + const input = makeInput(); + input.llmistLogPath = '/workspace/test-llmist.log'; const backend = new LlmistBackend(); - const result = await backend.execute(makeInput('planning')); + await backend.execute(input); - expect(result).toEqual({ - success: true, + expect(process.env.LLMIST_LOG_FILE).toBe('/workspace/test-llmist.log'); + expect(process.env.LLMIST_LOG_TEE).toBe('true'); + }); + + it('passes progressReporter to createConfiguredBuilder as progressMonitor', async () => { + mockRunAgentLoop.mockResolvedValue({ output: 'Done', - prUrl: 'https://github.com/o/r/pull/1', - error: undefined, - cost: 0.05, - logBuffer: Buffer.from('log'), + iterations: 5, + gadgetCalls: 8, + cost: 0.1, + loopTerminated: false, }); + + const { createConfiguredBuilder } = await import( + '../../../src/agents/shared/builderFactory.js' + ); + const mockCreateConfiguredBuilder = vi.mocked(createConfiguredBuilder); + + const mockProgressReporter = { + onIteration: vi.fn().mockResolvedValue(undefined), + onToolCall: vi.fn(), + onText: vi.fn(), + }; + + const input = makeInput(); + input.progressReporter = mockProgressReporter; + + const backend = new LlmistBackend(); + await backend.execute(input); + + expect(mockCreateConfiguredBuilder).toHaveBeenCalledWith( + expect.objectContaining({ + progressMonitor: mockProgressReporter, + }), + ); }); }); From dc43a83226d32c880015b1931fd29f2f46980ec2 Mon Sep 17 00:00:00 2001 From: aaight Date: Tue, 24 Feb 2026 21:24:31 +0100 Subject: [PATCH 05/13] feat(agents): rename briefing agent to splitting (#541) --- CLAUDE.md | 26 +++--- README.md | 10 +- config/projects.json | 8 +- src/agents/prompts/index.ts | 10 +- .../templates/{briefing.eta => splitting.eta} | 0 src/agents/shared/capabilities.ts | 2 +- src/agents/shared/taskPrompts.ts | 2 +- src/backends/agent-profiles.ts | 6 +- src/backends/claude-code/hooks.ts | 2 +- src/cli/dashboard/projects/pm-trigger-set.ts | 58 ++++++------ src/config/agentMessages.ts | 5 +- src/config/compactionConfig.ts | 4 +- src/config/hintConfig.ts | 2 +- src/config/triggerConfig.ts | 16 ++-- .../0015_rename_briefing_to_splitting.sql | 53 +++++++++++ src/db/migrations/meta/_journal.json | 7 ++ src/github/personas.ts | 2 +- src/router/trello.ts | 4 +- src/triggers/builtins.ts | 6 +- src/triggers/jira/issue-transitioned.ts | 2 +- src/triggers/jira/types.ts | 8 +- src/triggers/trello/card-moved.ts | 16 ++-- src/triggers/trello/label-added.ts | 10 +- tests/unit/agents/prompts.test.ts | 18 ++-- .../agents/shared/modelResolution.test.ts | 42 ++++----- .../unit/agents/shared/promptContext.test.ts | 2 +- tests/unit/api/routers/prompts.test.ts | 8 +- tests/unit/api/routers/webhooks.test.ts | 2 +- tests/unit/backends/adapter.test.ts | 2 +- tests/unit/backends/agent-profiles.test.ts | 34 +++---- tests/unit/backends/claude-code.test.ts | 2 +- tests/unit/backends/postProcess.test.ts | 2 +- tests/unit/backends/progress.test.ts | 6 +- tests/unit/config/compactionConfig.test.ts | 12 +-- tests/unit/config/schema.test.ts | 8 +- tests/unit/config/statusUpdateConfig.test.ts | 8 +- tests/unit/config/triggerConfig.test.ts | 92 +++++++++---------- .../unit/db/repositories/configMapper.test.ts | 6 +- .../db/repositories/configRepository.test.ts | 14 +-- tests/unit/db/runsRepository.test.ts | 4 +- .../unit/gadgets/session/core/finish.test.ts | 2 +- tests/unit/github/personas.test.ts | 2 +- tests/unit/pm/jira/adapter.test.ts | 2 +- tests/unit/pm/lifecycle.test.ts | 12 +-- tests/unit/pm/webhook-handler.test.ts | 2 +- tests/unit/router/ackMessageGenerator.test.ts | 8 +- tests/unit/router/adapters/trello.test.ts | 2 +- tests/unit/router/config.test.ts | 4 +- tests/unit/router/index.test.ts | 2 +- tests/unit/router/trello.test.ts | 6 +- tests/unit/server.test.ts | 2 +- tests/unit/triggers/builtins.test.ts | 6 +- tests/unit/triggers/card-moved.test.ts | 30 +++--- .../unit/triggers/check-suite-failure.test.ts | 2 +- .../unit/triggers/check-suite-success.test.ts | 2 +- tests/unit/triggers/debug-runner.test.ts | 4 +- tests/unit/triggers/debug-trigger.test.ts | 4 +- .../github-pr-comment-mention.test.ts | 2 +- .../triggers/jira-issue-transitioned.test.ts | 42 ++++----- tests/unit/triggers/jira-label-added.test.ts | 18 ++-- tests/unit/triggers/label-added.test.ts | 12 +-- tests/unit/triggers/manual-runner.test.ts | 2 +- tests/unit/triggers/pr-merged.test.ts | 4 +- tests/unit/triggers/pr-opened.test.ts | 2 +- tests/unit/triggers/pr-ready-to-merge.test.ts | 4 +- .../unit/triggers/pr-review-submitted.test.ts | 2 +- tests/unit/triggers/registry.test.ts | 8 +- tests/unit/triggers/review-requested.test.ts | 2 +- .../triggers/trello-comment-mention.test.ts | 2 +- tests/unit/web/triggerAgentMapping.test.ts | 26 +++--- tools/run-local.ts | 4 +- web/src/components/projects/pm-wizard.tsx | 4 +- .../projects/project-agent-configs.tsx | 2 +- web/src/components/runs/run-filters.tsx | 2 +- .../components/runs/trigger-run-dialog.tsx | 2 +- .../settings/agent-config-form-dialog.tsx | 2 +- web/src/components/shared/trigger-toggles.tsx | 4 +- web/src/lib/trigger-agent-mapping.ts | 28 +++--- 78 files changed, 423 insertions(+), 364 deletions(-) rename src/agents/prompts/templates/{briefing.eta => splitting.eta} (100%) create mode 100644 src/db/migrations/0015_rename_briefing_to_splitting.sql diff --git a/CLAUDE.md b/CLAUDE.md index 3b88820a..1de3eb97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ Trello/GitHub Webhook β†’ TriggerRegistry β†’ Agent β†’ Code Changes β†’ PR ``` - `src/triggers/` - Event handlers (Trello card moves, labels, GitHub PRs, attachments) -- `src/agents/` - AI agents (briefing, planning, implementation, review, debug) +- `src/agents/` - AI agents (splitting, planning, implementation, review, debug) - `src/gadgets/` - Tools agents can use (Trello API, Git operations, file system) ### Multi-Project Support @@ -156,7 +156,7 @@ npm run credentials:rotate-key # Re-encrypt with CREDENTIAL_MASTER_ CASCADE uses two dedicated GitHub bot accounts per project to prevent feedback loops: - **Implementer** (`GITHUB_TOKEN_IMPLEMENTER`) β€” writes code, creates PRs, responds to review comments - - Agents: `implementation`, `respond-to-review`, `respond-to-ci`, `respond-to-pr-comment`, `briefing`, `planning`, `respond-to-planning-comment` + - Agents: `implementation`, `respond-to-review`, `respond-to-ci`, `respond-to-pr-comment`, `splitting`, `planning`, `respond-to-planning-comment` - **Reviewer** (`GITHUB_TOKEN_REVIEWER`) β€” reviews PRs, can approve or request changes - Agents: `review` @@ -246,13 +246,13 @@ When `reviewTrigger` is absent, the system falls back to legacy booleans: ### PM Agent Trigger Modes -Briefing, planning, and implementation agents each have independent toggles for their PM triggers. **All modes default to `true`** for backward compatibility. +Splitting, planning, and implementation agents each have independent toggles for their PM triggers. **All modes default to `true`** for backward compatibility. #### Trello card-moved triggers | Flag | Description | |------|-------------| -| `cardMovedToBriefing` | Trigger briefing agent when a card is moved to the Briefing list | +| `cardMovedToSplitting` | Trigger splitting agent when a card is moved to the Splitting list | | `cardMovedToPlanning` | Trigger planning agent when a card is moved to the Planning list | | `cardMovedToTodo` | Trigger implementation agent when a card is moved to the Todo list | @@ -262,35 +262,35 @@ The `issueTransitioned` field supports both a legacy boolean (applies to all age | Agent | Field | Description | |-------|-------|-------------| -| briefing | `issueTransitioned.briefing` | Trigger briefing when issue transitions to Briefing status | +| splitting | `issueTransitioned.splitting` | Trigger splitting when issue transitions to Splitting status | | planning | `issueTransitioned.planning` | Trigger planning when issue transitions to Planning status | | implementation | `issueTransitioned.implementation` | Trigger implementation when issue transitions to Todo status | #### Setting via CLI ```bash -# Disable Trello card-moved trigger for briefing agent -cascade projects pm-trigger-set --no-card-moved-to-briefing +# Disable Trello card-moved trigger for splitting agent +cascade projects pm-trigger-set --no-card-moved-to-splitting # Disable JIRA issue-transitioned for implementation agent only cascade projects pm-trigger-set --no-issue-transitioned-implementation -# Enable JIRA triggers for briefing and planning, disable for implementation +# Enable JIRA triggers for splitting and planning, disable for implementation cascade projects pm-trigger-set \ - --issue-transitioned-briefing \ + --issue-transitioned-splitting \ --issue-transitioned-planning \ --no-issue-transitioned-implementation # Disable all Trello card-moved triggers cascade projects pm-trigger-set \ - --no-card-moved-to-briefing \ + --no-card-moved-to-splitting \ --no-card-moved-to-planning \ --no-card-moved-to-todo ``` #### Setting via Dashboard -In the **Agent Configs** tab, the briefing, planning, and implementation agent sections each show: +In the **Agent Configs** tab, the splitting, planning, and implementation agent sections each show: - **Card moved to [list]** β€” Trello card-moved toggle (Trello projects only) - **Issue Transitioned** β€” JIRA per-agent transition toggle (JIRA projects only) - **Ready to Process label** β€” label-based trigger toggle @@ -301,7 +301,7 @@ In the **Agent Configs** tab, the briefing, planning, and implementation agent s # Disable JIRA issue-transitioned for implementation only cascade projects integration-set \ --category pm --provider jira --config '{"projectKey":"PROJ","statuses":{...}}' \ - --triggers '{"issueTransitioned":{"briefing":true,"planning":true,"implementation":false}}' + --triggers '{"issueTransitioned":{"splitting":true,"planning":true,"implementation":false}}' ``` #### Backward Compatibility @@ -603,7 +603,7 @@ CASCADE includes a debug agent that automatically analyzes agent session logs: { "trello": { "lists": { - "briefing": "...", + "splitting": "...", "planning": "...", "todo": "...", "debug": "YOUR_DEBUG_LIST_ID" diff --git a/README.md b/README.md index 8d4779b8..125208ed 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # CASCADE -Multi-project Trello-to-code automation platform. CASCADE reacts to Trello card movements and triggers AI agents to handle briefing, planning, and implementation tasks. +Multi-project Trello-to-code automation platform. CASCADE reacts to Trello card movements and triggers AI agents to handle splitting, planning, and implementation tasks. ## Features - **Multi-project support** - Single deployment handles multiple repos/Trello boards - **Extensible trigger system** - Easy to add new triggers (card moved, label added, PR ready, etc.) -- **AI-powered agents** - Briefing, planning, implementation, review, and debug agents using llmist +- **AI-powered agents** - Splitting, planning, implementation, review, and debug agents using llmist - **Git workflow** - Automatic branch creation, commits, and PR creation - **Trello integration** - Full card management (labels, comments, attachments) - **GitHub integration** - PR review webhooks, automatic card movement, CI check monitoring @@ -102,7 +102,7 @@ Edit `config/projects.json` to add your projects: "trello": { "boardId": "your_board_id", "lists": { - "briefing": "list_id_for_briefing", + "splitting": "list_id_for_splitting", "planning": "list_id_for_planning", "todo": "list_id_for_todo", "inProgress": "list_id_for_in_progress", @@ -124,7 +124,7 @@ Edit `config/projects.json` to add your projects: | List | Purpose | |------|---------| -| `briefing` | Cards here trigger the briefing agent (refines requirements) | +| `splitting` | Cards here trigger the splitting agent (splits plan into work items) | | `planning` | Cards here trigger the planning agent (creates implementation plan) | | `todo` | Cards here trigger the implementation agent (writes code, creates PR) | | `inProgress` | Cards being actively worked on | @@ -215,7 +215,7 @@ export class MyCustomTrigger implements TriggerHandler { async handle(ctx: TriggerContext): Promise { return { - agentType: 'implementation', // or 'briefing', 'planning' + agentType: 'implementation', // or 'splitting', 'planning' agentInput: { /* data for the agent */ }, cardId: 'optional-card-id', }; diff --git a/config/projects.json b/config/projects.json index d319b1f2..a6d6f4fe 100644 --- a/config/projects.json +++ b/config/projects.json @@ -9,7 +9,7 @@ }, "agentIterations": { "planning": 50, - "briefing": 50 + "splitting": 50 }, "watchdogTimeoutMs": 2700000, "selfDestructTimeoutMs": 1800000, @@ -27,7 +27,7 @@ "trello": { "boardId": "694ec393370da080b52eb64c", "lists": { - "briefing": "694fc5e57256ac0717c3dfea", + "splitting": "694fc5e57256ac0717c3dfea", "stories": "69541b4151734a3cebab38c4", "planning": "694ec39e91e3487c1351a491", "todo": "694ec3a365a4c75df2493504", @@ -72,7 +72,7 @@ "trello": { "boardId": "698db5df2b873930c7c38bc0", "lists": { - "briefing": "698db5df2b873930c7c38bbb", + "splitting": "698db5df2b873930c7c38bbb", "stories": "698db5df2b873930c7c38bbd", "planning": "698db5df2b873930c7c38bb6", "todo": "698db5df2b873930c7c38bb7", @@ -102,7 +102,7 @@ "trello": { "boardId": "6970fa9aab0e56304a15fbac", "lists": { - "briefing": "6970faa01757ddb3286e7bae", + "splitting": "6970faa01757ddb3286e7bae", "planning": "6970fa9d48b639dad13dc8f7" }, "labels": { diff --git a/src/agents/prompts/index.ts b/src/agents/prompts/index.ts index c2c298b4..2318c6f0 100644 --- a/src/agents/prompts/index.ts +++ b/src/agents/prompts/index.ts @@ -11,7 +11,7 @@ const eta = new Eta({ views: templatesDir, autoEscape: false }); // Valid agent types const validTypes = [ - 'briefing', + 'splitting', 'planning', 'implementation', 'debug', @@ -37,7 +37,7 @@ export interface PromptContext { workItemNounPluralCap?: string; // "Cards" or "Issues" pmName?: string; // "Trello" or "JIRA" - // Briefing-specific + // Splitting-specific storiesListId?: string; processedLabelId?: string; @@ -189,8 +189,8 @@ export function getTemplateVariables(): Array<{ { name: 'workItemNounCap', group: 'PM', description: 'Card or Issue' }, { name: 'workItemNounPluralCap', group: 'PM', description: 'Cards or Issues' }, { name: 'pmName', group: 'PM', description: 'Trello or JIRA' }, - { name: 'storiesListId', group: 'Briefing', description: 'Trello stories list ID' }, - { name: 'processedLabelId', group: 'Briefing', description: 'Trello processed label ID' }, + { name: 'storiesListId', group: 'Splitting', description: 'Trello stories list ID' }, + { name: 'processedLabelId', group: 'Splitting', description: 'Trello processed label ID' }, { name: 'prNumber', group: 'CI', description: 'Pull request number' }, { name: 'prBranch', group: 'CI', description: 'Pull request branch name' }, { name: 'repoFullName', group: 'CI', description: 'Repository full name (owner/repo)' }, @@ -206,7 +206,7 @@ export function getTemplateVariables(): Array<{ } // Export individual prompts for backwards compatibility (rendered without context) -export const BRIEFING_SYSTEM_PROMPT = loadTemplate('briefing'); +export const SPLITTING_SYSTEM_PROMPT = loadTemplate('splitting'); export const PLANNING_SYSTEM_PROMPT = loadTemplate('planning'); export const IMPLEMENTATION_SYSTEM_PROMPT = loadTemplate('implementation'); export const DEBUG_SYSTEM_PROMPT = loadTemplate('debug'); diff --git a/src/agents/prompts/templates/briefing.eta b/src/agents/prompts/templates/splitting.eta similarity index 100% rename from src/agents/prompts/templates/briefing.eta rename to src/agents/prompts/templates/splitting.eta diff --git a/src/agents/shared/capabilities.ts b/src/agents/shared/capabilities.ts index 336ecae7..7c46a47b 100644 --- a/src/agents/shared/capabilities.ts +++ b/src/agents/shared/capabilities.ts @@ -40,7 +40,7 @@ const DEFAULT_CAPABILITIES: AgentCapabilities = { * AgentProfile in backends/agent-profiles.ts consumes these via getAgentCapabilities(). */ const CAPABILITIES_REGISTRY: Record = { - briefing: { + splitting: { canEditFiles: true, canCreatePR: false, canUpdateChecklists: true, diff --git a/src/agents/shared/taskPrompts.ts b/src/agents/shared/taskPrompts.ts index 1ffc791c..51716282 100644 --- a/src/agents/shared/taskPrompts.ts +++ b/src/agents/shared/taskPrompts.ts @@ -15,7 +15,7 @@ import { parseRepoFullName } from '../../utils/repo.js'; /** * Standard prompt for agents whose primary task is processing a work item - * (briefing, planning, implementation, debug). + * (splitting, planning, implementation, debug). */ export function buildWorkItemPrompt(cardId: string): string { return `Analyze and process the work item with ID: ${cardId}. The work item data has been pre-loaded.`; diff --git a/src/backends/agent-profiles.ts b/src/backends/agent-profiles.ts index 9bb76e89..85b0ca83 100644 --- a/src/backends/agent-profiles.ts +++ b/src/backends/agent-profiles.ts @@ -447,7 +447,7 @@ function buildPRCommentResponseTaskPrompt(input: AgentInput): string { // Agent Profiles // ============================================================================ -const briefingProfile: AgentProfile = { +const splittingProfile: AgentProfile = { filterTools: (allTools) => filterToolsByNames(allTools, [...PM_TOOLS, PM_CHECKLIST_TOOL, SESSION_TOOL]), sdkTools: ALL_SDK_TOOLS, @@ -455,7 +455,7 @@ const briefingProfile: AgentProfile = { needsGitHubToken: false, fetchContext: fetchWorkItemContext, buildTaskPrompt: buildWorkItemTaskPrompt, - capabilities: getAgentCapabilities('briefing'), + capabilities: getAgentCapabilities('splitting'), getLlmistGadgets: (agentType) => buildWorkItemGadgets(getAgentCapabilities(agentType)), }; @@ -584,7 +584,7 @@ const implementationProfile: AgentProfile = { // ============================================================================ const PROFILE_REGISTRY: Record = { - briefing: briefingProfile, + splitting: splittingProfile, planning: planningProfile, implementation: implementationProfile, review: reviewProfile, diff --git a/src/backends/claude-code/hooks.ts b/src/backends/claude-code/hooks.ts index 70d2db7d..2cb2cd3a 100644 --- a/src/backends/claude-code/hooks.ts +++ b/src/backends/claude-code/hooks.ts @@ -230,7 +230,7 @@ export function buildStopHooks( * Build all SDK hooks for the Claude Code backend. * * @param enableStopHooks - Whether to include Stop hooks that check for uncommitted/unpushed changes. - * Should be true for implementation agents, false for briefing/planning/review agents. + * Should be true for implementation agents, false for splitting/planning/review agents. * @param options.blockGitPush - Whether to block git push in hooks (defaults to true). * Set false for agents on existing PR branches (respond-to-pr-comment, respond-to-ci). */ diff --git a/src/cli/dashboard/projects/pm-trigger-set.ts b/src/cli/dashboard/projects/pm-trigger-set.ts index b56709ce..f525f271 100644 --- a/src/cli/dashboard/projects/pm-trigger-set.ts +++ b/src/cli/dashboard/projects/pm-trigger-set.ts @@ -5,13 +5,13 @@ import { DashboardCommand } from '../_shared/base.js'; * CLI command for configuring PM trigger modes per agent type. * * Usage: - * cascade projects pm-trigger-set [--card-moved-to-briefing] [--issue-transitioned-briefing] ... + * cascade projects pm-trigger-set [--card-moved-to-splitting] [--issue-transitioned-splitting] ... * * At least one flag must be provided. Pass `--no-` to disable a mode. * Uses the `projects.integrations.updateTriggers` tRPC endpoint, updating the * PM integration triggers config for the project. * - * Trello flags update the top-level boolean keys (cardMovedToBriefing, etc.). + * Trello flags update the top-level boolean keys (cardMovedToSplitting, etc.). * JIRA flags update the nested `issueTransitioned` object per agent type. */ export default class ProjectsPmTriggerSet extends DashboardCommand { @@ -27,8 +27,8 @@ export default class ProjectsPmTriggerSet extends DashboardCommand { static override flags = { ...DashboardCommand.baseFlags, // Trello card-moved triggers - 'card-moved-to-briefing': Flags.boolean({ - description: 'Enable briefing agent when a card is moved to the Briefing list (Trello).', + 'card-moved-to-splitting': Flags.boolean({ + description: 'Enable splitting agent when a card is moved to the Splitting list (Trello).', allowNo: true, default: undefined, }), @@ -43,9 +43,9 @@ export default class ProjectsPmTriggerSet extends DashboardCommand { default: undefined, }), // JIRA issue-transitioned triggers (per-agent) - 'issue-transitioned-briefing': Flags.boolean({ + 'issue-transitioned-splitting': Flags.boolean({ description: - 'Enable briefing agent when a JIRA issue transitions to the configured Briefing status.', + 'Enable splitting agent when a JIRA issue transitions to the configured Splitting status.', allowNo: true, default: undefined, }), @@ -65,31 +65,31 @@ export default class ProjectsPmTriggerSet extends DashboardCommand { /** Build the triggers patch object from parsed flag values. */ private buildTriggers(parsedFlags: { - cardMovedToBriefing: boolean | undefined; + cardMovedToSplitting: boolean | undefined; cardMovedToPlanning: boolean | undefined; cardMovedToTodo: boolean | undefined; - issueTransitionedBriefing: boolean | undefined; + issueTransitionedSplitting: boolean | undefined; issueTransitionedPlanning: boolean | undefined; issueTransitionedImplementation: boolean | undefined; }): Record> { const { - cardMovedToBriefing, + cardMovedToSplitting, cardMovedToPlanning, cardMovedToTodo, - issueTransitionedBriefing, + issueTransitionedSplitting, issueTransitionedPlanning, issueTransitionedImplementation, } = parsedFlags; const triggers: Record> = {}; - if (cardMovedToBriefing !== undefined) triggers.cardMovedToBriefing = cardMovedToBriefing; + if (cardMovedToSplitting !== undefined) triggers.cardMovedToSplitting = cardMovedToSplitting; if (cardMovedToPlanning !== undefined) triggers.cardMovedToPlanning = cardMovedToPlanning; if (cardMovedToTodo !== undefined) triggers.cardMovedToTodo = cardMovedToTodo; const issueTransitioned: Record = {}; - if (issueTransitionedBriefing !== undefined) - issueTransitioned.briefing = issueTransitionedBriefing; + if (issueTransitionedSplitting !== undefined) + issueTransitioned.splitting = issueTransitionedSplitting; if (issueTransitionedPlanning !== undefined) issueTransitioned.planning = issueTransitionedPlanning; if (issueTransitionedImplementation !== undefined) @@ -106,31 +106,31 @@ export default class ProjectsPmTriggerSet extends DashboardCommand { private formatOutput( projectId: string, parsedFlags: { - cardMovedToBriefing: boolean | undefined; + cardMovedToSplitting: boolean | undefined; cardMovedToPlanning: boolean | undefined; cardMovedToTodo: boolean | undefined; - issueTransitionedBriefing: boolean | undefined; + issueTransitionedSplitting: boolean | undefined; issueTransitionedPlanning: boolean | undefined; issueTransitionedImplementation: boolean | undefined; }, ): string { const { - cardMovedToBriefing, + cardMovedToSplitting, cardMovedToPlanning, cardMovedToTodo, - issueTransitionedBriefing, + issueTransitionedSplitting, issueTransitionedPlanning, issueTransitionedImplementation, } = parsedFlags; const lines: string[] = [`PM trigger modes updated for project: ${projectId}`]; - if (cardMovedToBriefing !== undefined) - lines.push(` cardMovedToBriefing: ${cardMovedToBriefing}`); + if (cardMovedToSplitting !== undefined) + lines.push(` cardMovedToSplitting: ${cardMovedToSplitting}`); if (cardMovedToPlanning !== undefined) lines.push(` cardMovedToPlanning: ${cardMovedToPlanning}`); if (cardMovedToTodo !== undefined) lines.push(` cardMovedToTodo: ${cardMovedToTodo}`); - if (issueTransitionedBriefing !== undefined) - lines.push(` issueTransitioned.briefing: ${issueTransitionedBriefing}`); + if (issueTransitionedSplitting !== undefined) + lines.push(` issueTransitioned.splitting: ${issueTransitionedSplitting}`); if (issueTransitionedPlanning !== undefined) lines.push(` issueTransitioned.planning: ${issueTransitionedPlanning}`); if (issueTransitionedImplementation !== undefined) @@ -141,35 +141,35 @@ export default class ProjectsPmTriggerSet extends DashboardCommand { async run(): Promise { const { args, flags } = await this.parse(ProjectsPmTriggerSet); - const cardMovedToBriefing = flags['card-moved-to-briefing']; + const cardMovedToSplitting = flags['card-moved-to-splitting']; const cardMovedToPlanning = flags['card-moved-to-planning']; const cardMovedToTodo = flags['card-moved-to-todo']; - const issueTransitionedBriefing = flags['issue-transitioned-briefing']; + const issueTransitionedSplitting = flags['issue-transitioned-splitting']; const issueTransitionedPlanning = flags['issue-transitioned-planning']; const issueTransitionedImplementation = flags['issue-transitioned-implementation']; const hasAnyFlag = - cardMovedToBriefing !== undefined || + cardMovedToSplitting !== undefined || cardMovedToPlanning !== undefined || cardMovedToTodo !== undefined || - issueTransitionedBriefing !== undefined || + issueTransitionedSplitting !== undefined || issueTransitionedPlanning !== undefined || issueTransitionedImplementation !== undefined; if (!hasAnyFlag) { this.error( 'At least one flag must be provided: ' + - '--card-moved-to-briefing, --card-moved-to-planning, --card-moved-to-todo, ' + - '--issue-transitioned-briefing, --issue-transitioned-planning, --issue-transitioned-implementation ' + + '--card-moved-to-splitting, --card-moved-to-planning, --card-moved-to-todo, ' + + '--issue-transitioned-splitting, --issue-transitioned-planning, --issue-transitioned-implementation ' + '(use --no- to disable).', ); } const parsedFlags = { - cardMovedToBriefing, + cardMovedToSplitting, cardMovedToPlanning, cardMovedToTodo, - issueTransitionedBriefing, + issueTransitionedSplitting, issueTransitionedPlanning, issueTransitionedImplementation, }; diff --git a/src/config/agentMessages.ts b/src/config/agentMessages.ts index 06171d6e..00bde289 100644 --- a/src/config/agentMessages.ts +++ b/src/config/agentMessages.ts @@ -6,7 +6,7 @@ * - statusUpdateConfig.ts β€” template fallback header */ export const AGENT_LABELS: Record = { - briefing: { emoji: 'πŸ“‹', label: 'Briefing Update' }, + splitting: { emoji: 'πŸ“‹', label: 'Splitting Update' }, planning: { emoji: 'πŸ—ΊοΈ', label: 'Planning Update' }, implementation: { emoji: 'πŸ§‘β€πŸ’»', label: 'Implementation Update' }, review: { emoji: 'πŸ”', label: 'Code Review Update' }, @@ -33,8 +33,7 @@ export function getAgentLabel(agentType: string): { emoji: string; label: string * - Router acknowledgments β€” immediate ack before worker starts */ export const INITIAL_MESSAGES: Record = { - briefing: - '**πŸ“‹ Analyzing brief** β€” Reading the card and gathering context to create a clear brief...', + splitting: '**πŸ“‹ Splitting plan** β€” Reading the plan and splitting it into ordered work items...', planning: '**πŸ—ΊοΈ Planning implementation** β€” Studying the codebase and designing a step-by-step plan...', implementation: diff --git a/src/config/compactionConfig.ts b/src/config/compactionConfig.ts index 723e6e9f..9d67d655 100644 --- a/src/config/compactionConfig.ts +++ b/src/config/compactionConfig.ts @@ -33,7 +33,7 @@ Previous conversation:`, }; /** - * Base compaction settings for other agents (briefing, planning, debug, respond-to-review, review). + * Base compaction settings for other agents (splitting, planning, debug, respond-to-review, review). * * These agents typically have shorter sessions, so we use: * - Standard trigger threshold (80%) @@ -90,7 +90,7 @@ function handleCompaction(event: CompactionEvent): void { /** * Get compaction configuration for a given agent type. * - * @param agentType - Type of agent (e.g., "implementation", "briefing", "planning") + * @param agentType - Type of agent (e.g., "implementation", "splitting", "planning") * @returns Compaction configuration */ export function getCompactionConfig(agentType: string): CompactionConfig { diff --git a/src/config/hintConfig.ts b/src/config/hintConfig.ts index e2ee6696..367bcee0 100644 --- a/src/config/hintConfig.ts +++ b/src/config/hintConfig.ts @@ -23,7 +23,7 @@ const AGENT_HINTS: Record = { // Read-only agents review: 'Focus on the current aspect of review before moving to the next. Read related files together.', - briefing: 'Gather all context needed for the current step before proceeding.', + splitting: 'Gather all context needed for the current step before proceeding.', planning: 'Complete the current planning step efficiently before moving to the next.', debug: 'Analyze the current issue fully before moving to the next.', diff --git a/src/config/triggerConfig.ts b/src/config/triggerConfig.ts index 96c7826d..32eb3690 100644 --- a/src/config/triggerConfig.ts +++ b/src/config/triggerConfig.ts @@ -12,7 +12,7 @@ export const ReadyToProcessLabelSchema = z .union([ z.boolean(), z.object({ - briefing: z.boolean().default(true), + splitting: z.boolean().default(true), planning: z.boolean().default(true), implementation: z.boolean().default(true), }), @@ -26,7 +26,7 @@ export type ReadyToProcessLabelConfig = z.infer'cardMovedToBriefing') +WHERE triggers ? 'cardMovedToBriefing'; + +-- 3b. JIRA triggers: issueTransitioned.briefing β†’ issueTransitioned.splitting +UPDATE project_integrations +SET triggers = jsonb_set( + triggers #- '{issueTransitioned,briefing}', + '{issueTransitioned,splitting}', + triggers->'issueTransitioned'->'briefing' +) +WHERE triggers->'issueTransitioned' ? 'briefing'; + +-- 3c. Trello config: lists.briefing β†’ lists.splitting +UPDATE project_integrations +SET config = config - 'lists' || jsonb_build_object( + 'lists', + (config->'lists') - 'briefing' || jsonb_build_object('splitting', config->'lists'->'briefing') +) +WHERE config->'lists' ? 'briefing'; + +-- 3d. Trello config: readyToProcessLabel.briefing β†’ readyToProcessLabel.splitting +UPDATE project_integrations +SET triggers = jsonb_set( + triggers #- '{readyToProcessLabel,briefing}', + '{readyToProcessLabel,splitting}', + triggers->'readyToProcessLabel'->'briefing' +) +WHERE triggers->'readyToProcessLabel' ? 'briefing'; + +-- 3e. JIRA config: statuses.briefing β†’ statuses.splitting +UPDATE project_integrations +SET config = config - 'statuses' || jsonb_build_object( + 'statuses', + (config->'statuses') - 'briefing' || jsonb_build_object('splitting', config->'statuses'->'briefing') +) +WHERE config->'statuses' ? 'briefing'; diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 2fb68a45..eeee30db 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1749000000000, "tag": "0014_pr_work_items", "breakpoints": false + }, + { + "idx": 14, + "version": "7", + "when": 1750000000000, + "tag": "0015_rename_briefing_to_splitting", + "breakpoints": false } ] } diff --git a/src/github/personas.ts b/src/github/personas.ts index ee6789af..e9b5708c 100644 --- a/src/github/personas.ts +++ b/src/github/personas.ts @@ -18,7 +18,7 @@ export interface PersonaIdentities { // ============================================================================ const AGENT_PERSONA_MAP: Record = { - briefing: 'implementer', + splitting: 'implementer', planning: 'implementer', implementation: 'implementer', 'respond-to-review': 'implementer', diff --git a/src/router/trello.ts b/src/router/trello.ts index 9de2cf2c..2cfee02d 100644 --- a/src/router/trello.ts +++ b/src/router/trello.ts @@ -15,7 +15,7 @@ import type { RouterProjectConfig } from './config.js'; /** * Check if filename matches agent log pattern: {agent-type}-{timestamp}.zip - * Examples: implementation-2026-01-02T16-30-24-339Z.zip, briefing-timeout-2026-01-02T12-34-56-789Z.zip + * Examples: implementation-2026-01-02T16-30-24-339Z.zip, splitting-timeout-2026-01-02T12-34-56-789Z.zip * The timestamp follows ISO 8601 format with colons replaced by hyphens: YYYY-MM-DDTHH-MM-SS-mmmZ */ export function isAgentLogFilename(filename: string): boolean { @@ -29,7 +29,7 @@ export function isCardInTriggerList( ): boolean { if (!project.trello) return false; const triggerLists = [ - project.trello.lists.briefing, + project.trello.lists.splitting, project.trello.lists.planning, project.trello.lists.todo, ]; diff --git a/src/triggers/builtins.ts b/src/triggers/builtins.ts index d125b2f6..b79b4274 100644 --- a/src/triggers/builtins.ts +++ b/src/triggers/builtins.ts @@ -24,8 +24,8 @@ import { JiraIssueTransitionedTrigger } from './jira/issue-transitioned.js'; import { JiraReadyToProcessLabelTrigger } from './jira/label-added.js'; import type { TriggerRegistry } from './registry.js'; import { - CardMovedToBriefingTrigger, CardMovedToPlanningTrigger, + CardMovedToSplittingTrigger, CardMovedToTodoTrigger, } from './trello/card-moved.js'; import { TrelloCommentMentionTrigger } from './trello/comment-mention.js'; @@ -37,7 +37,7 @@ export function registerBuiltInTriggers(registry: TriggerRegistry): void { registry.register(new TrelloCommentMentionTrigger()); // Trello: Card moved triggers (factory-created objects) - registry.register(CardMovedToBriefingTrigger); + registry.register(CardMovedToSplittingTrigger); registry.register(CardMovedToPlanningTrigger); registry.register(CardMovedToTodoTrigger); @@ -48,7 +48,7 @@ export function registerBuiltInTriggers(registry: TriggerRegistry): void { // Must be registered before issue transition trigger so it gets first crack at comment events registry.register(new JiraCommentMentionTrigger()); - // JIRA: Issue transitioned trigger (runs briefing/planning/implementation based on status) + // JIRA: Issue transitioned trigger (runs splitting/planning/implementation based on status) registry.register(new JiraIssueTransitionedTrigger()); // JIRA: Label trigger (runs agent based on current status when cascade-ready label is added) diff --git a/src/triggers/jira/issue-transitioned.ts b/src/triggers/jira/issue-transitioned.ts index 0b8d600e..45ad5754 100644 --- a/src/triggers/jira/issue-transitioned.ts +++ b/src/triggers/jira/issue-transitioned.ts @@ -2,7 +2,7 @@ * JIRA issue-transitioned trigger. * * Fires when a JIRA issue transitions to a configured status that maps to - * a CASCADE agent type (briefing, planning, implementation). + * a CASCADE agent type (splitting, planning, implementation). */ import { diff --git a/src/triggers/jira/types.ts b/src/triggers/jira/types.ts index 730396ec..bd74033e 100644 --- a/src/triggers/jira/types.ts +++ b/src/triggers/jira/types.ts @@ -39,13 +39,13 @@ export interface JiraWebhookPayload { * Maps CASCADE status keys to agent types. * * Project config maps CASCADE status names to JIRA status names, e.g.: - * { briefing: "Briefing", planning: "Planning", todo: "To Do" } + * { splitting: "Splitting", planning: "Planning", todo: "To Do" } * - * We invert that mapping at runtime: if the issue transitioned to "Briefing", - * we look up `briefing` β†’ `briefing` agent. + * We invert that mapping at runtime: if the issue transitioned to "Splitting", + * we look up `splitting` β†’ `splitting` agent. */ export const STATUS_TO_AGENT: Record = { - briefing: 'briefing', + splitting: 'splitting', planning: 'planning', todo: 'implementation', }; diff --git a/src/triggers/trello/card-moved.ts b/src/triggers/trello/card-moved.ts index d6a5b009..5c696293 100644 --- a/src/triggers/trello/card-moved.ts +++ b/src/triggers/trello/card-moved.ts @@ -16,9 +16,9 @@ import { isTrelloWebhookPayload } from '../types.js'; interface CardMovedConfig { name: string; description: string; - listKey: 'briefing' | 'planning' | 'todo'; + listKey: 'splitting' | 'planning' | 'todo'; agentType: string; - triggerConfigKey: 'cardMovedToBriefing' | 'cardMovedToPlanning' | 'cardMovedToTodo'; + triggerConfigKey: 'cardMovedToSplitting' | 'cardMovedToPlanning' | 'cardMovedToTodo'; } function createCardMovedTrigger(config: CardMovedConfig): TriggerHandler { @@ -74,12 +74,12 @@ function createCardMovedTrigger(config: CardMovedConfig): TriggerHandler { // Trigger Instances // ============================================================================ -export const CardMovedToBriefingTrigger = createCardMovedTrigger({ - name: 'card-moved-to-briefing', - description: 'Triggers briefing agent when card moved to briefing list', - listKey: 'briefing', - agentType: 'briefing', - triggerConfigKey: 'cardMovedToBriefing', +export const CardMovedToSplittingTrigger = createCardMovedTrigger({ + name: 'card-moved-to-splitting', + description: 'Triggers splitting agent when card moved to splitting list', + listKey: 'splitting', + agentType: 'splitting', + triggerConfigKey: 'cardMovedToSplitting', }); export const CardMovedToPlanningTrigger = createCardMovedTrigger({ diff --git a/src/triggers/trello/label-added.ts b/src/triggers/trello/label-added.ts index f9465ac4..a1810d1d 100644 --- a/src/triggers/trello/label-added.ts +++ b/src/triggers/trello/label-added.ts @@ -55,16 +55,16 @@ export class ReadyToProcessLabelTrigger implements TriggerHandler { const lists = getTrelloConfig(ctx.project)?.lists ?? {}; let agentType: string; - if (currentListId === lists.briefing) { - agentType = 'briefing'; + if (currentListId === lists.splitting) { + agentType = 'splitting'; } else if (currentListId === lists.planning) { agentType = 'planning'; } else if (currentListId === lists.todo) { agentType = 'implementation'; } else { - // Default to briefing if list not recognized - logger.warn('Card in unrecognized list, defaulting to briefing', { currentListId, lists }); - agentType = 'briefing'; + // Default to splitting if list not recognized + logger.warn('Card in unrecognized list, defaulting to splitting', { currentListId, lists }); + agentType = 'splitting'; } logger.info('Agent type determined', { agentType, cardId, listId: currentListId }); diff --git a/tests/unit/agents/prompts.test.ts b/tests/unit/agents/prompts.test.ts index 79662cbd..319bd754 100644 --- a/tests/unit/agents/prompts.test.ts +++ b/tests/unit/agents/prompts.test.ts @@ -12,8 +12,8 @@ import { } from '../../../src/agents/prompts/index.js'; describe('getSystemPrompt', () => { - it('returns briefing prompt for briefing agent', () => { - const prompt = getSystemPrompt('briefing'); + it('returns splitting prompt for splitting agent', () => { + const prompt = getSystemPrompt('splitting'); expect(prompt).toContain('product manager'); expect(prompt).toContain('DO NOT IMPLEMENT'); }); @@ -34,8 +34,8 @@ describe('getSystemPrompt', () => { expect(() => getSystemPrompt('unknown')).toThrow('Unknown agent type: unknown'); }); - it('renders context variables in briefing prompt', () => { - const prompt = getSystemPrompt('briefing', { + it('renders context variables in splitting prompt', () => { + const prompt = getSystemPrompt('splitting', { storiesListId: 'stories-123', processedLabelId: 'label-456', }); @@ -44,7 +44,7 @@ describe('getSystemPrompt', () => { }); it('uses default values when context is not provided', () => { - const prompt = getSystemPrompt('briefing'); + const prompt = getSystemPrompt('splitting'); expect(prompt).toContain('STORIES_LIST_ID: NOT_CONFIGURED'); expect(prompt).toContain('PROCESSED_LABEL_ID: NOT_CONFIGURED'); }); @@ -59,8 +59,8 @@ describe('getSystemPrompt', () => { }); describe('system prompts content', () => { - it('briefing prompt includes key instructions', () => { - const prompt = getSystemPrompt('briefing'); + it('splitting prompt includes key instructions', () => { + const prompt = getSystemPrompt('splitting'); expect(prompt).toContain('ReadWorkItem'); expect(prompt).toContain('CreateWorkItem'); expect(prompt).toContain('INVEST'); @@ -213,7 +213,7 @@ describe('validateTemplate', () => { describe('getRawTemplate', () => { it('returns raw .eta template content', () => { - const raw = getRawTemplate('briefing'); + const raw = getRawTemplate('splitting'); expect(raw).toContain('<%'); expect(raw).toBeTruthy(); }); @@ -240,7 +240,7 @@ describe('getValidAgentTypes', () => { const types = getValidAgentTypes(); expect(Array.isArray(types)).toBe(true); expect(types.length).toBeGreaterThan(0); - expect(types).toContain('briefing'); + expect(types).toContain('splitting'); expect(types).toContain('implementation'); expect(types).toContain('review'); }); diff --git a/tests/unit/agents/shared/modelResolution.test.ts b/tests/unit/agents/shared/modelResolution.test.ts index bcdcc479..a9316172 100644 --- a/tests/unit/agents/shared/modelResolution.test.ts +++ b/tests/unit/agents/shared/modelResolution.test.ts @@ -48,7 +48,7 @@ describe('resolveModelConfig', () => { describe('prompt resolution chain', () => { it('uses .eta file when no custom prompts configured', async () => { const result = await resolveModelConfig({ - agentType: 'briefing', + agentType: 'splitting', project: makeProject(), config: makeConfig(), repoDir: '/tmp/test', @@ -60,46 +60,46 @@ describe('resolveModelConfig', () => { it('uses project prompt when configured', async () => { const project = makeProject({ - prompts: { briefing: 'You are a custom briefing agent for <%= it.baseBranch %>.' }, + prompts: { splitting: 'You are a custom splitting agent for <%= it.baseBranch %>.' }, }); const result = await resolveModelConfig({ - agentType: 'briefing', + agentType: 'splitting', project, config: makeConfig(), repoDir: '/tmp/test', promptContext: { baseBranch: 'develop' }, }); - expect(result.systemPrompt).toBe('You are a custom briefing agent for develop.'); + expect(result.systemPrompt).toBe('You are a custom splitting agent for develop.'); }); it('uses defaults prompt when no project prompt', async () => { const config = makeConfig({ - prompts: { briefing: 'Global custom briefing for <%= it.projectId %>.' }, + prompts: { splitting: 'Global custom splitting for <%= it.projectId %>.' }, }); const result = await resolveModelConfig({ - agentType: 'briefing', + agentType: 'splitting', project: makeProject(), config, repoDir: '/tmp/test', promptContext: { projectId: 'p1' }, }); - expect(result.systemPrompt).toBe('Global custom briefing for p1.'); + expect(result.systemPrompt).toBe('Global custom splitting for p1.'); }); it('prefers project prompt over defaults prompt', async () => { const project = makeProject({ - prompts: { briefing: 'Project-level prompt.' }, + prompts: { splitting: 'Project-level prompt.' }, }); const config = makeConfig({ - prompts: { briefing: 'Defaults-level prompt.' }, + prompts: { splitting: 'Defaults-level prompt.' }, }); const result = await resolveModelConfig({ - agentType: 'briefing', + agentType: 'splitting', project, config, repoDir: '/tmp/test', @@ -114,24 +114,24 @@ describe('resolveModelConfig', () => { }); const result = await resolveModelConfig({ - agentType: 'briefing', + agentType: 'splitting', project: makeProject(), config, repoDir: '/tmp/test', }); - // Should fall back to .eta file for briefing + // Should fall back to .eta file for splitting expect(result.systemPrompt).toContain('product manager'); }); it('resolves includes in custom prompts via dbPartials', async () => { const project = makeProject({ - prompts: { briefing: 'Custom: <%~ include("partials/custom") %>' }, + prompts: { splitting: 'Custom: <%~ include("partials/custom") %>' }, }); const dbPartials = new Map([['custom', 'Injected partial content']]); const result = await resolveModelConfig({ - agentType: 'briefing', + agentType: 'splitting', project, config: makeConfig(), repoDir: '/tmp/test', @@ -159,7 +159,7 @@ describe('resolveModelConfig', () => { describe('model resolution', () => { it('uses default model when no overrides', async () => { const result = await resolveModelConfig({ - agentType: 'briefing', + agentType: 'splitting', project: makeProject(), config: makeConfig({ model: 'my-default' }), repoDir: '/tmp/test', @@ -172,7 +172,7 @@ describe('resolveModelConfig', () => { const project = makeProject({ model: 'project-model' }); const result = await resolveModelConfig({ - agentType: 'briefing', + agentType: 'splitting', project, config: makeConfig({ model: 'default-model' }), repoDir: '/tmp/test', @@ -184,11 +184,11 @@ describe('resolveModelConfig', () => { it('uses agent-specific model from project', async () => { const project = makeProject({ - agentModels: { briefing: 'agent-specific-model' }, + agentModels: { splitting: 'agent-specific-model' }, }); const result = await resolveModelConfig({ - agentType: 'briefing', + agentType: 'splitting', project, config: makeConfig(), repoDir: '/tmp/test', @@ -217,7 +217,7 @@ describe('resolveModelConfig', () => { describe('iterations resolution', () => { it('uses default maxIterations', async () => { const result = await resolveModelConfig({ - agentType: 'briefing', + agentType: 'splitting', project: makeProject(), config: makeConfig({ maxIterations: 42 }), repoDir: '/tmp/test', @@ -228,12 +228,12 @@ describe('resolveModelConfig', () => { it('uses agent-specific iterations', async () => { const config = makeConfig({ - agentIterations: { briefing: 10 }, + agentIterations: { splitting: 10 }, maxIterations: 50, }); const result = await resolveModelConfig({ - agentType: 'briefing', + agentType: 'splitting', project: makeProject(), config, repoDir: '/tmp/test', diff --git a/tests/unit/agents/shared/promptContext.test.ts b/tests/unit/agents/shared/promptContext.test.ts index 024767a2..34f7f535 100644 --- a/tests/unit/agents/shared/promptContext.test.ts +++ b/tests/unit/agents/shared/promptContext.test.ts @@ -22,7 +22,7 @@ function makeProject(overrides: Record = {}) { trello: { boardId: 'board1', lists: { - briefing: 'list1', + splitting: 'list1', planning: 'list2', todo: 'list3', stories: 'list-stories', diff --git a/tests/unit/api/routers/prompts.test.ts b/tests/unit/api/routers/prompts.test.ts index 5887255a..b2059d7d 100644 --- a/tests/unit/api/routers/prompts.test.ts +++ b/tests/unit/api/routers/prompts.test.ts @@ -54,7 +54,7 @@ describe('promptsRouter', () => { describe('agentTypes', () => { it('returns list of agent types', async () => { - const types = ['briefing', 'planning', 'implementation']; + const types = ['splitting', 'planning', 'implementation']; mockGetValidAgentTypes.mockReturnValue(types); const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); @@ -75,10 +75,10 @@ describe('promptsRouter', () => { mockGetRawTemplate.mockReturnValue('Template content: <%= it.baseBranch %>'); const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); - const result = await caller.getDefault({ agentType: 'briefing' }); + const result = await caller.getDefault({ agentType: 'splitting' }); expect(result).toEqual({ content: 'Template content: <%= it.baseBranch %>' }); - expect(mockGetRawTemplate).toHaveBeenCalledWith('briefing'); + expect(mockGetRawTemplate).toHaveBeenCalledWith('splitting'); }); it('throws NOT_FOUND for unknown agent type', async () => { @@ -94,7 +94,7 @@ describe('promptsRouter', () => { it('throws UNAUTHORIZED when not authenticated', async () => { const caller = createCaller({ user: null, effectiveOrgId: null }); - await expect(caller.getDefault({ agentType: 'briefing' })).rejects.toMatchObject({ + await expect(caller.getDefault({ agentType: 'splitting' })).rejects.toMatchObject({ code: 'UNAUTHORIZED', }); }); diff --git a/tests/unit/api/routers/webhooks.test.ts b/tests/unit/api/routers/webhooks.test.ts index 96ed1170..ada2e4d8 100644 --- a/tests/unit/api/routers/webhooks.test.ts +++ b/tests/unit/api/routers/webhooks.test.ts @@ -83,7 +83,7 @@ const mockJiraProject = { jira: { projectKey: 'PROJ', baseUrl: 'https://test.atlassian.net', - statuses: { briefing: 'Briefing' }, + statuses: { splitting: 'Briefing' }, labels: { processing: 'my-processing', processed: 'my-processed', diff --git a/tests/unit/backends/adapter.test.ts b/tests/unit/backends/adapter.test.ts index 50edda17..cca4b133 100644 --- a/tests/unit/backends/adapter.test.ts +++ b/tests/unit/backends/adapter.test.ts @@ -453,7 +453,7 @@ describe('executeWithBackend', () => { }); const input = makeInput(); - const result = await executeWithBackend(backend, 'briefing', input); + const result = await executeWithBackend(backend, 'splitting', input); expect(result.success).toBe(true); }); diff --git a/tests/unit/backends/agent-profiles.test.ts b/tests/unit/backends/agent-profiles.test.ts index c6fcb7a9..7fdae579 100644 --- a/tests/unit/backends/agent-profiles.test.ts +++ b/tests/unit/backends/agent-profiles.test.ts @@ -454,7 +454,7 @@ describe('AgentProfile.getLlmistGadgets', () => { it('each profile has a getLlmistGadgets method', () => { const agentTypes = [ - 'briefing', + 'splitting', 'planning', 'implementation', 'review', @@ -584,9 +584,9 @@ describe('AgentProfile.getLlmistGadgets', () => { expect(names).toContain('Finish'); }); - it('briefing includes file editing but not CreatePR', () => { - const profile = getAgentProfile('briefing'); - const names = gadgetNames(profile.getLlmistGadgets('briefing')); + it('splitting includes file editing but not CreatePR', () => { + const profile = getAgentProfile('splitting'); + const names = gadgetNames(profile.getLlmistGadgets('splitting')); // File editing (canEditFiles: true) expect(names).toContain('FileSearchAndReplace'); @@ -632,9 +632,9 @@ function makeContextParams(overrides: { } describe('fetchDirectoryListing', () => { - it('briefing fetchContext returns a ListDirectory injection with maxDepth:3', async () => { + it('splitting fetchContext returns a ListDirectory injection with maxDepth:3', async () => { mockResolveSquintDbPath.mockReturnValue(null); - const profile = getAgentProfile('briefing'); + const profile = getAgentProfile('splitting'); const params = makeContextParams({ cardId: undefined }); const injections = await profile.fetchContext( @@ -655,7 +655,7 @@ describe('fetchDirectoryListing', () => { describe('fetchContextFileInjections', () => { it('returns ReadFile injections for each context file', async () => { mockResolveSquintDbPath.mockReturnValue(null); - const profile = getAgentProfile('briefing'); + const profile = getAgentProfile('splitting'); const params = makeContextParams({ contextFiles: [ { path: 'CLAUDE.md', content: 'project guidelines' }, @@ -677,7 +677,7 @@ describe('fetchContextFileInjections', () => { it('returns no ReadFile injections when contextFiles is empty', async () => { mockResolveSquintDbPath.mockReturnValue(null); - const profile = getAgentProfile('briefing'); + const profile = getAgentProfile('splitting'); const params = makeContextParams({ contextFiles: [] }); const injections = await profile.fetchContext( @@ -693,7 +693,7 @@ describe('fetchSquintOverview', () => { it('returns SquintOverview injection when squint db is present', async () => { mockResolveSquintDbPath.mockReturnValue('/repo/.squint.db'); mockExecFileSync.mockReturnValue('squint overview output\n'); - const profile = getAgentProfile('briefing'); + const profile = getAgentProfile('splitting'); const params = makeContextParams({}); const injections = await profile.fetchContext( @@ -708,7 +708,7 @@ describe('fetchSquintOverview', () => { it('returns no SquintOverview injection when squint db is absent', async () => { mockResolveSquintDbPath.mockReturnValue(null); - const profile = getAgentProfile('briefing'); + const profile = getAgentProfile('splitting'); const params = makeContextParams({}); const injections = await profile.fetchContext( @@ -724,7 +724,7 @@ describe('fetchSquintOverview', () => { mockExecFileSync.mockImplementation(() => { throw new Error('squint not found'); }); - const profile = getAgentProfile('briefing'); + const profile = getAgentProfile('splitting'); const params = makeContextParams({}); const injections = await profile.fetchContext( @@ -738,7 +738,7 @@ describe('fetchSquintOverview', () => { it('returns no SquintOverview injection when squint output is empty', async () => { mockResolveSquintDbPath.mockReturnValue('/repo/.squint.db'); mockExecFileSync.mockReturnValue(' '); - const profile = getAgentProfile('briefing'); + const profile = getAgentProfile('splitting'); const params = makeContextParams({}); const injections = await profile.fetchContext( @@ -754,7 +754,7 @@ describe('fetchWorkItemInjection', () => { it('returns ReadWorkItem injection when readWorkItem resolves', async () => { mockResolveSquintDbPath.mockReturnValue(null); mockReadWorkItem.mockResolvedValue('# card title\n\ncard body'); - const profile = getAgentProfile('briefing'); + const profile = getAgentProfile('splitting'); const params = makeContextParams({ cardId: 'card-123' }); const injections = await profile.fetchContext( @@ -774,7 +774,7 @@ describe('fetchWorkItemInjection', () => { it('skips injection when readWorkItem throws', async () => { mockResolveSquintDbPath.mockReturnValue(null); mockReadWorkItem.mockRejectedValue(new Error('card not found')); - const profile = getAgentProfile('briefing'); + const profile = getAgentProfile('splitting'); const params = makeContextParams({ cardId: 'missing-card' }); const injections = await profile.fetchContext( @@ -787,7 +787,7 @@ describe('fetchWorkItemInjection', () => { it('never calls readWorkItem when cardId is absent', async () => { mockResolveSquintDbPath.mockReturnValue(null); - const profile = getAgentProfile('briefing'); + const profile = getAgentProfile('splitting'); const params = makeContextParams({ cardId: undefined }); await profile.fetchContext(params as Parameters[0]); @@ -801,7 +801,7 @@ describe('fetchWorkItemContext orchestration', () => { mockResolveSquintDbPath.mockReturnValue('/repo/.squint.db'); mockExecFileSync.mockReturnValue('squint output\n'); mockReadWorkItem.mockResolvedValue('card content'); - const profile = getAgentProfile('briefing'); + const profile = getAgentProfile('splitting'); const params = makeContextParams({ cardId: 'card-abc', contextFiles: [{ path: 'CLAUDE.md', content: 'guidelines' }], @@ -830,7 +830,7 @@ describe('fetchWorkItemContext orchestration', () => { it('gracefully omits squint and workItem when unavailable', async () => { mockResolveSquintDbPath.mockReturnValue(null); mockReadWorkItem.mockRejectedValue(new Error('unavailable')); - const profile = getAgentProfile('briefing'); + const profile = getAgentProfile('splitting'); const params = makeContextParams({ cardId: 'card-xyz' }); const injections = await profile.fetchContext( diff --git a/tests/unit/backends/claude-code.test.ts b/tests/unit/backends/claude-code.test.ts index fa0f921c..fbf9ac96 100644 --- a/tests/unit/backends/claude-code.test.ts +++ b/tests/unit/backends/claude-code.test.ts @@ -312,7 +312,7 @@ describe('ClaudeCodeBackend', () => { const backend = new ClaudeCodeBackend(); expect(backend.supportsAgentType('implementation')).toBe(true); expect(backend.supportsAgentType('review')).toBe(true); - expect(backend.supportsAgentType('briefing')).toBe(true); + expect(backend.supportsAgentType('splitting')).toBe(true); expect(backend.supportsAgentType('anything')).toBe(true); }); }); diff --git a/tests/unit/backends/postProcess.test.ts b/tests/unit/backends/postProcess.test.ts index b6906546..c0d52b6d 100644 --- a/tests/unit/backends/postProcess.test.ts +++ b/tests/unit/backends/postProcess.test.ts @@ -106,7 +106,7 @@ describe('postProcessResult', () => { const backend = makeBackend(); const input = makeInput(); - postProcessResult(result, 'briefing', backend, input, 'briefing-id'); + postProcessResult(result, 'splitting', backend, input, 'splitting-id'); expect(result.success).toBe(true); expect(logger.warn).not.toHaveBeenCalled(); diff --git a/tests/unit/backends/progress.test.ts b/tests/unit/backends/progress.test.ts index 7cb97e9d..5592606d 100644 --- a/tests/unit/backends/progress.test.ts +++ b/tests/unit/backends/progress.test.ts @@ -816,10 +816,10 @@ describe('ProgressMonitor β€” agent-specific initial messages', () => { return mockPMProvider.addComment.mock.calls[0][1] as string; } - it('posts briefing-specific message for briefing agent', async () => { - const message = await getInitialMessage('briefing'); + it('posts splitting-specific message for splitting agent', async () => { + const message = await getInitialMessage('splitting'); expect(message).toBe( - '**πŸ“‹ Analyzing brief** β€” Reading the card and gathering context to create a clear brief...', + '**πŸ“‹ Splitting plan** β€” Reading the plan and splitting it into ordered work items...', ); }); diff --git a/tests/unit/config/compactionConfig.test.ts b/tests/unit/config/compactionConfig.test.ts index 32ceee2d..fc368a68 100644 --- a/tests/unit/config/compactionConfig.test.ts +++ b/tests/unit/config/compactionConfig.test.ts @@ -38,7 +38,7 @@ describe('config/compactionConfig', () => { }); it('returns default config for other agents with higher threshold', () => { - const agentTypes = ['briefing', 'planning', 'debug', 'respond-to-review', 'review']; + const agentTypes = ['splitting', 'planning', 'debug', 'respond-to-review', 'review']; for (const agentType of agentTypes) { const config = getCompactionConfig(agentType); @@ -53,7 +53,7 @@ describe('config/compactionConfig', () => { it('implementation agent has more aggressive reduction targets', () => { const implConfig = getCompactionConfig('implementation'); - const otherConfig = getCompactionConfig('briefing'); + const otherConfig = getCompactionConfig('splitting'); expect(implConfig.triggerThresholdPercent).toBeLessThan(otherConfig.triggerThresholdPercent); expect(implConfig.targetPercent).toBeLessThan(otherConfig.targetPercent); @@ -84,7 +84,7 @@ describe('config/compactionConfig', () => { }); it('default prompt preserves key decisions and progress', () => { - const config = getCompactionConfig('briefing'); + const config = getCompactionConfig('splitting'); expect(config.summarizationPrompt).toContain('Key decisions made'); expect(config.summarizationPrompt).toContain('Current progress'); @@ -200,7 +200,7 @@ describe('config/compactionConfig', () => { it('all agent types return valid config structure', () => { const agentTypes = [ 'implementation', - 'briefing', + 'splitting', 'planning', 'debug', 'review', @@ -222,7 +222,7 @@ describe('config/compactionConfig', () => { }); it('target percent is less than trigger threshold', () => { - const agentTypes = ['implementation', 'briefing', 'planning']; + const agentTypes = ['implementation', 'splitting', 'planning']; for (const agentType of agentTypes) { const config = getCompactionConfig(agentType); @@ -233,7 +233,7 @@ describe('config/compactionConfig', () => { it('thresholds are reasonable percentages', () => { const implConfig = getCompactionConfig('implementation'); - const otherConfig = getCompactionConfig('briefing'); + const otherConfig = getCompactionConfig('splitting'); expect(implConfig.triggerThresholdPercent).toBeGreaterThanOrEqual(50); expect(implConfig.triggerThresholdPercent).toBeLessThanOrEqual(100); diff --git a/tests/unit/config/schema.test.ts b/tests/unit/config/schema.test.ts index 3ed7d7d6..56889181 100644 --- a/tests/unit/config/schema.test.ts +++ b/tests/unit/config/schema.test.ts @@ -11,7 +11,7 @@ describe('ProjectConfigSchema', () => { trello: { boardId: 'board123', lists: { - briefing: 'list1', + splitting: 'list1', planning: 'list2', todo: 'list3', }, @@ -149,7 +149,7 @@ describe('ProjectConfigSchema', () => { jira: { projectKey: 'TEST', baseUrl: 'https://test.atlassian.net', - statuses: { briefing: 'Briefing' }, + statuses: { splitting: 'Briefing' }, labels: { processing: 'my-processing', processed: 'my-processed', @@ -173,7 +173,7 @@ describe('ProjectConfigSchema', () => { jira: { projectKey: 'TEST', baseUrl: 'https://test.atlassian.net', - statuses: { briefing: 'Briefing' }, + statuses: { splitting: 'Briefing' }, labels: {}, }, }; @@ -194,7 +194,7 @@ describe('ProjectConfigSchema', () => { jira: { projectKey: 'TEST', baseUrl: 'https://test.atlassian.net', - statuses: { briefing: 'Briefing' }, + statuses: { splitting: 'Briefing' }, }, }; diff --git a/tests/unit/config/statusUpdateConfig.test.ts b/tests/unit/config/statusUpdateConfig.test.ts index 049b8d11..3d041f00 100644 --- a/tests/unit/config/statusUpdateConfig.test.ts +++ b/tests/unit/config/statusUpdateConfig.test.ts @@ -22,7 +22,7 @@ describe('config/statusUpdateConfig', () => { describe('getStatusUpdateConfig', () => { it('returns enabled config for non-debug agents', () => { - const agentTypes = ['implementation', 'briefing', 'planning', 'review']; + const agentTypes = ['implementation', 'splitting', 'planning', 'review']; for (const agentType of agentTypes) { const config = getStatusUpdateConfig(agentType); @@ -66,9 +66,9 @@ describe('config/statusUpdateConfig', () => { expect(result).toEqual({ emoji: 'πŸ”', label: 'Code Review Update' }); }); - it('returns correct emoji and label for briefing', () => { - const result = getAgentLabel('briefing'); - expect(result).toEqual({ emoji: 'πŸ“‹', label: 'Briefing Update' }); + it('returns correct emoji and label for splitting', () => { + const result = getAgentLabel('splitting'); + expect(result).toEqual({ emoji: 'πŸ“‹', label: 'Splitting Update' }); }); it('returns correct emoji and label for planning', () => { diff --git a/tests/unit/config/triggerConfig.test.ts b/tests/unit/config/triggerConfig.test.ts index 51ad213e..0770a804 100644 --- a/tests/unit/config/triggerConfig.test.ts +++ b/tests/unit/config/triggerConfig.test.ts @@ -17,7 +17,7 @@ describe('TrelloTriggerConfigSchema', () => { it('defaults boolean fields to true', () => { const result = TrelloTriggerConfigSchema.parse({}); expect(result).toEqual({ - cardMovedToBriefing: true, + cardMovedToSplitting: true, cardMovedToPlanning: true, cardMovedToTodo: true, // readyToProcessLabel is optional β€” not present in default parse @@ -33,15 +33,15 @@ describe('TrelloTriggerConfigSchema', () => { }); expect(result.cardMovedToPlanning).toBe(false); expect(result.readyToProcessLabel).toBe(false); - expect(result.cardMovedToBriefing).toBe(true); // default still true + expect(result.cardMovedToSplitting).toBe(true); // default still true }); it('accepts per-agent readyToProcessLabel object', () => { const result = TrelloTriggerConfigSchema.parse({ - readyToProcessLabel: { briefing: true, planning: false, implementation: true }, + readyToProcessLabel: { splitting: true, planning: false, implementation: true }, }); expect(result.readyToProcessLabel).toEqual({ - briefing: true, + splitting: true, planning: false, implementation: true, }); @@ -63,10 +63,10 @@ describe('JiraTriggerConfigSchema', () => { it('accepts per-agent issueTransitioned object', () => { const result = JiraTriggerConfigSchema.parse({ - issueTransitioned: { briefing: true, planning: false, implementation: true }, + issueTransitioned: { splitting: true, planning: false, implementation: true }, }); expect(result.issueTransitioned).toEqual({ - briefing: true, + splitting: true, planning: false, implementation: true, }); @@ -109,19 +109,19 @@ describe('GitHubTriggerConfigSchema', () => { describe('resolveTrelloTriggerEnabled', () => { it('returns true when config is undefined (backward compatible)', () => { - expect(resolveTrelloTriggerEnabled(undefined, 'cardMovedToBriefing')).toBe(true); + expect(resolveTrelloTriggerEnabled(undefined, 'cardMovedToSplitting')).toBe(true); expect(resolveTrelloTriggerEnabled(undefined, 'readyToProcessLabel')).toBe(true); expect(resolveTrelloTriggerEnabled(undefined, 'commentMention')).toBe(true); }); it('returns true when key is not present in config', () => { - expect(resolveTrelloTriggerEnabled({}, 'cardMovedToBriefing')).toBe(true); + expect(resolveTrelloTriggerEnabled({}, 'cardMovedToSplitting')).toBe(true); }); it('returns false when key is explicitly disabled', () => { - expect(resolveTrelloTriggerEnabled({ cardMovedToBriefing: false }, 'cardMovedToBriefing')).toBe( - false, - ); + expect( + resolveTrelloTriggerEnabled({ cardMovedToSplitting: false }, 'cardMovedToSplitting'), + ).toBe(false); }); it('returns true when key is explicitly enabled', () => { @@ -139,7 +139,7 @@ describe('resolveTrelloTriggerEnabled', () => { it('returns true for readyToProcessLabel when any agent is enabled in object form', () => { expect( resolveTrelloTriggerEnabled( - { readyToProcessLabel: { briefing: false, planning: true, implementation: false } }, + { readyToProcessLabel: { splitting: false, planning: true, implementation: false } }, 'readyToProcessLabel', ), ).toBe(true); @@ -148,7 +148,7 @@ describe('resolveTrelloTriggerEnabled', () => { it('returns false for readyToProcessLabel when all agents disabled in object form', () => { expect( resolveTrelloTriggerEnabled( - { readyToProcessLabel: { briefing: false, planning: false, implementation: false } }, + { readyToProcessLabel: { splitting: false, planning: false, implementation: false } }, 'readyToProcessLabel', ), ).toBe(false); @@ -175,7 +175,7 @@ describe('resolveJiraTriggerEnabled', () => { it('returns true for issueTransitioned object when any agent is enabled', () => { expect( resolveJiraTriggerEnabled( - { issueTransitioned: { briefing: false, planning: true, implementation: false } }, + { issueTransitioned: { splitting: false, planning: true, implementation: false } }, 'issueTransitioned', ), ).toBe(true); @@ -184,7 +184,7 @@ describe('resolveJiraTriggerEnabled', () => { it('returns false for issueTransitioned object when all agents disabled', () => { expect( resolveJiraTriggerEnabled( - { issueTransitioned: { briefing: false, planning: false, implementation: false } }, + { issueTransitioned: { splitting: false, planning: false, implementation: false } }, 'issueTransitioned', ), ).toBe(false); @@ -234,48 +234,48 @@ describe('resolveGitHubTriggerEnabled', () => { describe('resolveReadyToProcessEnabled', () => { it('returns true when config is undefined (backward compatible)', () => { - expect(resolveReadyToProcessEnabled(undefined, 'briefing')).toBe(true); + expect(resolveReadyToProcessEnabled(undefined, 'splitting')).toBe(true); expect(resolveReadyToProcessEnabled(undefined, 'planning')).toBe(true); expect(resolveReadyToProcessEnabled(undefined, 'implementation')).toBe(true); }); it('returns true when readyToProcessLabel is not set', () => { - expect(resolveReadyToProcessEnabled({}, 'briefing')).toBe(true); + expect(resolveReadyToProcessEnabled({}, 'splitting')).toBe(true); }); it('applies legacy boolean true to all agents', () => { const config = { readyToProcessLabel: true as const }; - expect(resolveReadyToProcessEnabled(config, 'briefing')).toBe(true); + expect(resolveReadyToProcessEnabled(config, 'splitting')).toBe(true); expect(resolveReadyToProcessEnabled(config, 'planning')).toBe(true); expect(resolveReadyToProcessEnabled(config, 'implementation')).toBe(true); }); it('applies legacy boolean false to all agents', () => { const config = { readyToProcessLabel: false as const }; - expect(resolveReadyToProcessEnabled(config, 'briefing')).toBe(false); + expect(resolveReadyToProcessEnabled(config, 'splitting')).toBe(false); expect(resolveReadyToProcessEnabled(config, 'planning')).toBe(false); expect(resolveReadyToProcessEnabled(config, 'implementation')).toBe(false); }); it('returns per-agent value from nested object', () => { const config = { - readyToProcessLabel: { briefing: true, planning: false, implementation: true }, + readyToProcessLabel: { splitting: true, planning: false, implementation: true }, }; - expect(resolveReadyToProcessEnabled(config, 'briefing')).toBe(true); + expect(resolveReadyToProcessEnabled(config, 'splitting')).toBe(true); expect(resolveReadyToProcessEnabled(config, 'planning')).toBe(false); expect(resolveReadyToProcessEnabled(config, 'implementation')).toBe(true); }); it('defaults to true for unknown agent types', () => { const config = { - readyToProcessLabel: { briefing: false, planning: false, implementation: false }, + readyToProcessLabel: { splitting: false, planning: false, implementation: false }, }; expect(resolveReadyToProcessEnabled(config, 'unknown-agent')).toBe(true); }); it('defaults to true for known non-toggle agents like respond-to-review', () => { const config = { - readyToProcessLabel: { briefing: false, planning: false, implementation: false }, + readyToProcessLabel: { splitting: false, planning: false, implementation: false }, }; expect(resolveReadyToProcessEnabled(config, 'respond-to-review')).toBe(true); expect(resolveReadyToProcessEnabled(config, 'debug')).toBe(true); @@ -283,7 +283,7 @@ describe('resolveReadyToProcessEnabled', () => { it('defaults all agents to true when nested object is empty (Zod fills defaults)', () => { const parsed = TrelloTriggerConfigSchema.parse({ readyToProcessLabel: {} }); - expect(resolveReadyToProcessEnabled(parsed, 'briefing')).toBe(true); + expect(resolveReadyToProcessEnabled(parsed, 'splitting')).toBe(true); expect(resolveReadyToProcessEnabled(parsed, 'planning')).toBe(true); expect(resolveReadyToProcessEnabled(parsed, 'implementation')).toBe(true); }); @@ -291,48 +291,48 @@ describe('resolveReadyToProcessEnabled', () => { describe('resolveIssueTransitionedEnabled', () => { it('returns true when config is undefined (backward compatible)', () => { - expect(resolveIssueTransitionedEnabled(undefined, 'briefing')).toBe(true); + expect(resolveIssueTransitionedEnabled(undefined, 'splitting')).toBe(true); expect(resolveIssueTransitionedEnabled(undefined, 'planning')).toBe(true); expect(resolveIssueTransitionedEnabled(undefined, 'implementation')).toBe(true); }); it('returns true when issueTransitioned is not set', () => { - expect(resolveIssueTransitionedEnabled({}, 'briefing')).toBe(true); + expect(resolveIssueTransitionedEnabled({}, 'splitting')).toBe(true); }); it('applies legacy boolean true to all agents', () => { const config = { issueTransitioned: true as const }; - expect(resolveIssueTransitionedEnabled(config, 'briefing')).toBe(true); + expect(resolveIssueTransitionedEnabled(config, 'splitting')).toBe(true); expect(resolveIssueTransitionedEnabled(config, 'planning')).toBe(true); expect(resolveIssueTransitionedEnabled(config, 'implementation')).toBe(true); }); it('applies legacy boolean false to all agents', () => { const config = { issueTransitioned: false as const }; - expect(resolveIssueTransitionedEnabled(config, 'briefing')).toBe(false); + expect(resolveIssueTransitionedEnabled(config, 'splitting')).toBe(false); expect(resolveIssueTransitionedEnabled(config, 'planning')).toBe(false); expect(resolveIssueTransitionedEnabled(config, 'implementation')).toBe(false); }); it('returns per-agent value from nested object', () => { const config = { - issueTransitioned: { briefing: true, planning: false, implementation: true }, + issueTransitioned: { splitting: true, planning: false, implementation: true }, }; - expect(resolveIssueTransitionedEnabled(config, 'briefing')).toBe(true); + expect(resolveIssueTransitionedEnabled(config, 'splitting')).toBe(true); expect(resolveIssueTransitionedEnabled(config, 'planning')).toBe(false); expect(resolveIssueTransitionedEnabled(config, 'implementation')).toBe(true); }); it('defaults to true for unknown agent types', () => { const config = { - issueTransitioned: { briefing: false, planning: false, implementation: false }, + issueTransitioned: { splitting: false, planning: false, implementation: false }, }; expect(resolveIssueTransitionedEnabled(config, 'unknown-agent')).toBe(true); }); it('defaults to true for known non-toggle agents like respond-to-review', () => { const config = { - issueTransitioned: { briefing: false, planning: false, implementation: false }, + issueTransitioned: { splitting: false, planning: false, implementation: false }, }; expect(resolveIssueTransitionedEnabled(config, 'respond-to-review')).toBe(true); expect(resolveIssueTransitionedEnabled(config, 'debug')).toBe(true); @@ -340,7 +340,7 @@ describe('resolveIssueTransitionedEnabled', () => { it('defaults all agents to true when nested object is empty (Zod fills defaults)', () => { const parsed = JiraTriggerConfigSchema.parse({ issueTransitioned: {} }); - expect(resolveIssueTransitionedEnabled(parsed, 'briefing')).toBe(true); + expect(resolveIssueTransitionedEnabled(parsed, 'splitting')).toBe(true); expect(resolveIssueTransitionedEnabled(parsed, 'planning')).toBe(true); expect(resolveIssueTransitionedEnabled(parsed, 'implementation')).toBe(true); }); @@ -429,7 +429,7 @@ describe('resolveReviewTriggerConfig', () => { describe('resolvePerAgentToggle', () => { describe('undefined value', () => { it('returns true for all known agent types', () => { - expect(resolvePerAgentToggle(undefined, 'briefing')).toBe(true); + expect(resolvePerAgentToggle(undefined, 'splitting')).toBe(true); expect(resolvePerAgentToggle(undefined, 'planning')).toBe(true); expect(resolvePerAgentToggle(undefined, 'implementation')).toBe(true); }); @@ -442,14 +442,14 @@ describe('resolvePerAgentToggle', () => { describe('boolean value', () => { it('returns true when value is true, for all agent types', () => { - expect(resolvePerAgentToggle(true, 'briefing')).toBe(true); + expect(resolvePerAgentToggle(true, 'splitting')).toBe(true); expect(resolvePerAgentToggle(true, 'planning')).toBe(true); expect(resolvePerAgentToggle(true, 'implementation')).toBe(true); expect(resolvePerAgentToggle(true, 'unknown')).toBe(true); }); it('returns false when value is false, for all agent types', () => { - expect(resolvePerAgentToggle(false, 'briefing')).toBe(false); + expect(resolvePerAgentToggle(false, 'splitting')).toBe(false); expect(resolvePerAgentToggle(false, 'planning')).toBe(false); expect(resolvePerAgentToggle(false, 'implementation')).toBe(false); expect(resolvePerAgentToggle(false, 'unknown')).toBe(false); @@ -458,21 +458,21 @@ describe('resolvePerAgentToggle', () => { describe('per-agent object', () => { it('returns the correct value for each known agent type', () => { - const obj = { briefing: true, planning: false, implementation: true }; - expect(resolvePerAgentToggle(obj, 'briefing')).toBe(true); + const obj = { splitting: true, planning: false, implementation: true }; + expect(resolvePerAgentToggle(obj, 'splitting')).toBe(true); expect(resolvePerAgentToggle(obj, 'planning')).toBe(false); expect(resolvePerAgentToggle(obj, 'implementation')).toBe(true); }); it('defaults to true for unknown agent types', () => { - const obj = { briefing: false, planning: false, implementation: false }; + const obj = { splitting: false, planning: false, implementation: false }; expect(resolvePerAgentToggle(obj, 'respond-to-review')).toBe(true); expect(resolvePerAgentToggle(obj, 'debug')).toBe(true); expect(resolvePerAgentToggle(obj, 'anything-else')).toBe(true); }); it('defaults missing fields to true', () => { - const obj = { briefing: false }; // planning and implementation are undefined + const obj = { splitting: false }; // planning and implementation are undefined expect(resolvePerAgentToggle(obj, 'planning')).toBe(true); expect(resolvePerAgentToggle(obj, 'implementation')).toBe(true); }); @@ -528,7 +528,7 @@ describe('resolveTriggerEnabled', () => { it('returns true if any agent in the object is enabled', () => { expect( resolveTriggerEnabled( - { rtp: { briefing: false, planning: true, implementation: false } }, + { rtp: { splitting: false, planning: true, implementation: false } }, 'rtp', { nestedKeys: ['rtp'] }, ), @@ -538,7 +538,7 @@ describe('resolveTriggerEnabled', () => { it('returns false if all agents in the object are disabled', () => { expect( resolveTriggerEnabled( - { rtp: { briefing: false, planning: false, implementation: false } }, + { rtp: { splitting: false, planning: false, implementation: false } }, 'rtp', { nestedKeys: ['rtp'] }, ), @@ -562,17 +562,17 @@ describe('resolveTriggerEnabled', () => { describe('backward-compat verification β€” wrapper behavior matches generic', () => { it('resolveTrelloTriggerEnabled matches resolveTriggerEnabled for all Trello cases', () => { const cases: [Record, string, boolean][] = [ - [{}, 'cardMovedToBriefing', true], - [{ cardMovedToBriefing: false }, 'cardMovedToBriefing', false], + [{}, 'cardMovedToSplitting', true], + [{ cardMovedToSplitting: false }, 'cardMovedToSplitting', false], [{ readyToProcessLabel: false }, 'readyToProcessLabel', false], [{ readyToProcessLabel: true }, 'readyToProcessLabel', true], [ - { readyToProcessLabel: { briefing: false, planning: true, implementation: false } }, + { readyToProcessLabel: { splitting: false, planning: true, implementation: false } }, 'readyToProcessLabel', true, ], [ - { readyToProcessLabel: { briefing: false, planning: false, implementation: false } }, + { readyToProcessLabel: { splitting: false, planning: false, implementation: false } }, 'readyToProcessLabel', false, ], diff --git a/tests/unit/db/repositories/configMapper.test.ts b/tests/unit/db/repositories/configMapper.test.ts index d21909d8..1b964f27 100644 --- a/tests/unit/db/repositories/configMapper.test.ts +++ b/tests/unit/db/repositories/configMapper.test.ts @@ -39,7 +39,7 @@ const trelloConfig = { const jiraConfig = { projectKey: 'PROJ', baseUrl: 'https://test.atlassian.net', - statuses: { briefing: 'Briefing', todo: 'To Do' }, + statuses: { splitting: 'Briefing', todo: 'To Do' }, }; const trelloIntegrationRow: IntegrationRow = { @@ -127,7 +127,7 @@ describe('buildAgentMaps', () => { { orgId: null, projectId: null, - agentType: 'briefing', + agentType: 'splitting', model: null, maxIterations: null, agentBackend: null, @@ -316,7 +316,7 @@ describe('mapProjectRow', () => { const result = mapProjectRow(makeInput({ trelloConfig: undefined, jiraConfig })); expect(result.jira?.projectKey).toBe('PROJ'); expect(result.jira?.baseUrl).toBe('https://test.atlassian.net'); - expect(result.jira?.statuses).toEqual({ briefing: 'Briefing', todo: 'To Do' }); + expect(result.jira?.statuses).toEqual({ splitting: 'Briefing', todo: 'To Do' }); }); it('includes jira triggers when non-empty', () => { diff --git a/tests/unit/db/repositories/configRepository.test.ts b/tests/unit/db/repositories/configRepository.test.ts index aa25324d..e26547f2 100644 --- a/tests/unit/db/repositories/configRepository.test.ts +++ b/tests/unit/db/repositories/configRepository.test.ts @@ -77,7 +77,7 @@ const jiraIntegration = { config: { projectKey: 'PROJ', baseUrl: 'https://test.atlassian.net', - statuses: { briefing: 'Briefing', planning: 'Planning', todo: 'To Do' }, + statuses: { splitting: 'Splitting', planning: 'Planning', todo: 'To Do' }, labels: { processing: 'my-proc', readyToProcess: 'my-ready' }, }, triggers: {}, @@ -115,8 +115,8 @@ const orgAgentConfig = { id: 3, orgId: 'default', projectId: null, - agentType: 'briefing', - model: 'org-briefing-model', + agentType: 'splitting', + model: 'org-splitting-model', maxIterations: 20, agentBackend: null, prompt: null, @@ -201,7 +201,7 @@ describe('configRepository', () => { expect(proj.jira?.projectKey).toBe('PROJ'); expect(proj.jira?.baseUrl).toBe('https://test.atlassian.net'); expect(proj.jira?.statuses).toEqual({ - briefing: 'Briefing', + splitting: 'Splitting', planning: 'Planning', todo: 'To Do', }); @@ -215,7 +215,7 @@ describe('configRepository', () => { config: { projectKey: 'PROJ', baseUrl: 'https://test.atlassian.net', - statuses: { briefing: 'Briefing' }, + statuses: { splitting: 'Splitting' }, }, }; const mockDb = createSequentialMockDb([[defaultsRow], [projectRow], [], [jiraNoLabels]]); @@ -288,11 +288,11 @@ describe('configRepository', () => { expect(config.defaults.agentModels).toEqual({ review: 'global-review-model', - briefing: 'org-briefing-model', + splitting: 'org-splitting-model', }); expect(config.defaults.agentIterations).toEqual({ review: 30, - briefing: 20, + splitting: 20, }); }); diff --git a/tests/unit/db/runsRepository.test.ts b/tests/unit/db/runsRepository.test.ts index 372af90a..e699c367 100644 --- a/tests/unit/db/runsRepository.test.ts +++ b/tests/unit/db/runsRepository.test.ts @@ -97,7 +97,7 @@ describe('runsRepository', () => { const result = await createRun({ projectId: 'proj-1', - agentType: 'briefing', + agentType: 'splitting', backend: 'claude-code', }); @@ -105,7 +105,7 @@ describe('runsRepository', () => { expect(mockValues).toHaveBeenCalledWith( expect.objectContaining({ projectId: 'proj-1', - agentType: 'briefing', + agentType: 'splitting', backend: 'claude-code', status: 'running', cardId: undefined, diff --git a/tests/unit/gadgets/session/core/finish.test.ts b/tests/unit/gadgets/session/core/finish.test.ts index 6184db8b..f51126c7 100644 --- a/tests/unit/gadgets/session/core/finish.test.ts +++ b/tests/unit/gadgets/session/core/finish.test.ts @@ -276,7 +276,7 @@ describe('validateFinish', () => { it('other agent types β†’ valid', async () => { const result = await validateFinish({ - agentType: 'briefing', + agentType: 'splitting', prCreated: false, reviewSubmitted: false, }); diff --git a/tests/unit/github/personas.test.ts b/tests/unit/github/personas.test.ts index 89010cfd..61f6e66e 100644 --- a/tests/unit/github/personas.test.ts +++ b/tests/unit/github/personas.test.ts @@ -41,7 +41,7 @@ describe('personas', () => { describe('getPersonaForAgentType', () => { it('maps implementation agents to implementer', () => { expect(getPersonaForAgentType('implementation')).toBe('implementer'); - expect(getPersonaForAgentType('briefing')).toBe('implementer'); + expect(getPersonaForAgentType('splitting')).toBe('implementer'); expect(getPersonaForAgentType('planning')).toBe('implementer'); expect(getPersonaForAgentType('respond-to-review')).toBe('implementer'); expect(getPersonaForAgentType('respond-to-ci')).toBe('implementer'); diff --git a/tests/unit/pm/jira/adapter.test.ts b/tests/unit/pm/jira/adapter.test.ts index e5609f12..7276baab 100644 --- a/tests/unit/pm/jira/adapter.test.ts +++ b/tests/unit/pm/jira/adapter.test.ts @@ -50,7 +50,7 @@ const mockConfig = { projectKey: 'PROJ', baseUrl: 'https://mycompany.atlassian.net', statuses: { - briefing: 'Briefing', + splitting: 'Briefing', planning: 'Planning', todo: 'To Do', done: 'Done', diff --git a/tests/unit/pm/lifecycle.test.ts b/tests/unit/pm/lifecycle.test.ts index 34e190d9..da1716d5 100644 --- a/tests/unit/pm/lifecycle.test.ts +++ b/tests/unit/pm/lifecycle.test.ts @@ -336,7 +336,7 @@ describe('pm/lifecycle', () => { describe('prepareForAgent', () => { it('adds processing label and removes ready/processed labels', async () => { - await manager.prepareForAgent('work-item-1', 'briefing'); + await manager.prepareForAgent('work-item-1', 'splitting'); expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-proc'); expect(mockProvider.removeLabel).toHaveBeenCalledWith('work-item-1', 'label-ready'); @@ -351,7 +351,7 @@ describe('pm/lifecycle', () => { }); it('does not move work item for non-implementation agents', async () => { - await manager.prepareForAgent('work-item-1', 'briefing'); + await manager.prepareForAgent('work-item-1', 'splitting'); expect(mockProvider.moveWorkItem).not.toHaveBeenCalled(); }); @@ -362,7 +362,7 @@ describe('pm/lifecycle', () => { statuses: {}, }); - await managerNoLabels.prepareForAgent('work-item-1', 'briefing'); + await managerNoLabels.prepareForAgent('work-item-1', 'splitting'); expect(mockProvider.addLabel).not.toHaveBeenCalled(); expect(mockProvider.removeLabel).not.toHaveBeenCalled(); @@ -371,7 +371,7 @@ describe('pm/lifecycle', () => { describe('handleSuccess', () => { it('adds processed label', async () => { - await manager.handleSuccess('work-item-1', 'briefing'); + await manager.handleSuccess('work-item-1', 'splitting'); expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-done'); }); @@ -412,13 +412,13 @@ describe('pm/lifecycle', () => { }); it('does not move work item for non-implementation agents', async () => { - await manager.handleSuccess('work-item-1', 'briefing'); + await manager.handleSuccess('work-item-1', 'splitting'); expect(mockProvider.moveWorkItem).not.toHaveBeenCalled(); }); it('does not call linkPR for non-implementation agents even with prUrl', async () => { - await manager.handleSuccess('work-item-1', 'briefing', 'https://github.com/pr/123'); + await manager.handleSuccess('work-item-1', 'splitting', 'https://github.com/pr/123'); expect(mockProvider.linkPR).not.toHaveBeenCalled(); }); diff --git a/tests/unit/pm/webhook-handler.test.ts b/tests/unit/pm/webhook-handler.test.ts index 26a67ae1..1b4740e3 100644 --- a/tests/unit/pm/webhook-handler.test.ts +++ b/tests/unit/pm/webhook-handler.test.ts @@ -247,7 +247,7 @@ describe('processPMWebhook', () => { const integration = createMockIntegration(); const registry = createMockRegistry(null); // registry would return null const preResolvedResult = { - agentType: 'briefing', + agentType: 'splitting', workItemId: 'card-pre', agentInput: { cardId: 'card-pre' }, }; diff --git a/tests/unit/router/ackMessageGenerator.test.ts b/tests/unit/router/ackMessageGenerator.test.ts index 074b8eec..4797398e 100644 --- a/tests/unit/router/ackMessageGenerator.test.ts +++ b/tests/unit/router/ackMessageGenerator.test.ts @@ -32,8 +32,8 @@ vi.mock('../../../src/config/agentMessages.js', () => ({ INITIAL_MESSAGES: { implementation: '**πŸš€ Implementing changes** β€” Writing code, running tests, and preparing a PR...', - briefing: - '**πŸ“‹ Analyzing brief** β€” Reading the card and gathering context to create a clear brief...', + splitting: + '**πŸ“‹ Splitting plan** β€” Reading the plan and splitting it into ordered work items...', review: '**πŸ” Reviewing code** β€” Examining the PR changes for quality and correctness...', }, })); @@ -319,10 +319,10 @@ describe('generateAckMessage', () => { } as never); vi.mocked(getOrgCredential).mockResolvedValue(null); - const result = await generateAckMessage('briefing', 'Card: Test', 'p1'); + const result = await generateAckMessage('splitting', 'Card: Test', 'p1'); expect(result).toBe( - '**πŸ“‹ Analyzing brief** β€” Reading the card and gathering context to create a clear brief...', + '**πŸ“‹ Splitting plan** β€” Reading the plan and splitting it into ordered work items...', ); }); diff --git a/tests/unit/router/adapters/trello.test.ts b/tests/unit/router/adapters/trello.test.ts index 1ed79fe1..d07765aa 100644 --- a/tests/unit/router/adapters/trello.test.ts +++ b/tests/unit/router/adapters/trello.test.ts @@ -55,7 +55,7 @@ const mockProject: RouterProjectConfig = { trello: { boardId: 'board1', lists: { - briefing: 'list-briefing', + splitting: 'list-splitting', planning: 'list-planning', todo: 'list-todo', debug: 'list-debug', diff --git a/tests/unit/router/config.test.ts b/tests/unit/router/config.test.ts index e233607c..fc30b88a 100644 --- a/tests/unit/router/config.test.ts +++ b/tests/unit/router/config.test.ts @@ -70,7 +70,7 @@ describe('loadProjectConfig', () => { pm: { type: 'trello' }, trello: { boardId: 'board1', - lists: { briefing: 'list1', planning: 'list2', todo: 'list3' }, + lists: { splitting: 'list1', planning: 'list2', todo: 'list3' }, labels: { readyToProcess: 'label1', processed: 'label2' }, }, }, @@ -87,7 +87,7 @@ describe('loadProjectConfig', () => { pmType: 'trello', trello: { boardId: 'board1', - lists: { briefing: 'list1', planning: 'list2', todo: 'list3' }, + lists: { splitting: 'list1', planning: 'list2', todo: 'list3' }, labels: { readyToProcess: 'label1', processed: 'label2' }, }, }); diff --git a/tests/unit/router/index.test.ts b/tests/unit/router/index.test.ts index fdd2a700..baaba14e 100644 --- a/tests/unit/router/index.test.ts +++ b/tests/unit/router/index.test.ts @@ -39,7 +39,7 @@ describe('router config integration', () => { pmType: 'trello', trello: { boardId: 'board1', - lists: { briefing: 'list1', planning: 'list2', todo: 'list3', debug: 'list4' }, + lists: { splitting: 'list1', planning: 'list2', todo: 'list3', debug: 'list4' }, labels: { readyToProcess: 'label1' }, }, }, diff --git a/tests/unit/router/trello.test.ts b/tests/unit/router/trello.test.ts index 420d4227..d69c324f 100644 --- a/tests/unit/router/trello.test.ts +++ b/tests/unit/router/trello.test.ts @@ -30,7 +30,7 @@ const mockProject: RouterProjectConfig = { trello: { boardId: 'board1', lists: { - briefing: 'list-briefing', + splitting: 'list-splitting', planning: 'list-planning', todo: 'list-todo', debug: 'list-debug', @@ -46,7 +46,7 @@ beforeEach(() => { describe('isAgentLogFilename', () => { it('matches valid agent log filenames', () => { expect(isAgentLogFilename('implementation-2026-01-02T16-30-24-339Z.zip')).toBe(true); - expect(isAgentLogFilename('briefing-timeout-2026-01-02T12-34-56-789Z.zip')).toBe(true); + expect(isAgentLogFilename('splitting-timeout-2026-01-02T12-34-56-789Z.zip')).toBe(true); }); it('matches multi-hyphen agent names (e.g. respond-to-review)', () => { @@ -89,7 +89,7 @@ describe('isCardInTriggerList', () => { it('returns true when card created in trigger list', () => { const result = isCardInTriggerList( 'createCard', - { list: { id: 'list-briefing' } }, + { list: { id: 'list-splitting' } }, mockProject, ); expect(result).toBe(true); diff --git a/tests/unit/server.test.ts b/tests/unit/server.test.ts index cf62381b..32b133c9 100644 --- a/tests/unit/server.test.ts +++ b/tests/unit/server.test.ts @@ -85,7 +85,7 @@ function buildDeps(overrides: Partial = {}): ServerDependenc pm: { type: 'trello' }, trello: { boardId: 'board-123', - lists: { briefing: 'l1', planning: 'l2', todo: 'l3' }, + lists: { splitting: 'l1', planning: 'l2', todo: 'l3' }, labels: {}, }, }, diff --git a/tests/unit/triggers/builtins.test.ts b/tests/unit/triggers/builtins.test.ts index 9fa58775..b75fab79 100644 --- a/tests/unit/triggers/builtins.test.ts +++ b/tests/unit/triggers/builtins.test.ts @@ -37,7 +37,7 @@ 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' }, + CardMovedToSplittingTrigger: { name: 'card-moved-to-splitting' }, CardMovedToPlanningTrigger: { name: 'card-moved-to-planning' }, CardMovedToTodoTrigger: { name: 'card-moved-to-todo' }, })); @@ -101,7 +101,7 @@ describe('registerBuiltInTriggers', () => { 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-splitting'); expect(registeredNames).toContain('card-moved-to-planning'); expect(registeredNames).toContain('card-moved-to-todo'); }); @@ -140,7 +140,7 @@ describe('registerBuiltInTriggers', () => { 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'); + const cardMovedIdx = names.indexOf('card-moved-to-splitting'); expect(commentMentionIdx).toBeLessThan(cardMovedIdx); }); diff --git a/tests/unit/triggers/card-moved.test.ts b/tests/unit/triggers/card-moved.test.ts index 58297b7f..03a46d73 100644 --- a/tests/unit/triggers/card-moved.test.ts +++ b/tests/unit/triggers/card-moved.test.ts @@ -40,14 +40,14 @@ vi.mock('../../../src/router/reactions.js', () => ({ import '../../../src/pm/index.js'; import { - CardMovedToBriefingTrigger, CardMovedToPlanningTrigger, + CardMovedToSplittingTrigger, CardMovedToTodoTrigger, } from '../../../src/triggers/trello/card-moved.js'; import type { TriggerContext } from '../../../src/triggers/types.js'; -describe('CardMovedToBriefingTrigger', () => { - const trigger = CardMovedToBriefingTrigger; +describe('CardMovedToSplittingTrigger', () => { + const trigger = CardMovedToSplittingTrigger; const mockProject = { id: 'test', @@ -58,7 +58,7 @@ describe('CardMovedToBriefingTrigger', () => { trello: { boardId: 'board123', lists: { - briefing: 'briefing-list-id', + splitting: 'splitting-list-id', planning: 'planning-list-id', todo: 'todo-list-id', }, @@ -66,7 +66,7 @@ describe('CardMovedToBriefingTrigger', () => { }, }; - it('matches when card moved to briefing list', () => { + it('matches when card moved to splitting list', () => { const ctx: TriggerContext = { project: mockProject, source: 'trello', @@ -80,7 +80,7 @@ describe('CardMovedToBriefingTrigger', () => { data: { card: { id: 'card1', name: 'Test Card', idShort: 1, shortLink: 'abc' }, listBefore: { id: 'other-list', name: 'Other' }, - listAfter: { id: 'briefing-list-id', name: 'Briefing' }, + listAfter: { id: 'splitting-list-id', name: 'Splitting' }, }, }, }, @@ -89,7 +89,7 @@ describe('CardMovedToBriefingTrigger', () => { expect(trigger.matches(ctx)).toBe(true); }); - it('does not match when card moved from briefing to briefing', () => { + it('does not match when card moved from splitting to splitting', () => { const ctx: TriggerContext = { project: mockProject, source: 'trello', @@ -102,8 +102,8 @@ describe('CardMovedToBriefingTrigger', () => { date: '2024-01-01', data: { card: { id: 'card1', name: 'Test Card', idShort: 1, shortLink: 'abc' }, - listBefore: { id: 'briefing-list-id', name: 'Briefing' }, - listAfter: { id: 'briefing-list-id', name: 'Briefing' }, + listBefore: { id: 'splitting-list-id', name: 'Splitting' }, + listAfter: { id: 'splitting-list-id', name: 'Splitting' }, }, }, }, @@ -112,7 +112,7 @@ describe('CardMovedToBriefingTrigger', () => { expect(trigger.matches(ctx)).toBe(false); }); - it('matches when card created directly in briefing list', () => { + it('matches when card created directly in splitting list', () => { const ctx: TriggerContext = { project: mockProject, source: 'trello', @@ -125,7 +125,7 @@ describe('CardMovedToBriefingTrigger', () => { date: '2024-01-01', data: { card: { id: 'card1', name: 'Test Card', idShort: 1, shortLink: 'abc' }, - list: { id: 'briefing-list-id', name: 'Briefing' }, + list: { id: 'splitting-list-id', name: 'Splitting' }, }, }, }, @@ -166,7 +166,7 @@ describe('CardMovedToBriefingTrigger', () => { expect(trigger.matches(ctx)).toBe(false); }); - it('handles and returns briefing agent', async () => { + it('handles and returns splitting agent', async () => { const ctx: TriggerContext = { project: mockProject, source: 'trello', @@ -180,7 +180,7 @@ describe('CardMovedToBriefingTrigger', () => { data: { card: { id: 'card123', name: 'Test Card', idShort: 1, shortLink: 'abc' }, listBefore: { id: 'other-list', name: 'Other' }, - listAfter: { id: 'briefing-list-id', name: 'Briefing' }, + listAfter: { id: 'splitting-list-id', name: 'Splitting' }, }, }, }, @@ -188,7 +188,7 @@ describe('CardMovedToBriefingTrigger', () => { const result = await trigger.handle(ctx); - expect(result?.agentType).toBe('briefing'); + expect(result?.agentType).toBe('splitting'); expect(result?.workItemId).toBe('card123'); expect(result?.agentInput.cardId).toBe('card123'); }); @@ -206,7 +206,7 @@ describe('CardMovedToTodoTrigger', () => { trello: { boardId: 'board123', lists: { - briefing: 'briefing-list-id', + splitting: 'splitting-list-id', planning: 'planning-list-id', todo: 'todo-list-id', }, diff --git a/tests/unit/triggers/check-suite-failure.test.ts b/tests/unit/triggers/check-suite-failure.test.ts index e1b15ee7..a77b9ff2 100644 --- a/tests/unit/triggers/check-suite-failure.test.ts +++ b/tests/unit/triggers/check-suite-failure.test.ts @@ -32,7 +32,7 @@ describe('CheckSuiteFailureTrigger', () => { trello: { boardId: 'board123', lists: { - briefing: 'briefing-list-id', + splitting: 'splitting-list-id', planning: 'planning-list-id', todo: 'todo-list-id', }, diff --git a/tests/unit/triggers/check-suite-success.test.ts b/tests/unit/triggers/check-suite-success.test.ts index adb31956..d8c32553 100644 --- a/tests/unit/triggers/check-suite-success.test.ts +++ b/tests/unit/triggers/check-suite-success.test.ts @@ -30,7 +30,7 @@ describe('CheckSuiteSuccessTrigger', () => { trello: { boardId: 'board123', lists: { - briefing: 'briefing-list-id', + splitting: 'splitting-list-id', planning: 'planning-list-id', todo: 'todo-list-id', }, diff --git a/tests/unit/triggers/debug-runner.test.ts b/tests/unit/triggers/debug-runner.test.ts index f2cb466a..b7106c00 100644 --- a/tests/unit/triggers/debug-runner.test.ts +++ b/tests/unit/triggers/debug-runner.test.ts @@ -59,7 +59,7 @@ const mockProject = { branchPrefix: 'feature/', trello: { boardId: 'board-1', - lists: { briefing: 'l1', planning: 'l2', todo: 'l3' }, + lists: { splitting: 'l1', planning: 'l2', todo: 'l3' }, labels: {}, }, } as unknown as ProjectConfig; @@ -259,7 +259,7 @@ describe('triggerDebugAnalysis', () => { it('writes LLM call files to temp dir', async () => { vi.mocked(getRunById).mockResolvedValue({ id: 'run-1', - agentType: 'briefing', + agentType: 'splitting', status: 'failed', } as ReturnType extends Promise ? NonNullable : never); diff --git a/tests/unit/triggers/debug-trigger.test.ts b/tests/unit/triggers/debug-trigger.test.ts index 055fb959..31794325 100644 --- a/tests/unit/triggers/debug-trigger.test.ts +++ b/tests/unit/triggers/debug-trigger.test.ts @@ -111,7 +111,7 @@ describe('shouldTriggerDebug', () => { it('returns debug target for timed_out run', async () => { vi.mocked(getRunById).mockResolvedValue({ id: 'run-2', - agentType: 'briefing', + agentType: 'splitting', status: 'timed_out', cardId: 'card-2', } as ReturnType extends Promise ? NonNullable : never); @@ -120,7 +120,7 @@ describe('shouldTriggerDebug', () => { const result = await shouldTriggerDebug('run-2'); expect(result).toEqual({ runId: 'run-2', - agentType: 'briefing', + agentType: 'splitting', cardId: 'card-2', }); }); diff --git a/tests/unit/triggers/github-pr-comment-mention.test.ts b/tests/unit/triggers/github-pr-comment-mention.test.ts index 3ca5dc66..7c876b96 100644 --- a/tests/unit/triggers/github-pr-comment-mention.test.ts +++ b/tests/unit/triggers/github-pr-comment-mention.test.ts @@ -48,7 +48,7 @@ const mockProject = { branchPrefix: 'feature/', trello: { boardId: 'board-123', - lists: { briefing: 'b', planning: 'p', todo: 't' }, + lists: { splitting: 'b', planning: 'p', todo: 't' }, labels: {}, }, } as TriggerContext['project']; diff --git a/tests/unit/triggers/jira-issue-transitioned.test.ts b/tests/unit/triggers/jira-issue-transitioned.test.ts index c0e95d0a..1c58d34a 100644 --- a/tests/unit/triggers/jira-issue-transitioned.test.ts +++ b/tests/unit/triggers/jira-issue-transitioned.test.ts @@ -21,7 +21,7 @@ const mockProject = { jira: { projectKey: 'PROJ', statuses: { - briefing: 'Briefing', + splitting: 'Splitting', planning: 'Planning', todo: 'To Do', done: 'Done', @@ -57,7 +57,7 @@ function buildCtx( : { key: 'PROJ-42', fields: { summary: 'Test Issue' } }, changelog: { items: overrides.statusChangeItems ?? [ - { field: 'status', fromString: 'Backlog', toString: 'Briefing' }, + { field: 'status', fromString: 'Backlog', toString: 'Splitting' }, ], }, }, @@ -118,19 +118,19 @@ describe('JiraIssueTransitionedTrigger', () => { expect(result?.agentInput).toEqual({ cardId: 'PROJ-42' }); }); - it('returns briefing agent for "Briefing" transition', async () => { + it('returns splitting agent for "Briefing" transition', async () => { const ctx = buildCtx({ - statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'Briefing' }], + statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'Splitting' }], }); const result = await trigger.handle(ctx); - expect(result?.agentType).toBe('briefing'); + expect(result?.agentType).toBe('splitting'); }); it('returns planning agent for "Planning" transition', async () => { const ctx = buildCtx({ - statusChangeItems: [{ field: 'status', fromString: 'Briefing', toString: 'Planning' }], + statusChangeItems: [{ field: 'status', fromString: 'Splitting', toString: 'Planning' }], }); const result = await trigger.handle(ctx); @@ -140,12 +140,12 @@ describe('JiraIssueTransitionedTrigger', () => { it('is case insensitive when matching status names', async () => { const ctx = buildCtx({ - statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'briefing' }], + statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'splitting' }], }); const result = await trigger.handle(ctx); - expect(result?.agentType).toBe('briefing'); + expect(result?.agentType).toBe('splitting'); }); it('returns null for unmapped status transitions', async () => { @@ -199,18 +199,18 @@ describe('JiraIssueTransitionedTrigger', () => { describe('per-agent issueTransitioned toggle', () => { it('fires when issueTransitioned toggle is true for agent (legacy boolean)', async () => { const ctx = buildCtx({ - statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'Briefing' }], + statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'Splitting' }], triggers: { issueTransitioned: true }, }); const result = await trigger.handle(ctx); - expect(result?.agentType).toBe('briefing'); + expect(result?.agentType).toBe('splitting'); }); it('returns null when issueTransitioned disabled globally (legacy boolean false)', async () => { const ctx = buildCtx({ - statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'Briefing' }], + statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'Splitting' }], triggers: { issueTransitioned: false }, }); @@ -219,24 +219,24 @@ describe('JiraIssueTransitionedTrigger', () => { expect(result).toBeNull(); }); - it('fires when per-agent issueTransitioned.briefing is enabled', async () => { + it('fires when per-agent issueTransitioned.splitting is enabled', async () => { const ctx = buildCtx({ - statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'Briefing' }], + statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'Splitting' }], triggers: { - issueTransitioned: { briefing: true, planning: false, implementation: false }, + issueTransitioned: { splitting: true, planning: false, implementation: false }, }, }); const result = await trigger.handle(ctx); - expect(result?.agentType).toBe('briefing'); + expect(result?.agentType).toBe('splitting'); }); - it('returns null when per-agent issueTransitioned.briefing is disabled', async () => { + it('returns null when per-agent issueTransitioned.splitting is disabled', async () => { const ctx = buildCtx({ - statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'Briefing' }], + statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'Splitting' }], triggers: { - issueTransitioned: { briefing: false, planning: true, implementation: true }, + issueTransitioned: { splitting: false, planning: true, implementation: true }, }, }); @@ -247,9 +247,9 @@ describe('JiraIssueTransitionedTrigger', () => { it('fires planning agent when issueTransitioned.planning is enabled', async () => { const ctx = buildCtx({ - statusChangeItems: [{ field: 'status', fromString: 'Briefing', toString: 'Planning' }], + statusChangeItems: [{ field: 'status', fromString: 'Splitting', toString: 'Planning' }], triggers: { - issueTransitioned: { briefing: false, planning: true, implementation: false }, + issueTransitioned: { splitting: false, planning: true, implementation: false }, }, }); @@ -262,7 +262,7 @@ describe('JiraIssueTransitionedTrigger', () => { const ctx = buildCtx({ statusChangeItems: [{ field: 'status', fromString: 'Planning', toString: 'To Do' }], triggers: { - issueTransitioned: { briefing: true, planning: true, implementation: false }, + issueTransitioned: { splitting: true, planning: true, implementation: false }, }, }); diff --git a/tests/unit/triggers/jira-label-added.test.ts b/tests/unit/triggers/jira-label-added.test.ts index 0abe529b..79fbac90 100644 --- a/tests/unit/triggers/jira-label-added.test.ts +++ b/tests/unit/triggers/jira-label-added.test.ts @@ -39,7 +39,7 @@ const baseJiraConfig = { projectKey: 'TEST', baseUrl: 'https://test.atlassian.net', statuses: { - briefing: 'Briefing', + splitting: 'Splitting', planning: 'Planning', todo: 'To Do', inProgress: 'In Progress', @@ -76,7 +76,7 @@ function buildCtx(overrides: { key: overrides.issueKey ?? 'TEST-42', fields: { project: { key: 'TEST' }, - status: { name: overrides.statusName ?? 'Briefing' }, + status: { name: overrides.statusName ?? 'Splitting' }, summary: 'Test issue', }, }, @@ -123,7 +123,7 @@ describe('JiraReadyToProcessLabelTrigger', () => { buildCtx({ changelogItems: [ { field: 'labels', fromString: '', toString: 'cascade-ready' }, - { field: 'status', fromString: 'Backlog', toString: 'Briefing' }, + { field: 'status', fromString: 'Backlog', toString: 'Splitting' }, ], }), ), @@ -202,7 +202,7 @@ describe('JiraReadyToProcessLabelTrigger', () => { source: 'jira', payload: { webhookEvent: 'jira:issue_updated', - issue: { key: 'TEST-1', fields: { status: { name: 'Briefing' } } }, + issue: { key: 'TEST-1', fields: { status: { name: 'Splitting' } } }, }, }; expect(trigger.matches(ctx)).toBe(false); @@ -210,10 +210,10 @@ describe('JiraReadyToProcessLabelTrigger', () => { }); describe('handle()', () => { - it('returns briefing agent for issue in Briefing status', async () => { - const result = await trigger.handle(buildCtx({ statusName: 'Briefing' })); + it('returns splitting agent for issue in Briefing status', async () => { + const result = await trigger.handle(buildCtx({ statusName: 'Splitting' })); expect(result).not.toBeNull(); - expect(result?.agentType).toBe('briefing'); + expect(result?.agentType).toBe('splitting'); expect(result?.workItemId).toBe('TEST-42'); expect(result?.agentInput.cardId).toBe('TEST-42'); }); @@ -257,9 +257,9 @@ describe('JiraReadyToProcessLabelTrigger', () => { }); it('performs case-insensitive status matching', async () => { - const result = await trigger.handle(buildCtx({ statusName: 'briefing' })); + const result = await trigger.handle(buildCtx({ statusName: 'splitting' })); expect(result).not.toBeNull(); - expect(result?.agentType).toBe('briefing'); + expect(result?.agentType).toBe('splitting'); }); it('returns null when status field is missing from issue', async () => { diff --git a/tests/unit/triggers/label-added.test.ts b/tests/unit/triggers/label-added.test.ts index 3c9b83c6..5821fa86 100644 --- a/tests/unit/triggers/label-added.test.ts +++ b/tests/unit/triggers/label-added.test.ts @@ -47,7 +47,7 @@ describe('ReadyToProcessLabelTrigger', () => { trello: { boardId: 'board123', lists: { - briefing: 'briefing-list-id', + splitting: 'splitting-list-id', planning: 'planning-list-id', todo: 'todo-list-id', }, @@ -139,14 +139,14 @@ describe('ReadyToProcessLabelTrigger', () => { }); describe('handle', () => { - it('returns briefing agent when card is in briefing list', async () => { + it('returns splitting agent when card is in splitting list', async () => { mockGetCard.mockResolvedValue({ id: 'card123', name: 'Test Card', desc: '', url: 'https://trello.com/c/abc', shortUrl: 'https://trello.com/c/abc', - idList: 'briefing-list-id', + idList: 'splitting-list-id', labels: [], }); @@ -170,7 +170,7 @@ describe('ReadyToProcessLabelTrigger', () => { const result = await trigger.handle(ctx); - expect(result.agentType).toBe('briefing'); + expect(result.agentType).toBe('splitting'); expect(result.workItemId).toBe('card123'); expect(mockGetCard).toHaveBeenCalledWith('card123'); }); @@ -245,7 +245,7 @@ describe('ReadyToProcessLabelTrigger', () => { expect(result.workItemId).toBe('card789'); }); - it('defaults to briefing agent when card is in unknown list', async () => { + it('defaults to splitting agent when card is in unknown list', async () => { mockGetCard.mockResolvedValue({ id: 'card999', name: 'Unknown List Card', @@ -276,7 +276,7 @@ describe('ReadyToProcessLabelTrigger', () => { const result = await trigger.handle(ctx); - expect(result.agentType).toBe('briefing'); + expect(result.agentType).toBe('splitting'); }); it('returns null when card ID is missing', async () => { diff --git a/tests/unit/triggers/manual-runner.test.ts b/tests/unit/triggers/manual-runner.test.ts index 9f3ae166..b3264843 100644 --- a/tests/unit/triggers/manual-runner.test.ts +++ b/tests/unit/triggers/manual-runner.test.ts @@ -52,7 +52,7 @@ const mockProject: ProjectConfig = { branchPrefix: 'feature/', trello: { boardId: 'board-1', - lists: { briefing: 'l1', planning: 'l2', todo: 'l3' }, + lists: { splitting: 'l1', planning: 'l2', todo: 'l3' }, labels: {}, }, } as unknown as ProjectConfig; diff --git a/tests/unit/triggers/pr-merged.test.ts b/tests/unit/triggers/pr-merged.test.ts index 6337f232..f7f8e9d0 100644 --- a/tests/unit/triggers/pr-merged.test.ts +++ b/tests/unit/triggers/pr-merged.test.ts @@ -68,7 +68,7 @@ describe('PRMergedTrigger', () => { trello: { boardId: 'board123', lists: { - briefing: 'briefing-list-id', + splitting: 'splitting-list-id', planning: 'planning-list-id', todo: 'todo-list-id', merged: 'merged-list-id', @@ -321,7 +321,7 @@ describe('PRMergedTrigger', () => { trello: { ...mockProject.trello, lists: { - briefing: 'briefing-list-id', + splitting: 'splitting-list-id', planning: 'planning-list-id', todo: 'todo-list-id', // merged list not configured diff --git a/tests/unit/triggers/pr-opened.test.ts b/tests/unit/triggers/pr-opened.test.ts index a8ea862e..212d7219 100644 --- a/tests/unit/triggers/pr-opened.test.ts +++ b/tests/unit/triggers/pr-opened.test.ts @@ -19,7 +19,7 @@ describe('PROpenedTrigger', () => { trello: { boardId: 'board123', lists: { - briefing: 'briefing-list-id', + splitting: 'splitting-list-id', planning: 'planning-list-id', todo: 'todo-list-id', }, diff --git a/tests/unit/triggers/pr-ready-to-merge.test.ts b/tests/unit/triggers/pr-ready-to-merge.test.ts index dc5bf582..d47736a5 100644 --- a/tests/unit/triggers/pr-ready-to-merge.test.ts +++ b/tests/unit/triggers/pr-ready-to-merge.test.ts @@ -69,7 +69,7 @@ describe('PRReadyToMergeTrigger', () => { trello: { boardId: 'board123', lists: { - briefing: 'briefing-list-id', + splitting: 'splitting-list-id', planning: 'planning-list-id', todo: 'todo-list-id', done: 'done-list-id', @@ -656,7 +656,7 @@ describe('PRReadyToMergeTrigger', () => { trello: { ...mockProject.trello, lists: { - briefing: 'briefing-list-id', + splitting: 'splitting-list-id', planning: 'planning-list-id', todo: 'todo-list-id', // no done list diff --git a/tests/unit/triggers/pr-review-submitted.test.ts b/tests/unit/triggers/pr-review-submitted.test.ts index 3b3ab7c7..fa2a6a06 100644 --- a/tests/unit/triggers/pr-review-submitted.test.ts +++ b/tests/unit/triggers/pr-review-submitted.test.ts @@ -24,7 +24,7 @@ describe('PRReviewSubmittedTrigger', () => { trello: { boardId: 'board123', lists: { - briefing: 'briefing-list-id', + splitting: 'splitting-list-id', planning: 'planning-list-id', todo: 'todo-list-id', }, diff --git a/tests/unit/triggers/registry.test.ts b/tests/unit/triggers/registry.test.ts index da43cc05..f8cae82f 100644 --- a/tests/unit/triggers/registry.test.ts +++ b/tests/unit/triggers/registry.test.ts @@ -24,7 +24,7 @@ describe('TriggerRegistry', () => { description: 'Test handler', matches: (ctx) => ctx.source === 'trello', handle: vi.fn().mockResolvedValue({ - agentType: 'briefing', + agentType: 'splitting', agentInput: { cardId: 'card123' }, }), }; @@ -40,7 +40,7 @@ describe('TriggerRegistry', () => { const result = await registry.dispatch(ctx); expect(result).not.toBeNull(); - expect(result?.agentType).toBe('briefing'); + expect(result?.agentType).toBe('splitting'); expect(handler.handle).toHaveBeenCalledWith(ctx); }); @@ -94,7 +94,7 @@ describe('TriggerRegistry', () => { description: 'First', matches: () => true, handle: vi.fn().mockResolvedValue({ - agentType: 'briefing', + agentType: 'splitting', agentInput: {}, }), }; @@ -117,7 +117,7 @@ describe('TriggerRegistry', () => { const result = await registry.dispatch(ctx); - expect(result?.agentType).toBe('briefing'); + expect(result?.agentType).toBe('splitting'); expect(handler1.handle).toHaveBeenCalledWith(ctx); expect(handler2.handle).not.toHaveBeenCalled(); }); diff --git a/tests/unit/triggers/review-requested.test.ts b/tests/unit/triggers/review-requested.test.ts index 6539ca05..b01b38a3 100644 --- a/tests/unit/triggers/review-requested.test.ts +++ b/tests/unit/triggers/review-requested.test.ts @@ -19,7 +19,7 @@ describe('ReviewRequestedTrigger', () => { trello: { boardId: 'board123', lists: { - briefing: 'briefing-list-id', + splitting: 'splitting-list-id', planning: 'planning-list-id', todo: 'todo-list-id', }, diff --git a/tests/unit/triggers/trello-comment-mention.test.ts b/tests/unit/triggers/trello-comment-mention.test.ts index 1261b223..1eb356d1 100644 --- a/tests/unit/triggers/trello-comment-mention.test.ts +++ b/tests/unit/triggers/trello-comment-mention.test.ts @@ -43,7 +43,7 @@ const mockProject = { trello: { boardId: 'board-123', lists: { - briefing: 'briefing-list-id', + splitting: 'splitting-list-id', planning: PLANNING_LIST_ID, todo: 'todo-list-id', }, diff --git a/tests/unit/web/triggerAgentMapping.test.ts b/tests/unit/web/triggerAgentMapping.test.ts index e4972b49..a9a392e3 100644 --- a/tests/unit/web/triggerAgentMapping.test.ts +++ b/tests/unit/web/triggerAgentMapping.test.ts @@ -26,8 +26,8 @@ describe('getTriggersForAgent', () => { } }); - it('returns PM-only triggers for briefing with category: pm and pmProvider: trello', () => { - const triggers = getTriggersForAgent('briefing', { category: 'pm', pmProvider: 'trello' }); + it('returns PM-only triggers for splitting with category: pm and pmProvider: trello', () => { + const triggers = getTriggersForAgent('splitting', { category: 'pm', pmProvider: 'trello' }); expect(triggers.length).toBeGreaterThan(0); for (const t of triggers) { expect(t.category).toBe('pm'); @@ -37,23 +37,23 @@ describe('getTriggersForAgent', () => { } } const keys = triggers.map((t) => t.key); - expect(keys).toContain('cardMovedToBriefing'); - expect(keys).toContain('readyToProcessLabel.briefing'); - expect(keys).not.toContain('issueTransitioned.briefing'); + expect(keys).toContain('cardMovedToSplitting'); + expect(keys).toContain('readyToProcessLabel.splitting'); + expect(keys).not.toContain('issueTransitioned.splitting'); }); - it('returns empty array for briefing with category: scm', () => { - const triggers = getTriggersForAgent('briefing', { category: 'scm' }); + it('returns empty array for splitting with category: scm', () => { + const triggers = getTriggersForAgent('splitting', { category: 'scm' }); expect(triggers).toHaveLength(0); }); it('filters by pmProvider without category', () => { - const jiraTriggers = getTriggersForAgent('briefing', { pmProvider: 'jira' }); - const trelloTriggers = getTriggersForAgent('briefing', { pmProvider: 'trello' }); - // JIRA provider should exclude cardMovedToBriefing (trello-only) - expect(jiraTriggers.map((t) => t.key)).not.toContain('cardMovedToBriefing'); - // Trello provider should exclude issueTransitioned.briefing (jira-only) - expect(trelloTriggers.map((t) => t.key)).not.toContain('issueTransitioned.briefing'); + const jiraTriggers = getTriggersForAgent('splitting', { pmProvider: 'jira' }); + const trelloTriggers = getTriggersForAgent('splitting', { pmProvider: 'trello' }); + // JIRA provider should exclude cardMovedToSplitting (trello-only) + expect(jiraTriggers.map((t) => t.key)).not.toContain('cardMovedToSplitting'); + // Trello provider should exclude issueTransitioned.splitting (jira-only) + expect(trelloTriggers.map((t) => t.key)).not.toContain('issueTransitioned.splitting'); }); it('returns empty array for unknown agent type', () => { diff --git a/tools/run-local.ts b/tools/run-local.ts index 29d41e4e..a383cc3f 100644 --- a/tools/run-local.ts +++ b/tools/run-local.ts @@ -3,7 +3,7 @@ * Run a CASCADE agent locally in Docker against a Trello card or GitHub PR. * * Usage: - * npm run tool:run-local -- briefing https://trello.com/c/abc123/card-name + * npm run tool:run-local -- splitting https://trello.com/c/abc123/card-name * npm run tool:run-local -- implementation abc123 * npm run tool:run-local -- respond-to-review https://github.com/owner/repo/pull/123 * npm run tool:run-local -- planning abc123 --rebuild @@ -17,7 +17,7 @@ import { resolve } from 'node:path'; import { program } from 'commander'; const VALID_AGENTS = [ - 'briefing', + 'splitting', 'planning', 'implementation', 'debug', diff --git a/web/src/components/projects/pm-wizard.tsx b/web/src/components/projects/pm-wizard.tsx index 1d768615..ff47a626 100644 --- a/web/src/components/projects/pm-wizard.tsx +++ b/web/src/components/projects/pm-wizard.tsx @@ -575,7 +575,7 @@ function FieldMappingRow({ // ============================================================================ const TRELLO_LIST_SLOTS = [ - 'briefing', + 'splitting', 'stories', 'planning', 'todo', @@ -589,7 +589,7 @@ const TRELLO_LIST_SLOTS = [ const TRELLO_LABEL_SLOTS = ['readyToProcess', 'processing', 'processed', 'error']; const JIRA_STATUS_SLOTS = [ - 'briefing', + 'splitting', 'planning', 'todo', 'inProgress', diff --git a/web/src/components/projects/project-agent-configs.tsx b/web/src/components/projects/project-agent-configs.tsx index 4e242fab..5f731b64 100644 --- a/web/src/components/projects/project-agent-configs.tsx +++ b/web/src/components/projects/project-agent-configs.tsx @@ -33,7 +33,7 @@ interface AgentConfig { /** Friendly labels for known agent types */ const AGENT_LABELS: Record = { - briefing: 'Briefing', + splitting: 'Splitting', planning: 'Planning', implementation: 'Implementation', review: 'Review', diff --git a/web/src/components/runs/run-filters.tsx b/web/src/components/runs/run-filters.tsx index 287bc2a0..fac28034 100644 --- a/web/src/components/runs/run-filters.tsx +++ b/web/src/components/runs/run-filters.tsx @@ -12,7 +12,7 @@ interface RunFiltersProps { const statuses = ['running', 'completed', 'failed', 'timed_out']; const agentTypes = [ - 'briefing', + 'splitting', 'planning', 'implementation', 'review', diff --git a/web/src/components/runs/trigger-run-dialog.tsx b/web/src/components/runs/trigger-run-dialog.tsx index 7ef40bd7..cf64e11f 100644 --- a/web/src/components/runs/trigger-run-dialog.tsx +++ b/web/src/components/runs/trigger-run-dialog.tsx @@ -15,7 +15,7 @@ import { useCallback, useState } from 'react'; // Keep in sync with AgentType in src/types/index.ts const agentTypes = [ - 'briefing', + 'splitting', 'planning', 'implementation', 'review', diff --git a/web/src/components/settings/agent-config-form-dialog.tsx b/web/src/components/settings/agent-config-form-dialog.tsx index efe262e3..745d5f58 100644 --- a/web/src/components/settings/agent-config-form-dialog.tsx +++ b/web/src/components/settings/agent-config-form-dialog.tsx @@ -93,7 +93,7 @@ export function AgentConfigFormDialog({ open, onOpenChange, config }: AgentConfi id="gac-agentType" value={agentType} onChange={(e) => setAgentType(e.target.value)} - placeholder="e.g. implementation, review, briefing" + placeholder="e.g. implementation, review, splitting" required /> diff --git a/web/src/components/shared/trigger-toggles.tsx b/web/src/components/shared/trigger-toggles.tsx index dab2e6ed..6c9576dc 100644 --- a/web/src/components/shared/trigger-toggles.tsx +++ b/web/src/components/shared/trigger-toggles.tsx @@ -5,8 +5,8 @@ export type { TriggerDef }; /** * Renders a list of trigger toggle checkboxes. - * Supports both flat keys (e.g., "cardMovedToBriefing") and nested dot-notation - * keys (e.g., "readyToProcessLabel.briefing"). + * Supports both flat keys (e.g., "cardMovedToSplitting") and nested dot-notation + * keys (e.g., "readyToProcessLabel.splitting"). */ export function TriggerToggles({ title, diff --git a/web/src/lib/trigger-agent-mapping.ts b/web/src/lib/trigger-agent-mapping.ts index 5179c790..fa263f1d 100644 --- a/web/src/lib/trigger-agent-mapping.ts +++ b/web/src/lib/trigger-agent-mapping.ts @@ -4,7 +4,7 @@ */ export interface TriggerDef { - /** Dot-notation path into the triggers config, e.g. "cardMovedToBriefing" or "readyToProcessLabel.briefing" */ + /** Dot-notation path into the triggers config, e.g. "cardMovedToSplitting" or "readyToProcessLabel.splitting" */ key: string; label: string; description: string; @@ -58,29 +58,29 @@ export const SHARED_PM_TRIGGERS: TriggerDef[] = [ * Map from agent type to the trigger toggles relevant to it. */ export const AGENT_TRIGGER_MAP: Record = { - briefing: [ + splitting: [ { - key: 'cardMovedToBriefing', - label: 'Card moved to Briefing', - description: 'Trigger briefing agent when a card is moved to the Briefing list.', + key: 'cardMovedToSplitting', + label: 'Card moved to Splitting', + description: 'Trigger splitting agent when a card is moved to the Splitting list.', defaultValue: true, pmProvider: 'trello', category: 'pm', }, { - key: 'issueTransitioned.briefing', + key: 'issueTransitioned.splitting', label: 'Issue Transitioned', description: - 'Trigger briefing agent when a JIRA issue transitions to the configured Briefing status.', + 'Trigger splitting agent when a JIRA issue transitions to the configured Splitting status.', defaultValue: true, pmProvider: 'jira', category: 'pm', }, { - key: 'readyToProcessLabel.briefing', + key: 'readyToProcessLabel.splitting', label: 'Ready to Process label', description: - 'Trigger briefing agent when the "Ready to Process" label is added to a card in the Briefing list.', + 'Trigger splitting agent when the "Ready to Process" label is added to a card in the Splitting list.', defaultValue: true, category: 'pm', }, @@ -228,7 +228,7 @@ export function getTriggersForAgent( /** * Get the trigger value from a flat triggers record using dot-notation path. - * e.g. "readyToProcessLabel.briefing" reads triggers.readyToProcessLabel.briefing + * e.g. "readyToProcessLabel.splitting" reads triggers.readyToProcessLabel.splitting */ export function getTriggerValue( triggers: Record, @@ -241,7 +241,7 @@ export function getTriggerValue( if (typeof val === 'boolean') return val; return defaultValue; } - // Nested path (e.g., readyToProcessLabel.briefing) + // Nested path (e.g., readyToProcessLabel.splitting) const [parent, child] = parts; const parentVal = triggers[parent]; if (typeof parentVal === 'boolean') { @@ -267,14 +267,14 @@ export function setTriggerValue( if (parts.length === 1) { return { ...triggers, [key]: value }; } - // Nested path (e.g., readyToProcessLabel.briefing) + // Nested path (e.g., readyToProcessLabel.splitting) const [parent, child] = parts; const parentVal = triggers[parent]; let parentObj: Record = {}; if (typeof parentVal === 'boolean') { // Expand legacy boolean into object β€” apply the boolean value to all agents parentObj = { - briefing: parentVal, + splitting: parentVal, planning: parentVal, implementation: parentVal, }; @@ -291,7 +291,7 @@ export function setTriggerValue( * All known agent types in display order. */ export const ALL_AGENT_TYPES = [ - 'briefing', + 'splitting', 'planning', 'implementation', 'review', From 04a6621fadfdfa76b2774aacccdf1098ef43c3ef Mon Sep 17 00:00:00 2001 From: aaight Date: Tue, 24 Feb 2026 23:10:25 +0100 Subject: [PATCH 06/13] refactor(router): extract platformClients god module into focused sub-modules (#542) --- src/router/acknowledgments.ts | 2 +- src/router/adapters/jira.ts | 2 +- src/router/adapters/trello.ts | 2 +- src/router/notifications.ts | 2 +- src/router/platformClients.ts | 367 ---------------------- src/router/platformClients/credentials.ts | 64 ++++ src/router/platformClients/github.ts | 54 ++++ src/router/platformClients/index.ts | 19 ++ src/router/platformClients/jira.ts | 154 +++++++++ src/router/platformClients/trello.ts | 57 ++++ src/router/platformClients/types.ts | 37 +++ src/router/pre-actions.ts | 2 +- src/router/reactions.ts | 2 +- tests/unit/router/adapters/jira.test.ts | 2 +- tests/unit/router/adapters/trello.test.ts | 2 +- tests/unit/router/platformClients.test.ts | 2 +- 16 files changed, 394 insertions(+), 376 deletions(-) delete mode 100644 src/router/platformClients.ts create mode 100644 src/router/platformClients/credentials.ts create mode 100644 src/router/platformClients/github.ts create mode 100644 src/router/platformClients/index.ts create mode 100644 src/router/platformClients/jira.ts create mode 100644 src/router/platformClients/trello.ts create mode 100644 src/router/platformClients/types.ts diff --git a/src/router/acknowledgments.ts b/src/router/acknowledgments.ts index c0fefaeb..0c4e8998 100644 --- a/src/router/acknowledgments.ts +++ b/src/router/acknowledgments.ts @@ -21,7 +21,7 @@ import { TrelloPlatformClient, resolveJiraCredentials, resolveTrelloCredentials, -} from './platformClients.js'; +} from './platformClients/index.js'; // --------------------------------------------------------------------------- // Trello diff --git a/src/router/adapters/jira.ts b/src/router/adapters/jira.ts index fae60465..cff4f0e1 100644 --- a/src/router/adapters/jira.ts +++ b/src/router/adapters/jira.ts @@ -15,7 +15,7 @@ import { extractJiraContext, generateAckMessage } from '../ackMessageGenerator.j import { postJiraAck, resolveJiraBotAccountId } from '../acknowledgments.js'; import { type RouterProjectConfig, loadProjectConfig } from '../config.js'; import type { AckResult, ParsedWebhookEvent, RouterPlatformAdapter } from '../platform-adapter.js'; -import { resolveJiraCredentials } from '../platformClients.js'; +import { resolveJiraCredentials } from '../platformClients/index.js'; import type { CascadeJob, JiraJob } from '../queue.js'; import { sendAcknowledgeReaction } from '../reactions.js'; diff --git a/src/router/adapters/trello.ts b/src/router/adapters/trello.ts index a215b6c1..cb20ea66 100644 --- a/src/router/adapters/trello.ts +++ b/src/router/adapters/trello.ts @@ -15,7 +15,7 @@ import { extractTrelloContext, generateAckMessage } from '../ackMessageGenerator import { postTrelloAck } from '../acknowledgments.js'; import { type RouterProjectConfig, loadProjectConfig } from '../config.js'; import type { AckResult, ParsedWebhookEvent, RouterPlatformAdapter } from '../platform-adapter.js'; -import { resolveTrelloCredentials } from '../platformClients.js'; +import { resolveTrelloCredentials } from '../platformClients/index.js'; import type { CascadeJob, TrelloJob } from '../queue.js'; import { sendAcknowledgeReaction } from '../reactions.js'; import { diff --git a/src/router/notifications.ts b/src/router/notifications.ts index d2284ba2..80d7d7c1 100644 --- a/src/router/notifications.ts +++ b/src/router/notifications.ts @@ -5,7 +5,7 @@ import { GitHubPlatformClient, JiraPlatformClient, TrelloPlatformClient, -} from './platformClients.js'; +} from './platformClients/index.js'; import type { CascadeJob, GitHubJob, JiraJob, TrelloJob } from './queue.js'; /** diff --git a/src/router/platformClients.ts b/src/router/platformClients.ts deleted file mode 100644 index be57c7ef..00000000 --- a/src/router/platformClients.ts +++ /dev/null @@ -1,367 +0,0 @@ -/** - * Shared credential resolution and platform API header helpers for router modules. - * - * Resolves credentials once per call and returns typed objects. - * Callers use raw `fetch()` β€” the router Docker image does not bundle - * `src/trello/client.ts` or `src/github/client.ts`. - * - * Also exports `PlatformCommentClient` β€” a unified abstraction that eliminates - * the repeated "resolve creds β†’ build URL β†’ fetch β†’ log" pattern across - * acknowledgments.ts, notifications.ts, and reactions.ts. - */ - -import { findProjectById, getIntegrationCredential } from '../config/provider.js'; -import type { JiraCredentials } from '../jira/types.js'; -import { getJiraConfig } from '../pm/config.js'; -import type { TrelloCredentials } from '../trello/types.js'; -import { logger } from '../utils/logging.js'; - -// --------------------------------------------------------------------------- -// Credential resolution helpers -// --------------------------------------------------------------------------- - -export type { TrelloCredentials }; - -/** Extends JiraCredentials with a pre-computed Base64 Basic auth header value. */ -export interface JiraCredentialsWithAuth extends JiraCredentials { - /** Pre-computed Base64 Basic auth value: `email:apiToken` */ - auth: string; -} - -/** - * Resolve Trello credentials for a project. - * Returns `{ apiKey, token }` or `null` if credentials are missing. - */ -export async function resolveTrelloCredentials( - projectId: string, -): Promise { - try { - const apiKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); - const token = await getIntegrationCredential(projectId, 'pm', 'token'); - return { apiKey, token }; - } catch { - return null; - } -} - -/** - * Resolve JIRA credentials for a project. - * Returns `{ email, apiToken, baseUrl, auth }` or `null` if credentials/config are missing. - * The `auth` field is the pre-computed Base64 Basic auth string. - */ -export async function resolveJiraCredentials( - projectId: string, -): Promise { - try { - const email = await getIntegrationCredential(projectId, 'pm', 'email'); - const apiToken = await getIntegrationCredential(projectId, 'pm', 'api_token'); - const project = await findProjectById(projectId); - const baseUrl = (project ? getJiraConfig(project)?.baseUrl : undefined) ?? ''; - if (!baseUrl) throw new Error('Missing JIRA base URL'); - const auth = Buffer.from(`${email}:${apiToken}`).toString('base64'); - return { email, apiToken, baseUrl, auth }; - } catch { - return null; - } -} - -/** - * Build standard GitHub API request headers for a given token. - * Used in place of the 6+ inline header objects scattered across router files. - */ -export function resolveGitHubHeaders( - token: string, - extra?: Record, -): Record { - return { - Authorization: `Bearer ${token}`, - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - ...extra, - }; -} - -// --------------------------------------------------------------------------- -// PlatformCommentClient β€” unified abstraction for cross-platform comments -// --------------------------------------------------------------------------- - -/** - * Unified interface for posting and deleting comments and reactions across - * GitHub and JIRA. Implementations are fire-and-forget safe β€” they never - * throw; all errors (including network failures) are caught and logged internally. - */ -export interface PlatformCommentClient { - /** - * Post a comment. Returns the new comment's ID (string or number) on - * success, or `null` on any failure. - */ - postComment(target: string, message: string): Promise; - - /** - * Delete a previously-posted comment by ID. - * Silently returns on missing credentials or any failure. - */ - deleteComment(target: string, commentId: string | number): Promise; - - /** - * Post a reaction on a comment / action. - * Silently returns on missing credentials or any failure. - */ - postReaction?(target: string, reactionPayload: unknown): Promise; -} - -// --------------------------------------------------------------------------- -// TrelloPlatformClient -// --------------------------------------------------------------------------- - -export class TrelloPlatformClient implements PlatformCommentClient { - constructor(private readonly projectId: string) {} - - async postComment(cardId: string, message: string): Promise { - const creds = await resolveTrelloCredentials(this.projectId); - if (!creds) { - logger.warn('[PlatformClient] Missing Trello credentials, skipping comment'); - return null; - } - - try { - const url = `https://api.trello.com/1/cards/${cardId}/actions/comments?key=${creds.apiKey}&token=${creds.token}`; - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text: message }), - }); - - if (!response.ok) { - logger.warn( - '[PlatformClient] Trello comment failed:', - response.status, - await response.text(), - ); - return null; - } - - const data = (await response.json()) as { id?: string }; - logger.info('[PlatformClient] Trello comment posted for card:', cardId); - return data.id ?? null; - } catch (err) { - logger.warn('[PlatformClient] Failed to post Trello comment:', String(err)); - return null; - } - } - - async deleteComment(cardId: string, commentId: string): Promise { - const creds = await resolveTrelloCredentials(this.projectId); - if (!creds) return; - - const url = `https://api.trello.com/1/cards/${cardId}/actions/${commentId}/comments?key=${creds.apiKey}&token=${creds.token}`; - try { - await fetch(url, { method: 'DELETE' }); - logger.info('[PlatformClient] Trello comment deleted:', commentId); - } catch (err) { - logger.warn('[PlatformClient] Failed to delete Trello comment:', String(err)); - } - } -} - -// --------------------------------------------------------------------------- -// GitHubPlatformClient -// --------------------------------------------------------------------------- - -export class GitHubPlatformClient implements PlatformCommentClient { - constructor( - private readonly repoFullName: string, - private readonly token: string, - ) {} - - async postComment(prNumber: string | number, message: string): Promise { - try { - const url = `https://api.github.com/repos/${this.repoFullName}/issues/${prNumber}/comments`; - const response = await fetch(url, { - method: 'POST', - headers: resolveGitHubHeaders(this.token, { 'Content-Type': 'application/json' }), - body: JSON.stringify({ body: message }), - }); - - if (!response.ok) { - logger.warn( - '[PlatformClient] GitHub comment failed:', - response.status, - await response.text(), - ); - return null; - } - - const data = (await response.json()) as { id?: number }; - logger.info('[PlatformClient] GitHub comment posted for PR:', prNumber); - return data.id ?? null; - } catch (err) { - logger.warn('[PlatformClient] Failed to post GitHub comment:', String(err)); - return null; - } - } - - async deleteComment(_target: string, commentId: number): Promise { - const url = `https://api.github.com/repos/${this.repoFullName}/issues/comments/${commentId}`; - try { - await fetch(url, { - method: 'DELETE', - headers: resolveGitHubHeaders(this.token), - }); - logger.info('[PlatformClient] GitHub comment deleted:', commentId); - } catch (err) { - logger.warn('[PlatformClient] Failed to delete GitHub comment:', String(err)); - } - } -} - -// --------------------------------------------------------------------------- -// JiraPlatformClient -// --------------------------------------------------------------------------- - -/** In-memory JIRA CloudId cache keyed by baseUrl */ -const _jiraCloudIdCache = new Map(); - -/** @internal Visible for testing only */ -export function _resetJiraCloudIdCache(): void { - _jiraCloudIdCache.clear(); -} - -export class JiraPlatformClient implements PlatformCommentClient { - constructor(private readonly projectId: string) {} - - async postComment(issueKey: string, message: string): Promise { - const creds = await resolveJiraCredentials(this.projectId); - if (!creds) { - logger.warn('[PlatformClient] Missing JIRA credentials, skipping comment'); - return null; - } - - try { - const url = `${creds.baseUrl}/rest/api/2/issue/${issueKey}/comment`; - const response = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Basic ${creds.auth}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ body: message }), - }); - - if (!response.ok) { - logger.warn( - '[PlatformClient] JIRA comment failed:', - response.status, - await response.text(), - ); - return null; - } - - const data = (await response.json()) as { id?: string }; - logger.info('[PlatformClient] JIRA comment posted for issue:', issueKey); - return data.id ?? null; - } catch (err) { - logger.warn('[PlatformClient] Failed to post JIRA comment:', String(err)); - return null; - } - } - - async deleteComment(issueKey: string, commentId: string): Promise { - const creds = await resolveJiraCredentials(this.projectId); - if (!creds) return; - - const url = `${creds.baseUrl}/rest/api/2/issue/${issueKey}/comment/${commentId}`; - try { - await fetch(url, { - method: 'DELETE', - headers: { - Authorization: `Basic ${creds.auth}`, - 'Content-Type': 'application/json', - }, - }); - logger.info('[PlatformClient] JIRA comment deleted:', commentId); - } catch (err) { - logger.warn('[PlatformClient] Failed to delete JIRA comment:', String(err)); - } - } - - /** - * Post a JIRA reactions-API reaction on a comment. - * `target` is ignored (cloudId is resolved internally from credentials). - * `reactionPayload` is `{ issueId, commentId }`. - */ - async postReaction( - _target: string, - reactionPayload: { issueId: string; commentId: string }, - ): Promise { - const creds = await resolveJiraCredentials(this.projectId); - if (!creds) { - logger.warn('[PlatformClient] Missing JIRA credentials, skipping reaction'); - return; - } - - const cloudId = await this._getCloudId(creds.baseUrl, creds.auth); - if (!cloudId) return; - - try { - const { issueId, commentId } = reactionPayload; - const emojiId = 'atlassian-thought_balloon'; - const ari = `ari%3Acloud%3Ajira%3A${cloudId}%3Acomment%2F${issueId}%2F${commentId}`; - const reactionsUrl = `${creds.baseUrl}/rest/reactions/1.0/reactions/${ari}/${emojiId}`; - - const reactionResponse = await fetch(reactionsUrl, { - method: 'PUT', - headers: { - Authorization: `Basic ${creds.auth}`, - 'Content-Type': 'application/json', - }, - }); - - if (reactionResponse.ok) { - logger.info('[PlatformClient] JIRA reaction sent for comment:', commentId); - } else { - logger.warn( - '[PlatformClient] JIRA reactions API failed:', - reactionResponse.status, - 'β€” skipping (no fallback to avoid webhook loops)', - ); - } - } catch (err) { - logger.warn('[PlatformClient] Failed to post JIRA reaction:', String(err)); - } - } - - private async _getCloudId(baseUrl: string, auth: string): Promise { - const cached = _jiraCloudIdCache.get(baseUrl); - if (cached) return cached; - - let response: Response; - try { - response = await fetch(`${baseUrl}/_edge/tenant_info`, { - headers: { Authorization: `Basic ${auth}` }, - }); - } catch (err) { - logger.warn('[PlatformClient] Failed to fetch JIRA cloudId:', String(err)); - return null; - } - - if (!response.ok) { - logger.warn('[PlatformClient] JIRA tenant_info returned', response.status); - return null; - } - - const data = (await response.json()) as { cloudId?: string }; - if (!data.cloudId) { - logger.warn('[PlatformClient] JIRA tenant_info missing cloudId'); - return null; - } - - _jiraCloudIdCache.set(baseUrl, data.cloudId); - return data.cloudId; - } - - /** @internal Visible for testing only */ - static _reset(): void { - _jiraCloudIdCache.clear(); - } -} diff --git a/src/router/platformClients/credentials.ts b/src/router/platformClients/credentials.ts new file mode 100644 index 00000000..e2671cc0 --- /dev/null +++ b/src/router/platformClients/credentials.ts @@ -0,0 +1,64 @@ +/** + * Credential resolution helpers for router platform clients. + * + * Resolves credentials once per call and returns typed objects. + * Callers use raw `fetch()` β€” the router Docker image does not bundle + * `src/trello/client.ts` or `src/github/client.ts`. + */ + +import { findProjectById, getIntegrationCredential } from '../../config/provider.js'; +import { getJiraConfig } from '../../pm/config.js'; +import type { JiraCredentialsWithAuth, TrelloCredentials } from './types.js'; + +/** + * Resolve Trello credentials for a project. + * Returns `{ apiKey, token }` or `null` if credentials are missing. + */ +export async function resolveTrelloCredentials( + projectId: string, +): Promise { + try { + const apiKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); + const token = await getIntegrationCredential(projectId, 'pm', 'token'); + return { apiKey, token }; + } catch { + return null; + } +} + +/** + * Resolve JIRA credentials for a project. + * Returns `{ email, apiToken, baseUrl, auth }` or `null` if credentials/config are missing. + * The `auth` field is the pre-computed Base64 Basic auth string. + */ +export async function resolveJiraCredentials( + projectId: string, +): Promise { + try { + const email = await getIntegrationCredential(projectId, 'pm', 'email'); + const apiToken = await getIntegrationCredential(projectId, 'pm', 'api_token'); + const project = await findProjectById(projectId); + const baseUrl = (project ? getJiraConfig(project)?.baseUrl : undefined) ?? ''; + if (!baseUrl) throw new Error('Missing JIRA base URL'); + const auth = Buffer.from(`${email}:${apiToken}`).toString('base64'); + return { email, apiToken, baseUrl, auth }; + } catch { + return null; + } +} + +/** + * Build standard GitHub API request headers for a given token. + * Used in place of the 6+ inline header objects scattered across router files. + */ +export function resolveGitHubHeaders( + token: string, + extra?: Record, +): Record { + return { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + ...extra, + }; +} diff --git a/src/router/platformClients/github.ts b/src/router/platformClients/github.ts new file mode 100644 index 00000000..50cdc5d1 --- /dev/null +++ b/src/router/platformClients/github.ts @@ -0,0 +1,54 @@ +/** + * GitHub platform client for posting/deleting PR/issue comments via the GitHub REST API. + */ + +import { logger } from '../../utils/logging.js'; +import { resolveGitHubHeaders } from './credentials.js'; +import type { PlatformCommentClient } from './types.js'; + +export class GitHubPlatformClient implements PlatformCommentClient { + constructor( + private readonly repoFullName: string, + private readonly token: string, + ) {} + + async postComment(prNumber: string | number, message: string): Promise { + try { + const url = `https://api.github.com/repos/${this.repoFullName}/issues/${prNumber}/comments`; + const response = await fetch(url, { + method: 'POST', + headers: resolveGitHubHeaders(this.token, { 'Content-Type': 'application/json' }), + body: JSON.stringify({ body: message }), + }); + + if (!response.ok) { + logger.warn( + '[PlatformClient] GitHub comment failed:', + response.status, + await response.text(), + ); + return null; + } + + const data = (await response.json()) as { id?: number }; + logger.info('[PlatformClient] GitHub comment posted for PR:', prNumber); + return data.id ?? null; + } catch (err) { + logger.warn('[PlatformClient] Failed to post GitHub comment:', String(err)); + return null; + } + } + + async deleteComment(_target: string, commentId: number): Promise { + const url = `https://api.github.com/repos/${this.repoFullName}/issues/comments/${commentId}`; + try { + await fetch(url, { + method: 'DELETE', + headers: resolveGitHubHeaders(this.token), + }); + logger.info('[PlatformClient] GitHub comment deleted:', commentId); + } catch (err) { + logger.warn('[PlatformClient] Failed to delete GitHub comment:', String(err)); + } + } +} diff --git a/src/router/platformClients/index.ts b/src/router/platformClients/index.ts new file mode 100644 index 00000000..e0c5efe4 --- /dev/null +++ b/src/router/platformClients/index.ts @@ -0,0 +1,19 @@ +/** + * Barrel export for the platform clients sub-module. + * + * Re-exports all public symbols from the focused sub-modules, preserving the + * same public API surface as the original `platformClients.ts` monolith. + * All existing imports (`from './platformClients.js'`) continue to work + * unchanged since Node.js resolves `./platformClients/index.js` from the + * directory path. + */ + +export type { JiraCredentialsWithAuth, PlatformCommentClient, TrelloCredentials } from './types.js'; +export { + resolveGitHubHeaders, + resolveJiraCredentials, + resolveTrelloCredentials, +} from './credentials.js'; +export { TrelloPlatformClient } from './trello.js'; +export { GitHubPlatformClient } from './github.js'; +export { JiraPlatformClient, _resetJiraCloudIdCache } from './jira.js'; diff --git a/src/router/platformClients/jira.ts b/src/router/platformClients/jira.ts new file mode 100644 index 00000000..2445bb49 --- /dev/null +++ b/src/router/platformClients/jira.ts @@ -0,0 +1,154 @@ +/** + * JIRA platform client for posting/deleting comments and reactions via the JIRA REST API. + */ + +import { logger } from '../../utils/logging.js'; +import { resolveJiraCredentials } from './credentials.js'; +import type { PlatformCommentClient } from './types.js'; + +/** In-memory JIRA CloudId cache keyed by baseUrl */ +const _jiraCloudIdCache = new Map(); + +/** @internal Visible for testing only */ +export function _resetJiraCloudIdCache(): void { + _jiraCloudIdCache.clear(); +} + +export class JiraPlatformClient implements PlatformCommentClient { + constructor(private readonly projectId: string) {} + + async postComment(issueKey: string, message: string): Promise { + const creds = await resolveJiraCredentials(this.projectId); + if (!creds) { + logger.warn('[PlatformClient] Missing JIRA credentials, skipping comment'); + return null; + } + + try { + const url = `${creds.baseUrl}/rest/api/2/issue/${issueKey}/comment`; + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Basic ${creds.auth}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ body: message }), + }); + + if (!response.ok) { + logger.warn( + '[PlatformClient] JIRA comment failed:', + response.status, + await response.text(), + ); + return null; + } + + const data = (await response.json()) as { id?: string }; + logger.info('[PlatformClient] JIRA comment posted for issue:', issueKey); + return data.id ?? null; + } catch (err) { + logger.warn('[PlatformClient] Failed to post JIRA comment:', String(err)); + return null; + } + } + + async deleteComment(issueKey: string, commentId: string): Promise { + const creds = await resolveJiraCredentials(this.projectId); + if (!creds) return; + + const url = `${creds.baseUrl}/rest/api/2/issue/${issueKey}/comment/${commentId}`; + try { + await fetch(url, { + method: 'DELETE', + headers: { + Authorization: `Basic ${creds.auth}`, + 'Content-Type': 'application/json', + }, + }); + logger.info('[PlatformClient] JIRA comment deleted:', commentId); + } catch (err) { + logger.warn('[PlatformClient] Failed to delete JIRA comment:', String(err)); + } + } + + /** + * Post a JIRA reactions-API reaction on a comment. + * `target` is ignored (cloudId is resolved internally from credentials). + * `reactionPayload` is `{ issueId, commentId }`. + */ + async postReaction( + _target: string, + reactionPayload: { issueId: string; commentId: string }, + ): Promise { + const creds = await resolveJiraCredentials(this.projectId); + if (!creds) { + logger.warn('[PlatformClient] Missing JIRA credentials, skipping reaction'); + return; + } + + const cloudId = await this._getCloudId(creds.baseUrl, creds.auth); + if (!cloudId) return; + + try { + const { issueId, commentId } = reactionPayload; + const emojiId = 'atlassian-thought_balloon'; + const ari = `ari%3Acloud%3Ajira%3A${cloudId}%3Acomment%2F${issueId}%2F${commentId}`; + const reactionsUrl = `${creds.baseUrl}/rest/reactions/1.0/reactions/${ari}/${emojiId}`; + + const reactionResponse = await fetch(reactionsUrl, { + method: 'PUT', + headers: { + Authorization: `Basic ${creds.auth}`, + 'Content-Type': 'application/json', + }, + }); + + if (reactionResponse.ok) { + logger.info('[PlatformClient] JIRA reaction sent for comment:', commentId); + } else { + logger.warn( + '[PlatformClient] JIRA reactions API failed:', + reactionResponse.status, + 'β€” skipping (no fallback to avoid webhook loops)', + ); + } + } catch (err) { + logger.warn('[PlatformClient] Failed to post JIRA reaction:', String(err)); + } + } + + private async _getCloudId(baseUrl: string, auth: string): Promise { + const cached = _jiraCloudIdCache.get(baseUrl); + if (cached) return cached; + + let response: Response; + try { + response = await fetch(`${baseUrl}/_edge/tenant_info`, { + headers: { Authorization: `Basic ${auth}` }, + }); + } catch (err) { + logger.warn('[PlatformClient] Failed to fetch JIRA cloudId:', String(err)); + return null; + } + + if (!response.ok) { + logger.warn('[PlatformClient] JIRA tenant_info returned', response.status); + return null; + } + + const data = (await response.json()) as { cloudId?: string }; + if (!data.cloudId) { + logger.warn('[PlatformClient] JIRA tenant_info missing cloudId'); + return null; + } + + _jiraCloudIdCache.set(baseUrl, data.cloudId); + return data.cloudId; + } + + /** @internal Visible for testing only */ + static _reset(): void { + _jiraCloudIdCache.clear(); + } +} diff --git a/src/router/platformClients/trello.ts b/src/router/platformClients/trello.ts new file mode 100644 index 00000000..8e01d462 --- /dev/null +++ b/src/router/platformClients/trello.ts @@ -0,0 +1,57 @@ +/** + * Trello platform client for posting/deleting comments via the Trello REST API. + */ + +import { logger } from '../../utils/logging.js'; +import { resolveTrelloCredentials } from './credentials.js'; +import type { PlatformCommentClient } from './types.js'; + +export class TrelloPlatformClient implements PlatformCommentClient { + constructor(private readonly projectId: string) {} + + async postComment(cardId: string, message: string): Promise { + const creds = await resolveTrelloCredentials(this.projectId); + if (!creds) { + logger.warn('[PlatformClient] Missing Trello credentials, skipping comment'); + return null; + } + + try { + const url = `https://api.trello.com/1/cards/${cardId}/actions/comments?key=${creds.apiKey}&token=${creds.token}`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: message }), + }); + + if (!response.ok) { + logger.warn( + '[PlatformClient] Trello comment failed:', + response.status, + await response.text(), + ); + return null; + } + + const data = (await response.json()) as { id?: string }; + logger.info('[PlatformClient] Trello comment posted for card:', cardId); + return data.id ?? null; + } catch (err) { + logger.warn('[PlatformClient] Failed to post Trello comment:', String(err)); + return null; + } + } + + async deleteComment(cardId: string, commentId: string): Promise { + const creds = await resolveTrelloCredentials(this.projectId); + if (!creds) return; + + const url = `https://api.trello.com/1/cards/${cardId}/actions/${commentId}/comments?key=${creds.apiKey}&token=${creds.token}`; + try { + await fetch(url, { method: 'DELETE' }); + logger.info('[PlatformClient] Trello comment deleted:', commentId); + } catch (err) { + logger.warn('[PlatformClient] Failed to delete Trello comment:', String(err)); + } + } +} diff --git a/src/router/platformClients/types.ts b/src/router/platformClients/types.ts new file mode 100644 index 00000000..50d91793 --- /dev/null +++ b/src/router/platformClients/types.ts @@ -0,0 +1,37 @@ +/** + * Shared types for the platform client abstraction layer. + */ + +import type { JiraCredentials } from '../../jira/types.js'; +export type { TrelloCredentials } from '../../trello/types.js'; + +/** Extends JiraCredentials with a pre-computed Base64 Basic auth header value. */ +export interface JiraCredentialsWithAuth extends JiraCredentials { + /** Pre-computed Base64 Basic auth value: `email:apiToken` */ + auth: string; +} + +/** + * Unified interface for posting and deleting comments and reactions across + * GitHub and JIRA. Implementations are fire-and-forget safe β€” they never + * throw; all errors (including network failures) are caught and logged internally. + */ +export interface PlatformCommentClient { + /** + * Post a comment. Returns the new comment's ID (string or number) on + * success, or `null` on any failure. + */ + postComment(target: string, message: string): Promise; + + /** + * Delete a previously-posted comment by ID. + * Silently returns on missing credentials or any failure. + */ + deleteComment(target: string, commentId: string | number): Promise; + + /** + * Post a reaction on a comment / action. + * Silently returns on missing credentials or any failure. + */ + postReaction?(target: string, reactionPayload: unknown): Promise; +} diff --git a/src/router/pre-actions.ts b/src/router/pre-actions.ts index d10e8a0e..114bd14b 100644 --- a/src/router/pre-actions.ts +++ b/src/router/pre-actions.ts @@ -1,7 +1,7 @@ import { findProjectByRepo, getIntegrationCredential } from '../config/provider.js'; import { logger } from '../utils/logging.js'; import { parseRepoFullName } from '../utils/repo.js'; -import { resolveGitHubHeaders } from './platformClients.js'; +import { resolveGitHubHeaders } from './platformClients/index.js'; import type { GitHubJob } from './queue.js'; /** diff --git a/src/router/reactions.ts b/src/router/reactions.ts index c12c4a01..9f871e00 100644 --- a/src/router/reactions.ts +++ b/src/router/reactions.ts @@ -19,7 +19,7 @@ import { _resetJiraCloudIdCache, resolveGitHubHeaders, resolveTrelloCredentials, -} from './platformClients.js'; +} from './platformClients/index.js'; /** @internal Visible for testing only β€” re-exported from JiraPlatformClient */ export { _resetJiraCloudIdCache }; diff --git a/tests/unit/router/adapters/jira.test.ts b/tests/unit/router/adapters/jira.test.ts index cee1bc9a..056a240a 100644 --- a/tests/unit/router/adapters/jira.test.ts +++ b/tests/unit/router/adapters/jira.test.ts @@ -26,7 +26,7 @@ vi.mock('../../../../src/router/ackMessageGenerator.js', () => ({ extractJiraContext: vi.fn().mockReturnValue('Issue: PROJ-1'), generateAckMessage: vi.fn().mockResolvedValue('Working on it...'), })); -vi.mock('../../../../src/router/platformClients.js', () => ({ +vi.mock('../../../../src/router/platformClients/index.js', () => ({ resolveJiraCredentials: vi.fn().mockResolvedValue({ email: 'bot@example.com', apiToken: 'tok', diff --git a/tests/unit/router/adapters/trello.test.ts b/tests/unit/router/adapters/trello.test.ts index d07765aa..beb4048e 100644 --- a/tests/unit/router/adapters/trello.test.ts +++ b/tests/unit/router/adapters/trello.test.ts @@ -26,7 +26,7 @@ vi.mock('../../../../src/router/ackMessageGenerator.js', () => ({ extractTrelloContext: vi.fn().mockReturnValue('Card: Test card'), generateAckMessage: vi.fn().mockResolvedValue('Starting implementation...'), })); -vi.mock('../../../../src/router/platformClients.js', () => ({ +vi.mock('../../../../src/router/platformClients/index.js', () => ({ resolveTrelloCredentials: vi.fn().mockResolvedValue({ apiKey: 'key', token: 'tok' }), })); vi.mock('../../../../src/trello/client.js', () => ({ diff --git a/tests/unit/router/platformClients.test.ts b/tests/unit/router/platformClients.test.ts index 85c5c6de..0291ccf7 100644 --- a/tests/unit/router/platformClients.test.ts +++ b/tests/unit/router/platformClients.test.ts @@ -35,7 +35,7 @@ import { resolveGitHubHeaders, resolveJiraCredentials, resolveTrelloCredentials, -} from '../../../src/router/platformClients.js'; +} from '../../../src/router/platformClients/index.js'; import { logger } from '../../../src/utils/logging.js'; const mockLogger = vi.mocked(logger); From 58d17d99e57279959f866017bdbe150644f64623 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Wed, 25 Feb 2026 10:54:25 +0100 Subject: [PATCH 07/13] fix(migration): guard runs table update for environments without it (#545) The 0015 migration failed on dev because it references the `runs` table which doesn't exist in all environments. Wrap the UPDATE in a DO block that checks for table existence first. Co-authored-by: Claude Opus 4.6 --- .../migrations/0015_rename_briefing_to_splitting.sql | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/db/migrations/0015_rename_briefing_to_splitting.sql b/src/db/migrations/0015_rename_briefing_to_splitting.sql index f62a7fbb..507a0b94 100644 --- a/src/db/migrations/0015_rename_briefing_to_splitting.sql +++ b/src/db/migrations/0015_rename_briefing_to_splitting.sql @@ -6,10 +6,13 @@ UPDATE agent_configs SET agent_type = 'splitting' WHERE agent_type = 'briefing'; --- 2. Update runs table -UPDATE runs -SET agent_type = 'splitting' -WHERE agent_type = 'briefing'; +-- 2. Update runs table (if it exists β€” some environments use external run storage) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'runs') THEN + UPDATE runs SET agent_type = 'splitting' WHERE agent_type = 'briefing'; + END IF; +END $$; -- 3. Update project_integrations JSONB fields From 1db99c306cd339a96d34a9806742ba1b7f62ef30 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Wed, 25 Feb 2026 11:16:58 +0100 Subject: [PATCH 08/13] fix(jira): resolve missing stories status, storiesListId, and progress model context (#546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The splitting agent on JIRA projects failed because: 1. The PM wizard's JIRA_STATUS_SLOTS omitted 'stories', so users couldn't configure the stories status mapping. 2. promptContext.ts only read storiesListId from Trello config, leaving it undefined for JIRA projects β€” the agent hallucinated a container ID. 3. The progress model had no agent-role context, generating misleading messages like "split main.py" instead of referencing work item breakdown. Changes: - Add 'stories' to JIRA_STATUS_SLOTS in pm-wizard - Fall back to JIRA project key for storiesListId in promptContext - Auto-transition new JIRA issues to stories status after creation - Extract AGENT_ROLE_HINTS to shared agentMessages.ts, wire into both ackMessageGenerator and progressModel - Add tests for all new behavior Co-authored-by: Claude Opus 4.6 --- src/agents/shared/promptContext.ts | 4 +- src/backends/progressModel.ts | 3 +- src/config/agentMessages.ts | 19 ++++++++ src/pm/jira/adapter.ts | 15 ++++++ src/router/ackMessageGenerator.ts | 5 +- .../unit/agents/shared/promptContext.test.ts | 14 ++++++ tests/unit/backends/progressModel.test.ts | 22 +++++++++ tests/unit/pm/jira/adapter.test.ts | 47 +++++++++++++++++++ tests/unit/router/ackMessageGenerator.test.ts | 5 ++ web/src/components/projects/pm-wizard.tsx | 1 + 10 files changed, 130 insertions(+), 5 deletions(-) diff --git a/src/agents/shared/promptContext.ts b/src/agents/shared/promptContext.ts index c75252a6..b769d530 100644 --- a/src/agents/shared/promptContext.ts +++ b/src/agents/shared/promptContext.ts @@ -1,4 +1,4 @@ -import { getTrelloConfig } from '../../pm/config.js'; +import { getJiraConfig, getTrelloConfig } from '../../pm/config.js'; import { getPMProvider } from '../../pm/index.js'; import type { ProjectConfig } from '../../types/index.js'; import type { PromptContext } from '../prompts/index.js'; @@ -30,7 +30,7 @@ export function buildPromptContext( cardUrl: cardId ? pmProvider.getWorkItemUrl(cardId) : undefined, projectId: project.id, baseBranch: project.baseBranch, - storiesListId: getTrelloConfig(project)?.lists?.stories, + storiesListId: getTrelloConfig(project)?.lists?.stories ?? getJiraConfig(project)?.projectKey, processedLabelId: getTrelloConfig(project)?.labels?.processed, pmType: pmProvider.type, workItemNoun: isJira ? 'issue' : 'card', diff --git a/src/backends/progressModel.ts b/src/backends/progressModel.ts index 22bd7241..5fa7ed33 100644 --- a/src/backends/progressModel.ts +++ b/src/backends/progressModel.ts @@ -7,7 +7,7 @@ import { LLMist, type ModelSpec } from 'llmist'; -import { getAgentLabel } from '../config/agentMessages.js'; +import { AGENT_ROLE_HINTS, getAgentLabel } from '../config/agentMessages.js'; import type { Todo } from '../gadgets/todo/storage.js'; export interface ProgressContext { @@ -40,6 +40,7 @@ function formatProgressUserPrompt(context: ProgressContext): string { const sections: string[] = [ `Agent: ${agentType}`, + `Agent role: ${AGENT_ROLE_HINTS[agentType] ?? 'Processes the request'}`, `Progress header: **${emoji} ${label}** (${Math.round(elapsedMinutes)} min)`, `Task: ${taskDescription.slice(0, 500)}`, `Time elapsed: ${Math.round(elapsedMinutes)} minutes`, diff --git a/src/config/agentMessages.ts b/src/config/agentMessages.ts index 00bde289..127e8e41 100644 --- a/src/config/agentMessages.ts +++ b/src/config/agentMessages.ts @@ -25,6 +25,25 @@ export function getAgentLabel(agentType: string): { emoji: string; label: string return AGENT_LABELS[agentType] ?? { emoji: 'βš™οΈ', label: 'Progress Update' }; } +/** + * Agent role hints β€” give LLMs context about what each agent type does. + * + * Used by: + * - ackMessageGenerator.ts β€” contextual acknowledgment messages + * - progressModel.ts β€” progress update generation + */ +export const AGENT_ROLE_HINTS: Record = { + splitting: 'Breaks down a feature plan into smaller, ordered work items (subtasks)', + planning: 'Studies the codebase and designs a step-by-step implementation plan', + implementation: 'Writes code, runs tests, and prepares a pull request', + review: 'Reviews pull request changes for quality and correctness', + 'respond-to-planning-comment': 'Reads user feedback and updates the plan accordingly', + 'respond-to-review': 'Addresses code review feedback by making requested changes', + 'respond-to-pr-comment': 'Reads a PR comment and takes action', + 'respond-to-ci': 'Analyzes failed CI checks and works on a fix', + debug: 'Analyzes session logs to identify what went wrong', +}; + /** * Human-readable initial messages per agent type. * diff --git a/src/pm/jira/adapter.ts b/src/pm/jira/adapter.ts index 34786c4d..ef906daf 100644 --- a/src/pm/jira/adapter.ts +++ b/src/pm/jira/adapter.ts @@ -149,6 +149,21 @@ export class JiraPMProvider implements PMProvider { ...(config.labels?.length ? { labels: config.labels } : {}), }); const key = result.key ?? ''; + + // Transition to stories status if configured (mirrors Trello's stories list) + const storiesStatus = this.config.statuses?.stories; + if (storiesStatus) { + try { + await this.moveWorkItem(key, storiesStatus); + } catch (err) { + logger.warn('[JIRA] Failed to transition new issue to stories status', { + issueKey: key, + targetStatus: storiesStatus, + error: String(err), + }); + } + } + return { id: key, title: config.title, diff --git a/src/router/ackMessageGenerator.ts b/src/router/ackMessageGenerator.ts index b856f266..7e400d1a 100644 --- a/src/router/ackMessageGenerator.ts +++ b/src/router/ackMessageGenerator.ts @@ -8,7 +8,7 @@ import { LLMist, type ModelSpec } from 'llmist'; -import { INITIAL_MESSAGES } from '../config/agentMessages.js'; +import { AGENT_ROLE_HINTS, INITIAL_MESSAGES } from '../config/agentMessages.js'; import { CUSTOM_MODELS } from '../config/customModels.js'; import { getOrgCredential, loadConfig } from '../config/provider.js'; import { logger } from '../utils/logging.js'; @@ -240,7 +240,8 @@ async function callAckModel( contextSnippet: string, ): Promise { const client = new LLMist({ customModels: CUSTOM_MODELS as ModelSpec[] }); - const userPrompt = `Agent type: ${agentType}\n\nRequest context:\n${contextSnippet}`; + const roleHint = AGENT_ROLE_HINTS[agentType] ?? 'Processes the request'; + const userPrompt = `Agent type: ${agentType}\nAgent role: ${roleHint}\n\nRequest context:\n${contextSnippet}`; const result = await client.text.complete(userPrompt, { model, diff --git a/tests/unit/agents/shared/promptContext.test.ts b/tests/unit/agents/shared/promptContext.test.ts index 34f7f535..e14a6d46 100644 --- a/tests/unit/agents/shared/promptContext.test.ts +++ b/tests/unit/agents/shared/promptContext.test.ts @@ -133,6 +133,20 @@ describe('buildPromptContext', () => { const ctx = buildPromptContext('PROJ-123', makeProject() as never); expect(ctx.pmType).toBe('jira'); }); + + it('sets storiesListId to JIRA project key when no Trello config', () => { + const jiraProject = makeProject({ + trello: undefined, + pm: { type: 'jira' }, + jira: { + projectKey: 'BTS', + baseUrl: 'https://company.atlassian.net', + statuses: { todo: 'To Do' }, + }, + }); + const ctx = buildPromptContext('BTS-148', jiraProject as never); + expect(ctx.storiesListId).toBe('BTS'); + }); }); describe('with prContext', () => { diff --git a/tests/unit/backends/progressModel.test.ts b/tests/unit/backends/progressModel.test.ts index b77a3033..d3724db8 100644 --- a/tests/unit/backends/progressModel.test.ts +++ b/tests/unit/backends/progressModel.test.ts @@ -103,4 +103,26 @@ describe('callProgressModel', () => { expect(mockTextComplete).toHaveBeenCalledTimes(1); expect(MockLLMist).toHaveBeenCalledTimes(1); }); + + it('includes agent role hint in the user prompt', async () => { + mockTextComplete.mockResolvedValue('Progress update.'); + + await callProgressModel('test-model', makeContext({ agentType: 'splitting' }), []); + + const userPrompt = mockTextComplete.mock.calls[0][0] as string; + expect(userPrompt).toContain('Agent: splitting'); + expect(userPrompt).toContain( + 'Agent role: Breaks down a feature plan into smaller, ordered work items (subtasks)', + ); + }); + + it('uses fallback role hint for unknown agent types', async () => { + mockTextComplete.mockResolvedValue('Progress update.'); + + await callProgressModel('test-model', makeContext({ agentType: 'unknown-agent' }), []); + + const userPrompt = mockTextComplete.mock.calls[0][0] as string; + expect(userPrompt).toContain('Agent: unknown-agent'); + expect(userPrompt).toContain('Agent role: Processes the request'); + }); }); diff --git a/tests/unit/pm/jira/adapter.test.ts b/tests/unit/pm/jira/adapter.test.ts index 7276baab..e3f52ebb 100644 --- a/tests/unit/pm/jira/adapter.test.ts +++ b/tests/unit/pm/jira/adapter.test.ts @@ -257,6 +257,53 @@ describe('JiraPMProvider', () => { expect.not.objectContaining({ labels: expect.anything() }), ); }); + + it('transitions new issue to stories status when configured', async () => { + const storiesProvider = new JiraPMProvider({ + ...mockConfig, + statuses: { ...mockConfig.statuses, stories: 'Stories' }, + }); + mockJiraClient.createIssue.mockResolvedValue({ key: 'PROJ-100' }); + mockJiraClient.getTransitions.mockResolvedValue([ + { id: '31', name: 'Stories', to: { name: 'Stories' } }, + ]); + mockJiraClient.transitionIssue.mockResolvedValue(undefined); + + await storiesProvider.createWorkItem({ + containerId: 'PROJ', + title: 'Story task', + }); + + expect(mockJiraClient.getTransitions).toHaveBeenCalledWith('PROJ-100'); + expect(mockJiraClient.transitionIssue).toHaveBeenCalledWith('PROJ-100', '31'); + }); + + it('does not transition when stories status is not configured', async () => { + mockJiraClient.createIssue.mockResolvedValue({ key: 'PROJ-101' }); + + await provider.createWorkItem({ + containerId: 'PROJ', + title: 'Regular task', + }); + + expect(mockJiraClient.getTransitions).not.toHaveBeenCalled(); + }); + + it('logs warning and continues when stories transition fails', async () => { + const storiesProvider = new JiraPMProvider({ + ...mockConfig, + statuses: { ...mockConfig.statuses, stories: 'Stories' }, + }); + mockJiraClient.createIssue.mockResolvedValue({ key: 'PROJ-102' }); + mockJiraClient.getTransitions.mockRejectedValue(new Error('API error')); + + const result = await storiesProvider.createWorkItem({ + containerId: 'PROJ', + title: 'Task with failing transition', + }); + + expect(result.id).toBe('PROJ-102'); + }); }); describe('listWorkItems', () => { diff --git a/tests/unit/router/ackMessageGenerator.test.ts b/tests/unit/router/ackMessageGenerator.test.ts index 4797398e..da10d716 100644 --- a/tests/unit/router/ackMessageGenerator.test.ts +++ b/tests/unit/router/ackMessageGenerator.test.ts @@ -36,6 +36,11 @@ vi.mock('../../../src/config/agentMessages.js', () => ({ '**πŸ“‹ Splitting plan** β€” Reading the plan and splitting it into ordered work items...', review: '**πŸ” Reviewing code** β€” Examining the PR changes for quality and correctness...', }, + AGENT_ROLE_HINTS: { + splitting: 'Breaks down a feature plan into smaller, ordered work items (subtasks)', + implementation: 'Writes code, runs tests, and prepares a pull request', + review: 'Reviews pull request changes for quality and correctness', + }, })); import { getOrgCredential, loadConfig } from '../../../src/config/provider.js'; diff --git a/web/src/components/projects/pm-wizard.tsx b/web/src/components/projects/pm-wizard.tsx index ff47a626..e03ca409 100644 --- a/web/src/components/projects/pm-wizard.tsx +++ b/web/src/components/projects/pm-wizard.tsx @@ -590,6 +590,7 @@ const TRELLO_LABEL_SLOTS = ['readyToProcess', 'processing', 'processed', 'error' const JIRA_STATUS_SLOTS = [ 'splitting', + 'stories', 'planning', 'todo', 'inProgress', From 83e27b0b05830c979310fb20affc5a8401842d49 Mon Sep 17 00:00:00 2001 From: aaight Date: Wed, 25 Feb 2026 11:33:12 +0100 Subject: [PATCH 09/13] fix(planning): remove Step N prefix from AddChecklist items (#547) Co-authored-by: Cascade Bot --- src/agents/prompts/templates/planning.eta | 2 ++ .../templates/respond-to-planning-comment.eta | 1 + tests/unit/agents/prompts.test.ts | 12 ++++++++++++ 3 files changed, 15 insertions(+) diff --git a/src/agents/prompts/templates/planning.eta b/src/agents/prompts/templates/planning.eta index 40cfcf73..fceb224a 100644 --- a/src/agents/prompts/templates/planning.eta +++ b/src/agents/prompts/templates/planning.eta @@ -101,6 +101,7 @@ Update the <%= it.workItemNoun || 'card' %> description with **emoji section hea **IMPORTANT:** - After updating the <%= it.workItemNoun || 'card' %>, ALWAYS call `AddChecklist` to create an interactive "πŸ“‹ Implementation Steps" checklist with each step as an item. +- When calling `AddChecklist`, use only the task name as each item β€” do NOT include "Step N:" prefixes. For example, use "Add helper function" instead of "Step 1: Add helper function". The checklist order already implies sequence. <% if (it.pmType === 'jira') { %>- When calling `AddChecklist`, pass items as objects with `name` and `description`. The description should include the files to modify, specific changes, and testing notes from the corresponding Implementation Step section. This becomes the JIRA subtask description. <% } %>- When referencing other <%= it.workItemNounPlural || 'cards' %> (related stories, dependencies), ALWAYS use markdown links: `[<%= it.workItemNounCap || 'Card' %> Title](URL)` @@ -141,6 +142,7 @@ Review the updated description and move to TODO when ready to implement! - ALWAYS explore the codebase before creating the plan - ALWAYS use `UpdateWorkItem` to save your plan - DON'T JUST OUTPUT TEXT - ALWAYS call `AddChecklist` after updating the <%= it.workItemNoun || 'card' %> to create interactive checklists +- NEVER include "Step N:" prefixes in `AddChecklist` items β€” use clean task names like "Add helper function", not "Step 1: Add helper function" - ALWAYS use emoji section headers (🎯, πŸ“‹, πŸ§ͺ, ⚠️, πŸ”—) and **bold key terms** in descriptions - ALWAYS include a 🎯 TLDR section at the top of the <%= it.workItemNoun || 'card' %> description - ALWAYS use markdown link syntax `[title](url)` when referencing other <%= it.workItemNounPlural || 'cards' %> diff --git a/src/agents/prompts/templates/respond-to-planning-comment.eta b/src/agents/prompts/templates/respond-to-planning-comment.eta index 1ab7178b..ddd9f1cb 100644 --- a/src/agents/prompts/templates/respond-to-planning-comment.eta +++ b/src/agents/prompts/templates/respond-to-planning-comment.eta @@ -74,6 +74,7 @@ When modifying the plan, **update the existing checklists in place** β€” do NOT - **Renaming/rewriting steps**: Use `UpdateChecklistItem` to change the text of existing checklist items. - **Removing steps**: Use `DeleteChecklistItem` to permanently remove checklist items / subtasks that are no longer needed. Do NOT mark removed items as "complete" β€” they were never done, so deleting is the correct action. - **Reordering**: Delete and re-add items as needed to achieve the desired order. +- **Checklist item names**: Use clean task names without "Step N:" prefixes β€” for example "Add helper function", NOT "Step 1: Add helper function". The checklist order already implies sequence. When the user asks to narrow scope, focus on a subset, or drop items from the plan, **always delete** the out-of-scope items rather than leaving them in the checklist. diff --git a/tests/unit/agents/prompts.test.ts b/tests/unit/agents/prompts.test.ts index 319bd754..668666b2 100644 --- a/tests/unit/agents/prompts.test.ts +++ b/tests/unit/agents/prompts.test.ts @@ -109,6 +109,18 @@ describe('system prompts content', () => { expect(prompt).toContain('Category B (Plan Update)'); expect(prompt).toContain('Category C (Both)'); }); + + it('planning prompt instructs AddChecklist items to not use Step N prefixes', () => { + const prompt = getSystemPrompt('planning'); + expect(prompt).toContain('do NOT include "Step N:" prefixes'); + expect(prompt).toContain('Add helper function'); + }); + + it('respond-to-planning-comment prompt instructs checklist items to not use Step N prefixes', () => { + const prompt = getSystemPrompt('respond-to-planning-comment'); + expect(prompt).toContain('Step N:'); + expect(prompt).toContain('clean task names without'); + }); }); describe('resolveIncludes', () => { From d10ae7d0541fea004b931d83d497b6a9ea4ce8a9 Mon Sep 17 00:00:00 2001 From: aaight Date: Wed, 25 Feb 2026 11:52:56 +0100 Subject: [PATCH 10/13] refactor(agents): remove dead legacy agent execution path (~2,100 lines) (#543) Co-authored-by: Cascade Bot --- src/agents/base.ts | 219 ---------- src/agents/index.ts | 1 - src/agents/respond-to-ci.ts | 397 ------------------ src/agents/respond-to-pr-comment.ts | 80 ---- src/agents/respond-to-review.ts | 58 --- src/agents/review.ts | 249 ----------- src/agents/shared/cleanup.ts | 2 +- src/agents/shared/githubAgent.ts | 265 ------------ src/agents/shared/lifecycle.ts | 323 -------------- src/agents/shared/prResponseAgent.ts | 232 ---------- src/agents/shared/runTracking.ts | 2 +- src/agents/shared/syntheticCalls.ts | 87 ---- src/agents/shared/workItemBuilder.ts | 165 -------- src/agents/shared/workItemContext.ts | 139 ------ .../agents/fetchImplementationSteps.test.ts | 136 ------ tests/unit/agents/shared/lifecycle.test.ts | 259 ------------ .../agents/shared/prResponseAgent.test.ts | 382 ----------------- .../unit/agents/shared/syntheticCalls.test.ts | 205 +-------- 18 files changed, 3 insertions(+), 3198 deletions(-) delete mode 100644 src/agents/base.ts delete mode 100644 src/agents/respond-to-ci.ts delete mode 100644 src/agents/respond-to-pr-comment.ts delete mode 100644 src/agents/respond-to-review.ts delete mode 100644 src/agents/review.ts delete mode 100644 src/agents/shared/githubAgent.ts delete mode 100644 src/agents/shared/lifecycle.ts delete mode 100644 src/agents/shared/prResponseAgent.ts delete mode 100644 src/agents/shared/workItemBuilder.ts delete mode 100644 src/agents/shared/workItemContext.ts delete mode 100644 tests/unit/agents/fetchImplementationSteps.test.ts delete mode 100644 tests/unit/agents/shared/lifecycle.test.ts delete mode 100644 tests/unit/agents/shared/prResponseAgent.test.ts diff --git a/src/agents/base.ts b/src/agents/base.ts deleted file mode 100644 index 2563227a..00000000 --- a/src/agents/base.ts +++ /dev/null @@ -1,219 +0,0 @@ -import type { ModelSpec } from 'llmist'; - -import { createProgressMonitor } from '../backends/progress.js'; -import { CUSTOM_MODELS } from '../config/customModels.js'; -import { getPMProvider } from '../pm/index.js'; -import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js'; -import { logger } from '../utils/logging.js'; -import { extractPRUrl } from '../utils/prUrl.js'; -import { type FileLogger, executeAgentLifecycle } from './shared/lifecycle.js'; -import { setupRepository as setupRepo } from './shared/repository.js'; -import { - createWorkItemAgentBuilder, - injectWorkItemSyntheticCalls, -} from './shared/workItemBuilder.js'; -import { buildAgentContext } from './shared/workItemContext.js'; -import type { AgentLogger } from './utils/logging.js'; - -export interface AgentContext { - project: ProjectConfig; - config: CascadeConfig; - cardId: string; - repoDir: string; -} - -export interface AgentRunner { - name: string; - run: (ctx: AgentContext) => Promise; -} - -// Re-export for backwards compatibility and test access -export { fetchImplementationSteps } from './shared/workItemContext.js'; - -// ============================================================================ -// Agent Execution -// ============================================================================ - -interface PRContext { - prNumber: number; - prBranch: string; - repoFullName: string; - headSha: string; -} - -function extractPRContext(input: AgentInput): PRContext | undefined { - if (input.triggerType !== 'check-failure') return undefined; - return { - prNumber: input.prNumber as number, - prBranch: input.prBranch as string, - repoFullName: input.repoFullName as string, - headSha: input.headSha as string, - }; -} - -function extractDebugContext(agentType: string, input: AgentInput) { - if (agentType !== 'debug' || !input.logDir) return undefined; - return { - logDir: input.logDir, - originalCardId: input.originalCardId as string, - originalCardName: input.originalCardName as string, - originalCardUrl: input.originalCardUrl as string, - detectedAgentType: input.detectedAgentType as string, - }; -} - -function getLoggerIdentifier( - agentType: string, - cardId: string | undefined, - prContext: PRContext | undefined, - debugCardId: string | undefined, -): string { - if (prContext) return `${agentType}-pr${prContext.prNumber}`; - return `${agentType}-${cardId || debugCardId}`; -} - -async function setupWorkingDirectory( - input: AgentInput, - project: ProjectConfig, - log: AgentLogger, - agentType: string, - prBranch?: string, -): Promise { - if (input.logDir && typeof input.logDir === 'string') { - log.info('Using log directory (no repo setup)', { logDir: input.logDir }); - return input.logDir; - } - - return setupRepo({ project, log, agentType, prBranch, warmTsCache: true }); -} - -export async function executeAgent( - agentType: string, - input: AgentInput & { project: ProjectConfig; config: CascadeConfig }, -): Promise { - const { project, config, cardId, interactive, autoAccept } = input; - const prContext = extractPRContext(input); - const isDebugAgent = input.logDir && typeof input.logDir === 'string'; - - if (!cardId && !prContext && !isDebugAgent) { - return { success: false, output: '', error: 'No card ID or PR context provided' }; - } - - const debugCardId = isDebugAgent ? (input.originalCardId as string) : undefined; - const identifier = getLoggerIdentifier(agentType, cardId, prContext, debugCardId); - - return executeAgentLifecycle({ - loggerIdentifier: identifier, - - onWatchdogTimeout: async (_fileLogger: FileLogger, runId?: string) => { - if (cardId) { - try { - const provider = getPMProvider(); - await provider.addComment( - cardId, - `⏱️ Agent timed out (watchdog).${runId ? ` Run ID: ${runId}` : ''}`, - ); - logger.info('Posted timeout comment to work item', { cardId, runId }); - } catch { - logger.warn('Failed to post timeout comment', { cardId, runId }); - } - } - }, - - setupRepoDir: (log) => - setupWorkingDirectory(input, project, log, agentType, prContext?.prBranch), - - buildContext: (repoDir, log) => { - const debugContext = extractDebugContext(agentType, input); - const commentContext = input.triggerCommentText - ? { text: input.triggerCommentText, author: input.triggerCommentAuthor || 'unknown' } - : undefined; - return buildAgentContext( - agentType, - cardId, - repoDir, - project, - config, - log, - input.triggerType, - prContext, - debugContext, - input.modelOverride, - commentContext, - ); - }, - - createBuilder: ({ - client, - ctx, - llmistLogger, - trackingContext, - fileLogger, - repoDir, - progressMonitor, - llmCallAccumulator, - runId, - }) => - createWorkItemAgentBuilder({ - client, - ctx, - llmistLogger, - trackingContext, - agentType, - logWriter: fileLogger.write.bind(fileLogger), - llmCallLogger: fileLogger.llmCallLogger, - repoDir, - progressMonitor: progressMonitor ?? undefined, - remainingBudgetUsd: input.remainingBudgetUsd as number | undefined, - llmCallAccumulator, - runId, - baseBranch: project.baseBranch, - projectId: project.id, - cardId, - }), - - injectSyntheticCalls: ({ builder, ctx, trackingContext, repoDir }) => - injectWorkItemSyntheticCalls( - builder, - cardId, - ctx.cardData, - ctx.contextFiles, - trackingContext, - repoDir, - ctx.implementationSteps, - ), - - createProgressMonitor: (fileLogger, repoDir) => - createProgressMonitor({ - logWriter: fileLogger.write.bind(fileLogger), - agentType, - taskDescription: cardId ? `Work item ${cardId}` : 'Unknown task', - progressModel: config.defaults.progressModel, - intervalMinutes: config.defaults.progressIntervalMinutes, - customModels: CUSTOM_MODELS as ModelSpec[], - repoDir, - trello: cardId ? { cardId } : undefined, - preSeededCommentId: input.ackCommentId as string | undefined, - }), - - interactive, - autoAccept, - customModels: CUSTOM_MODELS, - - postProcess: (output) => { - const prUrl = extractPRUrl(output); - return prUrl ? { prUrl } : {}; - }, - - runTracking: { - projectId: project.id, - cardId, - prNumber: prContext?.prNumber ?? (input.prNumber as number | undefined), - agentType, - backendName: 'llmist', - triggerType: input.triggerType, - }, - - squintDbUrl: project.squintDbUrl, - }); -} diff --git a/src/agents/index.ts b/src/agents/index.ts index 72c7ee27..a29a0fc6 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -1,3 +1,2 @@ -export { executeAgent, type AgentContext, type AgentRunner } from './base.js'; export { runAgent, registerBackend } from './registry.js'; export { getSystemPrompt } from './prompts/index.js'; diff --git a/src/agents/respond-to-ci.ts b/src/agents/respond-to-ci.ts deleted file mode 100644 index c11d99bb..00000000 --- a/src/agents/respond-to-ci.ts +++ /dev/null @@ -1,397 +0,0 @@ -import type { CheckSuiteStatus } from '../github/client.js'; -import { githubClient } from '../github/client.js'; -import type { AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js'; -import { logger } from '../utils/logging.js'; -import { runCommand as execCommand } from '../utils/repo.js'; -import { buildPRAgentGadgets } from './shared/gadgets.js'; -import { - type GitHubAgentContext, - type GitHubAgentDefinition, - type GitHubAgentInput, - type RepoIdentifier, - createInitialPRComment, - executeGitHubAgent, -} from './shared/githubAgent.js'; -import { resolveModelConfig } from './shared/modelResolution.js'; -import { formatPRDetails, formatPRDiff } from './shared/prFormatting.js'; -import { - injectContextFiles, - injectDirectoryListing, - injectSquintContext, - injectSyntheticCall, -} from './shared/syntheticCalls.js'; - -interface RespondToCIAgentInput extends GitHubAgentInput { - headSha: string; -} - -// ============================================================================ -// CI Data Formatting -// ============================================================================ - -function formatCheckStatus(checkStatus: CheckSuiteStatus): string { - const lines = ['## Check Suite Status', `Total checks: ${checkStatus.totalCount}`, '']; - - // Group by status/conclusion - const passed = checkStatus.checkRuns.filter( - (cr) => - cr.conclusion === 'success' || cr.conclusion === 'skipped' || cr.conclusion === 'neutral', - ); - const failed = checkStatus.checkRuns.filter( - (cr) => - cr.conclusion === 'failure' || - cr.conclusion === 'timed_out' || - cr.conclusion === 'action_required', - ); - const pending = checkStatus.checkRuns.filter((cr) => cr.status !== 'completed'); - - if (failed.length > 0) { - lines.push('### Failed Checks'); - for (const cr of failed) { - lines.push(`- **${cr.name}**: ${cr.conclusion}`); - } - lines.push(''); - } - - if (passed.length > 0) { - lines.push('### Passed Checks'); - for (const cr of passed) { - lines.push(`- ${cr.name}: ${cr.conclusion}`); - } - lines.push(''); - } - - if (pending.length > 0) { - lines.push('### Pending Checks'); - for (const cr of pending) { - lines.push(`- ${cr.name}: ${cr.status}`); - } - lines.push(''); - } - - return lines.join('\n'); -} - -// ============================================================================ -// CI Log Fetching -// ============================================================================ - -interface WorkflowRun { - databaseId: number; - name: string; - conclusion: string; - headSha: string; -} - -function truncateLogOutput(stdout: string, maxLines = 200): string { - const logLines = stdout.split('\n'); - if (logLines.length <= maxLines) { - return stdout; - } - const truncatedCount = logLines.length - maxLines; - return `[... truncated ${truncatedCount} lines ...]\n${logLines.slice(-maxLines).join('\n')}`; -} - -function formatCheckLogEntry(checkName: string, content: string, isCode = false): string { - if (isCode) { - return `## ${checkName}\n\n\`\`\`\n${content}\n\`\`\``; - } - return `## ${checkName}\n\n${content}`; -} - -async function fetchSingleRunLog( - runId: number, - owner: string, - repo: string, - repoDir: string, -): Promise<{ success: boolean; content: string }> { - const logResult = await execCommand( - 'gh', - ['run', 'view', String(runId), '--repo', `${owner}/${repo}`, '--log-failed'], - repoDir, - ); - - if (logResult.exitCode === 0 && logResult.stdout.trim()) { - return { success: true, content: truncateLogOutput(logResult.stdout) }; - } - return { success: false, content: logResult.stderr || 'No output' }; -} - -async function fetchFailedCheckLogs( - owner: string, - repo: string, - checkStatus: CheckSuiteStatus, - repoDir: string, - log: { - info: (msg: string, ctx?: Record) => void; - warn: (msg: string, ctx?: Record) => void; - }, -): Promise { - const failedRuns = checkStatus.checkRuns.filter( - (cr) => - cr.conclusion === 'failure' || - cr.conclusion === 'timed_out' || - cr.conclusion === 'action_required', - ); - - if (failedRuns.length === 0) { - return 'No failed check logs to display.'; - } - - log.info('Fetching failed check logs via gh CLI', { failedCount: failedRuns.length }); - - const listResult = await execCommand( - 'gh', - [ - 'run', - 'list', - '--repo', - `${owner}/${repo}`, - '--limit', - '20', - '--json', - 'databaseId,name,conclusion,headSha', - ], - repoDir, - ); - - if (listResult.exitCode !== 0) { - log.warn('Failed to list workflow runs', { stderr: listResult.stderr }); - return `Unable to fetch check logs: ${listResult.stderr}`; - } - - const runs = JSON.parse(listResult.stdout) as WorkflowRun[]; - const logs: string[] = []; - - for (const failedCheck of failedRuns) { - const logEntry = await processFailedCheck(failedCheck, runs, owner, repo, repoDir, log); - logs.push(logEntry); - } - - return logs.length > 0 ? logs.join('\n\n---\n\n') : 'No failed check logs available.'; -} - -async function processFailedCheck( - failedCheck: { name: string; conclusion: string | null }, - runs: WorkflowRun[], - owner: string, - repo: string, - repoDir: string, - log: { info: (msg: string, ctx?: Record) => void }, -): Promise { - const matchingRun = runs.find( - (r) => - r.name === failedCheck.name && (r.conclusion === 'failure' || r.conclusion === 'timed_out'), - ); - - if (!matchingRun) { - return formatCheckLogEntry( - failedCheck.name, - 'No matching workflow run found for log retrieval.', - ); - } - - log.info('Fetching logs for failed run', { - name: matchingRun.name, - id: matchingRun.databaseId, - }); - - const result = await fetchSingleRunLog(matchingRun.databaseId, owner, repo, repoDir); - - if (result.success) { - return formatCheckLogEntry(failedCheck.name, result.content, true); - } - return formatCheckLogEntry(failedCheck.name, `Unable to fetch logs: ${result.content}`); -} - -// ============================================================================ -// Context Building -// ============================================================================ - -interface CIContextData extends GitHubAgentContext { - contextFiles: Awaited>['contextFiles']; - prDetailsFormatted: string; - diffFormatted: string; - checkStatusFormatted: string; - failedLogsFormatted: string; -} - -async function buildCIContext( - owner: string, - repo: string, - prNumber: number, - prBranch: string, - headSha: string, - repoDir: string, - project: ProjectConfig, - config: CascadeConfig, - log: { - info: (msg: string, ctx?: Record) => void; - warn: (msg: string, ctx?: Record) => void; - }, - modelOverride?: string, -): Promise { - const { systemPrompt, model, maxIterations, contextFiles } = await resolveModelConfig({ - agentType: 'respond-to-ci', - project, - config, - repoDir, - modelOverride, - }); - - // Fetch PR details and diff - log.info('Fetching PR details and diff', { owner, repo, prNumber }); - const prDetails = await githubClient.getPR(owner, repo, prNumber); - const prDiff = await githubClient.getPRDiff(owner, repo, prNumber); - - // Get check suite status - log.info('Fetching check suite status', { owner, repo, headSha }); - const checkStatus = await githubClient.getCheckSuiteStatus(owner, repo, headSha); - - // Fetch failed check logs - const failedLogsFormatted = await fetchFailedCheckLogs(owner, repo, checkStatus, repoDir, log); - - // Format data - const prDetailsFormatted = formatPRDetails(prDetails); - const diffFormatted = formatPRDiff(prDiff); - const checkStatusFormatted = formatCheckStatus(checkStatus); - - // Build prompt - const prompt = buildCIPrompt(prBranch, prNumber, owner, repo); - - return { - systemPrompt, - model, - maxIterations, - contextFiles, - prDetailsFormatted, - diffFormatted, - checkStatusFormatted, - failedLogsFormatted, - prompt, - }; -} - -function buildCIPrompt(prBranch: string, prNumber: number, owner: string, repo: string): string { - return `You are on the branch \`${prBranch}\` for PR #${prNumber}. - -CI checks have failed. Analyze the failures and fix them. - -## GitHub Context - -Owner: ${owner} -Repo: ${repo} -PR Number: ${prNumber} - -Use these values when calling GitHub gadgets (GetPRDetails, PostPRComment, UpdatePRComment).`; -} - -// ============================================================================ -// Agent Definition -// ============================================================================ - -const ciAgentDefinition: GitHubAgentDefinition = { - agentType: 'respond-to-ci', - initialCommentDescription: 'Acknowledge CI failures', - timeoutMessage: '⚠️ CI fix agent timed out while attempting to fix failures.', - loggerPrefix: 'ci', - - getGadgets: () => buildPRAgentGadgets(), - - async preExecute(input: RespondToCIAgentInput, id: RepoIdentifier) { - const checkStatus = await githubClient.getCheckSuiteStatus(id.owner, id.repo, input.headSha); - const hasFailedChecks = checkStatus.checkRuns.some( - (cr) => - cr.conclusion === 'failure' || - cr.conclusion === 'timed_out' || - cr.conclusion === 'action_required', - ); - - if (!hasFailedChecks) { - logger.info('No failed checks found, skipping CI fix agent', { - prNumber: input.prNumber, - headSha: input.headSha, - totalChecks: checkStatus.totalCount, - }); - return { success: true, output: 'No failed checks to fix' }; - } - return null; - }, - - postInitialComment: (input, id, headerMessage) => - createInitialPRComment(input.prNumber, id, headerMessage), - - buildContext: ({ owner, repo }, input, repoDir, log) => - buildCIContext( - owner, - repo, - input.prNumber, - input.prBranch, - input.headSha, - repoDir, - input.project, - input.config, - log, - input.modelOverride, - ), - - async injectSyntheticCalls({ - builder, - ctx, - trackingContext, - repoDir, - id: { owner, repo }, - input, - }) { - let b = injectDirectoryListing(builder, trackingContext); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRDetails', - { comment: 'Pre-fetching PR details for context', owner, repo, prNumber: input.prNumber }, - ctx.prDetailsFormatted, - 'gc_pr_details', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRDiff', - { comment: 'Pre-fetching PR diff for context', owner, repo, prNumber: input.prNumber }, - ctx.diffFormatted, - 'gc_pr_diff', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetCheckStatus', - { comment: 'Pre-fetching CI check status', owner, repo, prNumber: input.prNumber }, - ctx.checkStatusFormatted, - 'gc_check_status', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetFailedCheckLogs', - { comment: 'Pre-fetching failed CI check logs', owner, repo, prNumber: input.prNumber }, - ctx.failedLogsFormatted, - 'gc_failed_logs', - ); - - b = injectContextFiles(b, trackingContext, ctx.contextFiles); - b = injectSquintContext(b, trackingContext, repoDir); - - return b; - }, -}; - -// ============================================================================ -// CI Agent Execution -// ============================================================================ - -export async function executeRespondToCIAgent(input: RespondToCIAgentInput): Promise { - return executeGitHubAgent(ciAgentDefinition, input); -} diff --git a/src/agents/respond-to-pr-comment.ts b/src/agents/respond-to-pr-comment.ts deleted file mode 100644 index a9494dbd..00000000 --- a/src/agents/respond-to-pr-comment.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { AgentResult } from '../types/index.js'; -import { buildPRAgentGadgets } from './shared/gadgets.js'; -import { type GitHubAgentDefinition, executeGitHubAgent } from './shared/githubAgent.js'; -import { - type PRResponseAgentInput, - type PRResponseContextData, - buildPRResponseContext, - buildPRResponsePrompt, - injectPRResponseSyntheticCalls, - postInitialPRResponseComment, -} from './shared/prResponseAgent.js'; -import { injectSyntheticCall } from './shared/syntheticCalls.js'; - -const respondToPRCommentDefinition: GitHubAgentDefinition< - PRResponseAgentInput, - PRResponseContextData -> = { - agentType: 'respond-to-pr-comment', - initialCommentDescription: 'Acknowledge PR comment request', - timeoutMessage: '⚠️ PR comment agent timed out while working on the request.', - loggerPrefix: 'pr-comment', - - getGadgets: () => buildPRAgentGadgets({ includeReviewComments: true }), - - postInitialComment: postInitialPRResponseComment, - - buildContext: ({ owner, repo }, input, repoDir, log) => - buildPRResponseContext( - owner, - repo, - input.prNumber, - input.prBranch, - repoDir, - input.project, - input.config, - log, - 'respond-to-pr-comment', - (prBranch, prNumber, o, r) => - buildPRResponsePrompt( - prBranch, - prNumber, - o, - r, - 'A user @mentioned you in a PR comment. Read their request and execute it.', - 'GetPRComments, ReplyToReviewComment, PostPRComment, UpdatePRComment', - ), - input.modelOverride, - ), - - async injectSyntheticCalls(params) { - return injectPRResponseSyntheticCalls(params, { - preSyntheticCalls: (builder, trackingContext, input) => - injectSyntheticCall( - builder, - trackingContext, - 'TriggeringComment', - { - comment: - 'The @mention comment that triggered this agent β€” this is your primary instruction', - commentId: input.triggerCommentId, - url: input.triggerCommentUrl, - path: input.triggerCommentPath || '(general PR comment)', - }, - input.triggerCommentBody, - 'gc_triggering_comment', - ), - commentDescriptions: { - prComments: 'Pre-fetching line-specific review comments for context', - prReviews: 'Pre-fetching review submissions for context', - prIssueComments: 'Pre-fetching general PR comments for context', - }, - }); - }, -}; - -export async function executeRespondToPRCommentAgent( - input: PRResponseAgentInput, -): Promise { - return executeGitHubAgent(respondToPRCommentDefinition, input); -} diff --git a/src/agents/respond-to-review.ts b/src/agents/respond-to-review.ts deleted file mode 100644 index 344a4fcd..00000000 --- a/src/agents/respond-to-review.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { AgentResult } from '../types/index.js'; -import { buildPRAgentGadgets } from './shared/gadgets.js'; -import { type GitHubAgentDefinition, executeGitHubAgent } from './shared/githubAgent.js'; -import { - type PRResponseAgentInput, - type PRResponseContextData, - buildPRResponseContext, - buildPRResponsePrompt, - injectPRResponseSyntheticCalls, - postInitialPRResponseComment, -} from './shared/prResponseAgent.js'; - -const respondToReviewDefinition: GitHubAgentDefinition< - PRResponseAgentInput, - PRResponseContextData -> = { - agentType: 'respond-to-review', - initialCommentDescription: 'Acknowledge review feedback', - timeoutMessage: '⚠️ Review agent timed out while addressing feedback.', - loggerPrefix: 'review', - - getGadgets: () => buildPRAgentGadgets({ includeReviewComments: true }), - - postInitialComment: postInitialPRResponseComment, - - buildContext: ({ owner, repo }, input, repoDir, log) => - buildPRResponseContext( - owner, - repo, - input.prNumber, - input.prBranch, - repoDir, - input.project, - input.config, - log, - 'respond-to-review', - (prBranch, prNumber, o, r) => - buildPRResponsePrompt( - prBranch, - prNumber, - o, - r, - 'Address the review comments and push your changes.', - 'GetPRComments, ReplyToReviewComment', - ), - input.modelOverride, - ), - - async injectSyntheticCalls(params) { - return injectPRResponseSyntheticCalls(params); - }, -}; - -export async function executeRespondToReviewAgent( - input: PRResponseAgentInput, -): Promise { - return executeGitHubAgent(respondToReviewDefinition, input); -} diff --git a/src/agents/review.ts b/src/agents/review.ts deleted file mode 100644 index f8761592..00000000 --- a/src/agents/review.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { Finish } from '../gadgets/Finish.js'; -import { ListDirectory } from '../gadgets/ListDirectory.js'; -import { ReadFile } from '../gadgets/ReadFile.js'; -import { Sleep } from '../gadgets/Sleep.js'; -import { - CreatePRReview, - GetPRChecks, - GetPRDetails, - GetPRDiff, - UpdatePRComment, - formatCheckStatus, -} from '../gadgets/github/index.js'; -import { Tmux } from '../gadgets/tmux.js'; -import { TodoDelete, TodoUpdateStatus, TodoUpsert } from '../gadgets/todo/index.js'; -import { githubClient } from '../github/client.js'; -import type { AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js'; -import { - type GitHubAgentContext, - type GitHubAgentDefinition, - type GitHubAgentInput, - createInitialPRComment, - executeGitHubAgent, -} from './shared/githubAgent.js'; -import { resolveModelConfig } from './shared/modelResolution.js'; -import { - type PRFileContents, - formatPRDetails, - formatPRDiff, - readPRFileContents, -} from './shared/prFormatting.js'; -import { - injectContextFiles, - injectSquintContext, - injectSyntheticCall, -} from './shared/syntheticCalls.js'; - -interface ReviewAgentInput extends GitHubAgentInput { - prNumber: number; - prBranch: string; - repoFullName: string; - project: ProjectConfig; - config: CascadeConfig; -} - -// ============================================================================ -// Context Building -// ============================================================================ - -interface ReviewContextData extends GitHubAgentContext { - contextFiles: Awaited>['contextFiles']; - prDetailsFormatted: string; - diffFormatted: string; - checkStatusFormatted: string; - fileContents: PRFileContents; -} - -async function buildReviewContext( - owner: string, - repo: string, - prNumber: number, - repoDir: string, - project: ProjectConfig, - config: CascadeConfig, - log: { info: (msg: string, ctx?: Record) => void }, - modelOverride?: string, -): Promise { - const { systemPrompt, model, maxIterations, contextFiles } = await resolveModelConfig({ - agentType: 'review', - project, - config, - repoDir, - modelOverride, - }); - - // Fetch PR details, diff, and check status - log.info('Fetching PR details, diff, and check status', { owner, repo, prNumber }); - const prDetails = await githubClient.getPR(owner, repo, prNumber); - const prDiff = await githubClient.getPRDiff(owner, repo, prNumber); - const checkStatus = await githubClient.getCheckSuiteStatus(owner, repo, prDetails.headSha); - - // Format PR data - const prDetailsFormatted = formatPRDetails(prDetails); - const diffFormatted = formatPRDiff(prDiff); - const checkStatusFormatted = formatCheckStatus(prNumber, checkStatus); - - // Read full contents of changed files (up to token limit) - log.info('Reading PR file contents', { fileCount: prDiff.length }); - const fileContents = await readPRFileContents(repoDir, prDiff); - log.info('File contents loaded', { - included: fileContents.included.length, - skipped: fileContents.skipped.length, - }); - - // Build prompt (include skipped files note if any) - const prompt = buildReviewPrompt(prNumber, owner, repo, fileContents.skipped); - - return { - systemPrompt, - model, - maxIterations, - contextFiles, - prDetailsFormatted, - diffFormatted, - checkStatusFormatted, - fileContents, - prompt, - }; -} - -function buildReviewPrompt( - prNumber: number, - owner: string, - repo: string, - skippedFiles: string[], -): string { - let prompt = `Review PR #${prNumber} in ${owner}/${repo}. - -Examine the code changes carefully and submit your review using CreatePRReview. - -## GitHub Context - -Owner: ${owner} -Repo: ${repo} -PR Number: ${prNumber} - -Use these values when calling GitHub gadgets (GetPRDetails, GetPRDiff, CreatePRReview).`; - - if (skippedFiles.length > 0) { - prompt += `\n\n## Files Not Pre-loaded - -The following files exceeded the token limit and were not pre-loaded. Use ReadFile if you need their full contents: -${skippedFiles.map((f) => `- ${f}`).join('\n')}`; - } - - return prompt; -} - -// ============================================================================ -// Agent Definition -// ============================================================================ - -const reviewAgentDefinition: GitHubAgentDefinition = { - agentType: 'review', - initialCommentDescription: 'Post initial review status comment', - timeoutMessage: '⚠️ Review agent timed out while reviewing the PR.', - loggerPrefix: 'review', - - getGadgets: () => [ - new ListDirectory(), - new ReadFile(), - new Tmux(), - new Sleep(), - new TodoUpsert(), - new TodoUpdateStatus(), - new TodoDelete(), - new GetPRDetails(), - new GetPRDiff(), - new GetPRChecks(), - new CreatePRReview(), - new UpdatePRComment(), - new Finish(), - ], - - postInitialComment: (input, id, headerMessage) => - createInitialPRComment(input.prNumber, id, headerMessage), - - buildContext: ({ owner, repo }, input, repoDir, log) => - buildReviewContext( - owner, - repo, - input.prNumber, - repoDir, - input.project, - input.config, - log, - input.modelOverride, - ), - - async injectSyntheticCalls({ - builder, - ctx, - trackingContext, - repoDir, - id: { owner, repo }, - input, - }) { - let b = injectSyntheticCall( - builder, - trackingContext, - 'GetPRDetails', - { - comment: 'Pre-fetching PR details for review context', - owner, - repo, - prNumber: input.prNumber, - }, - ctx.prDetailsFormatted, - 'gc_pr_details', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRDiff', - { comment: 'Pre-fetching PR diff for code review', owner, repo, prNumber: input.prNumber }, - ctx.diffFormatted, - 'gc_pr_diff', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRChecks', - { comment: 'Pre-fetching CI check status for review', owner, repo, prNumber: input.prNumber }, - ctx.checkStatusFormatted, - 'gc_pr_checks', - ); - - // Inject context files (CLAUDE.md, README.md, etc.) - b = injectContextFiles(b, trackingContext, ctx.contextFiles); - - // Inject Squint overview BEFORE file contents β€” agent sees architectural map - // before encountering specific file contents - b = injectSquintContext(b, trackingContext, repoDir); - - // Inject full contents of PR changed files (up to token limit) - for (let i = 0; i < ctx.fileContents.included.length; i++) { - const file = ctx.fileContents.included[i]; - b = injectSyntheticCall( - b, - trackingContext, - 'ReadFile', - { comment: `Pre-fetching ${file.path} for review`, filePath: file.path }, - `path=${file.path}\n\n${file.content}`, - `gc_file_${i + 1}`, - ); - } - - return b; - }, -}; - -// ============================================================================ -// Review Agent Execution -// ============================================================================ - -export async function executeReviewAgent(input: ReviewAgentInput): Promise { - return executeGitHubAgent(reviewAgentDefinition, input); -} diff --git a/src/agents/shared/cleanup.ts b/src/agents/shared/cleanup.ts index 27a88154..2eb07110 100644 --- a/src/agents/shared/cleanup.ts +++ b/src/agents/shared/cleanup.ts @@ -2,7 +2,7 @@ import { cleanupLogDirectory, cleanupLogFile } from '../../utils/fileLogger.js'; import { clearWatchdogCleanup } from '../../utils/lifecycle.js'; import { logger } from '../../utils/logging.js'; import { cleanupTempDir } from '../../utils/repo.js'; -import type { FileLogger } from './lifecycle.js'; +import type { FileLogger } from './executionPipeline.js'; /** * Clean up temporary resources after agent execution. diff --git a/src/agents/shared/githubAgent.ts b/src/agents/shared/githubAgent.ts deleted file mode 100644 index 94f71bc6..00000000 --- a/src/agents/shared/githubAgent.ts +++ /dev/null @@ -1,265 +0,0 @@ -import type { ModelSpec } from 'llmist'; - -import { createProgressMonitor } from '../../backends/progress.js'; -import { INITIAL_MESSAGES } from '../../config/agentMessages.js'; -import { CUSTOM_MODELS } from '../../config/customModels.js'; -import { recordInitialComment } from '../../gadgets/sessionState.js'; -import { githubClient, withGitHubToken } from '../../github/client.js'; -import { getPersonaToken } from '../../github/personas.js'; -import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../../types/index.js'; -import { logger } from '../../utils/logging.js'; -import { parseRepoFullName } from '../../utils/repo.js'; -import type { AgentLogger } from '../utils/logging.js'; -import type { TrackingContext } from '../utils/tracking.js'; -import { - type BuilderType, - type CreateBuilderOptions, - createConfiguredBuilder, -} from './builderFactory.js'; -import { type BaseAgentContext, executeAgentLifecycle } from './lifecycle.js'; -import { setupRepository } from './repository.js'; -import { injectSyntheticCall } from './syntheticCalls.js'; - -// ============================================================================ -// Types -// ============================================================================ - -export interface GitHubAgentInput extends AgentInput { - prNumber: number; - prBranch: string; - repoFullName: string; - project: ProjectConfig; - config: CascadeConfig; -} - -export interface RepoIdentifier { - owner: string; - repo: string; -} - -export interface InitialCommentResult { - id: number; - htmlUrl: string; - gadgetName: string; -} - -export interface GitHubAgentContext extends BaseAgentContext { - systemPrompt: string; -} - -export interface GitHubAgentDefinition< - TInput extends GitHubAgentInput, - TContext extends GitHubAgentContext, -> { - agentType: string; - /** Static header message β€” last-resort fallback when no ackMessage or INITIAL_MESSAGES entry. */ - headerMessage?: string; - initialCommentDescription: string; - timeoutMessage: string; - loggerPrefix: string; - - getGadgets(): CreateBuilderOptions['gadgets']; - - preExecute?(input: TInput, id: RepoIdentifier): Promise; - - postInitialComment( - input: TInput, - id: RepoIdentifier, - headerMessage: string, - ): Promise; - - buildContext( - id: RepoIdentifier, - input: TInput, - repoDir: string, - log: AgentLogger, - ): Promise; - - injectSyntheticCalls(params: { - builder: BuilderType; - ctx: TContext; - trackingContext: TrackingContext; - repoDir: string; - id: RepoIdentifier; - input: TInput; - }): Promise; - - wrapExecution?(input: TInput, runLifecycle: () => Promise): Promise; - - builderOptions?: Pick; -} - -// ============================================================================ -// Default Helpers -// ============================================================================ - -export async function createInitialPRComment( - prNumber: number, - id: RepoIdentifier, - headerMessage: string, -): Promise { - const comment = await githubClient.createPRComment(id.owner, id.repo, prNumber, headerMessage); - return { id: comment.id, htmlUrl: comment.htmlUrl, gadgetName: 'PostPRComment' }; -} - -// ============================================================================ -// Shared Execution -// ============================================================================ - -export async function executeGitHubAgent< - TInput extends GitHubAgentInput, - TContext extends GitHubAgentContext, ->(definition: GitHubAgentDefinition, input: TInput): Promise { - const { prNumber, prBranch, repoFullName, project, interactive, autoAccept } = input; - - let owner: string; - let repo: string; - try { - ({ owner, repo } = parseRepoFullName(repoFullName)); - } catch { - return { success: false, output: '', error: `Invalid repo format: ${repoFullName}` }; - } - const id: RepoIdentifier = { owner, repo }; - - if (definition.preExecute) { - const earlyResult = await definition.preExecute(input, id); - if (earlyResult) return earlyResult; - } - - // Resolve effective header: ackMessage (LLM-generated) > INITIAL_MESSAGES > definition fallback - const effectiveHeader = - (input.ackMessage as string | undefined) ?? - INITIAL_MESSAGES[definition.agentType] ?? - definition.headerMessage ?? - INITIAL_MESSAGES.implementation; - - // Pre-existing ack comment from router or webhook handler - const preExistingAckId = input.ackCommentId as number | undefined; - - const runLifecycle = () => - executeAgentLifecycle({ - loggerIdentifier: `${definition.loggerPrefix}-${prNumber}`, - - onWatchdogTimeout: async () => { - await githubClient.createPRComment(owner, repo, prNumber, definition.timeoutMessage); - logger.info('Posted timeout notice to PR', { prNumber }); - }, - - setupRepoDir: (log) => - setupRepository({ project, log, agentType: definition.agentType, prBranch }), - - buildContext: (repoDir, log) => definition.buildContext(id, input, repoDir, log), - - createBuilder: ({ - client, - ctx, - llmistLogger, - trackingContext, - fileLogger, - repoDir, - progressMonitor, - llmCallAccumulator, - runId, - }) => - createConfiguredBuilder({ - client, - agentType: definition.agentType, - model: ctx.model, - systemPrompt: ctx.systemPrompt, - maxIterations: ctx.maxIterations, - llmistLogger, - trackingContext, - logWriter: fileLogger.write.bind(fileLogger), - llmCallLogger: fileLogger.llmCallLogger, - repoDir, - gadgets: definition.getGadgets(), - progressMonitor: progressMonitor ?? undefined, - remainingBudgetUsd: input.remainingBudgetUsd as number | undefined, - llmCallAccumulator, - runId, - baseBranch: project.baseBranch, - projectId: project.id, - cardId: input.cardId, - ...definition.builderOptions, - }), - - injectSyntheticCalls: async ({ builder, ctx, trackingContext, repoDir }) => { - let initialCommentId: number; - let initialCommentHtmlUrl: string; - let gadgetName: string; - - if (preExistingAckId) { - // Ack comment already posted by router/webhook-handler β€” reuse it - recordInitialComment(preExistingAckId); - initialCommentId = preExistingAckId; - initialCommentHtmlUrl = `https://github.com/${owner}/${repo}/pull/${prNumber}#issuecomment-${preExistingAckId}`; - gadgetName = 'PostPRComment'; - } else { - // No pre-existing ack β€” post initial comment now - const initialComment = await definition.postInitialComment(input, id, effectiveHeader); - recordInitialComment(initialComment.id); - initialCommentId = initialComment.id; - initialCommentHtmlUrl = initialComment.htmlUrl; - gadgetName = initialComment.gadgetName; - } - - const withComment = injectSyntheticCall( - builder, - trackingContext, - gadgetName, - { - comment: definition.initialCommentDescription, - owner, - repo, - prNumber, - body: effectiveHeader, - }, - `Comment posted (id: ${initialCommentId}): ${initialCommentHtmlUrl}`, - 'gc_initial_comment', - ); - - return definition.injectSyntheticCalls({ - builder: withComment, - ctx, - trackingContext, - repoDir, - id, - input, - }); - }, - - createProgressMonitor: (fileLogger, _repoDir) => - createProgressMonitor({ - logWriter: fileLogger.write.bind(fileLogger), - agentType: definition.agentType, - taskDescription: `PR #${prNumber} in ${repoFullName}`, - progressModel: input.config.defaults.progressModel, - intervalMinutes: input.config.defaults.progressIntervalMinutes, - customModels: CUSTOM_MODELS as ModelSpec[], - github: { owner, repo, headerMessage: effectiveHeader }, - }), - - interactive, - autoAccept, - customModels: CUSTOM_MODELS, - - runTracking: { - projectId: project.id, - cardId: input.cardId, - prNumber, - agentType: definition.agentType, - backendName: 'llmist', - triggerType: input.triggerType, - }, - }); - - // Resolve the persona-based GitHub token (GITHUB_TOKEN_IMPLEMENTER or GITHUB_TOKEN_REVIEWER) - // for all PR interactions (comments, reviews). Individual agents can add further wrapping via wrapExecution. - const agentGitHubToken = await getPersonaToken(input.project.id, definition.agentType); - const scopedLifecycle = () => withGitHubToken(agentGitHubToken, runLifecycle); - - if (definition.wrapExecution) { - return definition.wrapExecution(input, scopedLifecycle); - } - return scopedLifecycle(); -} diff --git a/src/agents/shared/lifecycle.ts b/src/agents/shared/lifecycle.ts deleted file mode 100644 index 4d762dcf..00000000 --- a/src/agents/shared/lifecycle.ts +++ /dev/null @@ -1,323 +0,0 @@ -import fs from 'node:fs'; - -import { LLMist, type ModelSpec, createLogger } from 'llmist'; - -import type { ProgressMonitor } from '../../backends/progressMonitor.js'; -import { - type CompleteRunInput, - type LlmCallRecord, - storeLlmCallsBulk, - storeRunLogs, -} from '../../db/repositories/runsRepository.js'; -import { addBreadcrumb } from '../../sentry.js'; -import type { AgentResult } from '../../types/index.js'; -import { logger } from '../../utils/logging.js'; -import { runAgentLoop } from '../utils/agentLoop.js'; -import type { AccumulatedLlmCall } from '../utils/hooks.js'; -import { getLogLevel } from '../utils/index.js'; -import { createAgentLogger } from '../utils/logging.js'; -import { createTrackingContext } from '../utils/tracking.js'; -import type { BuilderType } from './builderFactory.js'; -import { - type AgentLogger, - type FileLogger, - type FinalizeRunOutcome, - type PipelineContext, - executeAgentPipeline, -} from './executionPipeline.js'; -import type { RunTrackingInput } from './runTracking.js'; -import { tryCompleteRun, tryCreateRun } from './runTracking.js'; - -export type { FileLogger, AgentLogger }; - -export interface BaseAgentContext { - model: string; - maxIterations: number; - prompt: string; -} - -export interface ExecuteAgentOptions { - /** Identifier for log file naming (e.g., "review-42", "ci-42") */ - loggerIdentifier: string; - - /** Called when the watchdog timer expires. FileLogger is already closed. */ - onWatchdogTimeout: (fileLogger: FileLogger, runId?: string) => Promise; - - /** Set up the working directory (clone repo, etc.) */ - setupRepoDir: (log: AgentLogger) => Promise; - - /** Build agent-specific context (model config, PR data, etc.) */ - buildContext: (repoDir: string, log: AgentLogger) => Promise; - - /** Create the configured agent builder with gadgets */ - createBuilder: (params: { - client: LLMist; - ctx: TContext; - llmistLogger: ReturnType; - trackingContext: ReturnType; - fileLogger: FileLogger; - repoDir: string; - progressMonitor: ProgressMonitor | null; - llmCallAccumulator: AccumulatedLlmCall[]; - /** Run ID for real-time LLM call logging (resolved before builder creation) */ - runId: string | undefined; - }) => BuilderType; - - /** Inject pre-fetched data as synthetic gadget calls */ - injectSyntheticCalls: (params: { - builder: BuilderType; - ctx: TContext; - trackingContext: ReturnType; - repoDir: string; - }) => Promise; - - /** Create a ProgressMonitor for time-based progress reporting */ - createProgressMonitor?: (fileLogger: FileLogger, repoDir: string) => ProgressMonitor | null; - - /** Whether to run in interactive mode */ - interactive?: boolean; - - /** Whether to auto-accept gadget calls */ - autoAccept?: boolean; - - /** Custom model definitions for LLMist */ - customModels?: ModelSpec[]; - - /** Extract additional fields from agent output (e.g., PR URL) */ - postProcess?: (output: string) => Partial; - - /** Run tracking configuration (if set, creates DB records) */ - runTracking?: RunTrackingInput; - - /** Remote Squint DB URL for projects that don't commit .squint.db */ - squintDbUrl?: string; -} - -// ============================================================================ -// Run Tracking Helpers -// ============================================================================ - -async function tryStoreLogsAndCalls( - runId: string, - fileLogger: FileLogger, - llmCallAccumulator: AccumulatedLlmCall[], - realtimeLoggingActive?: boolean, -): Promise { - try { - // Read log files from disk - const cascadeLog = fs.existsSync(fileLogger.logPath) - ? fs.readFileSync(fileLogger.logPath, 'utf-8') - : undefined; - const llmistLog = fs.existsSync(fileLogger.llmistLogPath) - ? fs.readFileSync(fileLogger.llmistLogPath, 'utf-8') - : undefined; - - await storeRunLogs(runId, cascadeLog, llmistLog); - - // Merge file-based request/response text with accumulator-based token/cost metrics - const llmLogFiles = fileLogger.llmCallLogger.getLogFiles(); - const requestFiles = new Map(); - const responseFiles = new Map(); - - for (const filePath of llmLogFiles) { - const basename = filePath.split('/').pop() ?? ''; - const match = basename.match(/^(\d+)\.(request|response)$/); - if (!match) continue; - const callNum = Number.parseInt(match[1], 10); - const content = fs.readFileSync(filePath, 'utf-8'); - if (match[2] === 'request') { - requestFiles.set(callNum, content); - } else { - responseFiles.set(callNum, content); - } - } - - // Build LLM call records by merging file content with accumulator metrics - const accumulatorMap = new Map(); - for (const acc of llmCallAccumulator) { - accumulatorMap.set(acc.callNumber, acc); - } - - const allCallNumbers = new Set([ - ...requestFiles.keys(), - ...responseFiles.keys(), - ...accumulatorMap.keys(), - ]); - - const calls: LlmCallRecord[] = []; - for (const callNumber of allCallNumbers) { - const acc = accumulatorMap.get(callNumber); - calls.push({ - runId, - callNumber, - request: requestFiles.get(callNumber), - response: responseFiles.get(callNumber), - inputTokens: acc?.inputTokens, - outputTokens: acc?.outputTokens, - cachedTokens: acc?.cachedTokens, - costUsd: acc?.costUsd, - durationMs: acc?.durationMs, - model: acc?.model, - }); - } - - // Skip bulk insert if real-time logging was active (calls already stored per-turn) - if (calls.length > 0 && !realtimeLoggingActive) { - await storeLlmCallsBulk(calls); - } - } catch (err) { - logger.warn('Failed to store run logs', { runId, error: String(err) }); - } -} - -async function finalizeRunWithLlmCalls( - runId: string | undefined, - fileLogger: FileLogger, - llmCallAccumulator: AccumulatedLlmCall[], - input: CompleteRunInput, - realtimeLoggingActive?: boolean, -): Promise { - if (!runId) return; - await tryStoreLogsAndCalls(runId, fileLogger, llmCallAccumulator, realtimeLoggingActive); - await tryCompleteRun(runId, input); -} - -// ============================================================================ -// Main Lifecycle -// ============================================================================ - -/** - * Shared agent execution lifecycle handling logger setup, watchdog, - * repository setup, LLMist agent creation, execution, and cleanup. - */ -export async function executeAgentLifecycle( - options: ExecuteAgentOptions, -): Promise { - const llmCallAccumulator: AccumulatedLlmCall[] = []; - - // Build the finalizeRun callback with access to llmCallAccumulator - const buildFinalizeRun = - (finalizeRunFn: typeof finalizeRunWithLlmCalls) => - async ( - runId: string | undefined, - fileLogger: FileLogger, - outcome: FinalizeRunOutcome, - ): Promise => { - const meta = outcome.metadata as { llmIterations?: number; gadgetCalls?: number } | undefined; - - const completeInput: CompleteRunInput = { - status: outcome.status, - durationMs: outcome.durationMs, - success: outcome.success, - error: outcome.error, - costUsd: outcome.costUsd, - prUrl: outcome.prUrl, - outputSummary: outcome.outputSummary, - llmIterations: meta?.llmIterations, - gadgetCalls: meta?.gadgetCalls, - }; - await finalizeRunFn(runId, fileLogger, llmCallAccumulator, completeInput, !!runId); - }; - - return executeAgentPipeline({ - loggerIdentifier: options.loggerIdentifier, - setupRepoDir: options.setupRepoDir, - squintDbUrl: options.squintDbUrl, - - onWatchdogTimeout: async (fileLogger, runId) => { - await options.onWatchdogTimeout(fileLogger, runId); - }, - - finalizeRun: buildFinalizeRun(finalizeRunWithLlmCalls), - - execute: async (ctx: PipelineContext) => { - const { repoDir, fileLogger, setRunId } = ctx; - - const log = createAgentLogger(fileLogger); - const ctx_ = await options.buildContext(repoDir, log); - - // Create run record now that we have model and maxIterations - let runId: string | undefined; - if (options.runTracking) { - runId = await tryCreateRun(options.runTracking, ctx_.model, ctx_.maxIterations); - if (runId) setRunId(runId); - } - - log.info('Starting llmist agent', { - model: ctx_.model, - maxIterations: ctx_.maxIterations, - promptLength: ctx_.prompt.length, - runId, - }); - - addBreadcrumb({ - category: 'agent', - message: `Starting ${options.loggerIdentifier}`, - data: { model: ctx_.model, maxIterations: ctx_.maxIterations, runId }, - }); - - process.env.LLMIST_LOG_FILE = fileLogger.llmistLogPath; - process.env.LLMIST_LOG_TEE = 'true'; - - const client = options.customModels - ? new LLMist({ customModels: options.customModels }) - : new LLMist(); - const llmistLogger = createLogger({ minLevel: getLogLevel() }); - const trackingContext = createTrackingContext(); - const progressMonitor = options.createProgressMonitor?.(fileLogger, repoDir) ?? null; - - let builder = options.createBuilder({ - client, - ctx: ctx_, - llmistLogger, - trackingContext, - fileLogger, - repoDir, - progressMonitor, - llmCallAccumulator, - runId, - }); - builder = await options.injectSyntheticCalls({ - builder, - ctx: ctx_, - trackingContext, - repoDir, - }); - - const agent = builder.ask(ctx_.prompt); - - progressMonitor?.start(); - let result: Awaited>; - try { - result = await runAgentLoop( - agent, - log, - trackingContext, - options.interactive === true, - options.autoAccept === true, - ); - } finally { - progressMonitor?.stop(); - } - - log.info('Agent completed', { - iterations: result.iterations, - gadgetCalls: result.gadgetCalls, - cost: result.cost, - loopTerminated: result.loopTerminated ?? false, - }); - - return { - success: !result.loopTerminated, - output: result.output, - error: result.loopTerminated ? 'Agent terminated due to persistent loop' : undefined, - cost: result.cost, - prUrl: options.postProcess?.(result.output)?.prUrl, - finalizeMetadata: { - llmIterations: result.iterations, - gadgetCalls: result.gadgetCalls, - }, - }; - }, - }); -} diff --git a/src/agents/shared/prResponseAgent.ts b/src/agents/shared/prResponseAgent.ts deleted file mode 100644 index 4f9a739b..00000000 --- a/src/agents/shared/prResponseAgent.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { githubClient } from '../../github/client.js'; -import type { CascadeConfig, ProjectConfig } from '../../types/index.js'; -import type { TrackingContext } from '../utils/tracking.js'; -import type { BuilderType } from './builderFactory.js'; -import type { GitHubAgentContext, GitHubAgentInput, RepoIdentifier } from './githubAgent.js'; -import { type InitialCommentResult, createInitialPRComment } from './githubAgent.js'; -import { resolveModelConfig } from './modelResolution.js'; -import { - formatPRComments, - formatPRDetails, - formatPRDiff, - formatPRIssueComments, - formatPRReviews, -} from './prFormatting.js'; -import { - injectContextFiles, - injectDirectoryListing, - injectSquintContext, - injectSyntheticCall, -} from './syntheticCalls.js'; - -// ============================================================================ -// Shared Types -// ============================================================================ - -export interface PRResponseAgentInput extends GitHubAgentInput { - triggerCommentId: number; - triggerCommentBody: string; - triggerCommentPath: string; - triggerCommentUrl: string; -} - -export interface PRResponseContextData extends GitHubAgentContext { - contextFiles: Awaited>['contextFiles']; - prDetailsFormatted: string; - commentsFormatted: string; - reviewsFormatted: string; - issueCommentsFormatted: string; - diffFormatted: string; -} - -// ============================================================================ -// Context Builder -// ============================================================================ - -export async function buildPRResponseContext( - owner: string, - repo: string, - prNumber: number, - prBranch: string, - repoDir: string, - project: ProjectConfig, - config: CascadeConfig, - log: { info: (msg: string, ctx?: Record) => void }, - agentType: string, - promptBuilder: (prBranch: string, prNumber: number, owner: string, repo: string) => string, - modelOverride?: string, -): Promise { - const { systemPrompt, model, maxIterations, contextFiles } = await resolveModelConfig({ - agentType, - project, - config, - repoDir, - modelOverride, - configKey: 'review', - }); - - log.info('Fetching PR details, comments, reviews, issue comments, and diff', { - owner, - repo, - prNumber, - }); - const prDetails = await githubClient.getPR(owner, repo, prNumber); - const prComments = await githubClient.getPRReviewComments(owner, repo, prNumber); - const prReviews = await githubClient.getPRReviews(owner, repo, prNumber); - const prIssueComments = await githubClient.getPRIssueComments(owner, repo, prNumber); - const prDiff = await githubClient.getPRDiff(owner, repo, prNumber); - - const prDetailsFormatted = formatPRDetails(prDetails); - const commentsFormatted = formatPRComments(prComments); - const reviewsFormatted = formatPRReviews(prReviews); - const issueCommentsFormatted = formatPRIssueComments(prIssueComments); - const diffFormatted = formatPRDiff(prDiff); - - const prompt = promptBuilder(prBranch, prNumber, owner, repo); - - return { - systemPrompt, - model, - maxIterations, - contextFiles, - prDetailsFormatted, - commentsFormatted, - reviewsFormatted, - issueCommentsFormatted, - diffFormatted, - prompt, - }; -} - -// ============================================================================ -// Prompt Builder -// ============================================================================ - -export function buildPRResponsePrompt( - prBranch: string, - prNumber: number, - owner: string, - repo: string, - instructionLine: string, - gadgetNames: string, -): string { - return `You are on the branch \`${prBranch}\` for PR #${prNumber}. - -${instructionLine} - -## GitHub Context - -Owner: ${owner} -Repo: ${repo} -PR Number: ${prNumber} - -Use these values when calling GitHub gadgets (${gadgetNames}).`; -} - -// ============================================================================ -// Initial Comment Handler -// ============================================================================ - -export async function postInitialPRResponseComment( - input: PRResponseAgentInput, - id: RepoIdentifier, - headerMessage: string, -): Promise { - return createInitialPRComment(input.prNumber, id, headerMessage); -} - -// ============================================================================ -// Synthetic Call Injection -// ============================================================================ - -/** Default comment descriptions used by respond-to-review. */ -const DEFAULT_COMMENT_DESCRIPTIONS = { - prComments: 'Pre-fetching line-specific review comments to address', - prReviews: 'Pre-fetching review submissions (approve/request changes with body text)', - prIssueComments: 'Pre-fetching general PR comments (issue-style conversation)', -}; - -export interface InjectPRResponseSyntheticCallsParams { - builder: BuilderType; - ctx: PRResponseContextData; - trackingContext: TrackingContext; - repoDir: string; - id: RepoIdentifier; - input: PRResponseAgentInput; -} - -export interface InjectPRResponseSyntheticCallsOptions { - /** Callback to inject additional synthetic calls before the standard PR data calls. */ - preSyntheticCalls?: ( - builder: BuilderType, - trackingContext: TrackingContext, - input: PRResponseAgentInput, - ) => BuilderType; - /** Override default comment descriptions for specific calls. */ - commentDescriptions?: Partial; -} - -export function injectPRResponseSyntheticCalls( - params: InjectPRResponseSyntheticCallsParams, - options?: InjectPRResponseSyntheticCallsOptions, -): BuilderType { - const { ctx, trackingContext, repoDir, input } = params; - const { owner, repo } = params.id; - const descriptions = { ...DEFAULT_COMMENT_DESCRIPTIONS, ...options?.commentDescriptions }; - - let b = injectDirectoryListing(params.builder, trackingContext); - - if (options?.preSyntheticCalls) { - b = options.preSyntheticCalls(b, trackingContext, input); - } - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRDetails', - { comment: 'Pre-fetching PR details for context', owner, repo, prNumber: input.prNumber }, - ctx.prDetailsFormatted, - 'gc_pr_details', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRComments', - { comment: descriptions.prComments, owner, repo, prNumber: input.prNumber }, - ctx.commentsFormatted, - 'gc_pr_comments', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRReviews', - { comment: descriptions.prReviews, owner, repo, prNumber: input.prNumber }, - ctx.reviewsFormatted, - 'gc_pr_reviews', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRIssueComments', - { comment: descriptions.prIssueComments, owner, repo, prNumber: input.prNumber }, - ctx.issueCommentsFormatted, - 'gc_pr_issue_comments', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRDiff', - { comment: 'Pre-fetching PR diff for context', owner, repo, prNumber: input.prNumber }, - ctx.diffFormatted, - 'gc_pr_diff', - ); - - b = injectContextFiles(b, trackingContext, ctx.contextFiles); - b = injectSquintContext(b, trackingContext, repoDir); - - return b; -} diff --git a/src/agents/shared/runTracking.ts b/src/agents/shared/runTracking.ts index 24c01b60..d0cc71ed 100644 --- a/src/agents/shared/runTracking.ts +++ b/src/agents/shared/runTracking.ts @@ -7,7 +7,7 @@ import { storeRunLogs, } from '../../db/repositories/runsRepository.js'; import { logger } from '../../utils/logging.js'; -import type { FileLogger } from './lifecycle.js'; +import type { FileLogger } from './executionPipeline.js'; // ============================================================================ // Run Tracking Configuration diff --git a/src/agents/shared/syntheticCalls.ts b/src/agents/shared/syntheticCalls.ts index b465e7e8..b3c5b8d0 100644 --- a/src/agents/shared/syntheticCalls.ts +++ b/src/agents/shared/syntheticCalls.ts @@ -1,7 +1,3 @@ -import { execFileSync } from 'node:child_process'; -import { ListDirectory } from '../../gadgets/ListDirectory.js'; -import { resolveSquintDbPath } from '../../utils/squintDb.js'; -import type { ContextFile } from '../utils/setup.js'; import { type TrackingContext, recordSyntheticInvocationId } from '../utils/tracking.js'; import type { BuilderType } from './builderFactory.js'; @@ -19,86 +15,3 @@ export function injectSyntheticCall( recordSyntheticInvocationId(trackingContext, invocationId); return builder.withSyntheticGadgetCall(gadgetName, params, result, invocationId); } - -/** - * Inject directory listing as synthetic ListDirectory call. - */ -export function injectDirectoryListing( - builder: BuilderType, - trackingContext: TrackingContext, - maxDepth = 3, -): BuilderType { - const listDirGadget = new ListDirectory(); - const listDirParams = { - comment: 'Pre-fetching codebase structure for context', - directoryPath: '.', - maxDepth, - includeGitIgnored: false, - }; - const listDirResult = listDirGadget.execute(listDirParams); - return injectSyntheticCall( - builder, - trackingContext, - 'ListDirectory', - listDirParams, - listDirResult, - 'gc_dir', - ); -} - -/** - * Inject context files (CLAUDE.md, AGENTS.md, etc.) as synthetic ReadFile calls. - */ -export function injectContextFiles( - builder: BuilderType, - trackingContext: TrackingContext, - contextFiles: ContextFile[], -): BuilderType { - let result = builder; - for (let i = 0; i < contextFiles.length; i++) { - const file = contextFiles[i]; - const invocationId = `gc_init_${i + 1}`; - result = injectSyntheticCall( - result, - trackingContext, - 'ReadFile', - { comment: `Pre-fetching ${file.path} for project context`, filePath: file.path }, - file.content, - invocationId, - ); - } - return result; -} - -/** - * Inject Squint overview if enabled (gives agent immediate codebase context). - */ -export function injectSquintContext( - builder: BuilderType, - trackingContext: TrackingContext, - repoDir: string, -): BuilderType { - const squintDb = resolveSquintDbPath(repoDir); - if (!squintDb) return builder; - - try { - const output = execFileSync('squint', ['overview', '-d', squintDb], { - encoding: 'utf-8', - timeout: 30_000, - }); - - if (!output || !output.trim()) return builder; - - return injectSyntheticCall( - builder, - trackingContext, - 'SquintOverview', - { comment: 'Pre-fetching Squint codebase overview for context', database: squintDb }, - output, - 'gc_squint_overview', - ); - } catch { - // Squint command failed, continue without it - return builder; - } -} diff --git a/src/agents/shared/workItemBuilder.ts b/src/agents/shared/workItemBuilder.ts deleted file mode 100644 index f5eeddd6..00000000 --- a/src/agents/shared/workItemBuilder.ts +++ /dev/null @@ -1,165 +0,0 @@ -import type { LLMist, createLogger } from 'llmist'; - -import type { ProgressMonitor } from '../../backends/progressMonitor.js'; -import type { LLMCallLogger } from '../../utils/llmLogging.js'; -import type { AccumulatedLlmCall } from '../utils/hooks.js'; -import type { TrackingContext } from '../utils/tracking.js'; -import { type BuilderType, createConfiguredBuilder } from './builderFactory.js'; -import { getAgentCapabilities } from './capabilities.js'; -import { buildWorkItemGadgets } from './gadgets.js'; -import { - injectContextFiles, - injectDirectoryListing, - injectSquintContext, - injectSyntheticCall, -} from './syntheticCalls.js'; -import type { AgentContextData } from './workItemContext.js'; - -import { - type Todo, - formatTodoList, - initTodoSession, - saveTodos, -} from '../../gadgets/todo/storage.js'; - -// ============================================================================ -// Gadget Helpers -// ============================================================================ - -export function getBaseAgentGadgets(agentType: string) { - return buildWorkItemGadgets(getAgentCapabilities(agentType)); -} - -// ============================================================================ -// Builder Creation -// ============================================================================ - -export interface CreateWorkItemAgentBuilderParams { - client: LLMist; - ctx: AgentContextData; - llmistLogger: ReturnType; - trackingContext: TrackingContext; - agentType: string; - logWriter: (level: string, message: string, context?: Record) => void; - llmCallLogger: LLMCallLogger; - repoDir: string; - progressMonitor?: ProgressMonitor; - remainingBudgetUsd?: number; - llmCallAccumulator?: AccumulatedLlmCall[]; - runId?: string; - baseBranch?: string; - projectId?: string; - cardId?: string; -} - -export function createWorkItemAgentBuilder(params: CreateWorkItemAgentBuilderParams): BuilderType { - const { - client, - ctx, - llmistLogger, - trackingContext, - agentType, - logWriter, - llmCallLogger, - repoDir, - progressMonitor, - remainingBudgetUsd, - llmCallAccumulator, - runId, - baseBranch, - projectId, - cardId, - } = params; - - return createConfiguredBuilder({ - client, - agentType, - model: ctx.model, - systemPrompt: ctx.systemPrompt, - maxIterations: ctx.maxIterations, - llmistLogger, - trackingContext, - logWriter, - llmCallLogger, - repoDir, - gadgets: getBaseAgentGadgets(agentType), - progressMonitor, - remainingBudgetUsd, - llmCallAccumulator, - runId, - baseBranch, - projectId, - cardId, - // Implementation agent uses sequential execution to ensure file operations - // are properly ordered (e.g., FileSearchAndReplace then ReadFile on same file) - postConfigure: - agentType === 'implementation' - ? (builder) => builder.withGadgetExecutionMode('sequential') - : undefined, - }); -} - -// ============================================================================ -// Synthetic Call Injection -// ============================================================================ - -export async function injectWorkItemSyntheticCalls( - initialBuilder: BuilderType, - cardId: string | undefined, - cardData: string, - contextFiles: AgentContextData['contextFiles'], - trackingContext: TrackingContext, - repoDir: string, - implementationSteps?: string[], -): Promise { - // Use maxDepth=5 to give agents better visibility into nested structures - let builder = injectDirectoryListing(initialBuilder, trackingContext, 5); - - // Inject context files (CLAUDE.md, AGENTS.md) β€” conventions first - builder = injectContextFiles(builder, trackingContext, contextFiles); - - // Inject Squint overview BEFORE card data β€” agent sees architectural map - // before encountering specific file paths from the card - builder = injectSquintContext(builder, trackingContext, repoDir); - - // Inject work item data as synthetic ReadWorkItem call (only if cardId exists) - if (cardId && cardData) { - builder = injectSyntheticCall( - builder, - trackingContext, - 'ReadWorkItem', - { workItemId: cardId, includeComments: true }, - cardData, - 'gc_card', - ); - } - - // Inject pre-populated todos LAST β€” strongest "start coding" signal - if (implementationSteps && implementationSteps.length > 0) { - initTodoSession(`impl-${Date.now()}`); - - const now = new Date().toISOString(); - const todos: Todo[] = implementationSteps.map((step, i) => ({ - id: String(i + 1), - content: step, - status: 'pending' as const, - createdAt: now, - updatedAt: now, - })); - saveTodos(todos); - - builder = injectSyntheticCall( - builder, - trackingContext, - 'TodoUpsert', - { - items: implementationSteps.map((step) => ({ content: step })), - comment: 'Pre-populated from Implementation Steps checklist', - }, - `βž• Created ${todos.length} todos.\n\n${formatTodoList(todos)}`, - 'gc_todos', - ); - } - - return builder; -} diff --git a/src/agents/shared/workItemContext.ts b/src/agents/shared/workItemContext.ts deleted file mode 100644 index d2a4f0cd..00000000 --- a/src/agents/shared/workItemContext.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { loadPartials } from '../../db/repositories/partialsRepository.js'; -import { readWorkItem } from '../../gadgets/pm/core/readWorkItem.js'; -import { getPMProvider } from '../../pm/index.js'; -import type { CascadeConfig, ProjectConfig } from '../../types/index.js'; -import { type ModelConfig, resolveModelConfig } from './modelResolution.js'; -import { buildPromptContext } from './promptContext.js'; -import { - buildCheckFailurePrompt, - buildCommentResponsePrompt, - buildDebugPrompt, - buildWorkItemPrompt, -} from './taskPrompts.js'; - -// ============================================================================ -// Types -// ============================================================================ - -export interface AgentContextData { - systemPrompt: string; - model: string; - maxIterations: number; - contextFiles: ModelConfig['contextFiles']; - cardData: string; - prompt: string; - implementationSteps?: string[]; -} - -// ============================================================================ -// Helpers -// ============================================================================ - -export async function fetchImplementationSteps(cardId: string): Promise { - try { - const provider = getPMProvider(); - const checklists = await provider.getChecklists(cardId); - const implChecklist = checklists.find((cl) => cl.name.includes('Implementation Steps')); - if (!implChecklist || implChecklist.items.length === 0) return undefined; - const incompleteItems = implChecklist.items.filter((item) => !item.complete); - return incompleteItems.length > 0 ? incompleteItems.map((item) => item.name) : undefined; - } catch { - return undefined; - } -} - -async function loadDbPartials(orgId: string): Promise | undefined> { - try { - return await loadPartials(orgId); - } catch { - // DB not available β€” fall back to disk-only partials - return undefined; - } -} - -function selectPrompt( - cardId: string | undefined, - commentContext?: { text: string; author: string }, - prContext?: { prNumber: number; prBranch: string; repoFullName: string; headSha: string }, - debugContext?: { - logDir: string; - originalCardName: string; - originalCardUrl: string; - detectedAgentType: string; - }, -): string { - if (commentContext) { - return buildCommentResponsePrompt(cardId ?? '', commentContext.text, commentContext.author); - } - if (prContext) return buildCheckFailurePrompt(prContext); - if (debugContext) return buildDebugPrompt(debugContext); - return buildWorkItemPrompt(cardId ?? ''); -} - -// ============================================================================ -// Main Context Builder -// ============================================================================ - -export async function buildAgentContext( - agentType: string, - cardId: string | undefined, - repoDir: string, - project: ProjectConfig, - config: CascadeConfig, - log: { info: (msg: string, ctx?: Record) => void }, - triggerType?: string, - prContext?: { prNumber: number; prBranch: string; repoFullName: string; headSha: string }, - debugContext?: { - logDir: string; - originalCardId: string; - originalCardName: string; - originalCardUrl: string; - detectedAgentType: string; - }, - modelOverride?: string, - commentContext?: { text: string; author: string }, -): Promise { - const promptContext = buildPromptContext(cardId, project, triggerType, prContext, debugContext); - const dbPartials = await loadDbPartials(project.orgId); - - // Some agents share model/iteration config with another agent type - const configKeyOverrides: Record = { - 'respond-to-planning-comment': 'planning', - }; - - const { systemPrompt, model, maxIterations, contextFiles } = await resolveModelConfig({ - agentType, - project, - config, - repoDir, - modelOverride, - promptContext, - configKey: configKeyOverrides[agentType], - dbPartials, - }); - - // Pre-fetch work item data for synthetic gadget call (only if cardId exists and not debug flow) - let cardData = ''; - if (cardId && !debugContext) { - log.info('Fetching work item data for context', { cardId }); - cardData = await readWorkItem(cardId, true); - } - - // Pre-fetch implementation steps for synthetic todo injection - let implementationSteps: string[] | undefined; - if (agentType === 'implementation' && cardId && !debugContext) { - implementationSteps = await fetchImplementationSteps(cardId); - } - - const prompt = selectPrompt(cardId, commentContext, prContext, debugContext); - - return { - systemPrompt, - model, - maxIterations, - contextFiles, - cardData, - prompt, - implementationSteps, - }; -} diff --git a/tests/unit/agents/fetchImplementationSteps.test.ts b/tests/unit/agents/fetchImplementationSteps.test.ts deleted file mode 100644 index 840ae1ba..00000000 --- a/tests/unit/agents/fetchImplementationSteps.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('../../../src/pm/index.js', () => ({ - getPMProvider: vi.fn(), -})); - -import { fetchImplementationSteps } from '../../../src/agents/base.js'; -import type { PMProvider } from '../../../src/pm/index.js'; -import { getPMProvider } from '../../../src/pm/index.js'; - -const mockPMProvider = { - getChecklists: vi.fn(), -}; - -describe('fetchImplementationSteps', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(getPMProvider).mockReturnValue(mockPMProvider as unknown as PMProvider); - }); - - it('extracts incomplete items from Implementation Steps checklist', async () => { - mockPMProvider.getChecklists.mockResolvedValue([ - { - id: 'cl1', - name: 'πŸ“‹ Implementation Steps', - items: [ - { id: 'ci1', name: 'Add helper function', complete: false }, - { id: 'ci2', name: 'Update prompt template', complete: false }, - { id: 'ci3', name: 'Write tests', complete: false }, - ], - }, - ]); - - const result = await fetchImplementationSteps('card1'); - - expect(result).toEqual(['Add helper function', 'Update prompt template', 'Write tests']); - expect(mockPMProvider.getChecklists).toHaveBeenCalledWith('card1'); - }); - - it('filters out already-complete items', async () => { - mockPMProvider.getChecklists.mockResolvedValue([ - { - id: 'cl1', - name: 'πŸ“‹ Implementation Steps', - items: [ - { id: 'ci1', name: 'Already done step', complete: true }, - { id: 'ci2', name: 'Remaining step', complete: false }, - ], - }, - ]); - - const result = await fetchImplementationSteps('card1'); - - expect(result).toEqual(['Remaining step']); - }); - - it('returns undefined when all items are complete', async () => { - mockPMProvider.getChecklists.mockResolvedValue([ - { - id: 'cl1', - name: 'πŸ“‹ Implementation Steps', - items: [ - { id: 'ci1', name: 'Done step 1', complete: true }, - { id: 'ci2', name: 'Done step 2', complete: true }, - ], - }, - ]); - - const result = await fetchImplementationSteps('card1'); - - expect(result).toBeUndefined(); - }); - - it('returns undefined when no Implementation Steps checklist exists', async () => { - mockPMProvider.getChecklists.mockResolvedValue([ - { - id: 'cl1', - name: 'βœ… Acceptance Criteria', - items: [{ id: 'ci1', name: 'Some criterion', complete: false }], - }, - ]); - - const result = await fetchImplementationSteps('card1'); - - expect(result).toBeUndefined(); - }); - - it('returns undefined when checklist has no items', async () => { - mockPMProvider.getChecklists.mockResolvedValue([ - { - id: 'cl1', - name: 'πŸ“‹ Implementation Steps', - items: [], - }, - ]); - - const result = await fetchImplementationSteps('card1'); - - expect(result).toBeUndefined(); - }); - - it('returns undefined when card has no checklists', async () => { - mockPMProvider.getChecklists.mockResolvedValue([]); - - const result = await fetchImplementationSteps('card1'); - - expect(result).toBeUndefined(); - }); - - it('returns undefined when API call fails', async () => { - mockPMProvider.getChecklists.mockRejectedValue(new Error('API error')); - - const result = await fetchImplementationSteps('card1'); - - expect(result).toBeUndefined(); - }); - - it('matches checklist by substring (handles emoji prefix)', async () => { - mockPMProvider.getChecklists.mockResolvedValue([ - { - id: 'cl1', - name: 'Some other checklist', - items: [{ id: 'ci1', name: 'Ignored', complete: false }], - }, - { - id: 'cl2', - name: 'πŸ“‹ Implementation Steps (Phase 1)', - items: [{ id: 'ci2', name: 'Phase 1 step', complete: false }], - }, - ]); - - const result = await fetchImplementationSteps('card1'); - - expect(result).toEqual(['Phase 1 step']); - }); -}); diff --git a/tests/unit/agents/shared/lifecycle.test.ts b/tests/unit/agents/shared/lifecycle.test.ts deleted file mode 100644 index 1ed04734..00000000 --- a/tests/unit/agents/shared/lifecycle.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -// Mock all external dependencies -vi.mock('../../../../src/agents/utils/agentLoop.js', () => ({ - runAgentLoop: vi.fn(), -})); - -vi.mock('../../../../src/utils/fileLogger.js', () => ({ - createFileLogger: vi.fn(), - cleanupLogFile: vi.fn(), - cleanupLogDirectory: vi.fn(), -})); - -vi.mock('../../../../src/agents/utils/logging.js', () => ({ - createAgentLogger: vi.fn(), -})); - -vi.mock('../../../../src/utils/cascadeEnv.js', () => ({ - loadCascadeEnv: vi.fn(), - unloadCascadeEnv: vi.fn(), -})); - -vi.mock('../../../../src/utils/repo.js', () => ({ - cleanupTempDir: vi.fn(), -})); - -vi.mock('../../../../src/utils/lifecycle.js', () => ({ - setWatchdogCleanup: vi.fn(), - clearWatchdogCleanup: vi.fn(), -})); - -vi.mock('../../../../src/db/repositories/runsRepository.js', () => ({ - createRun: vi.fn(), - completeRun: vi.fn(), - storeRunLogs: vi.fn(), - storeLlmCallsBulk: vi.fn(), -})); - -vi.mock('llmist', () => ({ - LLMist: vi.fn().mockImplementation(() => ({})), - createLogger: vi.fn().mockReturnValue({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), -})); - -vi.mock('../../../../src/agents/utils/tracking.js', () => ({ - createTrackingContext: vi.fn().mockReturnValue({}), -})); - -import { executeAgentLifecycle } from '../../../../src/agents/shared/lifecycle.js'; -import { runAgentLoop } from '../../../../src/agents/utils/agentLoop.js'; -import { createAgentLogger } from '../../../../src/agents/utils/logging.js'; -import { - completeRun, - createRun, - storeLlmCallsBulk, - storeRunLogs, -} from '../../../../src/db/repositories/runsRepository.js'; -import { loadCascadeEnv, unloadCascadeEnv } from '../../../../src/utils/cascadeEnv.js'; -import { - cleanupLogDirectory, - cleanupLogFile, - createFileLogger, -} from '../../../../src/utils/fileLogger.js'; -import { clearWatchdogCleanup } from '../../../../src/utils/lifecycle.js'; -import { cleanupTempDir } from '../../../../src/utils/repo.js'; - -const mockRunAgentLoop = vi.mocked(runAgentLoop); -const mockCreateFileLogger = vi.mocked(createFileLogger); -const mockCreateAgentLogger = vi.mocked(createAgentLogger); -const mockLoadCascadeEnv = vi.mocked(loadCascadeEnv); -const mockUnloadCascadeEnv = vi.mocked(unloadCascadeEnv); -const mockCleanupTempDir = vi.mocked(cleanupTempDir); -const mockCleanupLogFile = vi.mocked(cleanupLogFile); -const mockCleanupLogDirectory = vi.mocked(cleanupLogDirectory); -const mockClearWatchdogCleanup = vi.mocked(clearWatchdogCleanup); -const mockCreateRun = vi.mocked(createRun); -const mockCompleteRun = vi.mocked(completeRun); -const mockStoreRunLogs = vi.mocked(storeRunLogs); -const mockStoreLlmCallsBulk = vi.mocked(storeLlmCallsBulk); - -function setupMocks() { - const mockLoggerInstance = { - write: vi.fn(), - close: vi.fn(), - getZippedBuffer: vi.fn().mockResolvedValue(Buffer.from('logs')), - logPath: '/tmp/test.log', - llmistLogPath: '/tmp/test-llmist.log', - llmCallLogger: { - logDir: '/tmp/llm-calls', - getLogFiles: vi.fn().mockReturnValue([]), - }, - }; - mockCreateFileLogger.mockReturnValue(mockLoggerInstance as never); - mockCreateAgentLogger.mockReturnValue({ info: vi.fn(), warn: vi.fn(), error: vi.fn() } as never); - mockLoadCascadeEnv.mockReturnValue({}); - mockRunAgentLoop.mockResolvedValue({ - output: 'Task completed', - iterations: 5, - gadgetCalls: 10, - cost: 0.5, - loopTerminated: false, - } as never); - - return mockLoggerInstance; -} - -beforeEach(() => { - vi.clearAllMocks(); - process.env.CASCADE_LOCAL_MODE = ''; -}); - -describe('executeAgentLifecycle', () => { - it('returns durationMs in successful result', async () => { - setupMocks(); - - const result = await executeAgentLifecycle({ - loggerIdentifier: 'test-run', - onWatchdogTimeout: vi.fn(), - setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), - buildContext: vi.fn().mockResolvedValue({ - model: 'test-model', - maxIterations: 50, - prompt: 'Do something', - }), - createBuilder: vi.fn().mockReturnValue({ - ask: vi.fn().mockReturnValue({}), - } as never), - injectSyntheticCalls: vi.fn().mockImplementation(({ builder }) => Promise.resolve(builder)), - }); - - expect(result.success).toBe(true); - expect(result.durationMs).toBeDefined(); - expect(result.durationMs).toBeGreaterThanOrEqual(0); - expect(typeof result.durationMs).toBe('number'); - }); - - it('returns durationMs in error result', async () => { - setupMocks(); - - const result = await executeAgentLifecycle({ - loggerIdentifier: 'test-run', - onWatchdogTimeout: vi.fn(), - setupRepoDir: vi.fn().mockRejectedValue(new Error('Setup failed')), - buildContext: vi.fn().mockResolvedValue({ - model: 'test-model', - maxIterations: 50, - prompt: 'Do something', - }), - createBuilder: vi.fn().mockReturnValue({ - ask: vi.fn().mockReturnValue({}), - } as never), - injectSyntheticCalls: vi.fn().mockImplementation(({ builder }) => Promise.resolve(builder)), - }); - - expect(result.success).toBe(false); - expect(result.durationMs).toBeDefined(); - expect(result.durationMs).toBeGreaterThanOrEqual(0); - expect(typeof result.durationMs).toBe('number'); - }); - - it('returns durationMs when loop is terminated', async () => { - const loggerInstance = setupMocks(); - mockRunAgentLoop.mockResolvedValue({ - output: 'Loop detected', - iterations: 50, - gadgetCalls: 100, - cost: 2.0, - loopTerminated: true, - } as never); - - const result = await executeAgentLifecycle({ - loggerIdentifier: 'test-run', - onWatchdogTimeout: vi.fn(), - setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), - buildContext: vi.fn().mockResolvedValue({ - model: 'test-model', - maxIterations: 50, - prompt: 'Do something', - }), - createBuilder: vi.fn().mockReturnValue({ - ask: vi.fn().mockReturnValue({}), - } as never), - injectSyntheticCalls: vi.fn().mockImplementation(({ builder }) => Promise.resolve(builder)), - }); - - expect(result.success).toBe(false); - expect(result.error).toBe('Agent terminated due to persistent loop'); - expect(result.durationMs).toBeDefined(); - expect(result.durationMs).toBeGreaterThanOrEqual(0); - expect(typeof result.durationMs).toBe('number'); - }); - - it('passes durationMs to completeRun on success', async () => { - setupMocks(); - mockCreateRun.mockResolvedValue('run123'); - - await executeAgentLifecycle({ - loggerIdentifier: 'test-run', - onWatchdogTimeout: vi.fn(), - setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), - buildContext: vi.fn().mockResolvedValue({ - model: 'test-model', - maxIterations: 50, - prompt: 'Do something', - }), - createBuilder: vi.fn().mockReturnValue({ - ask: vi.fn().mockReturnValue({}), - } as never), - injectSyntheticCalls: vi.fn().mockImplementation(({ builder }) => Promise.resolve(builder)), - runTracking: { - projectId: 'test-project', - agentType: 'implementation', - backendName: 'llmist', - }, - }); - - expect(mockCompleteRun).toHaveBeenCalledWith( - 'run123', - expect.objectContaining({ - status: 'completed', - durationMs: expect.any(Number), - }), - ); - }); - - it('passes durationMs to completeRun on agent loop error', async () => { - const loggerInstance = setupMocks(); - mockCreateRun.mockResolvedValue('run123'); - mockRunAgentLoop.mockRejectedValue(new Error('Agent crashed')); - - await executeAgentLifecycle({ - loggerIdentifier: 'test-run', - onWatchdogTimeout: vi.fn(), - setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), - buildContext: vi.fn().mockResolvedValue({ - model: 'test-model', - maxIterations: 50, - prompt: 'Do something', - }), - createBuilder: vi.fn().mockReturnValue({ - ask: vi.fn().mockReturnValue({}), - } as never), - injectSyntheticCalls: vi.fn().mockImplementation(({ builder }) => Promise.resolve(builder)), - runTracking: { - projectId: 'test-project', - agentType: 'implementation', - backendName: 'llmist', - }, - }); - - expect(mockCompleteRun).toHaveBeenCalledWith( - 'run123', - expect.objectContaining({ - status: 'failed', - durationMs: expect.any(Number), - success: false, - }), - ); - }); -}); diff --git a/tests/unit/agents/shared/prResponseAgent.test.ts b/tests/unit/agents/shared/prResponseAgent.test.ts deleted file mode 100644 index 9e0cc7de..00000000 --- a/tests/unit/agents/shared/prResponseAgent.test.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('../../../../src/github/client.js', () => ({ - githubClient: { - getPR: vi.fn(), - getPRReviewComments: vi.fn(), - getPRReviews: vi.fn(), - getPRIssueComments: vi.fn(), - getPRDiff: vi.fn(), - updatePRComment: vi.fn(), - createPRComment: vi.fn(), - }, -})); - -vi.mock('../../../../src/agents/shared/modelResolution.js', () => ({ - resolveModelConfig: vi.fn(), -})); - -vi.mock('../../../../src/agents/shared/prFormatting.js', () => ({ - formatPRDetails: vi.fn((v) => `details:${v}`), - formatPRComments: vi.fn((v) => `comments:${v}`), - formatPRReviews: vi.fn((v) => `reviews:${v}`), - formatPRIssueComments: vi.fn((v) => `issueComments:${v}`), - formatPRDiff: vi.fn((v) => `diff:${v}`), -})); - -vi.mock('../../../../src/agents/shared/syntheticCalls.js', () => ({ - injectDirectoryListing: vi.fn((_b, _tc) => 'builder-after-dir'), - injectSyntheticCall: vi.fn((_b, _tc, name) => `builder-after-${name}`), - injectContextFiles: vi.fn((_b, _tc, _cf) => 'builder-after-context-files'), - injectSquintContext: vi.fn((_b, _tc, _rd) => 'builder-after-squint'), -})); - -vi.mock('../../../../src/agents/shared/githubAgent.js', () => ({ - createInitialPRComment: vi.fn(), -})); - -import { createInitialPRComment } from '../../../../src/agents/shared/githubAgent.js'; -import { resolveModelConfig } from '../../../../src/agents/shared/modelResolution.js'; -import { - type InjectPRResponseSyntheticCallsParams, - type PRResponseAgentInput, - type PRResponseContextData, - buildPRResponseContext, - buildPRResponsePrompt, - injectPRResponseSyntheticCalls, - postInitialPRResponseComment, -} from '../../../../src/agents/shared/prResponseAgent.js'; -import { - injectContextFiles, - injectDirectoryListing, - injectSquintContext, - injectSyntheticCall, -} from '../../../../src/agents/shared/syntheticCalls.js'; -import { githubClient } from '../../../../src/github/client.js'; - -const mockGithub = vi.mocked(githubClient); -const mockResolveModelConfig = vi.mocked(resolveModelConfig); -const mockCreateInitialPRComment = vi.mocked(createInitialPRComment); -const mockInjectDirectoryListing = vi.mocked(injectDirectoryListing); -const mockInjectSyntheticCall = vi.mocked(injectSyntheticCall); -const mockInjectContextFiles = vi.mocked(injectContextFiles); -const mockInjectSquintContext = vi.mocked(injectSquintContext); - -describe('prResponseAgent shared module', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - // ======================================================================== - // buildPRResponsePrompt - // ======================================================================== - - describe('buildPRResponsePrompt', () => { - it('generates prompt with the correct template values', () => { - const result = buildPRResponsePrompt( - 'feature/xyz', - 42, - 'myorg', - 'myrepo', - 'Address the review comments.', - 'GetPRComments, ReplyToReviewComment', - ); - - expect(result).toContain('`feature/xyz`'); - expect(result).toContain('PR #42'); - expect(result).toContain('Address the review comments.'); - expect(result).toContain('Owner: myorg'); - expect(result).toContain('Repo: myrepo'); - expect(result).toContain('PR Number: 42'); - expect(result).toContain('GetPRComments, ReplyToReviewComment'); - }); - - it('uses the instruction line and gadget names provided', () => { - const result = buildPRResponsePrompt( - 'fix/bug', - 7, - 'owner', - 'repo', - 'A user @mentioned you. Execute their request.', - 'PostPRComment, UpdatePRComment', - ); - - expect(result).toContain('A user @mentioned you. Execute their request.'); - expect(result).toContain('PostPRComment, UpdatePRComment'); - }); - }); - - // ======================================================================== - // postInitialPRResponseComment - // ======================================================================== - - describe('postInitialPRResponseComment', () => { - const id = { owner: 'org', repo: 'repo' }; - const baseInput = { - prNumber: 10, - prBranch: 'feat', - repoFullName: 'org/repo', - triggerCommentId: 1, - triggerCommentBody: 'body', - triggerCommentPath: 'path', - triggerCommentUrl: 'url', - } as PRResponseAgentInput; - - it('creates a new comment via createInitialPRComment', async () => { - mockCreateInitialPRComment.mockResolvedValue({ - id: 999, - htmlUrl: 'https://example.com/999', - gadgetName: 'PostPRComment', - }); - - const result = await postInitialPRResponseComment(baseInput, id, 'header'); - - expect(mockCreateInitialPRComment).toHaveBeenCalledWith(10, id, 'header'); - expect(result).toEqual({ - id: 999, - htmlUrl: 'https://example.com/999', - gadgetName: 'PostPRComment', - }); - }); - }); - - // ======================================================================== - // buildPRResponseContext - // ======================================================================== - - describe('buildPRResponseContext', () => { - const mockLog = { info: vi.fn() }; - - beforeEach(() => { - mockResolveModelConfig.mockResolvedValue({ - systemPrompt: 'sys', - model: 'gpt-4', - maxIterations: 10, - contextFiles: [{ path: 'CLAUDE.md', content: '# test' }], - }); - - mockGithub.getPR.mockResolvedValue('pr-raw' as never); - mockGithub.getPRReviewComments.mockResolvedValue('comments-raw' as never); - mockGithub.getPRReviews.mockResolvedValue('reviews-raw' as never); - mockGithub.getPRIssueComments.mockResolvedValue('issue-comments-raw' as never); - mockGithub.getPRDiff.mockResolvedValue('diff-raw' as never); - }); - - it('resolves model config with the correct agent type and configKey', async () => { - const promptBuilder = vi.fn().mockReturnValue('prompt'); - - await buildPRResponseContext( - 'org', - 'repo', - 42, - 'feat', - '/tmp/repo', - { id: 'proj' } as never, - { defaults: {} } as never, - mockLog, - 'respond-to-review', - promptBuilder, - ); - - expect(mockResolveModelConfig).toHaveBeenCalledWith({ - agentType: 'respond-to-review', - project: { id: 'proj' }, - config: { defaults: {} }, - repoDir: '/tmp/repo', - modelOverride: undefined, - configKey: 'review', - }); - }); - - it('fetches all 5 PR endpoints', async () => { - const promptBuilder = vi.fn().mockReturnValue('prompt'); - - await buildPRResponseContext( - 'org', - 'repo', - 42, - 'feat', - '/tmp/repo', - { id: 'proj' } as never, - { defaults: {} } as never, - mockLog, - 'respond-to-review', - promptBuilder, - ); - - expect(mockGithub.getPR).toHaveBeenCalledWith('org', 'repo', 42); - expect(mockGithub.getPRReviewComments).toHaveBeenCalledWith('org', 'repo', 42); - expect(mockGithub.getPRReviews).toHaveBeenCalledWith('org', 'repo', 42); - expect(mockGithub.getPRIssueComments).toHaveBeenCalledWith('org', 'repo', 42); - expect(mockGithub.getPRDiff).toHaveBeenCalledWith('org', 'repo', 42); - }); - - it('returns combined context data with formatted values', async () => { - const promptBuilder = vi.fn().mockReturnValue('my-prompt'); - - const result = await buildPRResponseContext( - 'org', - 'repo', - 42, - 'feat', - '/tmp/repo', - { id: 'proj' } as never, - { defaults: {} } as never, - mockLog, - 'respond-to-review', - promptBuilder, - ); - - expect(result).toEqual({ - systemPrompt: 'sys', - model: 'gpt-4', - maxIterations: 10, - contextFiles: [{ path: 'CLAUDE.md', content: '# test' }], - prDetailsFormatted: 'details:pr-raw', - commentsFormatted: 'comments:comments-raw', - reviewsFormatted: 'reviews:reviews-raw', - issueCommentsFormatted: 'issueComments:issue-comments-raw', - diffFormatted: 'diff:diff-raw', - prompt: 'my-prompt', - }); - }); - - it('passes modelOverride through to resolveModelConfig', async () => { - const promptBuilder = vi.fn().mockReturnValue('prompt'); - - await buildPRResponseContext( - 'org', - 'repo', - 42, - 'feat', - '/tmp/repo', - { id: 'proj' } as never, - { defaults: {} } as never, - mockLog, - 'respond-to-pr-comment', - promptBuilder, - 'custom-model', - ); - - expect(mockResolveModelConfig).toHaveBeenCalledWith( - expect.objectContaining({ - modelOverride: 'custom-model', - agentType: 'respond-to-pr-comment', - }), - ); - }); - }); - - // ======================================================================== - // injectPRResponseSyntheticCalls - // ======================================================================== - - describe('injectPRResponseSyntheticCalls', () => { - const baseParams: InjectPRResponseSyntheticCallsParams = { - builder: 'initial-builder' as never, - ctx: { - prDetailsFormatted: 'pd', - commentsFormatted: 'c', - reviewsFormatted: 'r', - issueCommentsFormatted: 'ic', - diffFormatted: 'd', - contextFiles: [], - systemPrompt: 'sys', - model: 'm', - maxIterations: 5, - prompt: 'p', - }, - trackingContext: {} as never, - repoDir: '/tmp/repo', - id: { owner: 'org', repo: 'repo' }, - input: { prNumber: 42 } as PRResponseAgentInput, - }; - - it('injects calls in correct order: dir β†’ PR details β†’ comments β†’ reviews β†’ issue comments β†’ diff β†’ context files β†’ squint', () => { - injectPRResponseSyntheticCalls(baseParams); - - expect(mockInjectDirectoryListing).toHaveBeenCalledTimes(1); - - const syntheticNames = mockInjectSyntheticCall.mock.calls.map((c) => c[2]); - expect(syntheticNames).toEqual([ - 'GetPRDetails', - 'GetPRComments', - 'GetPRReviews', - 'GetPRIssueComments', - 'GetPRDiff', - ]); - - expect(mockInjectContextFiles).toHaveBeenCalledTimes(1); - expect(mockInjectSquintContext).toHaveBeenCalledTimes(1); - }); - - it('uses default comment descriptions (respond-to-review style)', () => { - injectPRResponseSyntheticCalls(baseParams); - - const commentsCall = mockInjectSyntheticCall.mock.calls.find((c) => c[2] === 'GetPRComments'); - expect(commentsCall?.[3]).toEqual( - expect.objectContaining({ - comment: 'Pre-fetching line-specific review comments to address', - }), - ); - - const reviewsCall = mockInjectSyntheticCall.mock.calls.find((c) => c[2] === 'GetPRReviews'); - expect(reviewsCall?.[3]).toEqual( - expect.objectContaining({ - comment: 'Pre-fetching review submissions (approve/request changes with body text)', - }), - ); - - const issueCommentsCall = mockInjectSyntheticCall.mock.calls.find( - (c) => c[2] === 'GetPRIssueComments', - ); - expect(issueCommentsCall?.[3]).toEqual( - expect.objectContaining({ - comment: 'Pre-fetching general PR comments (issue-style conversation)', - }), - ); - }); - - it('calls preSyntheticCalls callback before standard calls', () => { - const preSyntheticCalls = vi.fn().mockReturnValue('builder-after-pre'); - - injectPRResponseSyntheticCalls(baseParams, { preSyntheticCalls }); - - expect(preSyntheticCalls).toHaveBeenCalledTimes(1); - expect(preSyntheticCalls).toHaveBeenCalledWith( - 'builder-after-dir', - baseParams.trackingContext, - baseParams.input, - ); - }); - - it('overrides comment descriptions when provided', () => { - injectPRResponseSyntheticCalls(baseParams, { - commentDescriptions: { - prComments: 'Pre-fetching line-specific review comments for context', - prReviews: 'Pre-fetching review submissions for context', - prIssueComments: 'Pre-fetching general PR comments for context', - }, - }); - - const commentsCall = mockInjectSyntheticCall.mock.calls.find((c) => c[2] === 'GetPRComments'); - expect(commentsCall?.[3]).toEqual( - expect.objectContaining({ - comment: 'Pre-fetching line-specific review comments for context', - }), - ); - - const reviewsCall = mockInjectSyntheticCall.mock.calls.find((c) => c[2] === 'GetPRReviews'); - expect(reviewsCall?.[3]).toEqual( - expect.objectContaining({ comment: 'Pre-fetching review submissions for context' }), - ); - - const issueCommentsCall = mockInjectSyntheticCall.mock.calls.find( - (c) => c[2] === 'GetPRIssueComments', - ); - expect(issueCommentsCall?.[3]).toEqual( - expect.objectContaining({ comment: 'Pre-fetching general PR comments for context' }), - ); - }); - }); -}); diff --git a/tests/unit/agents/shared/syntheticCalls.test.ts b/tests/unit/agents/shared/syntheticCalls.test.ts index 80a2ab13..e7c94040 100644 --- a/tests/unit/agents/shared/syntheticCalls.test.ts +++ b/tests/unit/agents/shared/syntheticCalls.test.ts @@ -1,36 +1,12 @@ 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 { 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() { @@ -59,7 +35,6 @@ function createTrackingContext() { beforeEach(() => { vi.clearAllMocks(); - mockResolveSquintDbPath.mockReturnValue(null); }); describe('injectSyntheticCall', () => { @@ -116,181 +91,3 @@ describe('injectSyntheticCall', () => { 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(); - }); -}); From edbcd3c73bdbf8753bdd76c40a9a38182068e5c3 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Wed, 25 Feb 2026 12:48:11 +0100 Subject: [PATCH 11/13] refactor(tests): add integration test infrastructure and review fixes (#548) * refactor(tests): add integration test infrastructure and review fixes Introduce vitest workspace with separate unit/integration projects, shared test helpers (factories, mock DB, mock personas), and a full integration test suite for credentialsRepository including encryption round-trip coverage. Review fixes: - OS-aware psql in ensure-services.sh for macOS compatibility - Explicit DATABASE_URL in setup.sh migrations (removes env race) - Build step added to CI integration-tests job - container_name in docker-compose.test.yml for easier debugging - Extract buildProgressMonitorConfig() to fix cognitive complexity lint Unit test cleanup: remove redundant mock boilerplate across 100+ test files by leveraging vitest workspace setup files and shared helpers. Co-Authored-By: Claude Opus 4.6 * fix(ci): run integration tests on PRs Remove the `if: github.event_name == 'push'` guard from the integration-tests job so it also runs on pull_request events. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Cascade Bot --- .cascade/ensure-services.sh | 25 ++ .cascade/env | 1 + .cascade/setup.sh | 32 ++- .github/workflows/ci.yml | 38 +++ docker-compose.test.yml | 17 ++ package.json | 11 +- src/backends/adapter.ts | 58 ++-- tests/helpers/factories.ts | 104 +++++++ tests/helpers/mockDb.ts | 89 ++++++ tests/helpers/mockPersonas.ts | 13 + .../db/credentialsRepository.test.ts | 259 ++++++++++++++++++ tests/integration/helpers/db.ts | 50 ++++ tests/integration/helpers/seed.ts | 117 ++++++++ tests/integration/setup.ts | 22 ++ tests/setup.ts | 12 +- tests/unit/agents/hooks.test.ts | 8 - tests/unit/agents/registry.test.ts | 4 - .../unit/agents/shared/builderFactory.test.ts | 1 - tests/unit/agents/shared/cleanup.test.ts | 1 - .../agents/shared/executionPipeline.test.ts | 1 - tests/unit/agents/shared/gadgets.test.ts | 4 - .../agents/shared/modelResolution.test.ts | 4 - tests/unit/agents/shared/runTracking.test.ts | 10 - tests/unit/agents/utils/agentLoop.test.ts | 1 - tests/unit/agents/utils/checklistSync.test.ts | 1 - tests/unit/agents/utils/logging.test.ts | 4 - tests/unit/agents/utils/setup.test.ts | 1 - tests/unit/api/access-control.test.ts | 24 +- tests/unit/api/auth/login.test.ts | 4 - tests/unit/api/auth/logout.test.ts | 4 - tests/unit/api/auth/session.test.ts | 4 - .../api/routers/_shared/projectAccess.test.ts | 1 - tests/unit/api/routers/agentConfigs.test.ts | 10 +- tests/unit/api/routers/auth.test.ts | 13 +- tests/unit/api/routers/credentials.test.ts | 10 +- tests/unit/api/routers/defaults.test.ts | 13 +- .../api/routers/integrationsDiscovery.test.ts | 10 +- tests/unit/api/routers/organization.test.ts | 13 +- tests/unit/api/routers/projects.test.ts | 10 +- tests/unit/api/routers/prompts.test.ts | 13 +- tests/unit/api/routers/runs.test.ts | 10 +- tests/unit/api/routers/webhookLogs.test.ts | 13 +- tests/unit/api/routers/webhooks.test.ts | 13 +- tests/unit/backends/accumulator.test.ts | 1 - tests/unit/backends/adapter.test.ts | 1 - tests/unit/backends/agent-profiles.test.ts | 4 - tests/unit/backends/claude-code-hooks.test.ts | 4 - tests/unit/backends/claude-code.test.ts | 4 - tests/unit/backends/githubPoster.test.ts | 4 - tests/unit/backends/llmist.test.ts | 4 - tests/unit/backends/pmPoster.test.ts | 1 - tests/unit/backends/postProcess.test.ts | 4 - tests/unit/backends/progress.test.ts | 1 - tests/unit/backends/progressModel.test.ts | 4 - tests/unit/backends/secretBuilder.test.ts | 1 - tests/unit/cli/credential-scoping.test.ts | 1 - tests/unit/cli/dashboard/base.test.ts | 4 - tests/unit/cli/dashboard/client.test.ts | 4 - tests/unit/cli/dashboard/config.test.ts | 1 - tests/unit/cli/file-input-flags.test.ts | 1 - tests/unit/config/compactionConfig.test.ts | 4 - tests/unit/config/hintConfig.test.ts | 1 - tests/unit/config/projects.test.ts | 21 -- tests/unit/config/provider.test.ts | 7 - tests/unit/config/statusUpdateConfig.test.ts | 4 - tests/unit/db/crypto.test.ts | 4 - .../db/repositories/configRepository.test.ts | 4 - .../credentialsRepository.test.ts | 46 +--- .../prWorkItemsRepository.test.ts | 29 +- .../runsRepository.dashboard.test.ts | 4 - .../repositories/settingsRepository.test.ts | 45 +-- .../db/repositories/usersRepository.test.ts | 2 - tests/unit/db/runsRepository.test.ts | 2 - tests/unit/db/webhookLogsRepository.test.ts | 2 - tests/unit/gadgets/fileInsertContent.test.ts | 1 - tests/unit/gadgets/fileRemoveContent.test.ts | 1 - tests/unit/gadgets/finish.test.ts | 4 - tests/unit/gadgets/github.test.ts | 4 - .../unit/gadgets/github/core/createPR.test.ts | 4 - tests/unit/gadgets/github/core/misc.test.ts | 4 - .../unit/gadgets/pm/core/addChecklist.test.ts | 4 - .../gadgets/pm/core/createWorkItem.test.ts | 4 - .../pm/core/deleteChecklistItem.test.ts | 4 - .../gadgets/pm/core/listWorkItems.test.ts | 4 - .../unit/gadgets/pm/core/postComment.test.ts | 1 - .../unit/gadgets/pm/core/readWorkItem.test.ts | 4 - .../pm/core/updateChecklistItem.test.ts | 4 - .../gadgets/pm/core/updateWorkItem.test.ts | 4 - .../unit/gadgets/session/core/finish.test.ts | 4 - .../gadgets/shared/diagnosticState.test.ts | 1 - tests/unit/gadgets/todo-storage.test.ts | 1 - tests/unit/gadgets/todo.test.ts | 8 - tests/unit/github/client.test.ts | 4 - tests/unit/github/personas.test.ts | 4 - tests/unit/jira/client.test.ts | 1 - tests/unit/pm/webhook-handler.test.ts | 1 - tests/unit/queue/retry-run-projectId.test.ts | 1 - tests/unit/router/ackMessageGenerator.test.ts | 4 - tests/unit/router/adapters/github.test.ts | 1 - tests/unit/router/adapters/jira.test.ts | 1 - tests/unit/router/adapters/trello.test.ts | 1 - tests/unit/router/trello.test.ts | 4 - tests/unit/router/webhook-processor.test.ts | 4 - tests/unit/sentry.test.ts | 2 - tests/unit/server.test.ts | 4 - tests/unit/server/webhookHandlers.test.ts | 13 - tests/unit/trello/client.test.ts | 12 - tests/unit/triggers/agent-execution.test.ts | 9 +- .../triggers/agent-result-handler.test.ts | 20 +- tests/unit/triggers/budget.test.ts | 14 +- tests/unit/triggers/builtins.test.ts | 4 - tests/unit/triggers/card-moved.test.ts | 35 +-- .../unit/triggers/check-suite-failure.test.ts | 36 +-- .../unit/triggers/check-suite-success.test.ts | 41 +-- tests/unit/triggers/debug-runner.test.ts | 23 +- tests/unit/triggers/debug-trigger.test.ts | 4 - .../github-pr-comment-mention.test.ts | 20 +- tests/unit/triggers/github-utils.test.ts | 1 - tests/unit/triggers/label-added.test.ts | 14 +- tests/unit/triggers/manual-runner.test.ts | 2 - tests/unit/triggers/pr-merged.test.ts | 11 +- tests/unit/triggers/pr-opened.test.ts | 34 +-- tests/unit/triggers/pr-ready-to-merge.test.ts | 11 +- .../unit/triggers/pr-review-submitted.test.ts | 25 +- tests/unit/triggers/review-requested.test.ts | 36 +-- tests/unit/utils/cascadeEnv.test.ts | 1 - tests/unit/utils/lifecycle.test.ts | 1 - tests/unit/utils/llmEnv.test.ts | 1 - tests/unit/utils/llmLogging.test.ts | 4 - tests/unit/utils/repo.test.ts | 1 - tests/unit/utils/safeOperation.test.ts | 4 - tests/unit/utils/squintDb.test.ts | 1 - tests/unit/utils/webhookLogger.test.ts | 1 - vitest.config.ts | 3 +- vitest.workspace.ts | 24 ++ web/src/components/projects/pm-wizard.tsx | 6 - 136 files changed, 950 insertions(+), 827 deletions(-) create mode 100644 docker-compose.test.yml create mode 100644 tests/helpers/factories.ts create mode 100644 tests/helpers/mockDb.ts create mode 100644 tests/helpers/mockPersonas.ts create mode 100644 tests/integration/db/credentialsRepository.test.ts create mode 100644 tests/integration/helpers/db.ts create mode 100644 tests/integration/helpers/seed.ts create mode 100644 tests/integration/setup.ts create mode 100644 vitest.workspace.ts diff --git a/.cascade/ensure-services.sh b/.cascade/ensure-services.sh index 32a2705d..21afbd0a 100755 --- a/.cascade/ensure-services.sh +++ b/.cascade/ensure-services.sh @@ -66,4 +66,29 @@ else fi fi +# Verify test database exists (needed for integration tests) +if pg_isready -q 2>/dev/null; then + # OS-aware psql command (macOS uses peer auth, Linux uses -U postgres) + case "$(uname -s)" in + Linux*) PSQL_CMD="psql -U postgres" ;; + *) PSQL_CMD="psql" ;; + esac + + if $PSQL_CMD -lqt 2>/dev/null | cut -d \| -f 1 | grep -qw cascade_test; then + echo "Test database (cascade_test): exists" + else + echo "Test database (cascade_test): missing - creating..." + if [ "$(uname -s)" = "Linux" ]; then + $PSQL_CMD -c "CREATE DATABASE cascade_test;" 2>/dev/null || true + else + createdb cascade_test 2>/dev/null || true + fi + if $PSQL_CMD -lqt 2>/dev/null | cut -d \| -f 1 | grep -qw cascade_test; then + echo "Test database (cascade_test): created" + else + echo "Test database (cascade_test): FAILED TO CREATE (integration tests will not work)" + fi + fi +fi + echo "=== All services running ===" diff --git a/.cascade/env b/.cascade/env index c1be4a9f..e4815a30 100644 --- a/.cascade/env +++ b/.cascade/env @@ -1,3 +1,4 @@ CI=true DATABASE_URL=postgresql://postgres:postgres@localhost:5432/cascade DATABASE_SSL=false +TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/cascade_test diff --git a/.cascade/setup.sh b/.cascade/setup.sh index 3f9dedb5..ea9c84e2 100755 --- a/.cascade/setup.sh +++ b/.cascade/setup.sh @@ -224,7 +224,7 @@ if pg_isready -q 2>/dev/null; then PSQL_CMD="sudo -u postgres psql" fi - # Create cascade database + # Create cascade database (development) if ! $PSQL_CMD -lqt 2>/dev/null | cut -d \| -f 1 | grep -qw cascade; then log_info "Creating cascade database..." if [ "$OS" = "linux" ]; then @@ -236,6 +236,18 @@ if pg_isready -q 2>/dev/null; then log_info "Database cascade already exists" fi + # Create cascade_test database (integration tests) + if ! $PSQL_CMD -lqt 2>/dev/null | cut -d \| -f 1 | grep -qw cascade_test; then + log_info "Creating cascade_test database..." + if [ "$OS" = "linux" ]; then + $PSQL_CMD -c "CREATE DATABASE cascade_test;" 2>/dev/null || true + else + createdb cascade_test 2>/dev/null || true + fi + else + log_info "Database cascade_test already exists" + fi + # On Linux, ensure postgres user has a known password for app connections if [ "$OS" = "linux" ]; then $PSQL_CMD -c "ALTER USER postgres WITH PASSWORD 'postgres';" 2>/dev/null || true @@ -266,9 +278,21 @@ echo "" echo "--- Database Migrations ---" if pg_isready -q 2>/dev/null; then - log_info "Running migrations..." - DATABASE_SSL=false npm run db:migrate 2>&1 || \ - log_warn "Migration failed - may need manual intervention" + if [ "$OS" = "linux" ]; then + DEV_DB_URL="postgresql://postgres:postgres@localhost:5432/cascade" + TEST_DB_URL="postgresql://postgres:postgres@localhost:5432/cascade_test" + else + DEV_DB_URL="postgresql://localhost:5432/cascade" + TEST_DB_URL="postgresql://localhost:5432/cascade_test" + fi + + log_info "Running migrations on cascade (dev)..." + DATABASE_URL="$DEV_DB_URL" DATABASE_SSL=false npm run db:migrate 2>&1 || \ + log_warn "Migration failed on cascade - may need manual intervention" + + log_info "Running migrations on cascade_test..." + DATABASE_URL="$TEST_DB_URL" DATABASE_SSL=false npm run db:migrate 2>&1 || \ + log_warn "Migration failed on cascade_test - may need manual intervention" else log_warn "PostgreSQL not ready, skipping migrations" fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a3afcf6..321e0347 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,44 @@ jobs: - name: Validate PR commits run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose + integration-tests: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: cascade_test + POSTGRES_PASSWORD: cascade_test + POSTGRES_DB: cascade_test + ports: + - 5433:5432 + options: >- + --health-cmd "pg_isready -U cascade_test -d cascade_test" + --health-interval 2s + --health-timeout 5s + --health-retries 10 + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Build backend + run: npm run build + + - name: Run integration tests + run: npm run test:integration + env: + TEST_DATABASE_URL: postgresql://cascade_test:cascade_test@localhost:5433/cascade_test + docker-build-check: name: Validate Docker builds runs-on: ubuntu-latest diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 00000000..e7e1f1fd --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,17 @@ +services: + postgres-test: + container_name: cascade-postgres-test + image: postgres:16-alpine + ports: + - "5433:5432" + environment: + POSTGRES_USER: cascade_test + POSTGRES_PASSWORD: cascade_test + POSTGRES_DB: cascade_test + tmpfs: + - /var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U cascade_test -d cascade_test"] + interval: 2s + timeout: 5s + retries: 10 diff --git a/package.json b/package.json index 266a76dd..e9928446 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,14 @@ "build": "tsc", "build:web": "cd web && npm run build", "start": "node dist/index.js", - "test": "vitest run", - "test:watch": "vitest", - "test:coverage": "vitest run --coverage", + "test": "vitest run --project unit", + "test:unit": "vitest run --project unit", + "test:integration": "vitest run --project integration", + "test:all": "vitest run", + "test:watch": "vitest --project unit", + "test:coverage": "vitest run --project unit --coverage", + "test:db:up": "docker compose -f docker-compose.test.yml up -d --wait", + "test:db:down": "docker compose -f docker-compose.test.yml down -v", "lint": "biome check .", "lint:fix": "biome check --write .", "typecheck": "tsc --noEmit", diff --git a/src/backends/adapter.ts b/src/backends/adapter.ts index b35d50c5..efe9af9d 100644 --- a/src/backends/adapter.ts +++ b/src/backends/adapter.ts @@ -139,6 +139,39 @@ async function buildBackendInput( }; } +/** + * Build progress-monitor config from pipeline inputs. + */ +function buildProgressMonitorConfig( + input: AgentInput & { config: CascadeConfig }, + agentType: string, + logWriter: LogWriter, + repoDir: string | null, + isGitHubAck: boolean, +) { + const { cardId } = input; + return { + logWriter, + agentType, + taskDescription: cardId ? `Work item ${cardId}` : 'Unknown task', + progressModel: input.config.defaults.progressModel, + intervalMinutes: input.config.defaults.progressIntervalMinutes, + customModels: CUSTOM_MODELS as ModelSpec[], + repoDir: repoDir ?? undefined, + trello: cardId ? { cardId } : undefined, + preSeededCommentId: isGitHubAck ? undefined : (input.ackCommentId as string | undefined), + ...(input.prNumber && input.repoFullName + ? { + github: { + owner: input.repoFullName.split('/')[0], + repo: input.repoFullName.split('/')[1], + headerMessage: input.ackMessage ?? '', + }, + } + : {}), + }; +} + export async function executeWithBackend( backend: AgentBackend, agentType: string, @@ -207,28 +240,9 @@ export async function executeWithBackend( recordInitialComment(input.ackCommentId as number); } - const monitor = createProgressMonitor({ - logWriter, - agentType, - taskDescription: cardId ? `Work item ${cardId}` : 'Unknown task', - progressModel: input.config.defaults.progressModel, - intervalMinutes: input.config.defaults.progressIntervalMinutes, - customModels: CUSTOM_MODELS as ModelSpec[], - repoDir: repoDir ?? undefined, - trello: cardId ? { cardId } : undefined, - // Only use preSeededCommentId for PM (Trello/JIRA) ack comments, not GitHub - preSeededCommentId: isGitHubAck ? undefined : (input.ackCommentId as string | undefined), - // Pass GitHub config so progress monitor can update the PR comment - ...(input.prNumber && input.repoFullName - ? { - github: { - owner: input.repoFullName.split('/')[0], - repo: input.repoFullName.split('/')[1], - headerMessage: input.ackMessage ?? '', - }, - } - : {}), - }); + const monitor = createProgressMonitor( + buildProgressMonitorConfig(input, agentType, logWriter, repoDir, isGitHubAck), + ); const backendInput: AgentBackendInput = { ...partialInput, diff --git a/tests/helpers/factories.ts b/tests/helpers/factories.ts new file mode 100644 index 00000000..0f0587f7 --- /dev/null +++ b/tests/helpers/factories.ts @@ -0,0 +1,104 @@ +import type { TRPCContext, TRPCUser } from '../../src/api/trpc.js'; +import type { ProjectConfig, TriggerContext } from '../../src/types/index.js'; + +// --------------------------------------------------------------------------- +// Project factories +// --------------------------------------------------------------------------- + +/** + * Creates a mock Trello project config. Sensible defaults for trigger tests; + * pass overrides (shallow-merged) for test-specific customisation. + */ +export function createMockProject(overrides?: Partial): ProjectConfig { + return { + id: 'test', + orgId: 'org-1', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'trello' }, + trello: { + boardId: 'board123', + lists: { + splitting: 'splitting-list-id', + planning: 'planning-list-id', + todo: 'todo-list-id', + }, + labels: {}, + }, + ...overrides, + } as ProjectConfig; +} + +/** + * Creates a mock JIRA project config. + */ +export function createMockJiraProject(overrides?: Partial): ProjectConfig { + return { + id: 'jira-project', + orgId: 'org-1', + name: 'JIRA Project', + repo: 'owner/jira-repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'jira' }, + jira: { + projectKey: 'PROJ', + baseUrl: 'https://test.atlassian.net', + statuses: { splitting: 'Briefing' }, + labels: { + processing: 'my-processing', + processed: 'my-processed', + error: 'my-error', + readyToProcess: 'my-ready', + }, + }, + ...overrides, + } as ProjectConfig; +} + +// --------------------------------------------------------------------------- +// tRPC factories +// --------------------------------------------------------------------------- + +/** + * Creates a mock tRPC user. Defaults to an admin user. + */ +export function createMockUser(overrides?: Partial): TRPCUser { + return { + id: 'user-1', + orgId: 'org-1', + email: 'test@example.com', + name: 'Test User', + role: 'admin', + ...overrides, + }; +} + +/** + * Creates a mock tRPC context with an authenticated user. + */ +export function createMockContext(userOverrides?: Partial): TRPCContext { + const user = createMockUser(userOverrides); + return { + user, + effectiveOrgId: user.orgId, + }; +} + +// --------------------------------------------------------------------------- +// Trigger context factory +// --------------------------------------------------------------------------- + +/** + * Creates a mock trigger context for trigger handler tests. + */ +export function createTriggerContext(overrides?: Partial): TriggerContext { + return { + project: createMockProject(), + source: 'trello', + payload: {}, + ...overrides, + } as TriggerContext; +} diff --git a/tests/helpers/mockDb.ts b/tests/helpers/mockDb.ts new file mode 100644 index 00000000..fd216968 --- /dev/null +++ b/tests/helpers/mockDb.ts @@ -0,0 +1,89 @@ +import { vi } from 'vitest'; + +export type MockDbChain = Record>; + +export interface MockDbResult { + db: { + select: ReturnType; + insert: ReturnType; + update: ReturnType; + delete: ReturnType; + }; + chain: MockDbChain; +} + +/** + * Creates a mock Drizzle query chain that supports the common patterns: + * + * - `select().from().where()` / `select().from().innerJoin().where()` + * - `select().from().innerJoin().innerJoin().where()` (double join) + * - `insert().values().returning()` / `insert().values().onConflictDoUpdate()` + * - `update().set().where()` + * - `delete().where()` + * + * Options let you extend the chain for repo-specific needs. + */ +export function createMockDb( + opts: { + /** Add `.limit()` support on select chains */ + withLimit?: boolean; + /** Add nested `.innerJoin().innerJoin().where()` support */ + withDoubleJoin?: boolean; + /** Add `.onConflictDoUpdate()` on insert chains */ + withUpsert?: boolean; + /** Make the chain itself thenable (for queries without `.where()` terminal) */ + withThenable?: boolean; + } = {}, +): MockDbResult { + const chain: MockDbChain = {}; + + // Terminal methods that return results + chain.returning = vi.fn().mockResolvedValue([]); + + // Limit support β€” limit is the terminal when present, where is a chaining step + if (opts.withLimit) { + chain.limit = vi.fn().mockResolvedValue([]); + chain.where = vi.fn().mockReturnValue({ limit: chain.limit }); + } else { + chain.where = vi.fn().mockResolvedValue([]); + } + + // Chain methods - innerJoin + const innerJoinResult: Record = { where: chain.where }; + if (opts.withDoubleJoin) { + innerJoinResult.innerJoin = vi.fn().mockReturnValue({ where: chain.where }); + } + chain.innerJoin = vi.fn().mockReturnValue(innerJoinResult); + + // From + chain.from = vi.fn().mockReturnValue({ + where: chain.where, + innerJoin: chain.innerJoin, + }); + + // Update chain + chain.set = vi.fn().mockReturnValue({ where: chain.where }); + + // Insert chain + const valuesResult: Record = { returning: chain.returning }; + if (opts.withUpsert) { + chain.onConflictDoUpdate = vi.fn().mockReturnValue({ returning: chain.returning }); + valuesResult.onConflictDoUpdate = chain.onConflictDoUpdate; + } + chain.values = vi.fn().mockReturnValue(valuesResult); + + // Thenable support for queries without .where() terminal + if (opts.withThenable) { + // biome-ignore lint/suspicious/noThenProperty: intentional thenable mock for Drizzle query chains + chain.then = (resolve: (v: unknown) => unknown) => Promise.resolve([]).then(resolve); + } + + const db = { + select: vi.fn().mockReturnValue({ from: chain.from }), + insert: vi.fn().mockReturnValue({ values: chain.values }), + update: vi.fn().mockReturnValue({ set: chain.set }), + delete: vi.fn().mockReturnValue({ where: chain.where }), + }; + + return { db, chain }; +} diff --git a/tests/helpers/mockPersonas.ts b/tests/helpers/mockPersonas.ts new file mode 100644 index 00000000..f7409a30 --- /dev/null +++ b/tests/helpers/mockPersonas.ts @@ -0,0 +1,13 @@ +import type { PersonaIdentities } from '../../src/github/personas.js'; + +/** + * Standard mock persona identities used in trigger tests. + */ +export const mockPersonaIdentities: PersonaIdentities = { + implementer: 'cascade-impl', + reviewer: 'cascade-reviewer', +}; + +/** Convenience constants for readable assertions. */ +export const IMPLEMENTER_USERNAME = 'cascade-impl'; +export const REVIEWER_USERNAME = 'cascade-reviewer'; diff --git a/tests/integration/db/credentialsRepository.test.ts b/tests/integration/db/credentialsRepository.test.ts new file mode 100644 index 00000000..7d304f87 --- /dev/null +++ b/tests/integration/db/credentialsRepository.test.ts @@ -0,0 +1,259 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + createCredential, + deleteCredential, + listOrgCredentials, + resolveAllIntegrationCredentials, + resolveAllOrgCredentials, + resolveIntegrationCredential, + resolveOrgCredential, + updateCredential, +} from '../../../src/db/repositories/credentialsRepository.js'; +import { truncateAll } from '../helpers/db.js'; +import { + seedCredential, + seedIntegration, + seedIntegrationCredential, + seedOrg, + seedProject, +} from '../helpers/seed.js'; + +describe('credentialsRepository (integration)', () => { + beforeEach(async () => { + await truncateAll(); + await seedOrg(); + await seedProject(); + }); + + // ========================================================================= + // CRUD + // ========================================================================= + + describe('createCredential', () => { + it('inserts a credential and returns the id', async () => { + const result = await createCredential({ + orgId: 'test-org', + name: 'My API Key', + envVarKey: 'MY_API_KEY', + value: 'secret-123', + }); + + expect(result.id).toBeGreaterThan(0); + }); + + it('defaults isDefault to false', async () => { + const { id } = await createCredential({ + orgId: 'test-org', + name: 'Key', + envVarKey: 'KEY', + value: 'val', + }); + + const creds = await listOrgCredentials('test-org'); + const cred = creds.find((c) => c.id === id); + expect(cred?.isDefault).toBe(false); + }); + }); + + describe('updateCredential', () => { + it('updates name and value', async () => { + const { id } = await createCredential({ + orgId: 'test-org', + name: 'Old Name', + envVarKey: 'UPD_KEY', + value: 'old-value', + }); + + await updateCredential(id, { name: 'New Name', value: 'new-value' }); + + const creds = await listOrgCredentials('test-org'); + const cred = creds.find((c) => c.id === id); + expect(cred?.name).toBe('New Name'); + expect(cred?.value).toBe('new-value'); + }); + }); + + describe('deleteCredential', () => { + it('removes the credential', async () => { + const { id } = await createCredential({ + orgId: 'test-org', + name: 'Temp', + envVarKey: 'TEMP', + value: 'tmp', + }); + + await deleteCredential(id); + + const creds = await listOrgCredentials('test-org'); + expect(creds.find((c) => c.id === id)).toBeUndefined(); + }); + }); + + describe('listOrgCredentials', () => { + it('returns all credentials for the org', async () => { + await createCredential({ orgId: 'test-org', name: 'A', envVarKey: 'A', value: 'a' }); + await createCredential({ orgId: 'test-org', name: 'B', envVarKey: 'B', value: 'b' }); + + const creds = await listOrgCredentials('test-org'); + expect(creds).toHaveLength(2); + expect(creds.map((c) => c.envVarKey).sort()).toEqual(['A', 'B']); + }); + + it('returns empty array for org with no credentials', async () => { + const creds = await listOrgCredentials('test-org'); + expect(creds).toEqual([]); + }); + }); + + // ========================================================================= + // Org-scoped credential resolution + // ========================================================================= + + describe('resolveOrgCredential', () => { + it('returns value for a default credential', async () => { + await createCredential({ + orgId: 'test-org', + name: 'OR Key', + envVarKey: 'OPENROUTER_API_KEY', + value: 'or-secret', + isDefault: true, + }); + + const result = await resolveOrgCredential('test-org', 'OPENROUTER_API_KEY'); + expect(result).toBe('or-secret'); + }); + + it('returns null for non-default credential', async () => { + await createCredential({ + orgId: 'test-org', + name: 'Non-default', + envVarKey: 'NON_DEFAULT', + value: 'val', + isDefault: false, + }); + + const result = await resolveOrgCredential('test-org', 'NON_DEFAULT'); + expect(result).toBeNull(); + }); + + it('returns null when credential does not exist', async () => { + const result = await resolveOrgCredential('test-org', 'MISSING_KEY'); + expect(result).toBeNull(); + }); + }); + + describe('resolveAllOrgCredentials', () => { + it('returns all default credentials as key-value map', async () => { + await createCredential({ + orgId: 'test-org', + name: 'K1', + envVarKey: 'KEY_1', + value: 'v1', + isDefault: true, + }); + await createCredential({ + orgId: 'test-org', + name: 'K2', + envVarKey: 'KEY_2', + value: 'v2', + isDefault: true, + }); + // Non-default β€” should be excluded + await createCredential({ + orgId: 'test-org', + name: 'K3', + envVarKey: 'KEY_3', + value: 'v3', + isDefault: false, + }); + + const result = await resolveAllOrgCredentials('test-org'); + expect(result).toEqual({ KEY_1: 'v1', KEY_2: 'v2' }); + }); + }); + + // ========================================================================= + // Integration credential resolution + // ========================================================================= + + describe('resolveIntegrationCredential', () => { + it('resolves a credential via integration link', async () => { + const cred = await seedCredential({ + envVarKey: 'TRELLO_API_KEY', + value: 'trello-key-secret', + }); + const integration = await seedIntegration({ category: 'pm', provider: 'trello' }); + await seedIntegrationCredential({ + integrationId: integration.id, + role: 'api_key', + credentialId: cred.id, + }); + + const result = await resolveIntegrationCredential('test-project', 'pm', 'api_key'); + expect(result).toBe('trello-key-secret'); + }); + + it('returns null when no link exists', async () => { + const result = await resolveIntegrationCredential('test-project', 'pm', 'api_key'); + expect(result).toBeNull(); + }); + }); + + describe('resolveAllIntegrationCredentials', () => { + it('resolves all credentials for a project', async () => { + const apiKeyCred = await seedCredential({ envVarKey: 'TRELLO_API_KEY', value: 'key1' }); + const tokenCred = await seedCredential({ + envVarKey: 'TRELLO_TOKEN', + value: 'token1', + name: 'Trello Token', + }); + const integration = await seedIntegration({ category: 'pm', provider: 'trello' }); + await seedIntegrationCredential({ + integrationId: integration.id, + role: 'api_key', + credentialId: apiKeyCred.id, + }); + await seedIntegrationCredential({ + integrationId: integration.id, + role: 'token', + credentialId: tokenCred.id, + }); + + const result = await resolveAllIntegrationCredentials('test-project'); + expect(result).toHaveLength(2); + expect(result).toEqual( + expect.arrayContaining([ + { category: 'pm', provider: 'trello', role: 'api_key', value: 'key1' }, + { category: 'pm', provider: 'trello', role: 'token', value: 'token1' }, + ]), + ); + }); + + it('returns empty array for project with no integrations', async () => { + const result = await resolveAllIntegrationCredentials('test-project'); + expect(result).toEqual([]); + }); + }); + + // ========================================================================= + // Encryption + // ========================================================================= + + describe('with encryption', () => { + it('round-trips through encrypt/decrypt transparently', async () => { + // 64-char hex = 32-byte AES-256 key + vi.stubEnv('CREDENTIAL_MASTER_KEY', 'a'.repeat(64)); + + const { id } = await createCredential({ + orgId: 'test-org', + name: 'Encrypted Key', + envVarKey: 'ENC_KEY', + value: 'plaintext-secret', + }); + + const creds = await listOrgCredentials('test-org'); + const cred = creds.find((c) => c.id === id); + expect(cred?.value).toBe('plaintext-secret'); // decrypted on read + }); + }); +}); diff --git a/tests/integration/helpers/db.ts b/tests/integration/helpers/db.ts new file mode 100644 index 00000000..a91f8e9a --- /dev/null +++ b/tests/integration/helpers/db.ts @@ -0,0 +1,50 @@ +import path from 'node:path'; +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import { closeDb, getDb } from '../../../src/db/client.js'; + +/** + * Runs Drizzle migrations against the test database. + * Uses the app's own getDb() which reads DATABASE_URL (set by integration/setup.ts). + */ +export async function runMigrations() { + const db = getDb(); + await migrate(db, { + migrationsFolder: path.resolve(import.meta.dirname, '../../../src/db/migrations'), + }); +} + +/** + * Truncates all application tables in dependency order. + * Call in `beforeEach` to isolate tests. + */ +export async function truncateAll() { + const db = getDb(); + // CASCADE handles FK dependencies; tables listed for explicitness + await db.execute(` + TRUNCATE TABLE + webhook_logs, + debug_analyses, + agent_run_llm_calls, + agent_run_logs, + agent_runs, + pr_work_items, + integration_credentials, + project_integrations, + agent_configs, + prompt_partials, + sessions, + users, + credentials, + projects, + cascade_defaults, + organizations + CASCADE + `); +} + +/** + * Closes the test database pool. Call in `afterAll`. + */ +export async function closeTestDb() { + await closeDb(); +} diff --git a/tests/integration/helpers/seed.ts b/tests/integration/helpers/seed.ts new file mode 100644 index 00000000..5c1c6a7f --- /dev/null +++ b/tests/integration/helpers/seed.ts @@ -0,0 +1,117 @@ +import { getDb } from '../../../src/db/client.js'; +import { + credentials, + integrationCredentials, + organizations, + projectIntegrations, + projects, +} from '../../../src/db/schema/index.js'; + +/** + * Seeds a test organization. + */ +export async function seedOrg(id = 'test-org', name = 'Test Org') { + const db = getDb(); + const [row] = await db.insert(organizations).values({ id, name }).returning(); + return row; +} + +/** + * Seeds a test project linked to an org. + */ +export async function seedProject( + overrides: { + id?: string; + orgId?: string; + name?: string; + repo?: string; + baseBranch?: string; + branchPrefix?: string; + } = {}, +) { + const db = getDb(); + const [row] = await db + .insert(projects) + .values({ + id: overrides.id ?? 'test-project', + orgId: overrides.orgId ?? 'test-org', + name: overrides.name ?? 'Test Project', + repo: overrides.repo ?? 'owner/repo', + baseBranch: overrides.baseBranch ?? 'main', + branchPrefix: overrides.branchPrefix ?? 'feature/', + }) + .returning(); + return row; +} + +/** + * Seeds a credential row. + */ +export async function seedCredential( + overrides: { + orgId?: string; + name?: string; + envVarKey?: string; + value?: string; + isDefault?: boolean; + } = {}, +) { + const db = getDb(); + const [row] = await db + .insert(credentials) + .values({ + orgId: overrides.orgId ?? 'test-org', + name: overrides.name ?? 'Test Key', + envVarKey: overrides.envVarKey ?? 'TEST_KEY', + value: overrides.value ?? 'test-value', + isDefault: overrides.isDefault ?? false, + }) + .returning(); + return row; +} + +/** + * Seeds a project integration (PM or SCM). + */ +export async function seedIntegration( + overrides: { + projectId?: string; + category?: string; + provider?: string; + config?: Record; + triggers?: Record; + } = {}, +) { + const db = getDb(); + const [row] = await db + .insert(projectIntegrations) + .values({ + projectId: overrides.projectId ?? 'test-project', + category: overrides.category ?? 'pm', + provider: overrides.provider ?? 'trello', + config: overrides.config ?? {}, + triggers: overrides.triggers ?? {}, + }) + .returning(); + return row; +} + +/** + * Seeds an integration credential link. + */ +export async function seedIntegrationCredential(overrides: { + integrationId: number; + role?: string; + credentialId: number; +}) { + const db = getDb(); + const [row] = await db + .insert(integrationCredentials) + .values({ + integrationId: overrides.integrationId, + role: overrides.role ?? 'api_key', + credentialId: overrides.credentialId, + }) + .returning(); + return row; +} diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts new file mode 100644 index 00000000..da27d1ae --- /dev/null +++ b/tests/integration/setup.ts @@ -0,0 +1,22 @@ +import { afterAll, beforeAll } from 'vitest'; +import { closeTestDb, runMigrations } from './helpers/db.js'; + +// Default: matches docker-compose.test.yml (port 5433, user cascade_test) +// Override via TEST_DATABASE_URL for: +// - .cascade/env: local PostgreSQL (port 5432, user postgres) +// - CI: GitHub Actions service container (port 5433, user cascade_test) +const TEST_DATABASE_URL = + process.env.TEST_DATABASE_URL ?? + 'postgresql://cascade_test:cascade_test@localhost:5433/cascade_test'; + +// Point the app's getDb() at the test database +process.env.DATABASE_URL = TEST_DATABASE_URL; +process.env.DATABASE_SSL = 'false'; + +beforeAll(async () => { + await runMigrations(); +}); + +afterAll(async () => { + await closeTestDb(); +}); diff --git a/tests/setup.ts b/tests/setup.ts index 47be3054..c3ce8832 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,6 +1,12 @@ -import { afterEach } from 'vitest'; -import { invalidateConfigCache } from '../src/config/provider.js'; +import { afterEach, beforeEach } from 'vitest'; +// Import configCache directly to avoid pulling in provider.js β†’ credentialsRepository.js β†’ client.js, +// which would pre-load real DB modules before test files can mock them. +import { configCache } from '../src/config/configCache.js'; + +beforeEach(() => { + configCache.invalidate(); +}); afterEach(() => { - invalidateConfigCache(); + configCache.invalidate(); }); diff --git a/tests/unit/agents/hooks.test.ts b/tests/unit/agents/hooks.test.ts index 56bc68c9..528b2231 100644 --- a/tests/unit/agents/hooks.test.ts +++ b/tests/unit/agents/hooks.test.ts @@ -25,10 +25,6 @@ describe('createObserverHooks - llmCallAccumulator', () => { getLogFiles: vi.fn().mockReturnValue([]), }; - beforeEach(() => { - vi.clearAllMocks(); - }); - it('accumulates LLM call metrics when accumulator is provided', async () => { const accumulator: AccumulatedLlmCall[] = []; const trackingContext = createTrackingContext(); @@ -171,10 +167,6 @@ describe('createObserverHooks - real-time DB logging', () => { getLogFiles: vi.fn().mockReturnValue([]), }; - beforeEach(() => { - vi.clearAllMocks(); - }); - it('calls storeLlmCall fire-and-forget when runId is set', async () => { const trackingContext = createTrackingContext(); const hooks = createObserverHooks({ diff --git a/tests/unit/agents/registry.test.ts b/tests/unit/agents/registry.test.ts index bf34f19f..962e31e8 100644 --- a/tests/unit/agents/registry.test.ts +++ b/tests/unit/agents/registry.test.ts @@ -78,10 +78,6 @@ function makeMockBackend(name: string, supportsAll = true): AgentBackend { }; } -beforeEach(() => { - vi.clearAllMocks(); -}); - describe('runAgent', () => { it('resolves backend name from config', async () => { const backend = makeMockBackend('llmist'); diff --git a/tests/unit/agents/shared/builderFactory.test.ts b/tests/unit/agents/shared/builderFactory.test.ts index 7ea267c9..065658f4 100644 --- a/tests/unit/agents/shared/builderFactory.test.ts +++ b/tests/unit/agents/shared/builderFactory.test.ts @@ -100,7 +100,6 @@ function createBaseOptions(overrides?: object) { } beforeEach(() => { - vi.clearAllMocks(); mockResolveSquintDbPath.mockReturnValue(null); // Reset all mock builder methods to return the builder instance diff --git a/tests/unit/agents/shared/cleanup.test.ts b/tests/unit/agents/shared/cleanup.test.ts index aee13ae8..37b01b78 100644 --- a/tests/unit/agents/shared/cleanup.test.ts +++ b/tests/unit/agents/shared/cleanup.test.ts @@ -47,7 +47,6 @@ describe('cleanupAgentResources', () => { const originalEnv = process.env.CASCADE_LOCAL_MODE; beforeEach(() => { - vi.clearAllMocks(); process.env.CASCADE_LOCAL_MODE = undefined; }); diff --git a/tests/unit/agents/shared/executionPipeline.test.ts b/tests/unit/agents/shared/executionPipeline.test.ts index 042d0978..8364f524 100644 --- a/tests/unit/agents/shared/executionPipeline.test.ts +++ b/tests/unit/agents/shared/executionPipeline.test.ts @@ -93,7 +93,6 @@ function setupMocks() { } beforeEach(() => { - vi.clearAllMocks(); process.env.CASCADE_LOCAL_MODE = ''; }); diff --git a/tests/unit/agents/shared/gadgets.test.ts b/tests/unit/agents/shared/gadgets.test.ts index 0a77d778..29591799 100644 --- a/tests/unit/agents/shared/gadgets.test.ts +++ b/tests/unit/agents/shared/gadgets.test.ts @@ -59,10 +59,6 @@ import { buildWorkItemGadgets, } from '../../../../src/agents/shared/gadgets.js'; -beforeEach(() => { - vi.clearAllMocks(); -}); - function names(gadgets: unknown[]): string[] { return gadgets.map((g) => (g as object).constructor.name); } diff --git a/tests/unit/agents/shared/modelResolution.test.ts b/tests/unit/agents/shared/modelResolution.test.ts index a9316172..e22da634 100644 --- a/tests/unit/agents/shared/modelResolution.test.ts +++ b/tests/unit/agents/shared/modelResolution.test.ts @@ -41,10 +41,6 @@ function makeConfig(overrides: Partial = {}): Cascade } describe('resolveModelConfig', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - describe('prompt resolution chain', () => { it('uses .eta file when no custom prompts configured', async () => { const result = await resolveModelConfig({ diff --git a/tests/unit/agents/shared/runTracking.test.ts b/tests/unit/agents/shared/runTracking.test.ts index 226ff7a1..80d98132 100644 --- a/tests/unit/agents/shared/runTracking.test.ts +++ b/tests/unit/agents/shared/runTracking.test.ts @@ -63,10 +63,6 @@ const baseInput: RunTrackingInput = { }; describe('tryCreateRun', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - it('creates a run and returns the run ID', async () => { mockCreateRun.mockResolvedValue('run-abc'); @@ -97,10 +93,6 @@ describe('tryCreateRun', () => { }); describe('tryCompleteRun', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - it('calls completeRun with the given input', async () => { mockCompleteRun.mockResolvedValue(undefined); @@ -123,7 +115,6 @@ describe('tryCompleteRun', () => { describe('tryStoreRunLogs', () => { beforeEach(() => { - vi.clearAllMocks(); mockExistsSync.mockReturnValue(false); }); @@ -159,7 +150,6 @@ describe('tryStoreRunLogs', () => { describe('finalizeBackendRun', () => { beforeEach(() => { - vi.clearAllMocks(); mockExistsSync.mockReturnValue(false); }); diff --git a/tests/unit/agents/utils/agentLoop.test.ts b/tests/unit/agents/utils/agentLoop.test.ts index dd06b6c9..e529793b 100644 --- a/tests/unit/agents/utils/agentLoop.test.ts +++ b/tests/unit/agents/utils/agentLoop.test.ts @@ -90,7 +90,6 @@ function createMockAgent(events: object[]) { } beforeEach(() => { - vi.clearAllMocks(); mockConsumePendingSessionNotices.mockReturnValue(new Map()); mockConsumeLoopWarning.mockReturnValue(null); mockConsumeLoopAction.mockReturnValue(null); diff --git a/tests/unit/agents/utils/checklistSync.test.ts b/tests/unit/agents/utils/checklistSync.test.ts index 83cac063..fc6b5a40 100644 --- a/tests/unit/agents/utils/checklistSync.test.ts +++ b/tests/unit/agents/utils/checklistSync.test.ts @@ -25,7 +25,6 @@ import { loadTodos } from '../../../../src/gadgets/todo/storage.js'; const mockLoadTodos = vi.mocked(loadTodos); beforeEach(() => { - vi.clearAllMocks(); clearSyncedTodos(); }); diff --git a/tests/unit/agents/utils/logging.test.ts b/tests/unit/agents/utils/logging.test.ts index 35cb1c00..2f7bbd9d 100644 --- a/tests/unit/agents/utils/logging.test.ts +++ b/tests/unit/agents/utils/logging.test.ts @@ -14,10 +14,6 @@ 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() }; diff --git a/tests/unit/agents/utils/setup.test.ts b/tests/unit/agents/utils/setup.test.ts index 576eff93..677adb32 100644 --- a/tests/unit/agents/utils/setup.test.ts +++ b/tests/unit/agents/utils/setup.test.ts @@ -24,7 +24,6 @@ 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'); }); diff --git a/tests/unit/api/access-control.test.ts b/tests/unit/api/access-control.test.ts index 1cc04466..90366f8a 100644 --- a/tests/unit/api/access-control.test.ts +++ b/tests/unit/api/access-control.test.ts @@ -116,36 +116,26 @@ import { protectedProcedure, router, } from '../../../src/api/trpc.js'; +import { createMockUser } from '../../helpers/factories.js'; // ========================================================================== // Shared test users // ========================================================================== -const adminUser: TRPCUser = { - id: 'user-1', - orgId: 'org-1', - email: 'admin@example.com', - name: 'Admin', - role: 'admin', -}; +const adminUser = createMockUser({ email: 'admin@example.com', name: 'Admin' }); -const memberUser: TRPCUser = { +const memberUser = createMockUser({ id: 'user-2', - orgId: 'org-1', email: 'member@example.com', name: 'Member', role: 'member', -}; +}); // ========================================================================== // Section 1: computeEffectiveOrgId // ========================================================================== describe('computeEffectiveOrgId', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - it('returns null when user is null', async () => { const result = await computeEffectiveOrgId(null, undefined); expect(result).toBeNull(); @@ -251,10 +241,6 @@ describe('Middleware edge cases', () => { // ========================================================================== describe('Auth router β€” role-based data exposure', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - it('member user gets no availableOrgs', async () => { const caller = authRouter.createCaller({ user: memberUser, effectiveOrgId: 'org-1' }); const result = await caller.me(); @@ -293,7 +279,6 @@ describe('Auth router β€” role-based data exposure', () => { describe('Router org-isolation with admin org-switching', () => { beforeEach(() => { - vi.clearAllMocks(); mockDbSelect.mockReturnValue({ from: mockDbFrom }); mockDbFrom.mockReturnValue({ where: mockDbWhere }); }); @@ -392,7 +377,6 @@ describe('Router org-isolation with admin org-switching', () => { describe('Cross-org ownership checks', () => { beforeEach(() => { - vi.clearAllMocks(); mockDbSelect.mockReturnValue({ from: mockDbFrom }); mockDbFrom.mockReturnValue({ where: mockDbWhere }); }); diff --git a/tests/unit/api/auth/login.test.ts b/tests/unit/api/auth/login.test.ts index a7c6fdc2..97a56459 100644 --- a/tests/unit/api/auth/login.test.ts +++ b/tests/unit/api/auth/login.test.ts @@ -42,10 +42,6 @@ const mockUser = { }; describe('loginHandler', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - it('returns 400 when email is missing', async () => { const app = createTestApp(); const res = await postLogin(app, { password: 'pass' }); diff --git a/tests/unit/api/auth/logout.test.ts b/tests/unit/api/auth/logout.test.ts index bd0ae680..59f61fcb 100644 --- a/tests/unit/api/auth/logout.test.ts +++ b/tests/unit/api/auth/logout.test.ts @@ -16,10 +16,6 @@ function createTestApp() { } describe('logoutHandler', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - it('deletes session and clears cookie when session cookie is present', async () => { mockDeleteSession.mockResolvedValue(undefined); const app = createTestApp(); diff --git a/tests/unit/api/auth/session.test.ts b/tests/unit/api/auth/session.test.ts index cb0360bf..33abe190 100644 --- a/tests/unit/api/auth/session.test.ts +++ b/tests/unit/api/auth/session.test.ts @@ -11,10 +11,6 @@ vi.mock('../../../../src/db/repositories/usersRepository.js', () => ({ import { resolveUserFromSession } from '../../../../src/api/auth/session.js'; describe('resolveUserFromSession', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - it('returns DashboardUser when token maps to valid session and user', async () => { const mockUser = { id: 'user-1', diff --git a/tests/unit/api/routers/_shared/projectAccess.test.ts b/tests/unit/api/routers/_shared/projectAccess.test.ts index 1d873501..dfea777b 100644 --- a/tests/unit/api/routers/_shared/projectAccess.test.ts +++ b/tests/unit/api/routers/_shared/projectAccess.test.ts @@ -19,7 +19,6 @@ import { verifyProjectOrgAccess } from '../../../../../src/api/routers/_shared/p describe('verifyProjectOrgAccess', () => { beforeEach(() => { - vi.clearAllMocks(); mockDbSelect.mockReturnValue({ from: mockDbFrom }); mockDbFrom.mockReturnValue({ where: mockDbWhere }); }); diff --git a/tests/unit/api/routers/agentConfigs.test.ts b/tests/unit/api/routers/agentConfigs.test.ts index 7cd35fef..1e35c76b 100644 --- a/tests/unit/api/routers/agentConfigs.test.ts +++ b/tests/unit/api/routers/agentConfigs.test.ts @@ -1,6 +1,7 @@ import { TRPCError } from '@trpc/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { TRPCContext } from '../../../../src/api/trpc.js'; +import { createMockUser } from '../../../helpers/factories.js'; const mockListAgentConfigs = vi.fn(); const mockCreateAgentConfig = vi.fn(); @@ -36,17 +37,10 @@ function createCaller(ctx: TRPCContext) { return agentConfigsRouter.createCaller(ctx); } -const mockUser = { - id: 'user-1', - orgId: 'org-1', - email: 'test@example.com', - name: 'Test', - role: 'admin', -}; +const mockUser = createMockUser(); describe('agentConfigsRouter', () => { beforeEach(() => { - vi.clearAllMocks(); mockDbSelect.mockReturnValue({ from: mockDbFrom }); mockDbFrom.mockReturnValue({ where: mockDbWhere }); }); diff --git a/tests/unit/api/routers/auth.test.ts b/tests/unit/api/routers/auth.test.ts index 96f8ff27..92d835a4 100644 --- a/tests/unit/api/routers/auth.test.ts +++ b/tests/unit/api/routers/auth.test.ts @@ -1,6 +1,7 @@ import { TRPCError } from '@trpc/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { TRPCContext } from '../../../../src/api/trpc.js'; +import { createMockUser } from '../../../helpers/factories.js'; const mockListAllOrganizations = vi.fn(); @@ -15,19 +16,9 @@ function createCaller(ctx: TRPCContext) { } describe('authRouter', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - describe('me', () => { it('returns user data from context', async () => { - const mockUser = { - id: 'user-1', - orgId: 'org-1', - email: 'test@example.com', - name: 'Test User', - role: 'admin', - }; + const mockUser = createMockUser(); mockListAllOrganizations.mockResolvedValue([{ id: 'org-1', name: 'Org One' }]); const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); diff --git a/tests/unit/api/routers/credentials.test.ts b/tests/unit/api/routers/credentials.test.ts index f9c29fe7..b924d860 100644 --- a/tests/unit/api/routers/credentials.test.ts +++ b/tests/unit/api/routers/credentials.test.ts @@ -1,6 +1,7 @@ import { TRPCError } from '@trpc/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { TRPCContext } from '../../../../src/api/trpc.js'; +import { createMockUser } from '../../../helpers/factories.js'; const mockListOrgCredentials = vi.fn(); const mockCreateCredential = vi.fn(); @@ -50,17 +51,10 @@ function createCaller(ctx: TRPCContext) { return credentialsRouter.createCaller(ctx); } -const mockUser = { - id: 'user-1', - orgId: 'org-1', - email: 'test@example.com', - name: 'Test', - role: 'admin', -}; +const mockUser = createMockUser(); describe('credentialsRouter', () => { beforeEach(() => { - vi.clearAllMocks(); mockDbSelect.mockReturnValue({ from: mockDbFrom }); mockDbFrom.mockReturnValue({ where: mockDbWhere }); }); diff --git a/tests/unit/api/routers/defaults.test.ts b/tests/unit/api/routers/defaults.test.ts index e749bd2a..2fbbb7be 100644 --- a/tests/unit/api/routers/defaults.test.ts +++ b/tests/unit/api/routers/defaults.test.ts @@ -1,6 +1,7 @@ import { TRPCError } from '@trpc/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { TRPCContext } from '../../../../src/api/trpc.js'; +import { createMockUser } from '../../../helpers/factories.js'; const mockGetCascadeDefaults = vi.fn(); const mockUpsertCascadeDefaults = vi.fn(); @@ -16,19 +17,9 @@ function createCaller(ctx: TRPCContext) { return defaultsRouter.createCaller(ctx); } -const mockUser = { - id: 'user-1', - orgId: 'org-1', - email: 'test@example.com', - name: 'Test', - role: 'admin', -}; +const mockUser = createMockUser(); describe('defaultsRouter', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - describe('get', () => { it('returns cascade defaults for user orgId', async () => { const mockDefaults = { diff --git a/tests/unit/api/routers/integrationsDiscovery.test.ts b/tests/unit/api/routers/integrationsDiscovery.test.ts index 5827aef0..86a8fa83 100644 --- a/tests/unit/api/routers/integrationsDiscovery.test.ts +++ b/tests/unit/api/routers/integrationsDiscovery.test.ts @@ -1,6 +1,7 @@ import { TRPCError } from '@trpc/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { TRPCContext } from '../../../../src/api/trpc.js'; +import { createMockUser } from '../../../helpers/factories.js'; const mockDecryptCredential = vi.fn((value: string) => value); @@ -72,13 +73,7 @@ function createCaller(ctx: TRPCContext) { return integrationsDiscoveryRouter.createCaller(ctx); } -const mockUser = { - id: 'user-1', - orgId: 'org-1', - email: 'test@example.com', - name: 'Test', - role: 'admin', -}; +const mockUser = createMockUser(); const trelloCredsInput = { apiKeyCredentialId: 1, tokenCredentialId: 2 }; const jiraCredsInput = { @@ -101,7 +96,6 @@ function setupDbCredentials(rows: Array<{ orgId: string; value: string }>) { describe('integrationsDiscoveryRouter', () => { beforeEach(() => { - vi.clearAllMocks(); mockDbSelect.mockReturnValue({ from: mockDbFrom }); mockDbFrom.mockReturnValue({ where: mockDbWhere }); }); diff --git a/tests/unit/api/routers/organization.test.ts b/tests/unit/api/routers/organization.test.ts index 48cbc234..a84191a2 100644 --- a/tests/unit/api/routers/organization.test.ts +++ b/tests/unit/api/routers/organization.test.ts @@ -1,6 +1,7 @@ import { TRPCError } from '@trpc/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { TRPCContext } from '../../../../src/api/trpc.js'; +import { createMockUser } from '../../../helpers/factories.js'; const mockGetOrganization = vi.fn(); const mockUpdateOrganization = vi.fn(); @@ -18,19 +19,9 @@ function createCaller(ctx: TRPCContext) { return organizationRouter.createCaller(ctx); } -const mockUser = { - id: 'user-1', - orgId: 'org-1', - email: 'test@example.com', - name: 'Test', - role: 'admin', -}; +const mockUser = createMockUser(); describe('organizationRouter', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - describe('get', () => { it('returns organization for user orgId', async () => { const mockOrg = { id: 'org-1', name: 'My Org' }; diff --git a/tests/unit/api/routers/projects.test.ts b/tests/unit/api/routers/projects.test.ts index 7b641a1c..1384cef5 100644 --- a/tests/unit/api/routers/projects.test.ts +++ b/tests/unit/api/routers/projects.test.ts @@ -1,6 +1,7 @@ import { TRPCError } from '@trpc/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { TRPCContext } from '../../../../src/api/trpc.js'; +import { createMockUser } from '../../../helpers/factories.js'; const mockListProjectsForOrg = vi.fn(); @@ -61,17 +62,10 @@ function createCaller(ctx: TRPCContext) { return projectsRouter.createCaller(ctx); } -const mockUser = { - id: 'user-1', - orgId: 'org-1', - email: 'test@example.com', - name: 'Test', - role: 'admin', -}; +const mockUser = createMockUser(); describe('projectsRouter', () => { beforeEach(() => { - vi.clearAllMocks(); mockDbSelect.mockReturnValue({ from: mockDbFrom }); mockDbFrom.mockReturnValue({ where: mockDbWhere }); }); diff --git a/tests/unit/api/routers/prompts.test.ts b/tests/unit/api/routers/prompts.test.ts index b2059d7d..84e2cf87 100644 --- a/tests/unit/api/routers/prompts.test.ts +++ b/tests/unit/api/routers/prompts.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { TRPCContext } from '../../../../src/api/trpc.js'; +import { createMockUser } from '../../../helpers/factories.js'; // Mock prompt functions const mockGetValidAgentTypes = vi.fn(); @@ -39,19 +40,9 @@ function createCaller(ctx: TRPCContext) { return promptsRouter.createCaller(ctx); } -const mockUser = { - id: 'user-1', - orgId: 'org-1', - email: 'test@example.com', - name: 'Test', - role: 'admin', -}; +const mockUser = createMockUser(); describe('promptsRouter', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - describe('agentTypes', () => { it('returns list of agent types', async () => { const types = ['splitting', 'planning', 'implementation']; diff --git a/tests/unit/api/routers/runs.test.ts b/tests/unit/api/routers/runs.test.ts index 4f9fbb66..a55d4230 100644 --- a/tests/unit/api/routers/runs.test.ts +++ b/tests/unit/api/routers/runs.test.ts @@ -1,6 +1,7 @@ import { TRPCError } from '@trpc/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { TRPCContext } from '../../../../src/api/trpc.js'; +import { createMockUser } from '../../../helpers/factories.js'; // Mock repository functions const mockListRuns = vi.fn(); @@ -73,19 +74,12 @@ function createCaller(ctx: TRPCContext) { return runsRouter.createCaller(ctx); } -const mockUser = { - id: 'user-1', - orgId: 'org-1', - email: 'test@example.com', - name: 'Test', - role: 'admin', -}; +const mockUser = createMockUser(); const RUN_UUID = 'aaaaaaaa-1111-2222-3333-444444444444'; describe('runsRouter', () => { beforeEach(() => { - vi.clearAllMocks(); // Set up DB chain for getById org check mockDbSelect.mockReturnValue({ from: mockDbFrom }); mockDbFrom.mockReturnValue({ where: mockDbWhere }); diff --git a/tests/unit/api/routers/webhookLogs.test.ts b/tests/unit/api/routers/webhookLogs.test.ts index df5b518e..ad134fff 100644 --- a/tests/unit/api/routers/webhookLogs.test.ts +++ b/tests/unit/api/routers/webhookLogs.test.ts @@ -1,6 +1,7 @@ import { TRPCError } from '@trpc/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { TRPCContext } from '../../../../src/api/trpc.js'; +import { createMockUser } from '../../../helpers/factories.js'; // Mock repository functions const mockListWebhookLogs = vi.fn(); @@ -19,21 +20,11 @@ function createCaller(ctx: TRPCContext) { return webhookLogsRouter.createCaller(ctx); } -const mockUser = { - id: 'user-1', - orgId: 'org-1', - email: 'test@example.com', - name: 'Test', - role: 'admin', -}; +const mockUser = createMockUser(); const LOG_UUID = 'aaaaaaaa-1111-2222-3333-444444444444'; describe('webhookLogsRouter', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - describe('list', () => { it('returns paginated webhook logs', async () => { const mockData = { diff --git a/tests/unit/api/routers/webhooks.test.ts b/tests/unit/api/routers/webhooks.test.ts index ada2e4d8..9c03c24b 100644 --- a/tests/unit/api/routers/webhooks.test.ts +++ b/tests/unit/api/routers/webhooks.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { TRPCContext } from '../../../../src/api/trpc.js'; +import { createMockUser } from '../../../helpers/factories.js'; // --- Mock dependencies --- @@ -60,13 +61,7 @@ function createCaller(ctx: TRPCContext) { return webhooksRouter.createCaller(ctx); } -const mockUser = { - id: 'user-1', - orgId: 'org-1', - email: 'test@example.com', - name: 'Test', - role: 'admin', -}; +const mockUser = createMockUser(); const mockProject = { id: 'my-project', @@ -122,10 +117,6 @@ function setupProjectContext(opts?: { noTrello?: boolean; noGithub?: boolean }) } describe('webhooksRouter', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - describe('list', () => { it('returns trello and github webhooks', async () => { setupProjectContext(); diff --git a/tests/unit/backends/accumulator.test.ts b/tests/unit/backends/accumulator.test.ts index 6786d01b..35e31a59 100644 --- a/tests/unit/backends/accumulator.test.ts +++ b/tests/unit/backends/accumulator.test.ts @@ -16,7 +16,6 @@ import { loadTodos } from '../../../src/gadgets/todo/storage.js'; const mockLoadTodos = vi.mocked(loadTodos); beforeEach(() => { - vi.clearAllMocks(); mockLoadTodos.mockReturnValue([]); }); diff --git a/tests/unit/backends/adapter.test.ts b/tests/unit/backends/adapter.test.ts index cca4b133..2dedc413 100644 --- a/tests/unit/backends/adapter.test.ts +++ b/tests/unit/backends/adapter.test.ts @@ -243,7 +243,6 @@ function setupMocks() { } beforeEach(() => { - vi.clearAllMocks(); process.env.CASCADE_LOCAL_MODE = ''; // Default runs repository mocks mockCreateRun.mockResolvedValue('run-uuid-123'); diff --git a/tests/unit/backends/agent-profiles.test.ts b/tests/unit/backends/agent-profiles.test.ts index 7fdae579..733c111f 100644 --- a/tests/unit/backends/agent-profiles.test.ts +++ b/tests/unit/backends/agent-profiles.test.ts @@ -137,10 +137,6 @@ const mockReadWorkItem = vi.mocked(readWorkItem); const mockGithub = vi.mocked(githubClient); -beforeEach(() => { - vi.clearAllMocks(); -}); - describe('getAgentProfile', () => { describe('respond-to-ci profile', () => { let profile: AgentProfile; diff --git a/tests/unit/backends/claude-code-hooks.test.ts b/tests/unit/backends/claude-code-hooks.test.ts index 9266e64e..5fd0430a 100644 --- a/tests/unit/backends/claude-code-hooks.test.ts +++ b/tests/unit/backends/claude-code-hooks.test.ts @@ -47,10 +47,6 @@ function makeStopInput(): StopHookInput { }; } -beforeEach(() => { - vi.clearAllMocks(); -}); - describe('buildPreToolUseHooks', () => { it('blocks gh pr create commands', async () => { const logWriter = makeLogWriter(); diff --git a/tests/unit/backends/claude-code.test.ts b/tests/unit/backends/claude-code.test.ts index fbf9ac96..b8a012d5 100644 --- a/tests/unit/backends/claude-code.test.ts +++ b/tests/unit/backends/claude-code.test.ts @@ -86,10 +86,6 @@ function makeInput(overrides: Partial = {}): AgentBackendInpu }; } -beforeEach(() => { - vi.clearAllMocks(); -}); - describe('buildToolGuidance', () => { it('returns empty string for empty tools', () => { expect(buildToolGuidance([])).toBe(''); diff --git a/tests/unit/backends/githubPoster.test.ts b/tests/unit/backends/githubPoster.test.ts index 90ed6dcb..c25f2200 100644 --- a/tests/unit/backends/githubPoster.test.ts +++ b/tests/unit/backends/githubPoster.test.ts @@ -23,10 +23,6 @@ const mockGithubClient = vi.mocked(githubClient); const mockGetSessionState = vi.mocked(getSessionState); const mockFormatGitHubProgressComment = vi.mocked(formatGitHubProgressComment); -beforeEach(() => { - vi.clearAllMocks(); -}); - function makePoster() { return new GitHubProgressPoster({ owner: 'myorg', diff --git a/tests/unit/backends/llmist.test.ts b/tests/unit/backends/llmist.test.ts index d5d35c6f..49416fc8 100644 --- a/tests/unit/backends/llmist.test.ts +++ b/tests/unit/backends/llmist.test.ts @@ -110,10 +110,6 @@ function makeInput(agentType = 'implementation'): AgentBackendInput { }; } -beforeEach(() => { - vi.clearAllMocks(); -}); - describe('LlmistBackend', () => { it('has name "llmist"', () => { const backend = new LlmistBackend(); diff --git a/tests/unit/backends/pmPoster.test.ts b/tests/unit/backends/pmPoster.test.ts index 86f880f8..9d2e6b74 100644 --- a/tests/unit/backends/pmPoster.test.ts +++ b/tests/unit/backends/pmPoster.test.ts @@ -27,7 +27,6 @@ const mockPMProvider = { }; beforeEach(() => { - vi.clearAllMocks(); // Default: state file exists mockReadProgressCommentId.mockReturnValue({ workItemId: 'card1', commentId: 'comment1' }); }); diff --git a/tests/unit/backends/postProcess.test.ts b/tests/unit/backends/postProcess.test.ts index c0d52b6d..263c3d28 100644 --- a/tests/unit/backends/postProcess.test.ts +++ b/tests/unit/backends/postProcess.test.ts @@ -48,10 +48,6 @@ function makeInput(overrides?: Partial): AgentInput & { project: } as AgentInput & { project: ProjectConfig }; } -beforeEach(() => { - vi.clearAllMocks(); -}); - describe('postProcessResult', () => { describe('PR validation for implementation agents', () => { it('marks as failed when implementation agent succeeds without prUrl', () => { diff --git a/tests/unit/backends/progress.test.ts b/tests/unit/backends/progress.test.ts index 5592606d..190ad471 100644 --- a/tests/unit/backends/progress.test.ts +++ b/tests/unit/backends/progress.test.ts @@ -73,7 +73,6 @@ const mockCallProgressModel = vi.mocked(callProgressModel); const mockSyncChecklist = vi.mocked(syncCompletedTodosToChecklist); beforeEach(() => { - vi.clearAllMocks(); vi.useFakeTimers(); mockLoadTodos.mockReturnValue([]); mockGetPMProvider.mockReturnValue(null); diff --git a/tests/unit/backends/progressModel.test.ts b/tests/unit/backends/progressModel.test.ts index d3724db8..c6b90b9f 100644 --- a/tests/unit/backends/progressModel.test.ts +++ b/tests/unit/backends/progressModel.test.ts @@ -27,10 +27,6 @@ function makeContext(overrides: Partial = {}): ProgressContext }; } -beforeEach(() => { - vi.clearAllMocks(); -}); - describe('callProgressModel', () => { it('returns text output from LLM on success', async () => { mockTextComplete.mockResolvedValue( diff --git a/tests/unit/backends/secretBuilder.test.ts b/tests/unit/backends/secretBuilder.test.ts index 33edb1d6..81a26c22 100644 --- a/tests/unit/backends/secretBuilder.test.ts +++ b/tests/unit/backends/secretBuilder.test.ts @@ -48,7 +48,6 @@ function makeProfile(overrides?: Partial): AgentProfile { } beforeEach(() => { - vi.clearAllMocks(); mockGetAllProjectCredentials.mockResolvedValue({}); }); diff --git a/tests/unit/cli/credential-scoping.test.ts b/tests/unit/cli/credential-scoping.test.ts index 2a7b8600..11e8f115 100644 --- a/tests/unit/cli/credential-scoping.test.ts +++ b/tests/unit/cli/credential-scoping.test.ts @@ -28,7 +28,6 @@ describe('CredentialScopedCommand', () => { const originalEnv = process.env; beforeEach(() => { - vi.clearAllMocks(); process.env = { ...originalEnv }; process.env.GITHUB_TOKEN = undefined; process.env.TRELLO_API_KEY = undefined; diff --git a/tests/unit/cli/dashboard/base.test.ts b/tests/unit/cli/dashboard/base.test.ts index 0cd0fff1..61a2c3ce 100644 --- a/tests/unit/cli/dashboard/base.test.ts +++ b/tests/unit/cli/dashboard/base.test.ts @@ -94,10 +94,6 @@ describe('extractBaseFlags', () => { }); describe('DashboardCommand', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - describe('config loading', () => { it('errors when not logged in (no config)', async () => { mockLoadConfig.mockReturnValue(null); diff --git a/tests/unit/cli/dashboard/client.test.ts b/tests/unit/cli/dashboard/client.test.ts index ff558e8c..c0c356b8 100644 --- a/tests/unit/cli/dashboard/client.test.ts +++ b/tests/unit/cli/dashboard/client.test.ts @@ -9,10 +9,6 @@ import { createTRPCClient, httpBatchLink } from '@trpc/client'; import { createDashboardClient } from '../../../../src/cli/dashboard/_shared/client.js'; describe('createDashboardClient', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - it('creates a tRPC client with links', () => { const config = { serverUrl: 'http://localhost:3000', sessionToken: 'my-token' }; diff --git a/tests/unit/cli/dashboard/config.test.ts b/tests/unit/cli/dashboard/config.test.ts index cc0612c0..11760d94 100644 --- a/tests/unit/cli/dashboard/config.test.ts +++ b/tests/unit/cli/dashboard/config.test.ts @@ -22,7 +22,6 @@ describe('config', () => { const originalEnv = process.env; beforeEach(() => { - vi.clearAllMocks(); process.env = { ...originalEnv }; process.env.CASCADE_SERVER_URL = undefined; process.env.CASCADE_SESSION_TOKEN = undefined; diff --git a/tests/unit/cli/file-input-flags.test.ts b/tests/unit/cli/file-input-flags.test.ts index f8d6cff8..c9a936eb 100644 --- a/tests/unit/cli/file-input-flags.test.ts +++ b/tests/unit/cli/file-input-flags.test.ts @@ -58,7 +58,6 @@ let tmpDir: string; const mockConfig = { runHook: vi.fn().mockResolvedValue({ successes: [], failures: [] }) }; beforeEach(() => { - vi.clearAllMocks(); tmpDir = mkdtempSync(join(tmpdir(), 'cascade-cli-test-')); }); diff --git a/tests/unit/config/compactionConfig.test.ts b/tests/unit/config/compactionConfig.test.ts index fc368a68..6f489108 100644 --- a/tests/unit/config/compactionConfig.test.ts +++ b/tests/unit/config/compactionConfig.test.ts @@ -20,10 +20,6 @@ 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'); diff --git a/tests/unit/config/hintConfig.test.ts b/tests/unit/config/hintConfig.test.ts index 00f71074..58b9d011 100644 --- a/tests/unit/config/hintConfig.test.ts +++ b/tests/unit/config/hintConfig.test.ts @@ -37,7 +37,6 @@ function getMessage(agentType: string | undefined, iteration = 3, maxIterations describe('getIterationTrailingMessage', () => { afterEach(() => { clearDiagnosticState(); - vi.clearAllMocks(); mockLoadTodos.mockReturnValue([]); mockFormatTodoList.mockReturnValue(''); mockExecSync.mockReturnValue(''); diff --git a/tests/unit/config/projects.test.ts b/tests/unit/config/projects.test.ts index b1f493f7..5ad09d6a 100644 --- a/tests/unit/config/projects.test.ts +++ b/tests/unit/config/projects.test.ts @@ -82,15 +82,6 @@ describe('config provider', () => { projects: [mockProject1, mockProject2], }; - beforeEach(() => { - vi.clearAllMocks(); - invalidateConfigCache(); - }); - - afterEach(() => { - invalidateConfigCache(); - }); - describe('loadConfig', () => { it('loads config from database', async () => { vi.mocked(loadConfigFromDb).mockResolvedValue(mockConfig); @@ -175,10 +166,6 @@ describe('config provider', () => { beforeEach(() => { vi.stubEnv('TRELLO_API_KEY', ''); }); - afterEach(() => { - vi.unstubAllEnvs(); - }); - it('resolves credential from DB', async () => { vi.mocked(resolveIntegrationCredential).mockResolvedValue('db-secret-value'); @@ -200,10 +187,6 @@ describe('config provider', () => { beforeEach(() => { vi.stubEnv('GITHUB_TOKEN_IMPLEMENTER', ''); }); - afterEach(() => { - vi.unstubAllEnvs(); - }); - it('returns credential value when found', async () => { vi.mocked(resolveIntegrationCredential).mockResolvedValue('secret-value'); @@ -249,10 +232,6 @@ describe('config provider', () => { beforeEach(() => { vi.stubEnv('GITHUB_TOKEN_IMPLEMENTER', ''); }); - afterEach(() => { - vi.unstubAllEnvs(); - }); - it('returns implementer token when available', async () => { vi.mocked(resolveIntegrationCredential).mockResolvedValue('implementer-token'); diff --git a/tests/unit/config/provider.test.ts b/tests/unit/config/provider.test.ts index efd4986c..3b2d00a9 100644 --- a/tests/unit/config/provider.test.ts +++ b/tests/unit/config/provider.test.ts @@ -111,18 +111,11 @@ describe('config/provider', () => { envKeysToClean.push(key); } - beforeEach(() => { - invalidateConfigCache(); - vi.clearAllMocks(); - }); - afterEach(() => { for (const key of envKeysToClean) { delete process.env[key]; } envKeysToClean.length = 0; - invalidateConfigCache(); - vi.clearAllMocks(); }); describe('loadConfig', () => { diff --git a/tests/unit/config/statusUpdateConfig.test.ts b/tests/unit/config/statusUpdateConfig.test.ts index 3d041f00..0845ecf9 100644 --- a/tests/unit/config/statusUpdateConfig.test.ts +++ b/tests/unit/config/statusUpdateConfig.test.ts @@ -16,10 +16,6 @@ vi.mock('../../../src/gadgets/todo/storage.js', () => ({ 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', 'splitting', 'planning', 'review']; diff --git a/tests/unit/db/crypto.test.ts b/tests/unit/db/crypto.test.ts index c2506f5f..054322d9 100644 --- a/tests/unit/db/crypto.test.ts +++ b/tests/unit/db/crypto.test.ts @@ -15,10 +15,6 @@ describe('crypto', () => { vi.stubEnv('CREDENTIAL_MASTER_KEY', TEST_KEY); }); - afterEach(() => { - vi.unstubAllEnvs(); - }); - describe('isEncryptionEnabled', () => { it('returns true when CREDENTIAL_MASTER_KEY is set', () => { expect(isEncryptionEnabled()).toBe(true); diff --git a/tests/unit/db/repositories/configRepository.test.ts b/tests/unit/db/repositories/configRepository.test.ts index e26547f2..e574d677 100644 --- a/tests/unit/db/repositories/configRepository.test.ts +++ b/tests/unit/db/repositories/configRepository.test.ts @@ -163,10 +163,6 @@ function createSequentialMockDb(results: QueryResult[]) { } describe('configRepository', () => { - afterEach(() => { - vi.clearAllMocks(); - }); - describe('loadConfigFromDb', () => { it('loads config with Trello integration from project_integrations', async () => { // loadConfigFromDb Promise.all order: defaults, projects, agentConfigs, integrations diff --git a/tests/unit/db/repositories/credentialsRepository.test.ts b/tests/unit/db/repositories/credentialsRepository.test.ts index 3d20d8ff..f735970f 100644 --- a/tests/unit/db/repositories/credentialsRepository.test.ts +++ b/tests/unit/db/repositories/credentialsRepository.test.ts @@ -1,5 +1,6 @@ import { randomBytes } from 'node:crypto'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockDb } from '../../../helpers/mockDb.js'; // Mock the DB client vi.mock('../../../../src/db/client.js', () => ({ @@ -18,55 +19,14 @@ import { updateCredential, } from '../../../../src/db/repositories/credentialsRepository.js'; -/** - * Creates a mock Drizzle query chain that supports the common patterns: - * select().from().innerJoin().where(), select().from().innerJoin().innerJoin().where(), - * insert().values().returning(), update().set().where(), delete().from().where() - */ -function createMockDb() { - const chain: Record> = {}; - - // Terminal methods that return results - chain.where = vi.fn().mockResolvedValue([]); - chain.returning = vi.fn().mockResolvedValue([]); - - // Chain methods - chain.innerJoin = vi.fn().mockReturnValue({ - where: chain.where, - innerJoin: vi.fn().mockReturnValue({ where: chain.where }), - }); - chain.from = vi.fn().mockReturnValue({ - where: chain.where, - innerJoin: chain.innerJoin, - }); - chain.set = vi.fn().mockReturnValue({ where: chain.where }); - chain.values = vi.fn().mockReturnValue({ - returning: chain.returning, - }); - - const db = { - select: vi.fn().mockReturnValue({ from: chain.from }), - insert: vi.fn().mockReturnValue({ values: chain.values }), - update: vi.fn().mockReturnValue({ set: chain.set }), - delete: vi.fn().mockReturnValue({ where: chain.where }), - }; - - return { db, chain }; -} - describe('credentialsRepository', () => { let mockDb: ReturnType; beforeEach(() => { - mockDb = createMockDb(); + mockDb = createMockDb({ withDoubleJoin: true }); vi.mocked(getDb).mockReturnValue(mockDb.db as never); }); - afterEach(() => { - vi.unstubAllEnvs(); - vi.clearAllMocks(); - }); - describe('resolveIntegrationCredential', () => { it('returns decrypted value when found', async () => { mockDb.chain.where.mockResolvedValueOnce([{ value: 'trello-api-key', orgId: 'org1' }]); diff --git a/tests/unit/db/repositories/prWorkItemsRepository.test.ts b/tests/unit/db/repositories/prWorkItemsRepository.test.ts index 46c5419c..670a0eec 100644 --- a/tests/unit/db/repositories/prWorkItemsRepository.test.ts +++ b/tests/unit/db/repositories/prWorkItemsRepository.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockDb } from '../../../helpers/mockDb.js'; vi.mock('../../../../src/db/client.js', () => ({ getDb: vi.fn(), @@ -19,38 +20,14 @@ import { lookupWorkItemForPR, } from '../../../../src/db/repositories/prWorkItemsRepository.js'; -function createMockDb() { - const chain: Record> = {}; - - chain.limit = vi.fn().mockResolvedValue([]); - chain.where = vi.fn().mockReturnValue({ limit: chain.limit }); - chain.from = vi.fn().mockReturnValue({ where: chain.where }); - - chain.onConflictDoUpdate = vi.fn().mockResolvedValue(undefined); - chain.values = vi.fn().mockReturnValue({ - onConflictDoUpdate: chain.onConflictDoUpdate, - }); - - const db = { - select: vi.fn().mockReturnValue({ from: chain.from }), - insert: vi.fn().mockReturnValue({ values: chain.values }), - }; - - return { db, chain }; -} - describe('prWorkItemsRepository', () => { let mockDb: ReturnType; beforeEach(() => { - mockDb = createMockDb(); + mockDb = createMockDb({ withLimit: true, withUpsert: true }); vi.mocked(getDb).mockReturnValue(mockDb.db as never); }); - afterEach(() => { - vi.clearAllMocks(); - }); - // ========================================================================== // linkPRToWorkItem // ========================================================================== diff --git a/tests/unit/db/repositories/runsRepository.dashboard.test.ts b/tests/unit/db/repositories/runsRepository.dashboard.test.ts index acefaef0..9472806f 100644 --- a/tests/unit/db/repositories/runsRepository.dashboard.test.ts +++ b/tests/unit/db/repositories/runsRepository.dashboard.test.ts @@ -69,10 +69,6 @@ function createChain(resolveValue: unknown = []) { } describe('runsRepository - dashboard queries', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - describe('listRuns', () => { it('returns data and total count', async () => { const dataChain = createChain([{ id: 'run-1', agentType: 'impl' }]); diff --git a/tests/unit/db/repositories/settingsRepository.test.ts b/tests/unit/db/repositories/settingsRepository.test.ts index 5d48073a..e922fdb8 100644 --- a/tests/unit/db/repositories/settingsRepository.test.ts +++ b/tests/unit/db/repositories/settingsRepository.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockDb } from '../../../helpers/mockDb.js'; vi.mock('../../../../src/db/client.js', () => ({ getDb: vi.fn(), @@ -24,54 +25,14 @@ import { upsertProjectIntegration, } from '../../../../src/db/repositories/settingsRepository.js'; -function createMockDb() { - const chain: Record> = {}; - - chain.where = vi.fn().mockResolvedValue([]); - chain.returning = vi.fn().mockResolvedValue([]); - chain.limit = vi.fn().mockReturnValue(chain); - - chain.innerJoin = vi.fn().mockReturnValue({ where: chain.where }); - chain.from = vi.fn().mockReturnValue({ - where: chain.where, - innerJoin: chain.innerJoin, - limit: chain.limit, - }); - chain.set = vi.fn().mockReturnValue({ where: chain.where }); - chain.onConflictDoUpdate = vi.fn().mockReturnValue({ - returning: chain.returning, - }); - chain.values = vi.fn().mockReturnValue({ - onConflictDoUpdate: chain.onConflictDoUpdate, - returning: chain.returning, - }); - - // Make chain itself thenable for queries without .where() terminal - // biome-ignore lint/suspicious/noThenProperty: intentional thenable mock for Drizzle query chains - chain.then = (resolve: (v: unknown) => unknown) => Promise.resolve([]).then(resolve); - - const db = { - select: vi.fn().mockReturnValue({ from: chain.from }), - insert: vi.fn().mockReturnValue({ values: chain.values }), - update: vi.fn().mockReturnValue({ set: chain.set }), - delete: vi.fn().mockReturnValue({ where: chain.where }), - }; - - return { db, chain }; -} - describe('settingsRepository', () => { let mockDb: ReturnType; beforeEach(() => { - mockDb = createMockDb(); + mockDb = createMockDb({ withUpsert: true, withThenable: true }); vi.mocked(getDb).mockReturnValue(mockDb.db as never); }); - afterEach(() => { - vi.clearAllMocks(); - }); - // ============================================================================ // Organizations // ============================================================================ diff --git a/tests/unit/db/repositories/usersRepository.test.ts b/tests/unit/db/repositories/usersRepository.test.ts index b2beac1e..dc9dceb7 100644 --- a/tests/unit/db/repositories/usersRepository.test.ts +++ b/tests/unit/db/repositories/usersRepository.test.ts @@ -44,8 +44,6 @@ import { describe('usersRepository', () => { beforeEach(() => { - vi.clearAllMocks(); - mockInsert.mockReturnValue({ values: mockValues }); mockValues.mockReturnValue({ returning: mockReturning }); mockSelect.mockReturnValue({ from: mockFrom }); diff --git a/tests/unit/db/runsRepository.test.ts b/tests/unit/db/runsRepository.test.ts index e699c367..d8e88f18 100644 --- a/tests/unit/db/runsRepository.test.ts +++ b/tests/unit/db/runsRepository.test.ts @@ -52,8 +52,6 @@ import { describe('runsRepository', () => { beforeEach(() => { - vi.clearAllMocks(); - // Set up chained mock returns mockInsert.mockReturnValue({ values: mockValues }); mockValues.mockReturnValue({ returning: mockReturning }); diff --git a/tests/unit/db/webhookLogsRepository.test.ts b/tests/unit/db/webhookLogsRepository.test.ts index d058edb4..290d5f1b 100644 --- a/tests/unit/db/webhookLogsRepository.test.ts +++ b/tests/unit/db/webhookLogsRepository.test.ts @@ -48,8 +48,6 @@ import { describe('webhookLogsRepository', () => { beforeEach(() => { - vi.clearAllMocks(); - // Set up chained mock returns mockInsert.mockReturnValue({ values: mockValues }); mockValues.mockReturnValue({ returning: mockReturning }); diff --git a/tests/unit/gadgets/fileInsertContent.test.ts b/tests/unit/gadgets/fileInsertContent.test.ts index 01d21bb7..b9fbdad4 100644 --- a/tests/unit/gadgets/fileInsertContent.test.ts +++ b/tests/unit/gadgets/fileInsertContent.test.ts @@ -52,7 +52,6 @@ beforeEach(() => { afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); - vi.clearAllMocks(); }); function createFile(name: string, content: string): string { diff --git a/tests/unit/gadgets/fileRemoveContent.test.ts b/tests/unit/gadgets/fileRemoveContent.test.ts index e71c17f8..f8846cf9 100644 --- a/tests/unit/gadgets/fileRemoveContent.test.ts +++ b/tests/unit/gadgets/fileRemoveContent.test.ts @@ -52,7 +52,6 @@ beforeEach(() => { afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); - vi.clearAllMocks(); }); function createFile(name: string, content: string): string { diff --git a/tests/unit/gadgets/finish.test.ts b/tests/unit/gadgets/finish.test.ts index 66d1fbc9..a3e571c3 100644 --- a/tests/unit/gadgets/finish.test.ts +++ b/tests/unit/gadgets/finish.test.ts @@ -22,10 +22,6 @@ vi.mock('../../../src/github/client.js', () => ({ })); describe('Finish gadget', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - it('has exclusive set to prevent parallel execution with other gadgets', () => { initSessionState('unknown'); const gadget = new Finish(); diff --git a/tests/unit/gadgets/github.test.ts b/tests/unit/gadgets/github.test.ts index 3f7d5f98..9132eaa0 100644 --- a/tests/unit/gadgets/github.test.ts +++ b/tests/unit/gadgets/github.test.ts @@ -54,10 +54,6 @@ function mockRunCommand( describe('GitHub Gadgets', () => { describe('CreatePR', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - it('is a valid llmist Gadget class', () => { const gadget = new CreatePR(); expect(gadget).toBeDefined(); diff --git a/tests/unit/gadgets/github/core/createPR.test.ts b/tests/unit/gadgets/github/core/createPR.test.ts index b0d331a4..edb73a48 100644 --- a/tests/unit/gadgets/github/core/createPR.test.ts +++ b/tests/unit/gadgets/github/core/createPR.test.ts @@ -37,10 +37,6 @@ function mockGitCommands( }); } -beforeEach(() => { - vi.clearAllMocks(); -}); - describe('detectOwnerRepo (tested through createPR)', () => { it('parses HTTPS URL', async () => { mockRunCommand.mockImplementation(async (_cmd, args) => { diff --git a/tests/unit/gadgets/github/core/misc.test.ts b/tests/unit/gadgets/github/core/misc.test.ts index 9fdb264b..72178c8e 100644 --- a/tests/unit/gadgets/github/core/misc.test.ts +++ b/tests/unit/gadgets/github/core/misc.test.ts @@ -28,10 +28,6 @@ import { githubClient } from '../../../../../src/github/client.js'; const mockGithub = vi.mocked(githubClient); -beforeEach(() => { - vi.clearAllMocks(); -}); - describe('getPRDetails', () => { it('formats PR with number, title, state, branches, URL', async () => { mockGithub.getPR.mockResolvedValue({ diff --git a/tests/unit/gadgets/pm/core/addChecklist.test.ts b/tests/unit/gadgets/pm/core/addChecklist.test.ts index 8e1131d5..2b9793d0 100644 --- a/tests/unit/gadgets/pm/core/addChecklist.test.ts +++ b/tests/unit/gadgets/pm/core/addChecklist.test.ts @@ -10,10 +10,6 @@ vi.mock('../../../../../src/pm/index.js', () => ({ import { addChecklist } from '../../../../../src/gadgets/pm/core/addChecklist.js'; -beforeEach(() => { - vi.clearAllMocks(); -}); - describe('addChecklist', () => { it('creates checklist and adds string items', async () => { mockProvider.createChecklist.mockResolvedValue({ diff --git a/tests/unit/gadgets/pm/core/createWorkItem.test.ts b/tests/unit/gadgets/pm/core/createWorkItem.test.ts index ccf831a0..4196cea8 100644 --- a/tests/unit/gadgets/pm/core/createWorkItem.test.ts +++ b/tests/unit/gadgets/pm/core/createWorkItem.test.ts @@ -10,10 +10,6 @@ vi.mock('../../../../../src/pm/index.js', () => ({ import { createWorkItem } from '../../../../../src/gadgets/pm/core/createWorkItem.js'; -beforeEach(() => { - vi.clearAllMocks(); -}); - describe('createWorkItem', () => { it('creates a work item and returns success message', async () => { mockProvider.createWorkItem.mockResolvedValue({ diff --git a/tests/unit/gadgets/pm/core/deleteChecklistItem.test.ts b/tests/unit/gadgets/pm/core/deleteChecklistItem.test.ts index 976ca898..cd3c84f9 100644 --- a/tests/unit/gadgets/pm/core/deleteChecklistItem.test.ts +++ b/tests/unit/gadgets/pm/core/deleteChecklistItem.test.ts @@ -10,10 +10,6 @@ vi.mock('../../../../../src/pm/index.js', () => ({ import { deleteChecklistItem } from '../../../../../src/gadgets/pm/core/deleteChecklistItem.js'; -beforeEach(() => { - vi.clearAllMocks(); -}); - describe('deleteChecklistItem', () => { it('deletes a checklist item and returns success message', async () => { mockProvider.deleteChecklistItem.mockResolvedValue(undefined); diff --git a/tests/unit/gadgets/pm/core/listWorkItems.test.ts b/tests/unit/gadgets/pm/core/listWorkItems.test.ts index 00328e94..33bc5f1f 100644 --- a/tests/unit/gadgets/pm/core/listWorkItems.test.ts +++ b/tests/unit/gadgets/pm/core/listWorkItems.test.ts @@ -10,10 +10,6 @@ vi.mock('../../../../../src/pm/index.js', () => ({ import { listWorkItems } from '../../../../../src/gadgets/pm/core/listWorkItems.js'; -beforeEach(() => { - vi.clearAllMocks(); -}); - describe('listWorkItems', () => { it('returns "No work items found." when list is empty', async () => { mockProvider.listWorkItems.mockResolvedValue([]); diff --git a/tests/unit/gadgets/pm/core/postComment.test.ts b/tests/unit/gadgets/pm/core/postComment.test.ts index 7cf2fa62..10c737e5 100644 --- a/tests/unit/gadgets/pm/core/postComment.test.ts +++ b/tests/unit/gadgets/pm/core/postComment.test.ts @@ -23,7 +23,6 @@ const mockReadProgressCommentId = vi.mocked(readProgressCommentId); const mockClearProgressCommentId = vi.mocked(clearProgressCommentId); beforeEach(() => { - vi.clearAllMocks(); mockReadProgressCommentId.mockReturnValue(null); }); diff --git a/tests/unit/gadgets/pm/core/readWorkItem.test.ts b/tests/unit/gadgets/pm/core/readWorkItem.test.ts index aa1ac666..5d502ad9 100644 --- a/tests/unit/gadgets/pm/core/readWorkItem.test.ts +++ b/tests/unit/gadgets/pm/core/readWorkItem.test.ts @@ -10,10 +10,6 @@ vi.mock('../../../../../src/pm/index.js', () => ({ import { readWorkItem } from '../../../../../src/gadgets/pm/core/readWorkItem.js'; -beforeEach(() => { - vi.clearAllMocks(); -}); - describe('readWorkItem', () => { const baseItem = { id: 'item1', diff --git a/tests/unit/gadgets/pm/core/updateChecklistItem.test.ts b/tests/unit/gadgets/pm/core/updateChecklistItem.test.ts index 032025c6..7065812e 100644 --- a/tests/unit/gadgets/pm/core/updateChecklistItem.test.ts +++ b/tests/unit/gadgets/pm/core/updateChecklistItem.test.ts @@ -10,10 +10,6 @@ vi.mock('../../../../../src/pm/index.js', () => ({ import { updateChecklistItem } from '../../../../../src/gadgets/pm/core/updateChecklistItem.js'; -beforeEach(() => { - vi.clearAllMocks(); -}); - describe('updateChecklistItem', () => { it('marks a checklist item as complete', async () => { mockProvider.updateChecklistItem.mockResolvedValue(undefined); diff --git a/tests/unit/gadgets/pm/core/updateWorkItem.test.ts b/tests/unit/gadgets/pm/core/updateWorkItem.test.ts index 9a263c89..21291f25 100644 --- a/tests/unit/gadgets/pm/core/updateWorkItem.test.ts +++ b/tests/unit/gadgets/pm/core/updateWorkItem.test.ts @@ -10,10 +10,6 @@ vi.mock('../../../../../src/pm/index.js', () => ({ import { updateWorkItem } from '../../../../../src/gadgets/pm/core/updateWorkItem.js'; -beforeEach(() => { - vi.clearAllMocks(); -}); - describe('updateWorkItem', () => { it('returns early message when nothing to update', async () => { const result = await updateWorkItem({ workItemId: 'item1' }); diff --git a/tests/unit/gadgets/session/core/finish.test.ts b/tests/unit/gadgets/session/core/finish.test.ts index f51126c7..0ba207c2 100644 --- a/tests/unit/gadgets/session/core/finish.test.ts +++ b/tests/unit/gadgets/session/core/finish.test.ts @@ -22,10 +22,6 @@ import { githubClient } from '../../../../../src/github/client.js'; const mockGithub = vi.mocked(githubClient); -beforeEach(() => { - vi.clearAllMocks(); -}); - describe('hasUncommittedChanges', () => { it('returns true when git status has output', () => { mockExecSync.mockReturnValue('M src/file.ts'); diff --git a/tests/unit/gadgets/shared/diagnosticState.test.ts b/tests/unit/gadgets/shared/diagnosticState.test.ts index 138f66c3..70f4d292 100644 --- a/tests/unit/gadgets/shared/diagnosticState.test.ts +++ b/tests/unit/gadgets/shared/diagnosticState.test.ts @@ -22,7 +22,6 @@ const mockShouldRunDiagnostics = vi.mocked(shouldRunDiagnostics); afterEach(() => { clearDiagnosticState(); - vi.clearAllMocks(); }); describe('updateDiagnosticState', () => { diff --git a/tests/unit/gadgets/todo-storage.test.ts b/tests/unit/gadgets/todo-storage.test.ts index 7bb6b8c0..e0538751 100644 --- a/tests/unit/gadgets/todo-storage.test.ts +++ b/tests/unit/gadgets/todo-storage.test.ts @@ -24,7 +24,6 @@ import { describe('todo storage', () => { beforeEach(() => { - vi.clearAllMocks(); // Reset session state by re-initializing vi.mocked(existsSync).mockReturnValue(true); }); diff --git a/tests/unit/gadgets/todo.test.ts b/tests/unit/gadgets/todo.test.ts index 6757bfe2..1e709da1 100644 --- a/tests/unit/gadgets/todo.test.ts +++ b/tests/unit/gadgets/todo.test.ts @@ -63,10 +63,6 @@ describe('TodoUpsert', () => { (storage as unknown as { _resetTodos: () => void })._resetTodos(); }); - afterEach(() => { - vi.clearAllMocks(); - }); - describe('gadget metadata', () => { it('has correct name', () => { expect(gadget.name).toBe('TodoUpsert'); @@ -203,10 +199,6 @@ describe('TodoUpdateStatus', () => { (storage as unknown as { _resetTodos: () => void })._resetTodos(); }); - afterEach(() => { - vi.clearAllMocks(); - }); - describe('gadget metadata', () => { it('has correct name', () => { expect(gadget.name).toBe('TodoUpdateStatus'); diff --git a/tests/unit/github/client.test.ts b/tests/unit/github/client.test.ts index ad922a4b..9de950f8 100644 --- a/tests/unit/github/client.test.ts +++ b/tests/unit/github/client.test.ts @@ -71,10 +71,6 @@ import { import { Octokit } from '@octokit/rest'; describe('githubClient', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - describe('getClient throws without scope', () => { it('throws when no withGitHubToken scope is active', async () => { await expect(githubClient.getPR('owner', 'repo', 1)).rejects.toThrow( diff --git a/tests/unit/github/personas.test.ts b/tests/unit/github/personas.test.ts index 61f6e66e..7e669060 100644 --- a/tests/unit/github/personas.test.ts +++ b/tests/unit/github/personas.test.ts @@ -30,10 +30,6 @@ import { import type { PersonaIdentities } from '../../../src/github/personas.js'; describe('personas', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - // ======================================================================== // getPersonaForAgentType // ======================================================================== diff --git a/tests/unit/jira/client.test.ts b/tests/unit/jira/client.test.ts index 758b8e15..88fe2dd9 100644 --- a/tests/unit/jira/client.test.ts +++ b/tests/unit/jira/client.test.ts @@ -109,7 +109,6 @@ describe('jiraClient', () => { // Note: We don't call vi.restoreAllMocks() here because it would reset // the Version3Client mock implementation from vi.mock(), breaking subsequent tests. // Instead we clear only the fetch spy manually. - vi.clearAllMocks(); }); describe('getCloudId', () => { diff --git a/tests/unit/pm/webhook-handler.test.ts b/tests/unit/pm/webhook-handler.test.ts index 1b4740e3..13aa3386 100644 --- a/tests/unit/pm/webhook-handler.test.ts +++ b/tests/unit/pm/webhook-handler.test.ts @@ -131,7 +131,6 @@ function createMockRegistry(result?: object | null) { } beforeEach(() => { - vi.clearAllMocks(); mockIsCurrentlyProcessing.mockReturnValue(false); mockIsCardActive.mockReturnValue(false); mockEnqueueWebhook.mockReturnValue(true); diff --git a/tests/unit/queue/retry-run-projectId.test.ts b/tests/unit/queue/retry-run-projectId.test.ts index 55f24385..6bda8650 100644 --- a/tests/unit/queue/retry-run-projectId.test.ts +++ b/tests/unit/queue/retry-run-projectId.test.ts @@ -79,7 +79,6 @@ const RUN_UUID = 'aaaaaaaa-1111-2222-3333-444444444444'; describe('retry-run job submission with projectId', () => { beforeEach(() => { - vi.clearAllMocks(); mockDbSelect.mockReturnValue({ from: mockDbFrom }); mockDbFrom.mockReturnValue({ where: mockDbWhere }); }); diff --git a/tests/unit/router/ackMessageGenerator.test.ts b/tests/unit/router/ackMessageGenerator.test.ts index da10d716..d9d8613a 100644 --- a/tests/unit/router/ackMessageGenerator.test.ts +++ b/tests/unit/router/ackMessageGenerator.test.ts @@ -51,10 +51,6 @@ import { generateAckMessage, } from '../../../src/router/ackMessageGenerator.js'; -beforeEach(() => { - vi.clearAllMocks(); -}); - // --------------------------------------------------------------------------- // Context extractors // --------------------------------------------------------------------------- diff --git a/tests/unit/router/adapters/github.test.ts b/tests/unit/router/adapters/github.test.ts index ade58d55..576c24d8 100644 --- a/tests/unit/router/adapters/github.test.ts +++ b/tests/unit/router/adapters/github.test.ts @@ -94,7 +94,6 @@ const mockTriggerRegistry = { } as unknown as TriggerRegistry; beforeEach(() => { - vi.clearAllMocks(); vi.mocked(loadProjectConfig).mockResolvedValue({ projects: [mockProject], fullProjects: [{ id: 'p1', repo: 'owner/repo' } as never], diff --git a/tests/unit/router/adapters/jira.test.ts b/tests/unit/router/adapters/jira.test.ts index 056a240a..2a847339 100644 --- a/tests/unit/router/adapters/jira.test.ts +++ b/tests/unit/router/adapters/jira.test.ts @@ -60,7 +60,6 @@ const mockTriggerRegistry = { } as unknown as TriggerRegistry; beforeEach(() => { - vi.clearAllMocks(); vi.mocked(loadProjectConfig).mockResolvedValue({ projects: [mockProject], fullProjects: [{ id: 'p1' } as never], diff --git a/tests/unit/router/adapters/trello.test.ts b/tests/unit/router/adapters/trello.test.ts index beb4048e..6eb9f7b0 100644 --- a/tests/unit/router/adapters/trello.test.ts +++ b/tests/unit/router/adapters/trello.test.ts @@ -69,7 +69,6 @@ const mockTriggerRegistry = { } as unknown as TriggerRegistry; beforeEach(() => { - vi.clearAllMocks(); vi.mocked(loadProjectConfig).mockResolvedValue({ projects: [mockProject], fullProjects: [{ id: 'p1' } as never], diff --git a/tests/unit/router/trello.test.ts b/tests/unit/router/trello.test.ts index d69c324f..01a6335d 100644 --- a/tests/unit/router/trello.test.ts +++ b/tests/unit/router/trello.test.ts @@ -39,10 +39,6 @@ const mockProject: RouterProjectConfig = { }, }; -beforeEach(() => { - vi.clearAllMocks(); -}); - describe('isAgentLogFilename', () => { it('matches valid agent log filenames', () => { expect(isAgentLogFilename('implementation-2026-01-02T16-30-24-339Z.zip')).toBe(true); diff --git a/tests/unit/router/webhook-processor.test.ts b/tests/unit/router/webhook-processor.test.ts index 55f02e93..eb8e2252 100644 --- a/tests/unit/router/webhook-processor.test.ts +++ b/tests/unit/router/webhook-processor.test.ts @@ -58,10 +58,6 @@ function makeMockAdapter(overrides: Partial = {}): Router }; } -beforeEach(() => { - vi.clearAllMocks(); -}); - describe('processRouterWebhook', () => { it('returns shouldProcess false when parseWebhook returns null', async () => { const adapter = makeMockAdapter({ diff --git a/tests/unit/sentry.test.ts b/tests/unit/sentry.test.ts index 57e8a250..a371d538 100644 --- a/tests/unit/sentry.test.ts +++ b/tests/unit/sentry.test.ts @@ -23,7 +23,6 @@ describe('sentry wrappers', () => { let sentry: typeof import('../../src/sentry.js'); beforeEach(async () => { - vi.clearAllMocks(); vi.resetModules(); // biome-ignore lint/performance/noDelete: process.env requires delete to truly unset delete process.env.SENTRY_DSN; @@ -59,7 +58,6 @@ describe('sentry wrappers', () => { let sentry: typeof import('../../src/sentry.js'); beforeEach(async () => { - vi.clearAllMocks(); vi.resetModules(); for (const k of Object.keys(mockScope)) mockScope[k as keyof typeof mockScope].mockClear(); process.env.SENTRY_DSN = 'https://fake@sentry.io/123'; diff --git a/tests/unit/server.test.ts b/tests/unit/server.test.ts index 32b133c9..f750515e 100644 --- a/tests/unit/server.test.ts +++ b/tests/unit/server.test.ts @@ -113,10 +113,6 @@ async function postJson( } describe('createServer', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - describe('Trello webhook', () => { it('calls sendAcknowledgeReaction for commentCard events', async () => { vi.useFakeTimers(); diff --git a/tests/unit/server/webhookHandlers.test.ts b/tests/unit/server/webhookHandlers.test.ts index 7a117fb3..9c6725c7 100644 --- a/tests/unit/server/webhookHandlers.test.ts +++ b/tests/unit/server/webhookHandlers.test.ts @@ -83,7 +83,6 @@ async function postJson( describe('createWebhookHandler', () => { beforeEach(() => { - vi.clearAllMocks(); mockIsCurrentlyProcessing.mockReturnValue(false); mockCanAcceptWebhook.mockReturnValue(true); }); @@ -460,10 +459,6 @@ describe('buildTrelloReactionSender', () => { ], }; - beforeEach(() => { - vi.clearAllMocks(); - }); - it('sends reaction for commentCard events', async () => { vi.useFakeTimers(); const sender = buildTrelloReactionSender(config); @@ -491,10 +486,6 @@ describe('buildTrelloReactionSender', () => { }); describe('buildGitHubReactionSender', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - it('sends reaction for issue_comment events', async () => { vi.useFakeTimers(); const mockProject = { id: 'proj-1' } as never; @@ -550,10 +541,6 @@ describe('buildJiraReactionSender', () => { ], }; - beforeEach(() => { - vi.clearAllMocks(); - }); - it('sends reaction for comment_created events', async () => { vi.useFakeTimers(); const sender = buildJiraReactionSender(config); diff --git a/tests/unit/trello/client.test.ts b/tests/unit/trello/client.test.ts index dfb34005..09b3a0a0 100644 --- a/tests/unit/trello/client.test.ts +++ b/tests/unit/trello/client.test.ts @@ -51,18 +51,6 @@ import { describe('trelloClient', () => { const creds = { apiKey: 'test-key', token: 'test-token' }; - beforeEach(() => { - // Reset individual mock functions without clearing implementations - for (const fn of Object.values(mockCards)) fn.mockReset(); - for (const fn of Object.values(mockChecklists)) fn.mockReset(); - for (const fn of Object.values(mockLists)) fn.mockReset(); - }); - - afterEach(() => { - // Don't call restoreAllMocks() as it would clear the Version3Client mock impl - vi.clearAllMocks(); - }); - // ===== trelloFetch helper ===== describe('trelloFetch (via public methods)', () => { diff --git a/tests/unit/triggers/agent-execution.test.ts b/tests/unit/triggers/agent-execution.test.ts index 5d5305b9..649c634c 100644 --- a/tests/unit/triggers/agent-execution.test.ts +++ b/tests/unit/triggers/agent-execution.test.ts @@ -47,22 +47,20 @@ import { triggerDebugAnalysis } from '../../../src/triggers/shared/debug-runner. import { shouldTriggerDebug } from '../../../src/triggers/shared/debug-trigger.js'; import type { TriggerResult } from '../../../src/triggers/types.js'; import type { AgentResult, CascadeConfig, ProjectConfig } from '../../../src/types/index.js'; +import { createMockProject } from '../../helpers/factories.js'; // ── Fixtures ────────────────────────────────────────────────────────────────── -const mockProject: ProjectConfig = { +const mockProject: ProjectConfig = createMockProject({ id: 'test-project', name: 'Test Project', - repo: 'owner/repo', - baseBranch: 'main', - branchPrefix: 'feature/', trello: { boardId: 'board123', lists: {}, labels: {}, customFields: { cost: 'cf-cost-123' }, }, -}; +}); const mockConfig: CascadeConfig = { defaults: { @@ -98,7 +96,6 @@ const mockLifecycle = { // ── Setup ───────────────────────────────────────────────────────────────────── beforeEach(() => { - vi.clearAllMocks(); vi.mocked(createPMProvider).mockReturnValue({} as ReturnType); vi.mocked(resolveProjectPMConfig).mockReturnValue({ labels: {}, statuses: {} }); vi.mocked(PMLifecycleManager).mockImplementation(() => mockLifecycle as never); diff --git a/tests/unit/triggers/agent-result-handler.test.ts b/tests/unit/triggers/agent-result-handler.test.ts index 9abda72e..63dd0560 100644 --- a/tests/unit/triggers/agent-result-handler.test.ts +++ b/tests/unit/triggers/agent-result-handler.test.ts @@ -12,6 +12,7 @@ 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'; +import { createMockJiraProject, createMockProject } from '../../helpers/factories.js'; const mockPMProvider = { getCustomFieldNumber: vi.fn(), @@ -20,39 +21,28 @@ const mockPMProvider = { vi.mocked(getPMProvider).mockReturnValue(mockPMProvider as unknown as PMProvider); -const mockTrelloProject: ProjectConfig = { - id: 'test', - name: 'Test', - repo: 'owner/repo', - baseBranch: 'main', - branchPrefix: 'feature/', +const mockTrelloProject: ProjectConfig = createMockProject({ trello: { boardId: 'board123', lists: {}, labels: {}, customFields: { cost: 'cf-cost-123' }, }, -}; +}); -const mockJiraProject: ProjectConfig = { +const mockJiraProject: ProjectConfig = createMockJiraProject({ 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); diff --git a/tests/unit/triggers/budget.test.ts b/tests/unit/triggers/budget.test.ts index 17c0d4b6..74e75cb1 100644 --- a/tests/unit/triggers/budget.test.ts +++ b/tests/unit/triggers/budget.test.ts @@ -8,23 +8,19 @@ import type { PMProvider } from '../../../src/pm/index.js'; import { getPMProvider } from '../../../src/pm/index.js'; import { checkBudgetExceeded, resolveCardBudget } from '../../../src/triggers/shared/budget.js'; import type { CascadeConfig, ProjectConfig } from '../../../src/types/index.js'; +import { createMockProject } from '../../helpers/factories.js'; const mockPMProvider = { getCustomFieldNumber: vi.fn() }; vi.mocked(getPMProvider).mockReturnValue(mockPMProvider as unknown as PMProvider); -const baseProject: ProjectConfig = { - id: 'test', - name: 'Test', - repo: 'owner/repo', - baseBranch: 'main', - branchPrefix: 'feature/', +const baseProject: ProjectConfig = createMockProject({ trello: { boardId: 'board123', lists: {}, labels: {}, customFields: { cost: 'cf-cost-123' }, }, -}; +}); const baseConfig: CascadeConfig = { defaults: { @@ -69,10 +65,6 @@ describe('resolveCardBudget', () => { }); describe('checkBudgetExceeded', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - it('returns null when no cost field configured', async () => { const project = { ...baseProject, diff --git a/tests/unit/triggers/builtins.test.ts b/tests/unit/triggers/builtins.test.ts index b75fab79..2f6bcd77 100644 --- a/tests/unit/triggers/builtins.test.ts +++ b/tests/unit/triggers/builtins.test.ts @@ -72,10 +72,6 @@ function createMockRegistry(): { register: ReturnType; handlers: o }; } -beforeEach(() => { - vi.clearAllMocks(); -}); - describe('registerBuiltInTriggers', () => { it('registers all expected trigger handlers', () => { const registry = createMockRegistry(); diff --git a/tests/unit/triggers/card-moved.test.ts b/tests/unit/triggers/card-moved.test.ts index 03a46d73..a171d1c6 100644 --- a/tests/unit/triggers/card-moved.test.ts +++ b/tests/unit/triggers/card-moved.test.ts @@ -45,26 +45,12 @@ import { CardMovedToTodoTrigger, } from '../../../src/triggers/trello/card-moved.js'; import type { TriggerContext } from '../../../src/triggers/types.js'; +import { createMockProject } from '../../helpers/factories.js'; describe('CardMovedToSplittingTrigger', () => { const trigger = CardMovedToSplittingTrigger; - const mockProject = { - id: 'test', - name: 'Test', - repo: 'owner/repo', - baseBranch: 'main', - branchPrefix: 'feature/', - trello: { - boardId: 'board123', - lists: { - splitting: 'splitting-list-id', - planning: 'planning-list-id', - todo: 'todo-list-id', - }, - labels: {}, - }, - }; + const mockProject = createMockProject(); it('matches when card moved to splitting list', () => { const ctx: TriggerContext = { @@ -197,22 +183,7 @@ describe('CardMovedToSplittingTrigger', () => { describe('CardMovedToTodoTrigger', () => { const trigger = CardMovedToTodoTrigger; - const mockProject = { - id: 'test', - name: 'Test', - repo: 'owner/repo', - baseBranch: 'main', - branchPrefix: 'feature/', - trello: { - boardId: 'board123', - lists: { - splitting: 'splitting-list-id', - planning: 'planning-list-id', - todo: 'todo-list-id', - }, - labels: {}, - }, - }; + const mockProject = createMockProject(); it('matches when card moved to todo list', () => { const ctx: TriggerContext = { diff --git a/tests/unit/triggers/check-suite-failure.test.ts b/tests/unit/triggers/check-suite-failure.test.ts index a77b9ff2..f2dae961 100644 --- a/tests/unit/triggers/check-suite-failure.test.ts +++ b/tests/unit/triggers/check-suite-failure.test.ts @@ -4,6 +4,8 @@ import { resetFixAttempts, } from '../../../src/triggers/github/check-suite-failure.js'; import type { TriggerContext } from '../../../src/triggers/types.js'; +import { createMockProject } from '../../helpers/factories.js'; +import { mockPersonaIdentities } from '../../helpers/mockPersonas.js'; vi.mock('../../../src/github/client.js', () => ({ githubClient: { @@ -23,22 +25,7 @@ import { lookupWorkItemForPR } from '../../../src/db/repositories/prWorkItemsRep describe('CheckSuiteFailureTrigger', () => { const trigger = new CheckSuiteFailureTrigger(); - const mockProject = { - id: 'test', - name: 'Test', - repo: 'owner/repo', - baseBranch: 'main', - branchPrefix: 'feature/', - trello: { - boardId: 'board123', - lists: { - splitting: 'splitting-list-id', - planning: 'planning-list-id', - todo: 'todo-list-id', - }, - labels: {}, - }, - }; + const mockProject = createMockProject(); const makeFailurePayload = (overrides: Record = {}) => ({ action: 'completed', @@ -55,7 +42,6 @@ describe('CheckSuiteFailureTrigger', () => { }); beforeEach(() => { - vi.clearAllMocks(); resetFixAttempts(42); vi.mocked(lookupWorkItemForPR).mockResolvedValue(null); }); @@ -160,7 +146,7 @@ describe('CheckSuiteFailureTrigger', () => { project: mockProject, source: 'github', payload: makeFailurePayload(), - personaIdentities: { implementer: 'cascade-impl', reviewer: 'cascade-reviewer' }, + personaIdentities: mockPersonaIdentities, }; const result = await trigger.handle(ctx); @@ -197,7 +183,7 @@ describe('CheckSuiteFailureTrigger', () => { project: mockProject, source: 'github', payload: makeFailurePayload(), - personaIdentities: { implementer: 'cascade-impl', reviewer: 'cascade-reviewer' }, + personaIdentities: mockPersonaIdentities, }; const result = await trigger.handle(ctx); @@ -223,7 +209,7 @@ describe('CheckSuiteFailureTrigger', () => { project: mockProject, source: 'github', payload: makeFailurePayload(), - personaIdentities: { implementer: 'cascade-impl', reviewer: 'cascade-reviewer' }, + personaIdentities: mockPersonaIdentities, }; const result = await trigger.handle(ctx); @@ -277,7 +263,7 @@ describe('CheckSuiteFailureTrigger', () => { project: mockProject, source: 'github', payload: makeFailurePayload(), - personaIdentities: { implementer: 'cascade-impl', reviewer: 'cascade-reviewer' }, + personaIdentities: mockPersonaIdentities, }; const result = await trigger.handle(ctx); @@ -312,7 +298,7 @@ describe('CheckSuiteFailureTrigger', () => { project: mockProject, source: 'github', payload: makeFailurePayload(), - personaIdentities: { implementer: 'cascade-impl', reviewer: 'cascade-reviewer' }, + personaIdentities: mockPersonaIdentities, }; const result = await trigger.handle(ctx); @@ -345,7 +331,7 @@ describe('CheckSuiteFailureTrigger', () => { project: mockProject, source: 'github', payload: makeFailurePayload(), - personaIdentities: { implementer: 'cascade-impl', reviewer: 'cascade-reviewer' }, + personaIdentities: mockPersonaIdentities, }; const result = await trigger.handle(ctx); @@ -375,7 +361,7 @@ describe('CheckSuiteFailureTrigger', () => { project: mockProject, source: 'github', payload: makeFailurePayload(), - personaIdentities: { implementer: 'cascade-impl', reviewer: 'cascade-reviewer' }, + personaIdentities: mockPersonaIdentities, }; // First 3 attempts should succeed @@ -417,7 +403,7 @@ describe('CheckSuiteFailureTrigger', () => { project: mockProject, source: 'github', payload: makeFailurePayload(), - personaIdentities: { implementer: 'cascade-impl', reviewer: 'cascade-reviewer' }, + personaIdentities: mockPersonaIdentities, }; // Use up 3 attempts diff --git a/tests/unit/triggers/check-suite-success.test.ts b/tests/unit/triggers/check-suite-success.test.ts index d8c32553..ce670064 100644 --- a/tests/unit/triggers/check-suite-success.test.ts +++ b/tests/unit/triggers/check-suite-success.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CheckSuiteSuccessTrigger } from '../../../src/triggers/github/check-suite-success.js'; import type { TriggerContext } from '../../../src/triggers/types.js'; +import { createMockProject } from '../../helpers/factories.js'; +import { mockPersonaIdentities } from '../../helpers/mockPersonas.js'; vi.mock('../../../src/github/client.js', () => ({ githubClient: { @@ -21,27 +23,7 @@ import { lookupWorkItemForPR } from '../../../src/db/repositories/prWorkItemsRep describe('CheckSuiteSuccessTrigger', () => { const trigger = new CheckSuiteSuccessTrigger(); - const mockProject = { - id: 'test', - name: 'Test', - repo: 'owner/repo', - baseBranch: 'main', - branchPrefix: 'feature/', - trello: { - boardId: 'board123', - lists: { - splitting: 'splitting-list-id', - planning: 'planning-list-id', - todo: 'todo-list-id', - }, - labels: {}, - }, - }; - - const mockPersonaIdentities = { - implementer: 'cascade-impl', - reviewer: 'cascade-reviewer', - }; + const mockProject = createMockProject(); const makeCheckSuitePayload = (overrides: Record = {}) => ({ action: 'completed', @@ -58,7 +40,6 @@ describe('CheckSuiteSuccessTrigger', () => { }); beforeEach(() => { - vi.clearAllMocks(); vi.mocked(lookupWorkItemForPR).mockResolvedValue(null); }); @@ -538,34 +519,31 @@ describe('CheckSuiteSuccessTrigger', () => { describe('reviewTrigger mode-aware behavior', () => { /** Project with only externalPrs enabled */ - const mockProjectExternalOnly = { - ...mockProject, + const mockProjectExternalOnly = createMockProject({ github: { triggers: { reviewTrigger: { ownPrsOnly: false, externalPrs: true, onReviewRequested: false }, }, }, - }; + }); /** Project with both ownPrsOnly and externalPrs enabled */ - const mockProjectBothModes = { - ...mockProject, + const mockProjectBothModes = createMockProject({ github: { triggers: { reviewTrigger: { ownPrsOnly: true, externalPrs: true, onReviewRequested: false }, }, }, - }; + }); /** Project with all modes disabled */ - const mockProjectNoModes = { - ...mockProject, + const mockProjectNoModes = createMockProject({ github: { triggers: { reviewTrigger: { ownPrsOnly: false, externalPrs: false, onReviewRequested: false }, }, }, - }; + }); it('does not match when all modes are disabled', () => { const ctx: TriggerContext = { @@ -677,7 +655,6 @@ describe('CheckSuiteSuccessTrigger', () => { expect(implResult).not.toBeNull(); // External PR - vi.clearAllMocks(); vi.mocked(lookupWorkItemForPR).mockResolvedValue(null); setupMocks('external-contributor'); const extCtx: TriggerContext = { diff --git a/tests/unit/triggers/debug-runner.test.ts b/tests/unit/triggers/debug-runner.test.ts index b7106c00..c94f7a31 100644 --- a/tests/unit/triggers/debug-runner.test.ts +++ b/tests/unit/triggers/debug-runner.test.ts @@ -49,26 +49,15 @@ import { } from '../../../src/triggers/shared/debug-status.js'; const mockPMProvider = { addComment: vi.fn() }; -import type { CascadeConfig, ProjectConfig } from '../../../src/types/index.js'; - -const mockProject = { - id: 'test-project', - name: 'Test', - repo: 'owner/repo', - baseBranch: 'main', - branchPrefix: 'feature/', - trello: { - boardId: 'board-1', - lists: { splitting: 'l1', planning: 'l2', todo: 'l3' }, - labels: {}, - }, -} as unknown as ProjectConfig; +import type { CascadeConfig } from '../../../src/types/index.js'; +import { createMockProject } from '../../helpers/factories.js'; + +const mockProject = createMockProject({ id: 'test-project' }); const mockConfig = {} as CascadeConfig; describe('triggerDebugAnalysis', () => { beforeEach(() => { - vi.clearAllMocks(); vi.mocked(getPMProvider).mockReturnValue(mockPMProvider as unknown as PMProvider); }); @@ -315,10 +304,6 @@ describe('triggerDebugAnalysis', () => { }); describe('parseDebugOutput (via triggerDebugAnalysis)', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - it('parses all structured sections from markdown', async () => { vi.mocked(getRunById).mockResolvedValue({ id: 'run-1', diff --git a/tests/unit/triggers/debug-trigger.test.ts b/tests/unit/triggers/debug-trigger.test.ts index 31794325..c9f31866 100644 --- a/tests/unit/triggers/debug-trigger.test.ts +++ b/tests/unit/triggers/debug-trigger.test.ts @@ -19,10 +19,6 @@ import { import { shouldTriggerDebug } from '../../../src/triggers/shared/debug-trigger.js'; describe('shouldTriggerDebug', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - it('returns null when runId is undefined', async () => { const result = await shouldTriggerDebug(undefined); expect(result).toBeNull(); diff --git a/tests/unit/triggers/github-pr-comment-mention.test.ts b/tests/unit/triggers/github-pr-comment-mention.test.ts index 7c876b96..2e703052 100644 --- a/tests/unit/triggers/github-pr-comment-mention.test.ts +++ b/tests/unit/triggers/github-pr-comment-mention.test.ts @@ -32,31 +32,27 @@ vi.mock('../../../src/db/repositories/prWorkItemsRepository.js', () => ({ import { lookupWorkItemForPR } from '../../../src/db/repositories/prWorkItemsRepository.js'; import { PRCommentMentionTrigger } from '../../../src/triggers/github/pr-comment-mention.js'; import type { TriggerContext } from '../../../src/triggers/types.js'; +import { createMockProject } from '../../helpers/factories.js'; +import { + IMPLEMENTER_USERNAME, + REVIEWER_USERNAME, + mockPersonaIdentities, +} from '../../helpers/mockPersonas.js'; -const IMPLEMENTER_USERNAME = 'cascade-impl'; -const REVIEWER_USERNAME = 'cascade-reviewer'; const HUMAN_USERNAME = 'alice-human'; const CARD_SHORT_ID = 'abc123card'; const PR_BODY_WITH_CARD = `Fixes https://trello.com/c/${CARD_SHORT_ID}/my-card`; const PR_BODY_NO_CARD = 'This PR has no Trello card link'; -const mockProject = { +const mockProject = createMockProject({ id: 'test-project', name: 'Test Project', - repo: 'owner/repo', - baseBranch: 'main', - branchPrefix: 'feature/', trello: { boardId: 'board-123', lists: { splitting: 'b', planning: 'p', todo: 't' }, labels: {}, }, -} as TriggerContext['project']; - -const mockPersonaIdentities = { - implementer: IMPLEMENTER_USERNAME, - reviewer: REVIEWER_USERNAME, -}; +}); /** Build an issue_comment.created payload (PR conversation comment) */ function buildIssueCommentPayload( diff --git a/tests/unit/triggers/github-utils.test.ts b/tests/unit/triggers/github-utils.test.ts index bf63adad..38b6bb9e 100644 --- a/tests/unit/triggers/github-utils.test.ts +++ b/tests/unit/triggers/github-utils.test.ts @@ -181,7 +181,6 @@ describe('requireWorkItemId', () => { describe('resolveWorkItemId', () => { beforeEach(() => { - vi.clearAllMocks(); vi.mocked(lookupWorkItemForPR).mockResolvedValue(null); }); diff --git a/tests/unit/triggers/label-added.test.ts b/tests/unit/triggers/label-added.test.ts index 5821fa86..d8f7be02 100644 --- a/tests/unit/triggers/label-added.test.ts +++ b/tests/unit/triggers/label-added.test.ts @@ -1,5 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { TriggerContext } from '../../../src/triggers/types.js'; +import { createMockProject } from '../../helpers/factories.js'; // Mocks required for PM integration registration (pm/index.js side-effect) vi.mock('../../../src/config/provider.js', () => ({ @@ -38,12 +39,7 @@ describe('ReadyToProcessLabelTrigger', () => { const trigger = new ReadyToProcessLabelTrigger(); const mockGetCard = vi.mocked(trelloClient.getCard); - const mockProject = { - id: 'test', - name: 'Test', - repo: 'owner/repo', - baseBranch: 'main', - branchPrefix: 'feature/', + const mockProject = createMockProject({ trello: { boardId: 'board123', lists: { @@ -55,10 +51,6 @@ describe('ReadyToProcessLabelTrigger', () => { readyToProcess: 'ready-label-id', }, }, - }; - - beforeEach(() => { - vi.clearAllMocks(); }); describe('matches', () => { diff --git a/tests/unit/triggers/manual-runner.test.ts b/tests/unit/triggers/manual-runner.test.ts index b3264843..ea9aa26f 100644 --- a/tests/unit/triggers/manual-runner.test.ts +++ b/tests/unit/triggers/manual-runner.test.ts @@ -61,7 +61,6 @@ const mockConfig = {} as CascadeConfig; describe('triggerManualRun', () => { beforeEach(() => { - vi.clearAllMocks(); clearTriggerTracking(); }); @@ -223,7 +222,6 @@ describe('triggerManualRun', () => { describe('triggerRetryRun', () => { beforeEach(() => { - vi.clearAllMocks(); clearTriggerTracking(); }); diff --git a/tests/unit/triggers/pr-merged.test.ts b/tests/unit/triggers/pr-merged.test.ts index f7f8e9d0..af904875 100644 --- a/tests/unit/triggers/pr-merged.test.ts +++ b/tests/unit/triggers/pr-merged.test.ts @@ -52,6 +52,7 @@ import '../../../src/pm/index.js'; import { PRMergedTrigger } from '../../../src/triggers/github/pr-merged.js'; import type { TriggerContext } from '../../../src/triggers/types.js'; +import { createMockProject } from '../../helpers/factories.js'; import { lookupWorkItemForPR } from '../../../src/db/repositories/prWorkItemsRepository.js'; import { githubClient } from '../../../src/github/client.js'; @@ -59,12 +60,7 @@ import { githubClient } from '../../../src/github/client.js'; describe('PRMergedTrigger', () => { const trigger = new PRMergedTrigger(); - const mockProject = { - id: 'test', - name: 'Test', - repo: 'owner/repo', - baseBranch: 'main', - branchPrefix: 'feature/', + const mockProject = createMockProject({ trello: { boardId: 'board123', lists: { @@ -75,10 +71,9 @@ describe('PRMergedTrigger', () => { }, labels: {}, }, - }; + }); beforeEach(() => { - vi.clearAllMocks(); vi.mocked(lookupWorkItemForPR).mockResolvedValue(null); }); diff --git a/tests/unit/triggers/pr-opened.test.ts b/tests/unit/triggers/pr-opened.test.ts index 212d7219..4ed09605 100644 --- a/tests/unit/triggers/pr-opened.test.ts +++ b/tests/unit/triggers/pr-opened.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { PROpenedTrigger } from '../../../src/triggers/github/pr-opened.js'; import type { TriggerContext } from '../../../src/triggers/types.js'; +import { createMockProject } from '../../helpers/factories.js'; vi.mock('../../../src/db/repositories/prWorkItemsRepository.js', () => ({ lookupWorkItemForPR: vi.fn(), @@ -10,49 +11,30 @@ import { lookupWorkItemForPR } from '../../../src/db/repositories/prWorkItemsRep describe('PROpenedTrigger', () => { const trigger = new PROpenedTrigger(); - const mockProject = { - id: 'test', - name: 'Test', - repo: 'owner/repo', - baseBranch: 'main', - branchPrefix: 'feature/', - trello: { - boardId: 'board123', - lists: { - splitting: 'splitting-list-id', - planning: 'planning-list-id', - todo: 'todo-list-id', - }, - labels: {}, - }, - }; + const mockProject = createMockProject(); /** Project with prOpened + externalPrs enabled (most common config for external PR review) */ - const mockProjectWithPrOpenedEnabled = { - ...mockProject, + const mockProjectWithPrOpenedEnabled = createMockProject({ github: { triggers: { prOpened: true, reviewTrigger: { externalPrs: true } }, }, - }; + }); /** Project with prOpened + ownPrsOnly (fires on implementer-authored PRs) */ - const mockProjectWithOwnPrsOnly = { - ...mockProject, + const mockProjectWithOwnPrsOnly = createMockProject({ github: { triggers: { prOpened: true, reviewTrigger: { ownPrsOnly: true } }, }, - }; + }); /** Project with prOpened + both modes (fires on all PRs) */ - const mockProjectWithBothModes = { - ...mockProject, + const mockProjectWithBothModes = createMockProject({ github: { triggers: { prOpened: true, reviewTrigger: { ownPrsOnly: true, externalPrs: true } }, }, - }; + }); beforeEach(() => { - vi.clearAllMocks(); vi.mocked(lookupWorkItemForPR).mockResolvedValue(null); }); diff --git a/tests/unit/triggers/pr-ready-to-merge.test.ts b/tests/unit/triggers/pr-ready-to-merge.test.ts index d47736a5..0dbc403a 100644 --- a/tests/unit/triggers/pr-ready-to-merge.test.ts +++ b/tests/unit/triggers/pr-ready-to-merge.test.ts @@ -53,6 +53,7 @@ import '../../../src/pm/index.js'; import { PRReadyToMergeTrigger } from '../../../src/triggers/github/pr-ready-to-merge.js'; import type { TriggerContext } from '../../../src/triggers/types.js'; +import { createMockProject } from '../../helpers/factories.js'; import { lookupWorkItemForPR } from '../../../src/db/repositories/prWorkItemsRepository.js'; import { githubClient } from '../../../src/github/client.js'; @@ -60,12 +61,7 @@ import { githubClient } from '../../../src/github/client.js'; describe('PRReadyToMergeTrigger', () => { const trigger = new PRReadyToMergeTrigger(); - const mockProject = { - id: 'test', - name: 'Test', - repo: 'owner/repo', - baseBranch: 'main', - branchPrefix: 'feature/', + const mockProject = createMockProject({ trello: { boardId: 'board123', lists: { @@ -76,10 +72,9 @@ describe('PRReadyToMergeTrigger', () => { }, labels: {}, }, - }; + }); beforeEach(() => { - vi.clearAllMocks(); vi.mocked(lookupWorkItemForPR).mockResolvedValue(null); }); diff --git a/tests/unit/triggers/pr-review-submitted.test.ts b/tests/unit/triggers/pr-review-submitted.test.ts index fa2a6a06..fb2301e6 100644 --- a/tests/unit/triggers/pr-review-submitted.test.ts +++ b/tests/unit/triggers/pr-review-submitted.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { PRReviewSubmittedTrigger } from '../../../src/triggers/github/pr-review-submitted.js'; import type { TriggerContext } from '../../../src/triggers/types.js'; +import { createMockProject } from '../../helpers/factories.js'; +import { mockPersonaIdentities } from '../../helpers/mockPersonas.js'; vi.mock('../../../src/db/repositories/prWorkItemsRepository.js', () => ({ lookupWorkItemForPR: vi.fn(), @@ -11,31 +13,10 @@ describe('PRReviewSubmittedTrigger', () => { const trigger = new PRReviewSubmittedTrigger(); beforeEach(() => { - vi.clearAllMocks(); vi.mocked(lookupWorkItemForPR).mockResolvedValue(null); }); - const mockProject = { - id: 'test', - name: 'Test', - repo: 'owner/repo', - baseBranch: 'main', - branchPrefix: 'feature/', - trello: { - boardId: 'board123', - lists: { - splitting: 'splitting-list-id', - planning: 'planning-list-id', - todo: 'todo-list-id', - }, - labels: {}, - }, - }; - - const mockPersonaIdentities = { - implementer: 'cascade-impl', - reviewer: 'cascade-reviewer', - }; + const mockProject = createMockProject(); const makeReviewPayload = (overrides: Record = {}) => ({ action: 'submitted', diff --git a/tests/unit/triggers/review-requested.test.ts b/tests/unit/triggers/review-requested.test.ts index b01b38a3..0cc1d745 100644 --- a/tests/unit/triggers/review-requested.test.ts +++ b/tests/unit/triggers/review-requested.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ReviewRequestedTrigger } from '../../../src/triggers/github/review-requested.js'; import type { TriggerContext } from '../../../src/triggers/types.js'; +import { createMockProject } from '../../helpers/factories.js'; +import { mockPersonaIdentities } from '../../helpers/mockPersonas.js'; vi.mock('../../../src/db/repositories/prWorkItemsRepository.js', () => ({ lookupWorkItemForPR: vi.fn(), @@ -10,49 +12,25 @@ import { lookupWorkItemForPR } from '../../../src/db/repositories/prWorkItemsRep describe('ReviewRequestedTrigger', () => { const trigger = new ReviewRequestedTrigger(); - const mockProject = { - id: 'test', - name: 'Test', - repo: 'owner/repo', - baseBranch: 'main', - branchPrefix: 'feature/', - trello: { - boardId: 'board123', - lists: { - splitting: 'splitting-list-id', - planning: 'planning-list-id', - todo: 'todo-list-id', - }, - labels: {}, - }, - // Review-requested is opt-in, default disabled - }; + const mockProject = createMockProject(); /** Project with reviewRequested trigger explicitly enabled (legacy style) */ - const mockProjectWithReviewRequested = { - ...mockProject, + const mockProjectWithReviewRequested = createMockProject({ github: { triggers: { reviewRequested: true }, }, - }; + }); /** Project with new structured reviewTrigger.onReviewRequested enabled */ - const mockProjectWithOnReviewRequested = { - ...mockProject, + const mockProjectWithOnReviewRequested = createMockProject({ github: { triggers: { reviewTrigger: { ownPrsOnly: false, externalPrs: false, onReviewRequested: true }, }, }, - }; - - const mockPersonaIdentities = { - implementer: 'cascade-impl', - reviewer: 'cascade-reviewer', - }; + }); beforeEach(() => { - vi.clearAllMocks(); vi.mocked(lookupWorkItemForPR).mockResolvedValue(null); }); diff --git a/tests/unit/utils/cascadeEnv.test.ts b/tests/unit/utils/cascadeEnv.test.ts index 1fe3f387..cd0a9d7d 100644 --- a/tests/unit/utils/cascadeEnv.test.ts +++ b/tests/unit/utils/cascadeEnv.test.ts @@ -22,7 +22,6 @@ describe('cascadeEnv', () => { const originalEnv = process.env; beforeEach(() => { - vi.clearAllMocks(); process.env = { ...originalEnv }; }); diff --git a/tests/unit/utils/lifecycle.test.ts b/tests/unit/utils/lifecycle.test.ts index 37e50675..20b1bedd 100644 --- a/tests/unit/utils/lifecycle.test.ts +++ b/tests/unit/utils/lifecycle.test.ts @@ -26,7 +26,6 @@ const mockFlush = vi.mocked(flush); describe('lifecycle', () => { beforeEach(() => { - vi.clearAllMocks(); vi.useFakeTimers(); vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); }); diff --git a/tests/unit/utils/llmEnv.test.ts b/tests/unit/utils/llmEnv.test.ts index 9126a45e..7ccdec69 100644 --- a/tests/unit/utils/llmEnv.test.ts +++ b/tests/unit/utils/llmEnv.test.ts @@ -19,7 +19,6 @@ 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'); }); diff --git a/tests/unit/utils/llmLogging.test.ts b/tests/unit/utils/llmLogging.test.ts index 3da19850..971e13da 100644 --- a/tests/unit/utils/llmLogging.test.ts +++ b/tests/unit/utils/llmLogging.test.ts @@ -23,10 +23,6 @@ import { } from '../../../src/utils/llmLogging.js'; describe('llmLogging', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - describe('formatCallNumber', () => { it('pads single digit', () => { expect(formatCallNumber(1)).toBe('0001'); diff --git a/tests/unit/utils/repo.test.ts b/tests/unit/utils/repo.test.ts index a773e6bf..0bd327e2 100644 --- a/tests/unit/utils/repo.test.ts +++ b/tests/unit/utils/repo.test.ts @@ -72,7 +72,6 @@ describe('repo utils', () => { const originalEnv = process.env; beforeEach(() => { - vi.clearAllMocks(); process.env = { ...originalEnv }; }); diff --git a/tests/unit/utils/safeOperation.test.ts b/tests/unit/utils/safeOperation.test.ts index 1d0490a7..d9390840 100644 --- a/tests/unit/utils/safeOperation.test.ts +++ b/tests/unit/utils/safeOperation.test.ts @@ -10,10 +10,6 @@ vi.mock('../../../src/utils/logging.js', () => ({ import { logger } from '../../../src/utils/logging.js'; describe('safeOperation', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - describe('safeOperation', () => { it('returns result on success', async () => { const result = await safeOperation(() => Promise.resolve('hello'), { diff --git a/tests/unit/utils/squintDb.test.ts b/tests/unit/utils/squintDb.test.ts index 118b624f..88773abd 100644 --- a/tests/unit/utils/squintDb.test.ts +++ b/tests/unit/utils/squintDb.test.ts @@ -27,7 +27,6 @@ describe('squintDb', () => { const originalEnv = process.env; beforeEach(() => { - vi.clearAllMocks(); process.env = { ...originalEnv }; }); diff --git a/tests/unit/utils/webhookLogger.test.ts b/tests/unit/utils/webhookLogger.test.ts index 0fa03d78..3e837e1e 100644 --- a/tests/unit/utils/webhookLogger.test.ts +++ b/tests/unit/utils/webhookLogger.test.ts @@ -29,7 +29,6 @@ const sampleInput: WebhookLogInput = { }; beforeEach(() => { - vi.clearAllMocks(); vi.useFakeTimers(); mockInsertWebhookLog.mockResolvedValue(undefined); mockPruneWebhookLogs.mockResolvedValue(undefined); diff --git a/vitest.config.ts b/vitest.config.ts index 72e00e78..16ebd3e2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,8 @@ export default defineConfig({ test: { globals: true, environment: 'node', - include: ['src/**/*.test.ts', 'tests/**/*.test.ts'], + clearMocks: true, + unstubEnvs: true, coverage: { provider: 'v8', reporter: ['text', 'lcov', 'html'], diff --git a/vitest.workspace.ts b/vitest.workspace.ts new file mode 100644 index 00000000..cfd71e4f --- /dev/null +++ b/vitest.workspace.ts @@ -0,0 +1,24 @@ +import { defineWorkspace } from 'vitest/config'; + +export default defineWorkspace([ + { + extends: './vitest.config.ts', + test: { + name: 'unit', + include: ['tests/unit/**/*.test.ts'], + setupFiles: ['./tests/setup.ts'], + }, + }, + { + extends: './vitest.config.ts', + test: { + name: 'integration', + include: ['tests/integration/**/*.test.ts'], + setupFiles: ['./tests/integration/setup.ts'], + testTimeout: 30_000, + hookTimeout: 30_000, + pool: 'forks', + poolOptions: { forks: { singleFork: true } }, + }, + }, +]); diff --git a/web/src/components/projects/pm-wizard.tsx b/web/src/components/projects/pm-wizard.tsx index e03ca409..1ad15db1 100644 --- a/web/src/components/projects/pm-wizard.tsx +++ b/web/src/components/projects/pm-wizard.tsx @@ -1661,12 +1661,6 @@ export function PMWizard({ Create Webhook -

- Callback URL:{' '} - - {callbackBaseUrl}/{state.provider === 'trello' ? 'trello' : 'jira'}/webhook - -

{createWebhookMutation.isError && (

{createWebhookMutation.error.message}

)} From 3ea14a460756aa32092f796505d5850996829b1e Mon Sep 17 00:00:00 2001 From: aaight Date: Wed, 25 Feb 2026 14:18:13 +0100 Subject: [PATCH 12/13] feat(tests): add integration test suites for all DB repositories (#549) * feat(tests): add integration test suites for all DB repositories * fix(tests): remove unused imports and dead code in integration tests Remove unused `seedRun` import from runsRepository.test.ts, and remove unused `seedPrWorkItem` and `seedRunLogs` helpers from seed.ts along with their now-unnecessary schema imports. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Cascade Bot Co-authored-by: Claude Opus 4.6 --- src/db/migrations/0000_base_schema.sql | 62 ++ src/db/migrations/meta/_journal.json | 35 +- tests/integration/db/configRepository.test.ts | 275 +++++++++ .../db/credentialResolution.test.ts | 202 +++++++ .../integration/db/partialsRepository.test.ts | 196 +++++++ .../db/prWorkItemsRepository.test.ts | 107 ++++ tests/integration/db/runsRepository.test.ts | 533 ++++++++++++++++++ .../integration/db/settingsRepository.test.ts | 444 +++++++++++++++ tests/integration/db/usersRepository.test.ts | 166 ++++++ .../db/webhookLogsRepository.test.ts | 210 +++++++ tests/integration/helpers/seed.ts | 185 ++++++ 11 files changed, 2401 insertions(+), 14 deletions(-) create mode 100644 src/db/migrations/0000_base_schema.sql create mode 100644 tests/integration/db/configRepository.test.ts create mode 100644 tests/integration/db/credentialResolution.test.ts create mode 100644 tests/integration/db/partialsRepository.test.ts create mode 100644 tests/integration/db/prWorkItemsRepository.test.ts create mode 100644 tests/integration/db/runsRepository.test.ts create mode 100644 tests/integration/db/settingsRepository.test.ts create mode 100644 tests/integration/db/usersRepository.test.ts create mode 100644 tests/integration/db/webhookLogsRepository.test.ts diff --git a/src/db/migrations/0000_base_schema.sql b/src/db/migrations/0000_base_schema.sql new file mode 100644 index 00000000..38335eb4 --- /dev/null +++ b/src/db/migrations/0000_base_schema.sql @@ -0,0 +1,62 @@ +-- Base Schema +-- Creates the initial tables needed before the incremental migration chain (0001+). +-- This migration is only applied to fresh databases. + +BEGIN; + +-- Projects (original schema before 0001) +CREATE TABLE IF NOT EXISTS "projects" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "repo" text NOT NULL, + "base_branch" text, + "branch_prefix" text, + "model" text, + "card_budget_usd" numeric(10, 2), + "agent_backend_default" text, + "github_token_env" text, + "reviewer_token_env" text, + "trello_board_id" text, + "trello_lists" jsonb, + "trello_labels" jsonb, + "trello_custom_fields" jsonb, + "triggers" jsonb, + "agent_models" jsonb, + "agent_backend_overrides" jsonb, + "prompts" jsonb, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS "idx_projects_repo" ON "projects" ("repo"); +CREATE INDEX IF NOT EXISTS "idx_projects_trello_board_id" ON "projects" ("trello_board_id"); + +-- Project secrets (original credential storage, replaced in 0003) +CREATE TABLE IF NOT EXISTS "project_secrets" ( + "id" serial PRIMARY KEY NOT NULL, + "project_id" text NOT NULL REFERENCES "projects"("id") ON DELETE CASCADE, + "key" text NOT NULL, + "value" text NOT NULL, + "created_at" timestamp DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS "uq_project_secrets_project_key" + ON "project_secrets" ("project_id", "key"); + +-- Cascade defaults (original schema before 0005) +CREATE TABLE IF NOT EXISTS "cascade_defaults" ( + "id" serial PRIMARY KEY NOT NULL, + "model" text, + "max_iterations" integer, + "watchdog_timeout_ms" integer, + "card_budget_usd" numeric(10, 2), + "agent_backend" text, + "progress_model" text, + "progress_interval_minutes" numeric(5, 1), + "agent_models" jsonb, + "agent_iterations" jsonb, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() +); + +COMMIT; diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index eeee30db..9dd14544 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -5,103 +5,110 @@ { "idx": 0, "version": "7", + "when": 1735000000000, + "tag": "0000_base_schema", + "breakpoints": false + }, + { + "idx": 1, + "version": "7", "when": 1736000000000, "tag": "0001_three_tier_normalization", "breakpoints": false }, { - "idx": 1, + "idx": 2, "version": "7", "when": 1737000000000, "tag": "0002_agent_run_tracking", "breakpoints": false }, { - "idx": 2, + "idx": 3, "version": "7", "when": 1738000000000, "tag": "0003_organizations_and_credentials", "breakpoints": false }, { - "idx": 3, + "idx": 4, "version": "7", "when": 1739000000000, "tag": "0004_agent_credential_overrides", "breakpoints": false }, { - "idx": 4, + "idx": 5, "version": "7", "when": 1740000000000, "tag": "0005_config_schema_cleanup", "breakpoints": false }, { - "idx": 5, + "idx": 6, "version": "7", "when": 1741000000000, "tag": "0006_users_and_sessions", "breakpoints": false }, { - "idx": 6, + "idx": 7, "version": "7", "when": 1742000000000, "tag": "0007_remove_flyio_columns", "breakpoints": false }, { - "idx": 7, + "idx": 8, "version": "7", "when": 1743000000000, "tag": "0008_prompt_partials", "breakpoints": false }, { - "idx": 8, + "idx": 9, "version": "7", "when": 1744000000000, "tag": "0009_add_squint_db_url", "breakpoints": false }, { - "idx": 9, + "idx": 10, "version": "7", "when": 1745000000000, "tag": "0010_webhook_logs", "breakpoints": false }, { - "idx": 10, + "idx": 11, "version": "7", "when": 1746000000000, "tag": "0011_remove_credentials_description", "breakpoints": false }, { - "idx": 11, + "idx": 12, "version": "7", "when": 1747000000000, "tag": "0012_llm_calls_realtime", "breakpoints": false }, { - "idx": 12, + "idx": 13, "version": "7", "when": 1748000000000, "tag": "0013_integration_model_refactor", "breakpoints": false }, { - "idx": 13, + "idx": 14, "version": "7", "when": 1749000000000, "tag": "0014_pr_work_items", "breakpoints": false }, { - "idx": 14, + "idx": 15, "version": "7", "when": 1750000000000, "tag": "0015_rename_briefing_to_splitting", diff --git a/tests/integration/db/configRepository.test.ts b/tests/integration/db/configRepository.test.ts new file mode 100644 index 00000000..112c496a --- /dev/null +++ b/tests/integration/db/configRepository.test.ts @@ -0,0 +1,275 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + findProjectByBoardIdFromDb, + findProjectByIdFromDb, + findProjectByJiraProjectKeyFromDb, + findProjectByRepoFromDb, + findProjectWithConfigByBoardId, + loadConfigFromDb, +} from '../../../src/db/repositories/configRepository.js'; +import { truncateAll } from '../helpers/db.js'; +import { + seedAgentConfig, + seedDefaults, + seedIntegration, + seedOrg, + seedProject, +} from '../helpers/seed.js'; + +describe('configRepository (integration)', () => { + beforeEach(async () => { + await truncateAll(); + await seedOrg(); + await seedProject(); + }); + + // ========================================================================= + // loadConfigFromDb + // ========================================================================= + + describe('loadConfigFromDb', () => { + it('returns a valid CascadeConfig with no data beyond org+project', async () => { + const config = await loadConfigFromDb(); + expect(config).toBeDefined(); + expect(config.projects).toHaveLength(1); + expect(config.projects[0].id).toBe('test-project'); + }); + + it('includes defaults when cascade_defaults row exists', async () => { + await seedDefaults({ model: 'claude-opus-4-5', maxIterations: 30 }); + const config = await loadConfigFromDb(); + expect(config.defaults.model).toBe('claude-opus-4-5'); + expect(config.defaults.maxIterations).toBe(30); + }); + + it('includes trello integration config in project', async () => { + await seedIntegration({ + category: 'pm', + provider: 'trello', + config: { boardId: 'board-123', lists: {}, labels: {} }, + }); + const config = await loadConfigFromDb(); + const project = config.projects[0]; + expect(project.trello?.boardId).toBe('board-123'); + }); + + it('handles multiple projects', async () => { + await seedProject({ id: 'project-2', name: 'Project 2', repo: 'owner/repo2' }); + const config = await loadConfigFromDb(); + expect(config.projects).toHaveLength(2); + expect(config.projects.map((p) => p.id).sort()).toEqual(['project-2', 'test-project']); + }); + + it('applies global agent config model overrides to defaults.agentModels', async () => { + await seedDefaults(); + await seedAgentConfig({ + orgId: null, + projectId: null, + agentType: 'implementation', + model: 'global-impl-model', + }); + const config = await loadConfigFromDb(); + expect(config.defaults.agentModels.implementation).toBe('global-impl-model'); + }); + + it('applies global agent config iteration overrides to defaults.agentIterations', async () => { + await seedDefaults(); + await seedAgentConfig({ + orgId: null, + projectId: null, + agentType: 'implementation', + maxIterations: 25, + }); + const config = await loadConfigFromDb(); + expect(config.defaults.agentIterations.implementation).toBe(25); + }); + + it('applies org-level agent config overrides to defaults.agentModels', async () => { + await seedDefaults(); + await seedAgentConfig({ + orgId: 'test-org', + projectId: null, + agentType: 'review', + model: 'org-review-model', + }); + const config = await loadConfigFromDb(); + expect(config.defaults.agentModels.review).toBe('org-review-model'); + }); + + it('applies project-level agent config overrides to project.agentModels', async () => { + await seedAgentConfig({ + orgId: null, + projectId: 'test-project', + agentType: 'implementation', + model: 'project-impl-model', + }); + const config = await loadConfigFromDb(); + const project = config.projects[0]; + expect(project.agentModels?.implementation).toBe('project-impl-model'); + }); + }); + + // ========================================================================= + // findProjectByBoardIdFromDb + // ========================================================================= + + describe('findProjectByBoardIdFromDb', () => { + it('finds a project by its Trello board ID', async () => { + await seedIntegration({ + category: 'pm', + provider: 'trello', + config: { boardId: 'board-abc', lists: {}, labels: {} }, + }); + const project = await findProjectByBoardIdFromDb('board-abc'); + expect(project).toBeDefined(); + expect(project?.id).toBe('test-project'); + }); + + it('returns undefined for non-existent board ID', async () => { + const project = await findProjectByBoardIdFromDb('nonexistent-board'); + expect(project).toBeUndefined(); + }); + }); + + // ========================================================================= + // findProjectByRepoFromDb + // ========================================================================= + + describe('findProjectByRepoFromDb', () => { + it('finds a project by its repo', async () => { + const project = await findProjectByRepoFromDb('owner/repo'); + expect(project).toBeDefined(); + expect(project?.id).toBe('test-project'); + }); + + it('returns undefined for non-existent repo', async () => { + const project = await findProjectByRepoFromDb('nonexistent/repo'); + expect(project).toBeUndefined(); + }); + }); + + // ========================================================================= + // findProjectByIdFromDb + // ========================================================================= + + describe('findProjectByIdFromDb', () => { + it('finds a project by its ID', async () => { + const project = await findProjectByIdFromDb('test-project'); + expect(project).toBeDefined(); + expect(project?.id).toBe('test-project'); + expect(project?.orgId).toBe('test-org'); + }); + + it('returns undefined for non-existent ID', async () => { + const project = await findProjectByIdFromDb('nonexistent-project'); + expect(project).toBeUndefined(); + }); + }); + + // ========================================================================= + // findProjectByJiraProjectKeyFromDb + // ========================================================================= + + describe('findProjectByJiraProjectKeyFromDb', () => { + it('finds a project by its JIRA project key', async () => { + await seedIntegration({ + category: 'pm', + provider: 'jira', + config: { + projectKey: 'PROJ', + baseUrl: 'https://example.atlassian.net', + statuses: { splitting: 'Splitting', todo: 'To Do' }, + }, + }); + const project = await findProjectByJiraProjectKeyFromDb('PROJ'); + expect(project).toBeDefined(); + expect(project?.id).toBe('test-project'); + }); + + it('returns undefined for non-existent JIRA project key', async () => { + const project = await findProjectByJiraProjectKeyFromDb('NONEXISTENT'); + expect(project).toBeUndefined(); + }); + }); + + // ========================================================================= + // findProjectWithConfigByBoardId + // ========================================================================= + + describe('findProjectWithConfigByBoardId', () => { + it('returns both project and config', async () => { + await seedDefaults({ model: 'claude-sonnet' }); + await seedIntegration({ + category: 'pm', + provider: 'trello', + config: { boardId: 'board-xyz', lists: {}, labels: {} }, + }); + const result = await findProjectWithConfigByBoardId('board-xyz'); + expect(result).toBeDefined(); + expect(result?.project.id).toBe('test-project'); + expect(result?.config).toBeDefined(); + expect(result?.config.defaults.model).toBe('claude-sonnet'); + }); + + it('returns undefined for non-existent board', async () => { + const result = await findProjectWithConfigByBoardId('no-such-board'); + expect(result).toBeUndefined(); + }); + }); + + // ========================================================================= + // Agent config inheritance: global β†’ org β†’ project + // ========================================================================= + + describe('agent config inheritance', () => { + it('project agentModels overrides global agentModels for the same agent', async () => { + await seedDefaults(); + await seedAgentConfig({ + orgId: null, + projectId: null, + agentType: 'implementation', + model: 'global-model', + }); + await seedAgentConfig({ + orgId: null, + projectId: 'test-project', + agentType: 'implementation', + model: 'project-model', + }); + const config = await loadConfigFromDb(); + const project = config.projects[0]; + // Project-level agentModels should take precedence + expect(project.agentModels?.implementation).toBe('project-model'); + // Global-level should be in defaults + expect(config.defaults.agentModels.implementation).toBe('global-model'); + }); + }); + + // ========================================================================= + // Multi-project config loading + // ========================================================================= + + describe('multi-project config loading', () => { + it('correctly loads integrations for each project separately', async () => { + await seedProject({ id: 'project-2', name: 'Project 2', repo: 'owner/repo2' }); + await seedIntegration({ + projectId: 'test-project', + category: 'pm', + provider: 'trello', + config: { boardId: 'board-project-1', lists: {}, labels: {} }, + }); + await seedIntegration({ + projectId: 'project-2', + category: 'pm', + provider: 'trello', + config: { boardId: 'board-project-2', lists: {}, labels: {} }, + }); + const config = await loadConfigFromDb(); + expect(config.projects).toHaveLength(2); + const p1 = config.projects.find((p) => p.id === 'test-project'); + const p2 = config.projects.find((p) => p.id === 'project-2'); + expect(p1?.trello?.boardId).toBe('board-project-1'); + expect(p2?.trello?.boardId).toBe('board-project-2'); + }); + }); +}); diff --git a/tests/integration/db/credentialResolution.test.ts b/tests/integration/db/credentialResolution.test.ts new file mode 100644 index 00000000..ca8c9f70 --- /dev/null +++ b/tests/integration/db/credentialResolution.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getAllProjectCredentials } from '../../../src/config/provider.js'; +import { createCredential } from '../../../src/db/repositories/credentialsRepository.js'; +import { truncateAll } from '../helpers/db.js'; +import { + seedCredential, + seedIntegration, + seedIntegrationCredential, + seedOrg, + seedProject, +} from '../helpers/seed.js'; + +describe('credentialResolution (integration)', () => { + beforeEach(async () => { + await truncateAll(); + await seedOrg(); + await seedProject(); + }); + + // ========================================================================= + // getAllProjectCredentials β€” end-to-end + // ========================================================================= + + describe('getAllProjectCredentials', () => { + it('returns empty object when no credentials configured', async () => { + const creds = await getAllProjectCredentials('test-project'); + expect(creds).toEqual({}); + }); + + it('includes default org credentials (LLM API keys)', async () => { + await seedCredential({ + orgId: 'test-org', + envVarKey: 'OPENROUTER_API_KEY', + value: 'or-key-secret', + isDefault: true, + }); + + const creds = await getAllProjectCredentials('test-project'); + expect(creds.OPENROUTER_API_KEY).toBe('or-key-secret'); + }); + + it('excludes non-default org credentials', async () => { + await seedCredential({ + orgId: 'test-org', + envVarKey: 'NON_DEFAULT_KEY', + value: 'should-not-appear', + isDefault: false, + }); + + const creds = await getAllProjectCredentials('test-project'); + expect(creds.NON_DEFAULT_KEY).toBeUndefined(); + }); + + it('includes integration credentials mapped to env var keys', async () => { + const apiKeyCred = await seedCredential({ + envVarKey: 'TRELLO_API_KEY', + value: 'trello-api-key-value', + }); + const tokenCred = await seedCredential({ + envVarKey: 'TRELLO_TOKEN', + value: 'trello-token-value', + name: 'Trello Token', + }); + const integration = await seedIntegration({ category: 'pm', provider: 'trello' }); + await seedIntegrationCredential({ + integrationId: integration.id, + role: 'api_key', + credentialId: apiKeyCred.id, + }); + await seedIntegrationCredential({ + integrationId: integration.id, + role: 'token', + credentialId: tokenCred.id, + }); + + const creds = await getAllProjectCredentials('test-project'); + expect(creds.TRELLO_API_KEY).toBe('trello-api-key-value'); + expect(creds.TRELLO_TOKEN).toBe('trello-token-value'); + }); + + it('integration credentials override org default credentials', async () => { + // Set up a default org credential for GITHUB_TOKEN_IMPLEMENTER + await seedCredential({ + orgId: 'test-org', + envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', + value: 'default-token', + isDefault: true, + }); + + // Set up a project-specific integration credential + const specificCred = await seedCredential({ + envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', + value: 'specific-token', + name: 'Specific Implementer Token', + }); + const integration = await seedIntegration({ category: 'scm', provider: 'github' }); + await seedIntegrationCredential({ + integrationId: integration.id, + role: 'implementer_token', + credentialId: specificCred.id, + }); + + const creds = await getAllProjectCredentials('test-project'); + // Integration credential should override org default + expect(creds.GITHUB_TOKEN_IMPLEMENTER).toBe('specific-token'); + }); + + it('includes both org defaults and integration credentials merged', async () => { + // Org default for LLM + await seedCredential({ + orgId: 'test-org', + envVarKey: 'OPENROUTER_API_KEY', + value: 'llm-key', + isDefault: true, + }); + + // Integration credentials for SCM + const ghCred = await seedCredential({ + envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', + value: 'gh-impl-token', + name: 'GH Implementer', + }); + const integration = await seedIntegration({ category: 'scm', provider: 'github' }); + await seedIntegrationCredential({ + integrationId: integration.id, + role: 'implementer_token', + credentialId: ghCred.id, + }); + + const creds = await getAllProjectCredentials('test-project'); + expect(creds.OPENROUTER_API_KEY).toBe('llm-key'); + expect(creds.GITHUB_TOKEN_IMPLEMENTER).toBe('gh-impl-token'); + }); + + it('throws when project not found', async () => { + await expect(getAllProjectCredentials('nonexistent-project')).rejects.toThrow( + 'Project not found: nonexistent-project', + ); + }); + }); + + // ========================================================================= + // Encryption round-trip + // ========================================================================= + + describe('with encryption', () => { + it('round-trips credentials through encrypt/decrypt transparently', async () => { + // 64-char hex = 32-byte AES-256 key + vi.stubEnv('CREDENTIAL_MASTER_KEY', 'b'.repeat(64)); + + const { id } = await createCredential({ + orgId: 'test-org', + name: 'Encrypted LLM Key', + envVarKey: 'OPENROUTER_API_KEY', + value: 'plaintext-llm-secret', + isDefault: true, + }); + + expect(id).toBeGreaterThan(0); + + // getAllProjectCredentials should transparently decrypt + const creds = await getAllProjectCredentials('test-project'); + expect(creds.OPENROUTER_API_KEY).toBe('plaintext-llm-secret'); + }); + + it('round-trips integration credentials through encrypt/decrypt', async () => { + vi.stubEnv('CREDENTIAL_MASTER_KEY', 'c'.repeat(64)); + + const cred = await createCredential({ + orgId: 'test-org', + name: 'Encrypted Trello Key', + envVarKey: 'TRELLO_API_KEY', + value: 'encrypted-api-key', + }); + const integration = await seedIntegration({ category: 'pm', provider: 'trello' }); + await seedIntegrationCredential({ + integrationId: integration.id, + role: 'api_key', + credentialId: cred.id, + }); + + const creds = await getAllProjectCredentials('test-project'); + expect(creds.TRELLO_API_KEY).toBe('encrypted-api-key'); + }); + }); + + // ========================================================================= + // Worker context + // ========================================================================= + + describe('worker context (CASCADE_CREDENTIAL_KEYS set)', () => { + it('returns credentials from process.env when CASCADE_CREDENTIAL_KEYS is set', async () => { + vi.stubEnv('CASCADE_CREDENTIAL_KEYS', 'OPENROUTER_API_KEY,GITHUB_TOKEN_IMPLEMENTER'); + vi.stubEnv('OPENROUTER_API_KEY', 'env-llm-key'); + vi.stubEnv('GITHUB_TOKEN_IMPLEMENTER', 'env-gh-token'); + + const creds = await getAllProjectCredentials('test-project'); + expect(creds.OPENROUTER_API_KEY).toBe('env-llm-key'); + expect(creds.GITHUB_TOKEN_IMPLEMENTER).toBe('env-gh-token'); + }); + }); +}); diff --git a/tests/integration/db/partialsRepository.test.ts b/tests/integration/db/partialsRepository.test.ts new file mode 100644 index 00000000..847cf298 --- /dev/null +++ b/tests/integration/db/partialsRepository.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + deletePartial, + getPartial, + listPartials, + loadPartials, + upsertPartial, +} from '../../../src/db/repositories/partialsRepository.js'; +import { truncateAll } from '../helpers/db.js'; +import { seedOrg, seedProject, seedPromptPartial } from '../helpers/seed.js'; + +describe('partialsRepository (integration)', () => { + beforeEach(async () => { + await truncateAll(); + await seedOrg(); + await seedProject(); + }); + + // ========================================================================= + // loadPartials + // ========================================================================= + + describe('loadPartials', () => { + it('returns empty map when no partials exist', async () => { + const partials = await loadPartials(); + expect(partials.size).toBe(0); + }); + + it('returns global partials only when no orgId given', async () => { + await seedPromptPartial({ orgId: null, name: 'global-partial', content: 'Global content' }); + await seedPromptPartial({ orgId: 'test-org', name: 'org-partial', content: 'Org content' }); + + const partials = await loadPartials(); + expect(partials.has('global-partial')).toBe(true); + expect(partials.has('org-partial')).toBe(false); + }); + + it('returns global partials when orgId given', async () => { + await seedPromptPartial({ orgId: null, name: 'global-partial', content: 'Global content' }); + + const partials = await loadPartials('test-org'); + expect(partials.has('global-partial')).toBe(true); + }); + + it('org partials overlay global partials with the same name', async () => { + await seedPromptPartial({ orgId: null, name: 'shared-partial', content: 'Global version' }); + await seedPromptPartial({ + orgId: 'test-org', + name: 'shared-partial', + content: 'Org version', + }); + + const partials = await loadPartials('test-org'); + expect(partials.get('shared-partial')).toBe('Org version'); + }); + + it('includes org-specific partials not in global', async () => { + await seedPromptPartial({ orgId: null, name: 'global-only', content: 'Global only' }); + await seedPromptPartial({ orgId: 'test-org', name: 'org-only', content: 'Org only' }); + + const partials = await loadPartials('test-org'); + expect(partials.has('global-only')).toBe(true); + expect(partials.has('org-only')).toBe(true); + expect(partials.size).toBe(2); + }); + }); + + // ========================================================================= + // listPartials + // ========================================================================= + + describe('listPartials', () => { + it('returns only global partials when no orgId given', async () => { + await seedPromptPartial({ orgId: null, name: 'global-p', content: 'global' }); + await seedPromptPartial({ orgId: 'test-org', name: 'org-p', content: 'org' }); + + const partials = await listPartials(); + expect(partials.every((p) => p.orgId === null)).toBe(true); + expect(partials.some((p) => p.name === 'global-p')).toBe(true); + }); + + it('returns both global and org-scoped partials when orgId given', async () => { + await seedPromptPartial({ orgId: null, name: 'global-p', content: 'global' }); + await seedPromptPartial({ orgId: 'test-org', name: 'org-p', content: 'org' }); + + const partials = await listPartials('test-org'); + expect(partials.some((p) => p.name === 'global-p')).toBe(true); + expect(partials.some((p) => p.name === 'org-p')).toBe(true); + }); + }); + + // ========================================================================= + // getPartial + // ========================================================================= + + describe('getPartial', () => { + it('returns global partial when found', async () => { + await seedPromptPartial({ orgId: null, name: 'my-partial', content: 'my content' }); + + const partial = await getPartial('my-partial'); + expect(partial).toBeDefined(); + expect(partial?.content).toBe('my content'); + }); + + it('returns null when partial not found', async () => { + const partial = await getPartial('nonexistent'); + expect(partial).toBeNull(); + }); + + it('returns org-scoped partial with priority over global', async () => { + await seedPromptPartial({ orgId: null, name: 'shared', content: 'global content' }); + await seedPromptPartial({ orgId: 'test-org', name: 'shared', content: 'org content' }); + + const partial = await getPartial('shared', 'test-org'); + expect(partial?.content).toBe('org content'); + }); + + it('falls back to global partial when org-scoped one not found', async () => { + await seedPromptPartial({ orgId: null, name: 'global-only', content: 'global content' }); + + const partial = await getPartial('global-only', 'test-org'); + expect(partial?.content).toBe('global content'); + }); + }); + + // ========================================================================= + // upsertPartial + // ========================================================================= + + describe('upsertPartial', () => { + it('inserts a new global partial', async () => { + const partial = await upsertPartial({ + orgId: null, + name: 'new-partial', + content: 'new content', + }); + expect(partial.name).toBe('new-partial'); + expect(partial.content).toBe('new content'); + expect(partial.orgId).toBeNull(); + }); + + it('inserts a new org-scoped partial', async () => { + const partial = await upsertPartial({ + orgId: 'test-org', + name: 'org-partial', + content: 'org content', + }); + expect(partial.orgId).toBe('test-org'); + }); + + it('updates an existing partial without creating a duplicate', async () => { + await upsertPartial({ orgId: null, name: 'dup-test', content: 'original' }); + await upsertPartial({ orgId: null, name: 'dup-test', content: 'updated' }); + + const allPartials = await listPartials(); + const matches = allPartials.filter((p) => p.name === 'dup-test'); + expect(matches).toHaveLength(1); + expect(matches[0].content).toBe('updated'); + }); + + it('updates an org-scoped partial', async () => { + await upsertPartial({ orgId: 'test-org', name: 'org-dup', content: 'v1' }); + const updated = await upsertPartial({ orgId: 'test-org', name: 'org-dup', content: 'v2' }); + expect(updated.content).toBe('v2'); + }); + }); + + // ========================================================================= + // deletePartial + // ========================================================================= + + describe('deletePartial', () => { + it('deletes a partial by ID', async () => { + const partial = await upsertPartial({ orgId: null, name: 'to-delete', content: 'delete me' }); + await deletePartial(partial.id); + + const found = await getPartial('to-delete'); + expect(found).toBeNull(); + }); + + it('deletes org-scoped partial without affecting global with same name', async () => { + await seedPromptPartial({ orgId: null, name: 'keep-global', content: 'global' }); + const orgPartial = await upsertPartial({ + orgId: 'test-org', + name: 'keep-global', + content: 'org', + }); + + await deletePartial(orgPartial.id); + + // Global still exists + const remaining = await getPartial('keep-global'); + expect(remaining?.content).toBe('global'); + }); + }); +}); diff --git a/tests/integration/db/prWorkItemsRepository.test.ts b/tests/integration/db/prWorkItemsRepository.test.ts new file mode 100644 index 00000000..e39dc419 --- /dev/null +++ b/tests/integration/db/prWorkItemsRepository.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + linkPRToWorkItem, + lookupWorkItemForPR, +} from '../../../src/db/repositories/prWorkItemsRepository.js'; +import { truncateAll } from '../helpers/db.js'; +import { seedOrg, seedProject } from '../helpers/seed.js'; + +describe('prWorkItemsRepository (integration)', () => { + beforeEach(async () => { + await truncateAll(); + await seedOrg(); + await seedProject(); + }); + + // ========================================================================= + // linkPRToWorkItem / lookupWorkItemForPR + // ========================================================================= + + describe('linkPRToWorkItem', () => { + it('links a PR to a work item', async () => { + await linkPRToWorkItem('test-project', 'owner/repo', 42, 'card-abc123'); + + const workItemId = await lookupWorkItemForPR('test-project', 42); + expect(workItemId).toBe('card-abc123'); + }); + + it('links multiple PRs to different work items', async () => { + await linkPRToWorkItem('test-project', 'owner/repo', 1, 'card-111'); + await linkPRToWorkItem('test-project', 'owner/repo', 2, 'card-222'); + + expect(await lookupWorkItemForPR('test-project', 1)).toBe('card-111'); + expect(await lookupWorkItemForPR('test-project', 2)).toBe('card-222'); + }); + }); + + describe('lookupWorkItemForPR', () => { + it('returns null for non-existent link', async () => { + const result = await lookupWorkItemForPR('test-project', 999); + expect(result).toBeNull(); + }); + + it('returns null for wrong project', async () => { + await linkPRToWorkItem('test-project', 'owner/repo', 10, 'card-xyz'); + + // Different project, same PR number + await seedProject({ id: 'other-project', repo: 'owner/other-repo' }); + const result = await lookupWorkItemForPR('other-project', 10); + expect(result).toBeNull(); + }); + }); + + // ========================================================================= + // Upsert behavior + // ========================================================================= + + describe('upsert (re-link same PR)', () => { + it('updates work item ID when same project+PR is re-linked', async () => { + await linkPRToWorkItem('test-project', 'owner/repo', 5, 'card-original'); + + // Re-link same PR to a different card + await linkPRToWorkItem('test-project', 'owner/repo', 5, 'card-updated'); + + const workItemId = await lookupWorkItemForPR('test-project', 5); + expect(workItemId).toBe('card-updated'); + }); + + it('updates repoFullName when re-linking', async () => { + await linkPRToWorkItem('test-project', 'owner/old-repo', 7, 'card-abc'); + await linkPRToWorkItem('test-project', 'owner/new-repo', 7, 'card-abc'); + + // Still resolvable by projectId+prNumber + const workItemId = await lookupWorkItemForPR('test-project', 7); + expect(workItemId).toBe('card-abc'); + }); + }); + + // ========================================================================= + // Cross-project isolation + // ========================================================================= + + describe('cross-project isolation', () => { + it('same PR number in different projects resolves to different work items', async () => { + await seedProject({ id: 'project-b', repo: 'owner/repo-b' }); + + await linkPRToWorkItem('test-project', 'owner/repo', 100, 'card-project-a'); + await linkPRToWorkItem('project-b', 'owner/repo-b', 100, 'card-project-b'); + + expect(await lookupWorkItemForPR('test-project', 100)).toBe('card-project-a'); + expect(await lookupWorkItemForPR('project-b', 100)).toBe('card-project-b'); + }); + + it('deleting one project link does not affect another', async () => { + await seedProject({ id: 'project-c', repo: 'owner/repo-c' }); + + await linkPRToWorkItem('test-project', 'owner/repo', 200, 'card-a'); + await linkPRToWorkItem('project-c', 'owner/repo-c', 200, 'card-c'); + + // Re-link project-c's PR to a new card (effectively "removing" the old link) + await linkPRToWorkItem('project-c', 'owner/repo-c', 200, 'card-c-new'); + + // test-project's link is unaffected + expect(await lookupWorkItemForPR('test-project', 200)).toBe('card-a'); + expect(await lookupWorkItemForPR('project-c', 200)).toBe('card-c-new'); + }); + }); +}); diff --git a/tests/integration/db/runsRepository.test.ts b/tests/integration/db/runsRepository.test.ts new file mode 100644 index 00000000..29cca650 --- /dev/null +++ b/tests/integration/db/runsRepository.test.ts @@ -0,0 +1,533 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + completeRun, + createRun, + deleteDebugAnalysisByRunId, + getDebugAnalysisByRunId, + getLlmCallByNumber, + getLlmCallsByRunId, + getRunById, + getRunLogs, + getRunsByCardId, + getRunsByProjectId, + listLlmCallsMeta, + listProjectsForOrg, + listRuns, + storeDebugAnalysis, + storeLlmCall, + storeLlmCallsBulk, + storeRunLogs, +} from '../../../src/db/repositories/runsRepository.js'; +import { truncateAll } from '../helpers/db.js'; +import { seedOrg, seedProject } from '../helpers/seed.js'; + +describe('runsRepository (integration)', () => { + beforeEach(async () => { + await truncateAll(); + await seedOrg(); + await seedProject(); + }); + + // ========================================================================= + // Run CRUD + // ========================================================================= + + describe('createRun', () => { + it('creates a run and returns its ID', async () => { + const id = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + expect(id).toBeTruthy(); + expect(typeof id).toBe('string'); + }); + + it('creates a run with optional fields', async () => { + const id = await createRun({ + projectId: 'test-project', + cardId: 'card-123', + prNumber: 42, + agentType: 'review', + backend: 'llmist', + triggerType: 'feature-implementation', + model: 'claude-opus-4-5', + maxIterations: 20, + }); + const run = await getRunById(id); + expect(run?.cardId).toBe('card-123'); + expect(run?.prNumber).toBe(42); + expect(run?.agentType).toBe('review'); + expect(run?.backend).toBe('llmist'); + expect(run?.model).toBe('claude-opus-4-5'); + expect(run?.maxIterations).toBe(20); + expect(run?.status).toBe('running'); + }); + }); + + describe('completeRun', () => { + it('marks a run as completed with metrics', async () => { + const id = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + + await completeRun(id, { + status: 'completed', + durationMs: 5000, + llmIterations: 10, + gadgetCalls: 25, + costUsd: 0.05, + success: true, + prUrl: 'https://github.com/owner/repo/pull/1', + outputSummary: 'Implemented feature X', + }); + + const run = await getRunById(id); + expect(run?.status).toBe('completed'); + expect(run?.durationMs).toBe(5000); + expect(run?.llmIterations).toBe(10); + expect(run?.gadgetCalls).toBe(25); + expect(run?.success).toBe(true); + expect(run?.prUrl).toBe('https://github.com/owner/repo/pull/1'); + expect(run?.completedAt).toBeDefined(); + }); + + it('marks a run as failed with error', async () => { + const id = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + + await completeRun(id, { + status: 'failed', + success: false, + error: 'Connection timeout', + }); + + const run = await getRunById(id); + expect(run?.status).toBe('failed'); + expect(run?.success).toBe(false); + expect(run?.error).toBe('Connection timeout'); + }); + }); + + describe('getRunById', () => { + it('returns the run', async () => { + const id = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + const run = await getRunById(id); + expect(run).toBeDefined(); + expect(run?.id).toBe(id); + }); + + it('returns null for non-existent ID', async () => { + const run = await getRunById('00000000-0000-0000-0000-000000000000'); + expect(run).toBeNull(); + }); + }); + + describe('getRunsByCardId', () => { + it('returns all runs for a card', async () => { + await createRun({ + projectId: 'test-project', + cardId: 'card-A', + agentType: 'implementation', + backend: 'claude-code', + }); + await createRun({ + projectId: 'test-project', + cardId: 'card-A', + agentType: 'review', + backend: 'claude-code', + }); + await createRun({ + projectId: 'test-project', + cardId: 'card-B', + agentType: 'implementation', + backend: 'claude-code', + }); + + const runs = await getRunsByCardId('card-A'); + expect(runs).toHaveLength(2); + expect(runs.every((r) => r.cardId === 'card-A')).toBe(true); + }); + + it('returns empty array for unknown card', async () => { + const runs = await getRunsByCardId('nonexistent-card'); + expect(runs).toEqual([]); + }); + }); + + describe('getRunsByProjectId', () => { + it('returns all runs for a project', async () => { + await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + await createRun({ projectId: 'test-project', agentType: 'review', backend: 'claude-code' }); + + const runs = await getRunsByProjectId('test-project'); + expect(runs).toHaveLength(2); + }); + }); + + // ========================================================================= + // Log Storage + // ========================================================================= + + describe('storeRunLogs / getRunLogs', () => { + it('stores and retrieves logs for a run', async () => { + const id = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + + await storeRunLogs(id, 'cascade log content', 'llmist log content'); + + const logs = await getRunLogs(id); + expect(logs?.cascadeLog).toBe('cascade log content'); + expect(logs?.llmistLog).toBe('llmist log content'); + }); + + it('returns null for run with no logs', async () => { + const id = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + const logs = await getRunLogs(id); + expect(logs).toBeNull(); + }); + }); + + // ========================================================================= + // LLM Calls + // ========================================================================= + + describe('storeLlmCall / getLlmCallsByRunId', () => { + it('stores and retrieves an LLM call', async () => { + const id = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + + await storeLlmCall({ + runId: id, + callNumber: 1, + request: '{"messages":[]}', + response: '{"content":"hello"}', + inputTokens: 100, + outputTokens: 50, + costUsd: 0.001, + durationMs: 500, + model: 'claude-opus-4-5', + }); + + const calls = await getLlmCallsByRunId(id); + expect(calls).toHaveLength(1); + expect(calls[0].callNumber).toBe(1); + expect(calls[0].inputTokens).toBe(100); + expect(calls[0].outputTokens).toBe(50); + expect(calls[0].model).toBe('claude-opus-4-5'); + }); + }); + + describe('storeLlmCallsBulk', () => { + it('stores multiple LLM calls at once', async () => { + const id = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + + await storeLlmCallsBulk([ + { + runId: id, + callNumber: 1, + model: 'model-1', + inputTokens: 10, + outputTokens: 5, + costUsd: 0.001, + }, + { + runId: id, + callNumber: 2, + model: 'model-2', + inputTokens: 20, + outputTokens: 10, + costUsd: 0.002, + }, + { + runId: id, + callNumber: 3, + model: 'model-3', + inputTokens: 30, + outputTokens: 15, + costUsd: 0.003, + }, + ]); + + const calls = await getLlmCallsByRunId(id); + expect(calls).toHaveLength(3); + expect(calls.map((c) => c.callNumber)).toEqual([1, 2, 3]); + }); + + it('does nothing when given empty array', async () => { + await expect(storeLlmCallsBulk([])).resolves.toBeUndefined(); + }); + }); + + describe('getLlmCallByNumber', () => { + it('returns a specific call by number', async () => { + const id = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + + await storeLlmCallsBulk([ + { runId: id, callNumber: 1, model: 'model-1' }, + { runId: id, callNumber: 2, model: 'model-2' }, + ]); + + const call = await getLlmCallByNumber(id, 2); + expect(call).toBeDefined(); + expect(call?.callNumber).toBe(2); + expect(call?.model).toBe('model-2'); + }); + + it('returns null for non-existent call number', async () => { + const id = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + const call = await getLlmCallByNumber(id, 99); + expect(call).toBeNull(); + }); + }); + + describe('listLlmCallsMeta', () => { + it('returns calls metadata without request/response bodies', async () => { + const id = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + + await storeLlmCall({ + runId: id, + callNumber: 1, + request: 'big request body', + response: 'big response body', + inputTokens: 100, + outputTokens: 50, + model: 'claude-opus-4-5', + }); + + const meta = await listLlmCallsMeta(id); + expect(meta).toHaveLength(1); + expect(meta[0].inputTokens).toBe(100); + // listLlmCallsMeta does not return request/response + expect('request' in meta[0]).toBe(false); + expect('response' in meta[0]).toBe(false); + }); + }); + + // ========================================================================= + // Debug Analysis + // ========================================================================= + + describe('storeDebugAnalysis / getDebugAnalysisByRunId / deleteDebugAnalysisByRunId', () => { + it('stores and retrieves a debug analysis', async () => { + const runId = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + + const analysisId = await storeDebugAnalysis({ + analyzedRunId: runId, + summary: 'Agent failed due to rate limit', + issues: 'Rate limit exceeded after 5 retries', + rootCause: 'Too many requests', + severity: 'high', + recommendations: 'Reduce request rate', + timeline: 'T+0: started, T+10: rate limit hit', + }); + + expect(analysisId).toBeTruthy(); + + const analysis = await getDebugAnalysisByRunId(runId); + expect(analysis).toBeDefined(); + expect(analysis?.summary).toBe('Agent failed due to rate limit'); + expect(analysis?.severity).toBe('high'); + }); + + it('returns null when no analysis exists', async () => { + const runId = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + const analysis = await getDebugAnalysisByRunId(runId); + expect(analysis).toBeNull(); + }); + + it('deletes a debug analysis', async () => { + const runId = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + + await storeDebugAnalysis({ + analyzedRunId: runId, + summary: 'Test summary', + issues: 'Test issues', + }); + + await deleteDebugAnalysisByRunId(runId); + + const analysis = await getDebugAnalysisByRunId(runId); + expect(analysis).toBeNull(); + }); + }); + + // ========================================================================= + // Dashboard queries + // ========================================================================= + + describe('listRuns', () => { + it('returns paginated runs with total count', async () => { + await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + await createRun({ projectId: 'test-project', agentType: 'review', backend: 'claude-code' }); + await createRun({ projectId: 'test-project', agentType: 'planning', backend: 'claude-code' }); + + const result = await listRuns({ orgId: 'test-org', limit: 10, offset: 0 }); + expect(result.data).toHaveLength(3); + expect(result.total).toBe(3); + }); + + it('filters by projectId', async () => { + await seedProject({ id: 'project-2', name: 'Project 2', repo: 'owner/repo2' }); + await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + await createRun({ + projectId: 'project-2', + agentType: 'implementation', + backend: 'claude-code', + }); + + const result = await listRuns({ + orgId: 'test-org', + projectId: 'test-project', + limit: 10, + offset: 0, + }); + expect(result.data).toHaveLength(1); + expect(result.data[0].projectId).toBe('test-project'); + }); + + it('filters by status', async () => { + const id1 = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + const id2 = await createRun({ + projectId: 'test-project', + agentType: 'review', + backend: 'claude-code', + }); + await completeRun(id1, { status: 'completed', success: true }); + await completeRun(id2, { status: 'failed', success: false }); + + const completed = await listRuns({ + orgId: 'test-org', + status: ['completed'], + limit: 10, + offset: 0, + }); + expect(completed.data).toHaveLength(1); + expect(completed.data[0].status).toBe('completed'); + }); + + it('filters by agentType', async () => { + await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + await createRun({ projectId: 'test-project', agentType: 'review', backend: 'claude-code' }); + + const result = await listRuns({ + orgId: 'test-org', + agentType: 'review', + limit: 10, + offset: 0, + }); + expect(result.data).toHaveLength(1); + expect(result.data[0].agentType).toBe('review'); + }); + + it('respects limit and offset for pagination', async () => { + for (let i = 0; i < 5; i++) { + await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + } + + const page1 = await listRuns({ orgId: 'test-org', limit: 2, offset: 0 }); + expect(page1.data).toHaveLength(2); + expect(page1.total).toBe(5); + + const page2 = await listRuns({ orgId: 'test-org', limit: 2, offset: 2 }); + expect(page2.data).toHaveLength(2); + expect(page2.total).toBe(5); + }); + + it('includes projectName in results', async () => { + await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + const result = await listRuns({ orgId: 'test-org', limit: 10, offset: 0 }); + expect(result.data[0].projectName).toBe('Test Project'); + }); + }); + + describe('listProjectsForOrg', () => { + it('returns all projects for an org', async () => { + await seedProject({ id: 'project-2', name: 'Project 2', repo: 'owner/repo2' }); + const projects = await listProjectsForOrg('test-org'); + expect(projects).toHaveLength(2); + expect(projects.map((p) => p.id).sort()).toEqual(['project-2', 'test-project']); + }); + + it('returns empty array for org with no projects', async () => { + await seedOrg('empty-org', 'Empty Org'); + const projects = await listProjectsForOrg('empty-org'); + expect(projects).toEqual([]); + }); + }); +}); diff --git a/tests/integration/db/settingsRepository.test.ts b/tests/integration/db/settingsRepository.test.ts new file mode 100644 index 00000000..18e5fa0f --- /dev/null +++ b/tests/integration/db/settingsRepository.test.ts @@ -0,0 +1,444 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + createAgentConfig, + createProject, + deleteAgentConfig, + deleteProject, + deleteProjectIntegration, + getCascadeDefaults, + getOrganization, + getProjectFull, + listAgentConfigs, + listAllOrganizations, + listIntegrationCredentials, + listProjectIntegrations, + listProjectsFull, + removeIntegrationCredential, + setIntegrationCredential, + updateAgentConfig, + updateOrganization, + updateProject, + updateProjectIntegrationTriggers, + upsertCascadeDefaults, + upsertProjectIntegration, +} from '../../../src/db/repositories/settingsRepository.js'; +import { truncateAll } from '../helpers/db.js'; +import { seedCredential, seedIntegration, seedOrg, seedProject } from '../helpers/seed.js'; + +describe('settingsRepository (integration)', () => { + beforeEach(async () => { + await truncateAll(); + await seedOrg(); + await seedProject(); + }); + + // ========================================================================= + // Organizations + // ========================================================================= + + describe('getOrganization', () => { + it('returns the organization', async () => { + const org = await getOrganization('test-org'); + expect(org).toBeDefined(); + expect(org?.id).toBe('test-org'); + expect(org?.name).toBe('Test Org'); + }); + + it('returns null for non-existent org', async () => { + const org = await getOrganization('nonexistent-org'); + expect(org).toBeNull(); + }); + }); + + describe('updateOrganization', () => { + it('updates the org name', async () => { + await updateOrganization('test-org', { name: 'Updated Org Name' }); + const org = await getOrganization('test-org'); + expect(org?.name).toBe('Updated Org Name'); + }); + }); + + describe('listAllOrganizations', () => { + it('returns all organizations', async () => { + await seedOrg('org-2', 'Org 2'); + const orgs = await listAllOrganizations(); + expect(orgs.length).toBeGreaterThanOrEqual(2); + expect(orgs.map((o) => o.id)).toContain('test-org'); + expect(orgs.map((o) => o.id)).toContain('org-2'); + }); + }); + + // ========================================================================= + // Cascade Defaults + // ========================================================================= + + describe('getCascadeDefaults', () => { + it('returns null when no defaults exist', async () => { + const defaults = await getCascadeDefaults('test-org'); + expect(defaults).toBeNull(); + }); + }); + + describe('upsertCascadeDefaults', () => { + it('inserts new defaults', async () => { + await upsertCascadeDefaults('test-org', { + model: 'claude-opus-4-5', + maxIterations: 30, + agentBackend: 'claude-code', + }); + const defaults = await getCascadeDefaults('test-org'); + expect(defaults?.model).toBe('claude-opus-4-5'); + expect(defaults?.maxIterations).toBe(30); + expect(defaults?.agentBackend).toBe('claude-code'); + }); + + it('updates existing defaults', async () => { + await upsertCascadeDefaults('test-org', { model: 'old-model', maxIterations: 20 }); + await upsertCascadeDefaults('test-org', { model: 'new-model', maxIterations: 40 }); + const defaults = await getCascadeDefaults('test-org'); + expect(defaults?.model).toBe('new-model'); + expect(defaults?.maxIterations).toBe(40); + }); + + it('allows null fields to clear values', async () => { + await upsertCascadeDefaults('test-org', { model: 'some-model' }); + await upsertCascadeDefaults('test-org', { model: null }); + const defaults = await getCascadeDefaults('test-org'); + expect(defaults?.model).toBeNull(); + }); + }); + + // ========================================================================= + // Projects + // ========================================================================= + + describe('createProject', () => { + it('creates a new project', async () => { + const project = await createProject('test-org', { + id: 'new-project', + name: 'New Project', + repo: 'owner/new-repo', + }); + expect(project.id).toBe('new-project'); + expect(project.orgId).toBe('test-org'); + expect(project.name).toBe('New Project'); + expect(project.baseBranch).toBe('main'); + }); + + it('creates a project with optional fields', async () => { + const project = await createProject('test-org', { + id: 'proj-opts', + name: 'Opts Project', + repo: 'owner/opts-repo', + baseBranch: 'develop', + branchPrefix: 'fix/', + model: 'claude-sonnet', + cardBudgetUsd: '10.00', + agentBackend: 'claude-code', + }); + expect(project.baseBranch).toBe('develop'); + expect(project.branchPrefix).toBe('fix/'); + expect(project.model).toBe('claude-sonnet'); + }); + }); + + describe('updateProject', () => { + it('updates project fields', async () => { + await updateProject('test-project', 'test-org', { + name: 'Updated Project', + model: 'claude-haiku', + }); + const project = await getProjectFull('test-project', 'test-org'); + expect(project?.name).toBe('Updated Project'); + expect(project?.model).toBe('claude-haiku'); + }); + }); + + describe('deleteProject', () => { + it('deletes a project', async () => { + await deleteProject('test-project', 'test-org'); + const project = await getProjectFull('test-project', 'test-org'); + expect(project).toBeNull(); + }); + }); + + describe('listProjectsFull', () => { + it('returns all projects for an org', async () => { + await seedProject({ id: 'project-2', name: 'Project 2', repo: 'owner/repo2' }); + const projects = await listProjectsFull('test-org'); + expect(projects).toHaveLength(2); + }); + }); + + describe('getProjectFull', () => { + it('returns the full project', async () => { + const project = await getProjectFull('test-project', 'test-org'); + expect(project).toBeDefined(); + expect(project?.id).toBe('test-project'); + expect(project?.orgId).toBe('test-org'); + expect(project?.repo).toBe('owner/repo'); + }); + + it('returns null for wrong org', async () => { + const project = await getProjectFull('test-project', 'wrong-org'); + expect(project).toBeNull(); + }); + }); + + // ========================================================================= + // Project Integrations + // ========================================================================= + + describe('upsertProjectIntegration', () => { + it('inserts a new integration', async () => { + const integration = await upsertProjectIntegration('test-project', 'pm', 'trello', { + boardId: 'board-123', + }); + expect(integration.projectId).toBe('test-project'); + expect(integration.category).toBe('pm'); + expect(integration.provider).toBe('trello'); + }); + + it('updates an existing integration on conflict', async () => { + await upsertProjectIntegration('test-project', 'pm', 'trello', { boardId: 'board-old' }); + const updated = await upsertProjectIntegration('test-project', 'pm', 'trello', { + boardId: 'board-new', + }); + expect((updated.config as Record).boardId).toBe('board-new'); + }); + + it('preserves existing triggers when not provided', async () => { + await upsertProjectIntegration( + 'test-project', + 'pm', + 'trello', + { boardId: 'board-1' }, + { cardMovedToTodo: true }, + ); + // Upsert without triggers β€” should preserve existing + const updated = await upsertProjectIntegration('test-project', 'pm', 'trello', { + boardId: 'board-2', + }); + expect((updated.triggers as Record).cardMovedToTodo).toBe(true); + }); + }); + + describe('updateProjectIntegrationTriggers', () => { + it('deep-merges triggers', async () => { + await upsertProjectIntegration( + 'test-project', + 'pm', + 'trello', + {}, + { cardMovedToTodo: true, cardMovedToPlanning: true }, + ); + + await updateProjectIntegrationTriggers('test-project', 'pm', { + cardMovedToTodo: false, + reviewTrigger: { ownPrsOnly: true }, + }); + + const integrations = await listProjectIntegrations('test-project'); + const pmIntegration = integrations.find((i) => i.category === 'pm'); + const triggers = pmIntegration?.triggers as Record; + expect(triggers.cardMovedToTodo).toBe(false); + expect(triggers.cardMovedToPlanning).toBe(true); // preserved + expect((triggers.reviewTrigger as Record).ownPrsOnly).toBe(true); + }); + + it('throws when no integration found', async () => { + await expect( + updateProjectIntegrationTriggers('test-project', 'scm', { ownPrsOnly: true }), + ).rejects.toThrow(); + }); + }); + + describe('deleteProjectIntegration', () => { + it('deletes a project integration', async () => { + await upsertProjectIntegration('test-project', 'pm', 'trello', {}); + await deleteProjectIntegration('test-project', 'pm'); + const integrations = await listProjectIntegrations('test-project'); + expect(integrations.find((i) => i.category === 'pm')).toBeUndefined(); + }); + }); + + // ========================================================================= + // Integration Credentials + // ========================================================================= + + describe('listIntegrationCredentials / setIntegrationCredential / removeIntegrationCredential', () => { + it('sets and lists integration credentials', async () => { + const integration = await seedIntegration({ category: 'scm', provider: 'github' }); + const cred = await seedCredential({ + envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', + value: 'ghp_123', + }); + + await setIntegrationCredential(integration.id, 'implementer_token', cred.id); + + const creds = await listIntegrationCredentials(integration.id); + expect(creds).toHaveLength(1); + expect(creds[0].role).toBe('implementer_token'); + expect(creds[0].credentialId).toBe(cred.id); + expect(creds[0].credentialName).toBe('Test Key'); + }); + + it('upserts an integration credential (replace existing role)', async () => { + const integration = await seedIntegration({ category: 'scm', provider: 'github' }); + const cred1 = await seedCredential({ envVarKey: 'GH_1', value: 'v1', name: 'Cred 1' }); + const cred2 = await seedCredential({ envVarKey: 'GH_2', value: 'v2', name: 'Cred 2' }); + + await setIntegrationCredential(integration.id, 'implementer_token', cred1.id); + await setIntegrationCredential(integration.id, 'implementer_token', cred2.id); + + const creds = await listIntegrationCredentials(integration.id); + expect(creds).toHaveLength(1); + expect(creds[0].credentialId).toBe(cred2.id); + }); + + it('removes an integration credential', async () => { + const integration = await seedIntegration({ category: 'scm', provider: 'github' }); + const cred = await seedCredential({ envVarKey: 'GH_KEY', value: 'ghp_abc' }); + + await setIntegrationCredential(integration.id, 'implementer_token', cred.id); + await removeIntegrationCredential(integration.id, 'implementer_token'); + + const creds = await listIntegrationCredentials(integration.id); + expect(creds).toHaveLength(0); + }); + }); + + // ========================================================================= + // Agent Configs + // ========================================================================= + + describe('listAgentConfigs', () => { + it('lists all agent configs when no filter given', async () => { + await createAgentConfig({ + orgId: null, + projectId: null, + agentType: 'implementation', + model: 'global-model', + }); + await createAgentConfig({ + orgId: 'test-org', + projectId: null, + agentType: 'review', + model: 'org-model', + }); + await createAgentConfig({ + orgId: null, + projectId: 'test-project', + agentType: 'planning', + model: 'proj-model', + }); + + const configs = await listAgentConfigs(); + expect(configs.length).toBeGreaterThanOrEqual(3); + }); + + it('filters by projectId', async () => { + await createAgentConfig({ + orgId: null, + projectId: 'test-project', + agentType: 'implementation', + model: 'proj-model', + }); + await createAgentConfig({ + orgId: null, + projectId: null, + agentType: 'review', + model: 'global-model', + }); + + const configs = await listAgentConfigs({ projectId: 'test-project' }); + expect(configs.every((c) => c.projectId === 'test-project')).toBe(true); + }); + + it('filters by orgId (returns global + org-level configs with null projectId)', async () => { + await createAgentConfig({ + orgId: null, + projectId: null, + agentType: 'implementation', + model: 'global-model', + }); + await createAgentConfig({ + orgId: 'test-org', + projectId: null, + agentType: 'review', + model: 'org-model', + }); + await createAgentConfig({ + orgId: null, + projectId: 'test-project', + agentType: 'planning', + model: 'proj-model', + }); + + const configs = await listAgentConfigs({ orgId: 'test-org' }); + // Should return configs where projectId is null (global + org-level) + expect(configs.every((c) => c.projectId === null)).toBe(true); + }); + }); + + describe('createAgentConfig', () => { + it('creates a global agent config', async () => { + const { id } = await createAgentConfig({ + orgId: null, + projectId: null, + agentType: 'implementation', + model: 'claude-opus-4-5', + maxIterations: 30, + }); + expect(id).toBeGreaterThan(0); + }); + + it('creates a project-scoped agent config', async () => { + const { id } = await createAgentConfig({ + orgId: null, + projectId: 'test-project', + agentType: 'review', + model: 'claude-sonnet', + }); + expect(id).toBeGreaterThan(0); + + const configs = await listAgentConfigs({ projectId: 'test-project' }); + expect(configs.find((c) => c.id === id)?.model).toBe('claude-sonnet'); + }); + }); + + describe('updateAgentConfig', () => { + it('updates an agent config', async () => { + const { id } = await createAgentConfig({ + orgId: null, + projectId: null, + agentType: 'implementation', + model: 'old-model', + maxIterations: 10, + }); + + await updateAgentConfig(id, { model: 'new-model', maxIterations: 20 }); + + const configs = await listAgentConfigs(); + const config = configs.find((c) => c.id === id); + expect(config?.model).toBe('new-model'); + expect(config?.maxIterations).toBe(20); + }); + }); + + describe('deleteAgentConfig', () => { + it('deletes an agent config', async () => { + const { id } = await createAgentConfig({ + orgId: null, + projectId: null, + agentType: 'implementation', + model: 'to-delete', + }); + + await deleteAgentConfig(id); + + const configs = await listAgentConfigs(); + expect(configs.find((c) => c.id === id)).toBeUndefined(); + }); + }); +}); diff --git a/tests/integration/db/usersRepository.test.ts b/tests/integration/db/usersRepository.test.ts new file mode 100644 index 00000000..7d0f8cd5 --- /dev/null +++ b/tests/integration/db/usersRepository.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + createSession, + deleteExpiredSessions, + deleteSession, + getSessionByToken, + getUserByEmail, + getUserById, +} from '../../../src/db/repositories/usersRepository.js'; +import { truncateAll } from '../helpers/db.js'; +import { seedOrg, seedProject, seedSession, seedUser } from '../helpers/seed.js'; + +describe('usersRepository (integration)', () => { + beforeEach(async () => { + await truncateAll(); + await seedOrg(); + await seedProject(); + }); + + // ========================================================================= + // getUserByEmail + // ========================================================================= + + describe('getUserByEmail', () => { + it('returns the user for an existing email', async () => { + await seedUser({ email: 'alice@example.com', name: 'Alice' }); + + const user = await getUserByEmail('alice@example.com'); + expect(user).toBeDefined(); + expect(user?.email).toBe('alice@example.com'); + expect(user?.name).toBe('Alice'); + }); + + it('returns null for non-existent email', async () => { + const user = await getUserByEmail('nobody@example.com'); + expect(user).toBeNull(); + }); + + it('returns the password hash (needed for auth)', async () => { + await seedUser({ email: 'bob@example.com', passwordHash: '$2b$10$abcdefghij' }); + const user = await getUserByEmail('bob@example.com'); + expect(user?.passwordHash).toBe('$2b$10$abcdefghij'); + }); + }); + + // ========================================================================= + // getUserById + // ========================================================================= + + describe('getUserById', () => { + it('returns the user without password hash', async () => { + const seeded = await seedUser({ email: 'carol@example.com', name: 'Carol', role: 'admin' }); + + const user = await getUserById(seeded.id); + expect(user).toBeDefined(); + expect(user?.id).toBe(seeded.id); + expect(user?.email).toBe('carol@example.com'); + expect(user?.name).toBe('Carol'); + expect(user?.role).toBe('admin'); + expect(user?.orgId).toBe('test-org'); + // getUserById returns DashboardUser which doesn't have passwordHash + expect('passwordHash' in (user ?? {})).toBe(false); + }); + + it('returns null for non-existent ID', async () => { + const user = await getUserById('00000000-0000-0000-0000-000000000000'); + expect(user).toBeNull(); + }); + }); + + // ========================================================================= + // createSession / getSessionByToken + // ========================================================================= + + describe('createSession', () => { + it('creates a session and returns the ID', async () => { + const user = await seedUser({ email: 'dave@example.com' }); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 30); + + const sessionId = await createSession(user.id, 'my-session-token', expiresAt); + expect(sessionId).toBeTruthy(); + }); + }); + + describe('getSessionByToken', () => { + it('returns session for valid non-expired token', async () => { + const user = await seedUser({ email: 'eve@example.com' }); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 30); + + await createSession(user.id, 'valid-token', expiresAt); + + const session = await getSessionByToken('valid-token'); + expect(session).toBeDefined(); + expect(session?.userId).toBe(user.id); + }); + + it('returns null for expired token', async () => { + const user = await seedUser({ email: 'frank@example.com' }); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() - 1); // expired yesterday + + await createSession(user.id, 'expired-token', expiresAt); + + const session = await getSessionByToken('expired-token'); + expect(session).toBeNull(); + }); + + it('returns null for non-existent token', async () => { + const session = await getSessionByToken('nonexistent-token'); + expect(session).toBeNull(); + }); + }); + + // ========================================================================= + // deleteSession + // ========================================================================= + + describe('deleteSession', () => { + it('removes the session', async () => { + const user = await seedUser({ email: 'grace@example.com' }); + await seedSession({ userId: user.id, token: 'to-delete-token' }); + + await deleteSession('to-delete-token'); + + const session = await getSessionByToken('to-delete-token'); + expect(session).toBeNull(); + }); + + it('does nothing when deleting non-existent token', async () => { + await expect(deleteSession('nonexistent-token')).resolves.toBeUndefined(); + }); + }); + + // ========================================================================= + // deleteExpiredSessions + // ========================================================================= + + describe('deleteExpiredSessions', () => { + it('removes expired sessions only', async () => { + const user = await seedUser({ email: 'henry@example.com' }); + + const validExpiry = new Date(); + validExpiry.setDate(validExpiry.getDate() + 30); + const expiredExpiry = new Date(); + expiredExpiry.setDate(expiredExpiry.getDate() - 1); + + await seedSession({ userId: user.id, token: 'valid-session', expiresAt: validExpiry }); + await seedSession({ userId: user.id, token: 'expired-session-1', expiresAt: expiredExpiry }); + await seedSession({ userId: user.id, token: 'expired-session-2', expiresAt: expiredExpiry }); + + await deleteExpiredSessions(); + + // Valid session still exists + const validSession = await getSessionByToken('valid-session'); + expect(validSession).toBeDefined(); + + // Expired sessions are gone + const expired1 = await getSessionByToken('expired-session-1'); + expect(expired1).toBeNull(); + const expired2 = await getSessionByToken('expired-session-2'); + expect(expired2).toBeNull(); + }); + }); +}); diff --git a/tests/integration/db/webhookLogsRepository.test.ts b/tests/integration/db/webhookLogsRepository.test.ts new file mode 100644 index 00000000..e19da085 --- /dev/null +++ b/tests/integration/db/webhookLogsRepository.test.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + getWebhookLogById, + getWebhookLogStats, + insertWebhookLog, + listWebhookLogs, + pruneWebhookLogs, +} from '../../../src/db/repositories/webhookLogsRepository.js'; +import { truncateAll } from '../helpers/db.js'; +import { seedOrg, seedProject, seedWebhookLog } from '../helpers/seed.js'; + +describe('webhookLogsRepository (integration)', () => { + beforeEach(async () => { + await truncateAll(); + await seedOrg(); + await seedProject(); + }); + + // ========================================================================= + // insertWebhookLog / getWebhookLogById + // ========================================================================= + + describe('insertWebhookLog', () => { + it('inserts a webhook log and returns the ID', async () => { + const id = await insertWebhookLog({ + source: 'trello', + method: 'POST', + path: '/webhooks/trello', + }); + expect(id).toBeTruthy(); + expect(typeof id).toBe('string'); + }); + + it('stores all fields including JSONB headers and body', async () => { + const id = await insertWebhookLog({ + source: 'github', + method: 'POST', + path: '/webhooks/github', + headers: { 'x-github-event': 'push', 'content-type': 'application/json' }, + body: { ref: 'refs/heads/main', repository: { full_name: 'owner/repo' } }, + bodyRaw: '{"ref":"refs/heads/main"}', + statusCode: 200, + projectId: 'test-project', + eventType: 'push', + processed: true, + }); + + const log = await getWebhookLogById(id); + expect(log).toBeDefined(); + expect(log?.source).toBe('github'); + expect(log?.method).toBe('POST'); + expect(log?.path).toBe('/webhooks/github'); + expect((log?.headers as Record)['x-github-event']).toBe('push'); + expect((log?.body as Record).ref).toBe('refs/heads/main'); + expect(log?.bodyRaw).toBe('{"ref":"refs/heads/main"}'); + expect(log?.statusCode).toBe(200); + expect(log?.projectId).toBe('test-project'); + expect(log?.eventType).toBe('push'); + expect(log?.processed).toBe(true); + }); + + it('defaults processed to false', async () => { + const id = await insertWebhookLog({ + source: 'trello', + method: 'POST', + path: '/webhooks/trello', + }); + const log = await getWebhookLogById(id); + expect(log?.processed).toBe(false); + }); + }); + + describe('getWebhookLogById', () => { + it('returns null for non-existent ID', async () => { + const log = await getWebhookLogById('00000000-0000-0000-0000-000000000000'); + expect(log).toBeNull(); + }); + }); + + // ========================================================================= + // listWebhookLogs + // ========================================================================= + + describe('listWebhookLogs', () => { + it('returns all logs with total count', async () => { + await seedWebhookLog({ source: 'trello' }); + await seedWebhookLog({ source: 'github' }); + await seedWebhookLog({ source: 'trello' }); + + const result = await listWebhookLogs({ limit: 10, offset: 0 }); + expect(result.data).toHaveLength(3); + expect(result.total).toBe(3); + }); + + it('filters by source', async () => { + await seedWebhookLog({ source: 'trello' }); + await seedWebhookLog({ source: 'github' }); + await seedWebhookLog({ source: 'trello' }); + + const result = await listWebhookLogs({ source: 'trello', limit: 10, offset: 0 }); + expect(result.data).toHaveLength(2); + expect(result.data.every((l) => l.source === 'trello')).toBe(true); + }); + + it('filters by eventType', async () => { + await seedWebhookLog({ source: 'trello', eventType: 'updateCard' }); + await seedWebhookLog({ source: 'trello', eventType: 'createCard' }); + await seedWebhookLog({ source: 'github', eventType: 'push' }); + + const result = await listWebhookLogs({ eventType: 'updateCard', limit: 10, offset: 0 }); + expect(result.data).toHaveLength(1); + expect(result.data[0].eventType).toBe('updateCard'); + }); + + it('respects limit and offset for pagination', async () => { + for (let i = 0; i < 5; i++) { + await seedWebhookLog({ source: 'trello' }); + } + + const page1 = await listWebhookLogs({ limit: 2, offset: 0 }); + expect(page1.data).toHaveLength(2); + expect(page1.total).toBe(5); + + const page2 = await listWebhookLogs({ limit: 2, offset: 2 }); + expect(page2.data).toHaveLength(2); + expect(page2.total).toBe(5); + }); + + it('returns logs ordered by receivedAt descending', async () => { + await seedWebhookLog({ source: 'trello', eventType: 'first' }); + await seedWebhookLog({ source: 'trello', eventType: 'second' }); + await seedWebhookLog({ source: 'trello', eventType: 'third' }); + + const result = await listWebhookLogs({ limit: 10, offset: 0 }); + // Most recent first + expect(result.data[0].eventType).toBe('third'); + expect(result.data[2].eventType).toBe('first'); + }); + + it('filters by receivedAfter date', async () => { + const before = new Date(); + before.setMinutes(before.getMinutes() - 10); + const after = new Date(); + after.setMinutes(after.getMinutes() + 10); + + await seedWebhookLog({ source: 'trello' }); + + const result = await listWebhookLogs({ receivedAfter: after, limit: 10, offset: 0 }); + expect(result.data).toHaveLength(0); + expect(result.total).toBe(0); + }); + }); + + // ========================================================================= + // pruneWebhookLogs + // ========================================================================= + + describe('pruneWebhookLogs', () => { + it('retains only the most recent N logs', async () => { + for (let i = 0; i < 5; i++) { + await seedWebhookLog({ source: 'trello', eventType: `event-${i}` }); + } + + await pruneWebhookLogs(3); + + const result = await listWebhookLogs({ limit: 100, offset: 0 }); + expect(result.data).toHaveLength(3); + expect(result.total).toBe(3); + }); + + it('does nothing when count is already below retention limit', async () => { + await seedWebhookLog({ source: 'trello' }); + await seedWebhookLog({ source: 'github' }); + + await pruneWebhookLogs(10); + + const result = await listWebhookLogs({ limit: 100, offset: 0 }); + expect(result.total).toBe(2); + }); + }); + + // ========================================================================= + // getWebhookLogStats + // ========================================================================= + + describe('getWebhookLogStats', () => { + it('returns count grouped by source', async () => { + await seedWebhookLog({ source: 'trello' }); + await seedWebhookLog({ source: 'trello' }); + await seedWebhookLog({ source: 'github' }); + await seedWebhookLog({ source: 'jira' }); + + const stats = await getWebhookLogStats(); + expect(stats.length).toBeGreaterThanOrEqual(3); + + const trelloStat = stats.find((s) => s.source === 'trello'); + const githubStat = stats.find((s) => s.source === 'github'); + const jiraStat = stats.find((s) => s.source === 'jira'); + + expect(trelloStat?.count).toBe(2); + expect(githubStat?.count).toBe(1); + expect(jiraStat?.count).toBe(1); + }); + + it('returns empty array when no logs exist', async () => { + const stats = await getWebhookLogStats(); + expect(stats).toEqual([]); + }); + }); +}); diff --git a/tests/integration/helpers/seed.ts b/tests/integration/helpers/seed.ts index 5c1c6a7f..53085934 100644 --- a/tests/integration/helpers/seed.ts +++ b/tests/integration/helpers/seed.ts @@ -1,10 +1,17 @@ import { getDb } from '../../../src/db/client.js'; import { + agentConfigs, + agentRuns, + cascadeDefaults, credentials, integrationCredentials, organizations, projectIntegrations, projects, + promptPartials, + sessions, + users, + webhookLogs, } from '../../../src/db/schema/index.js'; /** @@ -115,3 +122,181 @@ export async function seedIntegrationCredential(overrides: { .returning(); return row; } + +/** + * Seeds cascade defaults for an org. + */ +export async function seedDefaults( + overrides: { + orgId?: string; + model?: string | null; + maxIterations?: number | null; + agentBackend?: string | null; + } = {}, +) { + const db = getDb(); + const [row] = await db + .insert(cascadeDefaults) + .values({ + orgId: overrides.orgId ?? 'test-org', + model: overrides.model ?? null, + maxIterations: overrides.maxIterations ?? null, + agentBackend: overrides.agentBackend ?? null, + }) + .returning(); + return row; +} + +/** + * Seeds an agent config row. + */ +export async function seedAgentConfig( + overrides: { + orgId?: string | null; + projectId?: string | null; + agentType?: string; + model?: string | null; + maxIterations?: number | null; + agentBackend?: string | null; + } = {}, +) { + const db = getDb(); + const [row] = await db + .insert(agentConfigs) + .values({ + orgId: overrides.orgId ?? null, + projectId: overrides.projectId ?? null, + agentType: overrides.agentType ?? 'implementation', + model: overrides.model ?? null, + maxIterations: overrides.maxIterations ?? null, + agentBackend: overrides.agentBackend ?? null, + }) + .returning(); + return row; +} + +/** + * Seeds an agent run row. + */ +export async function seedRun( + overrides: { + projectId?: string; + cardId?: string; + agentType?: string; + backend?: string; + status?: string; + } = {}, +) { + const db = getDb(); + const [row] = await db + .insert(agentRuns) + .values({ + projectId: overrides.projectId ?? 'test-project', + cardId: overrides.cardId ?? 'test-card', + agentType: overrides.agentType ?? 'implementation', + backend: overrides.backend ?? 'claude-code', + status: overrides.status ?? 'running', + }) + .returning(); + return row; +} + +/** + * Seeds a user row linked to an org. + */ +export async function seedUser( + overrides: { + orgId?: string; + email?: string; + name?: string; + passwordHash?: string; + role?: string; + } = {}, +) { + const db = getDb(); + const [row] = await db + .insert(users) + .values({ + orgId: overrides.orgId ?? 'test-org', + email: overrides.email ?? 'test@example.com', + name: overrides.name ?? 'Test User', + passwordHash: overrides.passwordHash ?? '$2b$10$hashedpassword', + role: overrides.role ?? 'member', + }) + .returning(); + return row; +} + +/** + * Seeds a webhook log row. + */ +export async function seedWebhookLog( + overrides: { + source?: string; + method?: string; + path?: string; + eventType?: string; + projectId?: string; + headers?: Record; + body?: Record; + } = {}, +) { + const db = getDb(); + const [row] = await db + .insert(webhookLogs) + .values({ + source: overrides.source ?? 'trello', + method: overrides.method ?? 'POST', + path: overrides.path ?? '/webhooks/trello', + eventType: overrides.eventType ?? 'updateCard', + projectId: overrides.projectId, + headers: overrides.headers, + body: overrides.body, + }) + .returning(); + return row; +} + +/** + * Seeds a prompt partial row. + */ +export async function seedPromptPartial( + overrides: { + orgId?: string | null; + name?: string; + content?: string; + } = {}, +) { + const db = getDb(); + const [row] = await db + .insert(promptPartials) + .values({ + orgId: overrides.orgId ?? null, + name: overrides.name ?? 'test-partial', + content: overrides.content ?? 'Test partial content', + }) + .returning(); + return row; +} + +/** + * Seeds a session for a user. + */ +export async function seedSession(overrides: { + userId: string; + token?: string; + expiresAt?: Date; +}) { + const db = getDb(); + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 30); + const [row] = await db + .insert(sessions) + .values({ + userId: overrides.userId, + token: overrides.token ?? 'test-session-token', + expiresAt: overrides.expiresAt ?? futureDate, + }) + .returning(); + return row; +} From 6a7a626a059ec5df0fbb0390e70874f5f772c81d Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Wed, 25 Feb 2026 14:50:59 +0100 Subject: [PATCH 13/13] refactor(agents): configuration-driven agent definitions with YAML (#550) * refactor(agents): configuration-driven agent definitions with YAML + review fixes Phase 2 of the configuration-driven agents architecture. Extracts hardcoded agent profiles into declarative YAML definitions with typed schemas, registry-based strategy resolution, and Eta task prompt templates. Key changes: - Add YAML agent definitions (src/agents/definitions/*.yaml) with Zod-validated schema for identity, capabilities, tools, strategies, backend config, compaction, hints, and trailing messages - Extract context pipeline steps into composable functions (contextSteps.ts) wired via YAML contextPipeline arrays - Move task prompts from TS functions to Eta templates (src/agents/prompts/task-templates/*.eta) - Derive agent capabilities from YAML instead of hardcoded switch - Add DB column for per-agent task prompt overrides (migration 0016) - Wire task prompt override rendering through resolveModelConfig with full AgentInput context (fixes commentText/commentAuthor rendering in DB overrides) - Drive compaction, hint, and initial message configs from YAML definitions instead of hardcoded maps - Add guard clauses to PR context steps (fetchPRContextStep, fetchPRConversationStep, postInitialPRCommentHook) replacing unsafe `as` casts - Fix prCommentResponse.eta whitespace with Eta trimming tags - Remove identity-mapping TASK_PROMPT_TEMPLATE_REGISTRY (Zod schema validates allowed values directly) - Remove duplicate section header in strategies.ts Net: -905 lines removed, +2078 added (much of the addition is YAML definitions and comprehensive tests). 3259 tests pass. Co-Authored-By: Claude Opus 4.6 * fix: add task-templates to build and Docker images The new task prompt templates (ci.eta, commentResponse.eta, etc.) were missing from both the npm build script and the Dockerfiles. At runtime, renderTaskPrompt() uses readFileSync with __dirname-relative paths, which would throw ENOENT in production containers. Changes: - Add build:copy-task-templates script to package.json - Copy src/agents/prompts/task-templates/*.eta to dist/ during build - Add task-templates COPY directive to Dockerfile.worker and Dockerfile.dashboard Co-Authored-By: Claude Sonnet 4.5 --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Cascade Bot --- Dockerfile.dashboard | 1 + Dockerfile.worker | 1 + package-lock.json | 11 + package.json | 6 +- src/agents/definitions/contextSteps.ts | 262 ++++++++ src/agents/definitions/debug.yaml | 28 + src/agents/definitions/implementation.yaml | 40 ++ src/agents/definitions/index.ts | 22 + src/agents/definitions/loader.ts | 79 +++ src/agents/definitions/planning.yaml | 28 + src/agents/definitions/respond-to-ci.yaml | 33 + .../respond-to-planning-comment.yaml | 28 + .../definitions/respond-to-pr-comment.yaml | 31 + src/agents/definitions/respond-to-review.yaml | 34 + src/agents/definitions/review.yaml | 29 + src/agents/definitions/schema.ts | 80 +++ src/agents/definitions/splitting.yaml | 28 + src/agents/definitions/strategies.ts | 121 ++++ src/agents/prompts/index.ts | 76 ++- src/agents/prompts/task-templates/ci.eta | 3 + .../task-templates/commentResponse.eta | 9 + .../task-templates/prCommentResponse.eta | 13 + src/agents/prompts/task-templates/review.eta | 3 + .../prompts/task-templates/workItem.eta | 1 + src/agents/shared/capabilities.ts | 76 +-- src/agents/shared/modelResolution.ts | 48 +- src/agents/shared/taskPrompts.ts | 89 +-- src/backends/adapter.ts | 15 +- src/backends/agent-profiles.ts | 627 +++--------------- src/backends/llmist/index.ts | 20 +- src/backends/postProcess.ts | 11 +- src/config/agentMessages.ts | 77 ++- src/config/compactionConfig.ts | 18 +- src/config/hintConfig.ts | 170 ++--- src/config/schema.ts | 2 + .../0016_add_task_prompt_column.sql | 1 + src/db/migrations/meta/_journal.json | 7 + src/db/repositories/configMapper.ts | 13 +- src/db/schema/agentConfigs.ts | 1 + tests/unit/agents/definitions/loader.test.ts | 362 ++++++++++ tests/unit/agents/definitions/schema.test.ts | 180 +++++ .../agents/shared/modelResolution.test.ts | 111 ++++ tests/unit/agents/shared/taskPrompts.test.ts | 120 +++- tests/unit/backends/adapter.test.ts | 5 +- tests/unit/backends/agent-profiles.test.ts | 2 +- tests/unit/backends/llmist.test.ts | 8 +- tests/unit/backends/postProcess.test.ts | 32 +- tests/unit/backends/progressModel.test.ts | 13 +- tests/unit/config/compactionConfig.test.ts | 11 + 49 files changed, 2081 insertions(+), 905 deletions(-) create mode 100644 src/agents/definitions/contextSteps.ts create mode 100644 src/agents/definitions/debug.yaml create mode 100644 src/agents/definitions/implementation.yaml create mode 100644 src/agents/definitions/index.ts create mode 100644 src/agents/definitions/loader.ts create mode 100644 src/agents/definitions/planning.yaml create mode 100644 src/agents/definitions/respond-to-ci.yaml create mode 100644 src/agents/definitions/respond-to-planning-comment.yaml create mode 100644 src/agents/definitions/respond-to-pr-comment.yaml create mode 100644 src/agents/definitions/respond-to-review.yaml create mode 100644 src/agents/definitions/review.yaml create mode 100644 src/agents/definitions/schema.ts create mode 100644 src/agents/definitions/splitting.yaml create mode 100644 src/agents/definitions/strategies.ts create mode 100644 src/agents/prompts/task-templates/ci.eta create mode 100644 src/agents/prompts/task-templates/commentResponse.eta create mode 100644 src/agents/prompts/task-templates/prCommentResponse.eta create mode 100644 src/agents/prompts/task-templates/review.eta create mode 100644 src/agents/prompts/task-templates/workItem.eta create mode 100644 src/db/migrations/0016_add_task_prompt_column.sql create mode 100644 tests/unit/agents/definitions/loader.test.ts create mode 100644 tests/unit/agents/definitions/schema.test.ts diff --git a/Dockerfile.dashboard b/Dockerfile.dashboard index 0641d5cd..d773f34d 100644 --- a/Dockerfile.dashboard +++ b/Dockerfile.dashboard @@ -27,6 +27,7 @@ COPY --from=builder /app/dist ./dist # Copy .eta prompt templates (loaded at runtime by agents/prompts via readFileSync) COPY --from=builder /app/src/agents/prompts/templates ./dist/agents/prompts/templates +COPY --from=builder /app/src/agents/prompts/task-templates ./dist/agents/prompts/task-templates ENV PORT=3001 EXPOSE 3001 diff --git a/Dockerfile.worker b/Dockerfile.worker index 4e068eeb..be24bef9 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -97,6 +97,7 @@ RUN sudo ln -sf /app/bin/cascade-tools.js /usr/local/bin/cascade-tools # Copy Eta template files (not handled by TypeScript compiler) COPY --chown=node:node src/agents/prompts/templates ./dist/agents/prompts/templates +COPY --chown=node:node src/agents/prompts/task-templates ./dist/agents/prompts/task-templates # Copy config COPY --chown=node:node config ./config diff --git a/package-lock.json b/package-lock.json index 14d6b994..f990cda4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "eta": "^4.5.0", "hono": "^4.6.14", "jira.js": "^5.3.0", + "js-yaml": "^4.1.1", "llmist": "^15.19.0", "pg": "^8.18.0", "trello.js": "^1.2.8", @@ -50,6 +51,7 @@ "@types/bcrypt": "^6.0.0", "@types/diff-match-patch": "^1.0.36", "@types/dockerode": "^3.3.47", + "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.2", "@types/pg": "^8.16.0", "@types/react": "^19.2.14", @@ -3923,6 +3925,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mysql": { "version": "2.15.27", "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz", @@ -7310,6 +7319,8 @@ }, "node_modules/js-yaml": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/package.json b/package.json index e9928446..b10a26c0 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "scripts": { "dev": "node --env-file=.env --import tsx/esm --watch src/index.ts", "dev:web": "cd web && npx vite", - "build": "tsc", + "build": "tsc && npm run build:copy-yaml && npm run build:copy-task-templates", + "build:copy-yaml": "mkdir -p dist/agents/definitions && cp src/agents/definitions/*.yaml dist/agents/definitions/", + "build:copy-task-templates": "mkdir -p dist/agents/prompts/task-templates && cp src/agents/prompts/task-templates/*.eta dist/agents/prompts/task-templates/", "build:web": "cd web && npm run build", "start": "node dist/index.js", "test": "vitest run --project unit", @@ -68,6 +70,7 @@ "eta": "^4.5.0", "hono": "^4.6.14", "jira.js": "^5.3.0", + "js-yaml": "^4.1.1", "llmist": "^15.19.0", "pg": "^8.18.0", "trello.js": "^1.2.8", @@ -84,6 +87,7 @@ "@types/bcrypt": "^6.0.0", "@types/diff-match-patch": "^1.0.36", "@types/dockerode": "^3.3.47", + "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.2", "@types/pg": "^8.16.0", "@types/react": "^19.2.14", diff --git a/src/agents/definitions/contextSteps.ts b/src/agents/definitions/contextSteps.ts new file mode 100644 index 00000000..4b0e6965 --- /dev/null +++ b/src/agents/definitions/contextSteps.ts @@ -0,0 +1,262 @@ +/** + * Context pipeline step implementations and pre-execute hooks. + * + * Each step function takes a FetchContextParams and returns ContextInjection[]. + * These are the building blocks composed by the YAML contextPipeline arrays. + */ + +import { execFileSync } from 'node:child_process'; + +import type { ContextInjection, LogWriter } from '../../backends/types.js'; +import { INITIAL_MESSAGES } from '../../config/agentMessages.js'; +import { ListDirectory } from '../../gadgets/ListDirectory.js'; +import { formatCheckStatus } from '../../gadgets/github/core/getPRChecks.js'; +import { readWorkItem } from '../../gadgets/pm/core/readWorkItem.js'; +import { githubClient } from '../../github/client.js'; +import type { AgentInput } from '../../types/index.js'; +import { parseRepoFullName } from '../../utils/repo.js'; +import { resolveSquintDbPath } from '../../utils/squintDb.js'; +import { + formatPRComments, + formatPRDetails, + formatPRDiff, + formatPRIssueComments, + formatPRReviews, + readPRFileContents, +} from '../shared/prFormatting.js'; +import type { ContextFile } from '../utils/setup.js'; + +// ============================================================================ +// Shared interfaces +// ============================================================================ + +export interface FetchContextParams { + input: AgentInput; + repoDir: string; + contextFiles: ContextFile[]; + logWriter: LogWriter; +} + +export interface PreExecuteParams { + input: AgentInput; + logWriter: LogWriter; +} + +// ============================================================================ +// Atomic context step functions +// ============================================================================ + +export function fetchDirectoryListingStep(params: FetchContextParams): ContextInjection[] { + const listDirGadget = new ListDirectory(); + const gadgetParams = { + comment: 'Pre-fetching codebase structure for context', + directoryPath: params.repoDir, + maxDepth: 3, + includeGitIgnored: false, + }; + + const result = listDirGadget.execute(gadgetParams); + return [ + { + toolName: 'ListDirectory', + params: gadgetParams, + result, + description: 'Pre-fetched codebase structure', + }, + ]; +} + +export function fetchContextFilesStep(params: FetchContextParams): ContextInjection[] { + return params.contextFiles.map((file) => ({ + toolName: 'ReadFile', + params: { comment: `Pre-fetching ${file.path} for project context`, filePath: file.path }, + result: file.content, + description: `Pre-fetched ${file.path}`, + })); +} + +export function fetchSquintStep(params: FetchContextParams): ContextInjection[] { + const squintDb = resolveSquintDbPath(params.repoDir); + if (!squintDb) return []; + + try { + const output = execFileSync('squint', ['overview', '-d', squintDb], { + encoding: 'utf-8', + timeout: 30_000, + }); + if (!output?.trim()) return []; + + return [ + { + toolName: 'SquintOverview', + params: { + comment: 'Pre-fetching Squint codebase overview for context', + database: squintDb, + }, + result: output, + description: 'Pre-fetched Squint codebase overview', + }, + ]; + } catch { + return []; + } +} + +export async function fetchWorkItemStep(params: FetchContextParams): Promise { + if (!params.input.cardId) return []; + try { + const cardData = await readWorkItem(params.input.cardId, true); + return [ + { + toolName: 'ReadWorkItem', + params: { workItemId: params.input.cardId, includeComments: true }, + result: cardData, + description: 'Pre-fetched work item data', + }, + ]; + } catch { + return []; + } +} + +export async function fetchPRContextStep(params: FetchContextParams): Promise { + const { repoFullName, prNumber } = params.input; + if (!repoFullName || !prNumber) { + throw new Error('fetchPRContextStep requires repoFullName and prNumber in input'); + } + const injections: ContextInjection[] = []; + const { owner, repo } = parseRepoFullName(repoFullName); + + params.logWriter('INFO', 'Fetching PR details, diff, and check status', { + owner, + repo, + prNumber, + }); + + const prDetails = await githubClient.getPR(owner, repo, prNumber); + const prDiff = await githubClient.getPRDiff(owner, repo, prNumber); + const checkStatus = await githubClient.getCheckSuiteStatus(owner, repo, prDetails.headSha); + + const prDetailsFormatted = formatPRDetails(prDetails); + const diffFormatted = formatPRDiff(prDiff); + const checkStatusFormatted = formatCheckStatus(prNumber, checkStatus); + + injections.push({ + toolName: 'GetPRDetails', + params: { comment: 'Pre-fetching PR details for review context', owner, repo, prNumber }, + result: prDetailsFormatted, + description: 'Pre-fetched PR details', + }); + + injections.push({ + toolName: 'GetPRDiff', + params: { comment: 'Pre-fetching PR diff for code review', owner, repo, prNumber }, + result: diffFormatted, + description: 'Pre-fetched PR diff', + }); + + injections.push({ + toolName: 'GetPRChecks', + params: { comment: 'Pre-fetching CI check status for review', owner, repo, prNumber }, + result: checkStatusFormatted, + description: 'Pre-fetched CI check status', + }); + + // Read full contents of changed files + params.logWriter('INFO', 'Reading PR file contents', { fileCount: prDiff.length }); + const fileContents = await readPRFileContents(params.repoDir, prDiff); + params.logWriter('INFO', 'File contents loaded', { + included: fileContents.included.length, + skipped: fileContents.skipped.length, + }); + + for (const file of fileContents.included) { + injections.push({ + toolName: 'ReadFile', + params: { comment: `Pre-fetching ${file.path} for review`, filePath: file.path }, + result: `path=${file.path}\n\n${file.content}`, + description: `Pre-fetched ${file.path}`, + }); + } + + return injections; +} + +export async function fetchPRConversationStep( + params: FetchContextParams, +): Promise { + const { repoFullName, prNumber } = params.input; + if (!repoFullName || !prNumber) { + throw new Error('fetchPRConversationStep requires repoFullName and prNumber in input'); + } + const injections: ContextInjection[] = []; + const { owner, repo } = parseRepoFullName(repoFullName); + + params.logWriter('INFO', 'Fetching PR conversation context', { owner, repo, prNumber }); + + const [reviewComments, reviews, issueComments] = await Promise.all([ + githubClient.getPRReviewComments(owner, repo, prNumber), + githubClient.getPRReviews(owner, repo, prNumber), + githubClient.getPRIssueComments(owner, repo, prNumber), + ]); + + injections.push({ + toolName: 'GetPRComments', + params: { + comment: 'Pre-fetching PR review comments for conversation context', + owner, + repo, + prNumber, + }, + result: formatPRComments(reviewComments), + description: 'Pre-fetched PR review comments', + }); + + injections.push({ + toolName: 'GetPRComments', + params: { + comment: 'Pre-fetching PR reviews for conversation context', + owner, + repo, + prNumber, + }, + result: formatPRReviews(reviews), + description: 'Pre-fetched PR reviews', + }); + + injections.push({ + toolName: 'GetPRComments', + params: { + comment: 'Pre-fetching PR issue comments for conversation context', + owner, + repo, + prNumber, + }, + result: formatPRIssueComments(issueComments), + description: 'Pre-fetched PR issue comments', + }); + + return injections; +} + +// ============================================================================ +// Pre-execute hooks +// ============================================================================ + +export async function postInitialPRCommentHook( + agentType: string, + { input, logWriter }: PreExecuteParams, +): Promise { + // Skip if ack comment already posted by router or webhook handler + if (input.ackCommentId) return; + + const { repoFullName, prNumber } = input; + if (!repoFullName || !prNumber) { + throw new Error('postInitialPRCommentHook requires repoFullName and prNumber in input'); + } + const { owner, repo } = parseRepoFullName(repoFullName); + + const message = (input.ackMessage as string | undefined) ?? INITIAL_MESSAGES[agentType]; + logWriter('INFO', `Posting initial ${agentType} comment`, { owner, repo, prNumber }); + await githubClient.createPRComment(owner, repo, prNumber, message); +} diff --git a/src/agents/definitions/debug.yaml b/src/agents/definitions/debug.yaml new file mode 100644 index 00000000..ecccbc44 --- /dev/null +++ b/src/agents/definitions/debug.yaml @@ -0,0 +1,28 @@ +identity: + emoji: "\U0001F41B" + label: Debug Update + roleHint: Analyzes session logs to identify what went wrong + initialMessage: "**\U0001F41B Analyzing session logs** β€” Reviewing what happened and identifying issues..." + +capabilities: + canEditFiles: true + canCreatePR: true + canUpdateChecklists: true + isReadOnly: false + +tools: + sets: [all] + sdkTools: all + +strategies: + contextPipeline: [directoryListing, contextFiles, squint, workItem] + taskPromptBuilder: workItem + gadgetBuilder: workItem + +backend: + enableStopHooks: true + needsGitHubToken: false + +compaction: default + +hint: Analyze the current issue fully before moving to the next. diff --git a/src/agents/definitions/implementation.yaml b/src/agents/definitions/implementation.yaml new file mode 100644 index 00000000..5fa9ed2d --- /dev/null +++ b/src/agents/definitions/implementation.yaml @@ -0,0 +1,40 @@ +identity: + emoji: "\U0001F9D1\u200D\U0001F4BB" + label: Implementation Update + roleHint: Writes code, runs tests, and prepares a pull request + initialMessage: "**\U0001F680 Implementing changes** β€” Writing code, running tests, and preparing a PR..." + +capabilities: + canEditFiles: true + canCreatePR: true + canUpdateChecklists: true + isReadOnly: false + +tools: + sets: [pm, pm_checklist, session] + sdkTools: all + +strategies: + contextPipeline: [directoryListing, contextFiles, squint, workItem] + taskPromptBuilder: workItem + gadgetBuilder: workItem + +backend: + enableStopHooks: true + needsGitHubToken: true + requiresPR: true + postConfigure: sequentialGadgetExecution + +compaction: implementation + +hint: >- + Complete the current todo in as few iterations as possible. Batch related + edits together. Verify with Tmux after edits. NEVER mark acceptance criteria + complete without passing verification. + +trailingMessage: + includeDiagnostics: true + includeTodoProgress: true + includeGitStatus: true + includePRStatus: true + includeReminder: true diff --git a/src/agents/definitions/index.ts b/src/agents/definitions/index.ts new file mode 100644 index 00000000..9ba4fd86 --- /dev/null +++ b/src/agents/definitions/index.ts @@ -0,0 +1,22 @@ +export { AgentDefinitionSchema, type AgentDefinition } from './schema.js'; +export { + loadAgentDefinition, + loadAllAgentDefinitions, + getKnownAgentTypes, + clearDefinitionCache, +} from './loader.js'; +export { + TOOL_SET_REGISTRY, + SDK_TOOLS_REGISTRY, + GADGET_BUILDER_REGISTRY, + CONTEXT_STEP_REGISTRY, + PRE_EXECUTE_REGISTRY, + PM_TOOLS, + PM_CHECKLIST_TOOL, + GITHUB_REVIEW_TOOLS, + GITHUB_CI_TOOLS, + SESSION_TOOL, + ALL_SDK_TOOLS, + READ_ONLY_SDK_TOOLS, +} from './strategies.js'; +export type { FetchContextParams, PreExecuteParams } from './contextSteps.js'; diff --git a/src/agents/definitions/loader.ts b/src/agents/definitions/loader.ts new file mode 100644 index 00000000..b850c846 --- /dev/null +++ b/src/agents/definitions/loader.ts @@ -0,0 +1,79 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import yaml from 'js-yaml'; + +import { type AgentDefinition, AgentDefinitionSchema } from './schema.js'; + +// ============================================================================ +// YAML Loader +// ============================================================================ + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** Cache of parsed + validated agent definitions */ +const cache = new Map(); + +/** Lazily discovered set of agent types (from YAML filenames) */ +let knownTypes: string[] | null = null; + +/** + * Load and validate a single agent definition from YAML. + * Results are cached after first load. + */ +export function loadAgentDefinition(agentType: string): AgentDefinition { + const cached = cache.get(agentType); + if (cached) return cached; + + const filePath = join(__dirname, `${agentType}.yaml`); + let raw: string; + try { + raw = readFileSync(filePath, 'utf-8'); + } catch { + throw new Error(`Agent definition not found: ${agentType}.yaml (looked in ${__dirname})`); + } + + const parsed = yaml.load(raw); + const result = AgentDefinitionSchema.safeParse(parsed); + if (!result.success) { + const issues = result.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`).join('\n'); + throw new Error(`Invalid agent definition '${agentType}.yaml':\n${issues}`); + } + + cache.set(agentType, result.data); + return result.data; +} + +/** + * Load all agent definitions discovered from YAML files in the definitions directory. + */ +export function loadAllAgentDefinitions(): Map { + const types = getKnownAgentTypes(); + const result = new Map(); + for (const agentType of types) { + result.set(agentType, loadAgentDefinition(agentType)); + } + return result; +} + +/** + * Return the list of known agent types (derived from YAML filenames). + */ +export function getKnownAgentTypes(): string[] { + if (knownTypes) return knownTypes; + + const entries = readdirSync(__dirname); + knownTypes = entries + .filter((f) => f.endsWith('.yaml')) + .map((f) => f.replace(/\.yaml$/, '')) + .sort(); + return knownTypes; +} + +/** + * Clear the loader cache (useful in tests). + */ +export function clearDefinitionCache(): void { + cache.clear(); + knownTypes = null; +} diff --git a/src/agents/definitions/planning.yaml b/src/agents/definitions/planning.yaml new file mode 100644 index 00000000..8c065af5 --- /dev/null +++ b/src/agents/definitions/planning.yaml @@ -0,0 +1,28 @@ +identity: + emoji: "\U0001F5FA\uFE0F" + label: Planning Update + roleHint: Studies the codebase and designs a step-by-step implementation plan + initialMessage: "**\U0001F5FA\uFE0F Planning implementation** β€” Studying the codebase and designing a step-by-step plan..." + +capabilities: + canEditFiles: false + canCreatePR: false + canUpdateChecklists: false + isReadOnly: true + +tools: + sets: [pm, session] + sdkTools: readOnly + +strategies: + contextPipeline: [directoryListing, contextFiles, squint, workItem] + taskPromptBuilder: workItem + gadgetBuilder: workItem + +backend: + enableStopHooks: false + needsGitHubToken: false + +compaction: default + +hint: Complete the current planning step efficiently before moving to the next. diff --git a/src/agents/definitions/respond-to-ci.yaml b/src/agents/definitions/respond-to-ci.yaml new file mode 100644 index 00000000..827955b4 --- /dev/null +++ b/src/agents/definitions/respond-to-ci.yaml @@ -0,0 +1,33 @@ +identity: + emoji: "\U0001F527" + label: CI Fix Update + roleHint: Analyzes failed CI checks and works on a fix + initialMessage: "**\U0001F527 Fixing CI failures** β€” Analyzing the failed checks and working on a fix..." + +capabilities: + canEditFiles: true + canCreatePR: false + canUpdateChecklists: true + isReadOnly: false + +tools: + sets: [github_ci, pm, pm_checklist, session] + sdkTools: all + +strategies: + contextPipeline: [prContext, directoryListing, contextFiles, squint, workItem] + taskPromptBuilder: ci + gadgetBuilder: prAgent + +backend: + enableStopHooks: true + needsGitHubToken: true + blockGitPush: false + preExecute: postInitialPRComment + +compaction: default + +hint: Fix CI failures with minimal, focused changes. Batch related file edits together. + +trailingMessage: + includeDiagnostics: true diff --git a/src/agents/definitions/respond-to-planning-comment.yaml b/src/agents/definitions/respond-to-planning-comment.yaml new file mode 100644 index 00000000..ca40a7a2 --- /dev/null +++ b/src/agents/definitions/respond-to-planning-comment.yaml @@ -0,0 +1,28 @@ +identity: + emoji: "\U0001F4AC" + label: Planning Response Update + roleHint: Reads user feedback and updates the plan accordingly + initialMessage: "**\U0001F4AC Responding to feedback** β€” Reading your comment and updating the plan accordingly..." + +capabilities: + canEditFiles: false + canCreatePR: false + canUpdateChecklists: true + isReadOnly: true + +tools: + sets: [pm, pm_checklist, session] + sdkTools: readOnly + +strategies: + contextPipeline: [directoryListing, contextFiles, squint, workItem] + taskPromptBuilder: commentResponse + gadgetBuilder: workItem + +backend: + enableStopHooks: false + needsGitHubToken: false + +compaction: default + +hint: Complete the current task efficiently before moving to the next. diff --git a/src/agents/definitions/respond-to-pr-comment.yaml b/src/agents/definitions/respond-to-pr-comment.yaml new file mode 100644 index 00000000..d804a39e --- /dev/null +++ b/src/agents/definitions/respond-to-pr-comment.yaml @@ -0,0 +1,31 @@ +identity: + emoji: "\U0001F4AC" + label: PR Comment Response Update + roleHint: Reads a PR comment and takes action + initialMessage: "**\U0001F4AC Responding to PR comment** β€” Reading your comment and taking action..." + +capabilities: + canEditFiles: true + canCreatePR: false + canUpdateChecklists: false + isReadOnly: false + +tools: + sets: [github_review, session] + sdkTools: all + +strategies: + contextPipeline: [prContext, prConversation, directoryListing, contextFiles, squint] + taskPromptBuilder: prCommentResponse + gadgetBuilder: prAgent + gadgetBuilderOptions: + includeReviewComments: true + +backend: + enableStopHooks: true + needsGitHubToken: true + blockGitPush: false + +compaction: default + +hint: Complete the current task efficiently before moving to the next. diff --git a/src/agents/definitions/respond-to-review.yaml b/src/agents/definitions/respond-to-review.yaml new file mode 100644 index 00000000..557b6729 --- /dev/null +++ b/src/agents/definitions/respond-to-review.yaml @@ -0,0 +1,34 @@ +identity: + emoji: "\U0001F527" + label: Review Response Update + roleHint: Addresses code review feedback by making requested changes + initialMessage: "**\U0001F527 Addressing review feedback** β€” Making the requested changes from the code review..." + +capabilities: + canEditFiles: false + canCreatePR: false + canUpdateChecklists: false + isReadOnly: true + +tools: + sets: [github_review, session] + sdkTools: all + +strategies: + contextPipeline: [prContext, prConversation, directoryListing, contextFiles, squint] + taskPromptBuilder: prCommentResponse + gadgetBuilder: prAgent + gadgetBuilderOptions: + includeReviewComments: true + +backend: + enableStopHooks: true + needsGitHubToken: true + blockGitPush: false + +compaction: default + +hint: Address the current review comment fully before moving to the next. Batch related file edits together. + +trailingMessage: + includeDiagnostics: true diff --git a/src/agents/definitions/review.yaml b/src/agents/definitions/review.yaml new file mode 100644 index 00000000..d6ef59a4 --- /dev/null +++ b/src/agents/definitions/review.yaml @@ -0,0 +1,29 @@ +identity: + emoji: "\U0001F50D" + label: Code Review Update + roleHint: Reviews pull request changes for quality and correctness + initialMessage: "**\U0001F50D Reviewing code** β€” Examining the PR changes for quality and correctness..." + +capabilities: + canEditFiles: false + canCreatePR: false + canUpdateChecklists: false + isReadOnly: true + +tools: + sets: [github_review, session] + sdkTools: readOnly + +strategies: + contextPipeline: [prContext, contextFiles, squint] + taskPromptBuilder: review + gadgetBuilder: review + +backend: + enableStopHooks: false + needsGitHubToken: true + preExecute: postInitialPRComment + +compaction: default + +hint: Focus on the current aspect of review before moving to the next. Read related files together. diff --git a/src/agents/definitions/schema.ts b/src/agents/definitions/schema.ts new file mode 100644 index 00000000..d9e77a0d --- /dev/null +++ b/src/agents/definitions/schema.ts @@ -0,0 +1,80 @@ +import { z } from 'zod'; + +// ============================================================================ +// Agent Definition Schema +// ============================================================================ + +const IdentitySchema = z.object({ + emoji: z.string(), + label: z.string(), + roleHint: z.string(), + initialMessage: z.string(), +}); + +const CapabilitiesSchema = z.object({ + canEditFiles: z.boolean(), + canCreatePR: z.boolean(), + canUpdateChecklists: z.boolean(), + isReadOnly: z.boolean(), +}); + +const ToolsSchema = z.object({ + /** Named tool set references resolved via TOOL_SET_REGISTRY */ + sets: z.array(z.enum(['pm', 'pm_checklist', 'session', 'github_review', 'github_ci', 'all'])), + /** SDK tools preset: "all" or "readOnly" */ + sdkTools: z.enum(['all', 'readOnly']), +}); + +const GadgetBuilderOptionsSchema = z + .object({ + includeReviewComments: z.boolean().optional(), + }) + .optional(); + +const StrategiesSchema = z.object({ + contextPipeline: z.array( + z.enum([ + 'directoryListing', + 'contextFiles', + 'squint', + 'workItem', + 'prContext', + 'prConversation', + ]), + ), + taskPromptBuilder: z.enum(['workItem', 'commentResponse', 'review', 'ci', 'prCommentResponse']), + gadgetBuilder: z.enum(['workItem', 'review', 'prAgent']), + gadgetBuilderOptions: GadgetBuilderOptionsSchema, +}); + +const BackendSchema = z.object({ + enableStopHooks: z.boolean(), + needsGitHubToken: z.boolean(), + blockGitPush: z.boolean().optional(), + requiresPR: z.boolean().optional(), + preExecute: z.enum(['postInitialPRComment']).optional(), + postConfigure: z.enum(['sequentialGadgetExecution']).optional(), +}); + +const TrailingMessageSchema = z + .object({ + includeDiagnostics: z.boolean().optional(), + includeTodoProgress: z.boolean().optional(), + includeGitStatus: z.boolean().optional(), + includePRStatus: z.boolean().optional(), + includeReminder: z.boolean().optional(), + }) + .optional(); + +export const AgentDefinitionSchema = z.object({ + identity: IdentitySchema, + capabilities: CapabilitiesSchema, + tools: ToolsSchema, + strategies: StrategiesSchema, + backend: BackendSchema, + compaction: z.enum(['implementation', 'default']), + hint: z.string(), + trailingMessage: TrailingMessageSchema, +}); + +export type AgentDefinition = z.infer; diff --git a/src/agents/definitions/splitting.yaml b/src/agents/definitions/splitting.yaml new file mode 100644 index 00000000..4213d571 --- /dev/null +++ b/src/agents/definitions/splitting.yaml @@ -0,0 +1,28 @@ +identity: + emoji: "\U0001F4CB" + label: Splitting Update + roleHint: Breaks down a feature plan into smaller, ordered work items (subtasks) + initialMessage: "**\U0001F4CB Splitting plan** β€” Reading the plan and splitting it into ordered work items..." + +capabilities: + canEditFiles: true + canCreatePR: false + canUpdateChecklists: true + isReadOnly: false + +tools: + sets: [pm, pm_checklist, session] + sdkTools: all + +strategies: + contextPipeline: [directoryListing, contextFiles, squint, workItem] + taskPromptBuilder: workItem + gadgetBuilder: workItem + +backend: + enableStopHooks: false + needsGitHubToken: false + +compaction: default + +hint: Gather all context needed for the current step before proceeding. diff --git a/src/agents/definitions/strategies.ts b/src/agents/definitions/strategies.ts new file mode 100644 index 00000000..f6e75646 --- /dev/null +++ b/src/agents/definitions/strategies.ts @@ -0,0 +1,121 @@ +import type { ContextInjection } from '../../backends/types.js'; +import type { AgentCapabilities } from '../shared/capabilities.js'; +import { + buildPRAgentGadgets, + buildReviewGadgets, + buildWorkItemGadgets, +} from '../shared/gadgets.js'; +import { + type FetchContextParams, + type PreExecuteParams, + fetchContextFilesStep, + fetchDirectoryListingStep, + fetchPRContextStep, + fetchPRConversationStep, + fetchSquintStep, + fetchWorkItemStep, + postInitialPRCommentHook, +} from './contextSteps.js'; + +// ============================================================================ +// Tool Set Registry +// ============================================================================ + +/** PM tools available to most agents */ +export const PM_TOOLS = [ + 'ReadWorkItem', + 'PostComment', + 'UpdateWorkItem', + 'CreateWorkItem', + 'ListWorkItems', + 'AddChecklist', +]; + +/** PM checklist update β€” excluded from planning to prevent premature completion */ +export const PM_CHECKLIST_TOOL = 'UpdateChecklistItem'; + +/** GitHub review tools for code review agents */ +export const GITHUB_REVIEW_TOOLS = [ + 'GetPRDetails', + 'GetPRDiff', + 'GetPRChecks', + 'GetPRComments', + 'PostPRComment', + 'UpdatePRComment', + 'ReplyToReviewComment', + 'CreatePRReview', +]; + +/** GitHub CI tools for respond-to-ci agent (no CreatePR β€” pushes to existing branch) */ +export const GITHUB_CI_TOOLS = [ + 'GetPRDetails', + 'GetPRDiff', + 'GetPRChecks', + 'PostPRComment', + 'UpdatePRComment', +]; + +export const SESSION_TOOL = 'Finish'; + +export const ALL_SDK_TOOLS = ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep']; +export const READ_ONLY_SDK_TOOLS = ['Read', 'Bash', 'Glob', 'Grep']; + +/** + * Maps YAML tool set names to the actual tool name arrays. + */ +export const TOOL_SET_REGISTRY: Record = { + pm: PM_TOOLS, + pm_checklist: [PM_CHECKLIST_TOOL], + session: [SESSION_TOOL], + github_review: GITHUB_REVIEW_TOOLS, + github_ci: GITHUB_CI_TOOLS, + // 'all' is a sentinel β€” handled by returning allTools unfiltered +}; + +/** + * Maps YAML sdkTools names to actual SDK tool arrays. + */ +export const SDK_TOOLS_REGISTRY: Record = { + all: ALL_SDK_TOOLS, + readOnly: READ_ONLY_SDK_TOOLS, +}; + +// ============================================================================ +// Context Pipeline Step Registry +// ============================================================================ + +export const CONTEXT_STEP_REGISTRY: Record< + string, + (params: FetchContextParams) => ContextInjection[] | Promise +> = { + directoryListing: fetchDirectoryListingStep, + contextFiles: fetchContextFilesStep, + squint: fetchSquintStep, + workItem: fetchWorkItemStep, + prContext: fetchPRContextStep, + prConversation: fetchPRConversationStep, +}; + +// ============================================================================ +// Pre-Execute Hook Registry +// ============================================================================ + +export const PRE_EXECUTE_REGISTRY: Record< + string, + (agentType: string, params: PreExecuteParams) => Promise +> = { + postInitialPRComment: postInitialPRCommentHook, +}; + +// ============================================================================ +// Gadget Builder Registry +// ============================================================================ + +export const GADGET_BUILDER_REGISTRY: Record< + string, + (caps: AgentCapabilities, options?: { includeReviewComments?: boolean }) => unknown[] +> = { + workItem: (caps) => buildWorkItemGadgets(caps), + review: () => buildReviewGadgets(), + prAgent: (_caps, options) => buildPRAgentGadgets(options), +}; diff --git a/src/agents/prompts/index.ts b/src/agents/prompts/index.ts index 2318c6f0..6419832f 100644 --- a/src/agents/prompts/index.ts +++ b/src/agents/prompts/index.ts @@ -3,24 +3,18 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Eta } from 'eta'; +import { getKnownAgentTypes } from '../definitions/index.js'; + const __dirname = dirname(fileURLToPath(import.meta.url)); const templatesDir = join(__dirname, 'templates'); +const taskTemplatesDir = join(__dirname, 'task-templates'); // Initialize Eta with the templates directory const eta = new Eta({ views: templatesDir, autoEscape: false }); +const taskEta = new Eta({ views: taskTemplatesDir, autoEscape: false }); -// Valid agent types -const validTypes = [ - 'splitting', - 'planning', - 'implementation', - 'debug', - 'respond-to-review', - 'respond-to-ci', - 'respond-to-pr-comment', - 'respond-to-planning-comment', - 'review', -]; +// Valid agent types β€” derived from YAML definition files +const validTypes = getKnownAgentTypes(); // Template context interface export interface PromptContext { @@ -141,6 +135,51 @@ export function getSystemPrompt( return eta.renderString(template, context); } +// ============================================================================ +// Task Prompt Templates +// ============================================================================ + +/** Context for task prompt Eta rendering */ +export interface TaskPromptContext { + cardId?: string; + commentText?: string; + commentAuthor?: string; + prNumber?: number; + prBranch?: string; + commentBody?: string; + commentPath?: string; + [key: string]: unknown; +} + +const taskTemplateCache = new Map(); + +function loadTaskTemplate(templateName: string): string { + const cached = taskTemplateCache.get(templateName); + if (cached) return cached; + + const templatePath = join(taskTemplatesDir, `${templateName}.eta`); + const template = readFileSync(templatePath, 'utf-8'); + taskTemplateCache.set(templateName, template); + return template; +} + +/** + * Render a task prompt from a named `.eta` template in `task-templates/`. + * Supports DB partials via `include()` directives (same pattern as system prompts). + */ +export function renderTaskPrompt( + templateName: string, + context: TaskPromptContext = {}, + dbPartials?: Map, +): string { + const template = loadTaskTemplate(templateName); + if (dbPartials && dbPartials.size > 0) { + const expanded = resolveIncludes(template, dbPartials); + return taskEta.renderString(expanded, context); + } + return taskEta.renderString(template, context); +} + /** Returns the raw .eta template source from disk (before rendering). */ export function getRawTemplate(agentType: string): string { if (!validTypes.includes(agentType)) { @@ -204,16 +243,3 @@ export function getTemplateVariables(): Array<{ { name: 'debugListId', group: 'Debug', description: 'Debug list ID for output cards' }, ]; } - -// Export individual prompts for backwards compatibility (rendered without context) -export const SPLITTING_SYSTEM_PROMPT = loadTemplate('splitting'); -export const PLANNING_SYSTEM_PROMPT = loadTemplate('planning'); -export const IMPLEMENTATION_SYSTEM_PROMPT = loadTemplate('implementation'); -export const DEBUG_SYSTEM_PROMPT = loadTemplate('debug'); -export const RESPOND_TO_REVIEW_SYSTEM_PROMPT = loadTemplate('respond-to-review'); -export const RESPOND_TO_CI_SYSTEM_PROMPT = loadTemplate('respond-to-ci'); -export const RESPOND_TO_PR_COMMENT_SYSTEM_PROMPT = loadTemplate('respond-to-pr-comment'); -export const RESPOND_TO_PLANNING_COMMENT_SYSTEM_PROMPT = loadTemplate( - 'respond-to-planning-comment', -); -export const REVIEW_SYSTEM_PROMPT = loadTemplate('review'); diff --git a/src/agents/prompts/task-templates/ci.eta b/src/agents/prompts/task-templates/ci.eta new file mode 100644 index 00000000..9b9eca5f --- /dev/null +++ b/src/agents/prompts/task-templates/ci.eta @@ -0,0 +1,3 @@ +You are on the branch `<%= it.prBranch %>` for PR #<%= it.prNumber %>. + +CI checks have failed. Analyze the failures and fix them. \ No newline at end of file diff --git a/src/agents/prompts/task-templates/commentResponse.eta b/src/agents/prompts/task-templates/commentResponse.eta new file mode 100644 index 00000000..bf12da6d --- /dev/null +++ b/src/agents/prompts/task-templates/commentResponse.eta @@ -0,0 +1,9 @@ +A user (@<%= it.commentAuthor %>) mentioned you in a comment on work item <%= it.cardId %>. + +Their comment: +--- +<%= it.commentText %> +--- + +The work item data (title, description, checklists, attachments, comments) has been pre-loaded above. +Read the user's comment carefully and classify it: if they ask a question or request clarification, reply with a thorough answer via PostComment (do not modify the plan). If they request plan changes, make surgical, targeted updates. If the comment contains both a question and a change request, do both. Default to plan updates when intent is ambiguous. \ No newline at end of file diff --git a/src/agents/prompts/task-templates/prCommentResponse.eta b/src/agents/prompts/task-templates/prCommentResponse.eta new file mode 100644 index 00000000..e8a421bf --- /dev/null +++ b/src/agents/prompts/task-templates/prCommentResponse.eta @@ -0,0 +1,13 @@ +You are on the branch `<%= it.prBranch %>` for PR #<%= it.prNumber %>. + +A user commented on this PR and mentioned you. Respond to their comment. +<% if (it.commentPath) { -%> +File: <%= it.commentPath %> +<% } -%> + +Their comment: +--- +<%= it.commentBody %> +--- + +Read the comment carefully and respond accordingly. If they ask for code changes, make the changes, commit, and push. If they ask a question, reply with a PR comment. Default to surgical, targeted changes unless they clearly ask for something broader. \ No newline at end of file diff --git a/src/agents/prompts/task-templates/review.eta b/src/agents/prompts/task-templates/review.eta new file mode 100644 index 00000000..830397b8 --- /dev/null +++ b/src/agents/prompts/task-templates/review.eta @@ -0,0 +1,3 @@ +Review PR #<%= it.prNumber %>. + +Examine the code changes carefully and submit your review using CreatePRReview. \ No newline at end of file diff --git a/src/agents/prompts/task-templates/workItem.eta b/src/agents/prompts/task-templates/workItem.eta new file mode 100644 index 00000000..99d28c5e --- /dev/null +++ b/src/agents/prompts/task-templates/workItem.eta @@ -0,0 +1 @@ +Analyze and process the work item with ID: <%= it.cardId %>. The work item data has been pre-loaded. \ No newline at end of file diff --git a/src/agents/shared/capabilities.ts b/src/agents/shared/capabilities.ts index 7c46a47b..fccf9e64 100644 --- a/src/agents/shared/capabilities.ts +++ b/src/agents/shared/capabilities.ts @@ -1,3 +1,5 @@ +import { loadAgentDefinition } from '../definitions/loader.js'; + // ============================================================================ // AgentCapabilities // ============================================================================ @@ -21,10 +23,6 @@ export interface AgentCapabilities { isReadOnly: boolean; } -// ============================================================================ -// Capabilities Registry -// ============================================================================ - /** * Default capabilities for unknown agent types β€” full access. */ @@ -35,71 +33,15 @@ const DEFAULT_CAPABILITIES: AgentCapabilities = { isReadOnly: false, }; -/** - * Capabilities per agent type β€” single source of truth. - * AgentProfile in backends/agent-profiles.ts consumes these via getAgentCapabilities(). - */ -const CAPABILITIES_REGISTRY: Record = { - splitting: { - canEditFiles: true, - canCreatePR: false, - canUpdateChecklists: true, - isReadOnly: false, - }, - planning: { - canEditFiles: false, - canCreatePR: false, - canUpdateChecklists: false, - isReadOnly: true, - }, - implementation: { - canEditFiles: true, - canCreatePR: true, - canUpdateChecklists: true, - isReadOnly: false, - }, - review: { - canEditFiles: false, - canCreatePR: false, - canUpdateChecklists: false, - isReadOnly: true, - }, - 'respond-to-planning-comment': { - canEditFiles: false, - canCreatePR: false, - canUpdateChecklists: true, - isReadOnly: true, - }, - 'respond-to-review': { - canEditFiles: false, - canCreatePR: false, - canUpdateChecklists: false, - isReadOnly: true, - }, - 'respond-to-ci': { - canEditFiles: true, - canCreatePR: false, - canUpdateChecklists: true, - isReadOnly: false, - }, - 'respond-to-pr-comment': { - canEditFiles: true, - canCreatePR: false, - canUpdateChecklists: false, - isReadOnly: false, - }, - debug: { - canEditFiles: true, - canCreatePR: true, - canUpdateChecklists: true, - isReadOnly: false, - }, -}; - /** * Look up capabilities for a given agent type. - * Falls back to full-access defaults for unknown types. + * Reads from YAML definition; falls back to full-access defaults for unknown types. */ export function getAgentCapabilities(agentType: string): AgentCapabilities { - return CAPABILITIES_REGISTRY[agentType] ?? DEFAULT_CAPABILITIES; + try { + const def = loadAgentDefinition(agentType); + return def.capabilities; + } catch { + return DEFAULT_CAPABILITIES; + } } diff --git a/src/agents/shared/modelResolution.ts b/src/agents/shared/modelResolution.ts index 30760c97..baee9241 100644 --- a/src/agents/shared/modelResolution.ts +++ b/src/agents/shared/modelResolution.ts @@ -1,10 +1,17 @@ -import type { CascadeConfig, ProjectConfig } from '../../types/index.js'; +import type { AgentInput, CascadeConfig, ProjectConfig } from '../../types/index.js'; import { type ContextFile, readContextFiles } from '../utils/setup.js'; -import { type PromptContext, getSystemPrompt, renderCustomPrompt } from '../prompts/index.js'; +import { + type PromptContext, + type TaskPromptContext, + getSystemPrompt, + renderCustomPrompt, +} from '../prompts/index.js'; export interface ModelConfig { systemPrompt: string; + /** Resolved task prompt override from DB (undefined = use default .eta template) */ + taskPrompt?: string; model: string; maxIterations: number; contextFiles: ContextFile[]; @@ -21,6 +28,31 @@ export interface ResolveModelConfigOptions { configKey?: string; /** DB partials for template include resolution */ dbPartials?: Map; + /** Agent input for task-specific template variables (commentText, commentAuthor, etc.) */ + agentInput?: AgentInput; +} + +/** + * Build a merged context for DB task prompt overrides. + * Combines PromptContext fields (cardId, prNumber, etc.) with task-specific + * fields from AgentInput (commentText, commentAuthor, commentBody, commentPath). + */ +function buildTaskOverrideContext( + promptContext: PromptContext | undefined, + agentInput: AgentInput | undefined, +): TaskPromptContext { + return { + ...(promptContext ?? {}), + // Common fields from AgentInput + cardId: agentInput?.cardId || (promptContext?.cardId as string | undefined), + prNumber: agentInput?.prNumber ?? (promptContext?.prNumber as number | undefined), + prBranch: agentInput?.prBranch ?? (promptContext?.prBranch as string | undefined), + // Task-specific fields from AgentInput + commentText: agentInput?.triggerCommentText as string | undefined, + commentAuthor: (agentInput?.triggerCommentAuthor as string) || undefined, + commentBody: agentInput?.triggerCommentBody as string | undefined, + commentPath: (agentInput?.triggerCommentPath as string) || undefined, + }; } export async function resolveModelConfig(options: ResolveModelConfigOptions): Promise { @@ -47,7 +79,17 @@ export async function resolveModelConfig(options: ResolveModelConfigOptions): Pr const maxIterations = config.defaults.agentIterations?.[configKey] || config.defaults.maxIterations; + // Resolve task prompt override: project β†’ defaults β†’ undefined (use .eta default) + const customTaskPromptSource = + project.taskPrompts?.[agentType] ?? config.defaults.taskPrompts?.[agentType]; + + let taskPrompt: string | undefined; + if (customTaskPromptSource) { + const taskContext = buildTaskOverrideContext(promptContext, options.agentInput); + taskPrompt = renderCustomPrompt(customTaskPromptSource, taskContext, dbPartials); + } + const contextFiles = await readContextFiles(repoDir); - return { systemPrompt, model, maxIterations, contextFiles }; + return { systemPrompt, taskPrompt, model, maxIterations, contextFiles }; } diff --git a/src/agents/shared/taskPrompts.ts b/src/agents/shared/taskPrompts.ts index 51716282..a9ef419e 100644 --- a/src/agents/shared/taskPrompts.ts +++ b/src/agents/shared/taskPrompts.ts @@ -1,91 +1,16 @@ /** - * Shared task prompt builders used by both backends. + * Shared task prompt builders for prompts NOT managed via the YAML profile system. * - * The llmist backend (agents/base.ts) and the Claude Code backend - * (backends/agent-profiles.ts) both need task-level prompts for each agent type. - * This module is the single source of truth so the two backends produce - * identical instructions for each agent type. + * Task prompts managed through YAML profiles (workItem, commentResponse, review, + * ci, prCommentResponse) are now .eta templates in `src/agents/prompts/task-templates/` + * rendered via `renderTaskPrompt()` in the profile builder. + * + * This module retains only the two prompts called directly by trigger handlers/agents, + * not through the profile system: `buildCheckFailurePrompt` and `buildDebugPrompt`. */ import { parseRepoFullName } from '../../utils/repo.js'; -// ============================================================================ -// Work-item agents -// ============================================================================ - -/** - * Standard prompt for agents whose primary task is processing a work item - * (splitting, planning, implementation, debug). - */ -export function buildWorkItemPrompt(cardId: string): string { - return `Analyze and process the work item with ID: ${cardId}. The work item data has been pre-loaded.`; -} - -/** - * Prompt for agents responding to a PM comment mentioning them. - */ -export function buildCommentResponsePrompt( - cardId: string, - commentText: string, - commentAuthor: string, -): string { - return `A user (@${commentAuthor}) mentioned you in a comment on work item ${cardId}. - -Their comment: ---- -${commentText} ---- - -The work item data (title, description, checklists, attachments, comments) has been pre-loaded above. -Read the user's comment carefully and classify it: if they ask a question or request clarification, reply with a thorough answer via PostComment (do not modify the plan). If they request plan changes, make surgical, targeted updates. If the comment contains both a question and a change request, do both. Default to plan updates when intent is ambiguous.`; -} - -// ============================================================================ -// PR agents -// ============================================================================ - -/** - * Prompt for the review agent. - */ -export function buildReviewPrompt(prNumber: number): string { - return `Review PR #${prNumber}. - -Examine the code changes carefully and submit your review using CreatePRReview.`; -} - -/** - * Prompt for the respond-to-ci agent. - */ -export function buildCIResponsePrompt(prBranch: string, prNumber: number): string { - return `You are on the branch \`${prBranch}\` for PR #${prNumber}. - -CI checks have failed. Analyze the failures and fix them.`; -} - -/** - * Prompt for PR-comment-response agents (respond-to-review, respond-to-pr-comment). - */ -export function buildPRCommentResponsePrompt( - prBranch: string, - prNumber: number, - commentBody: string, - commentPath?: string, -): string { - const pathContext = commentPath ? `\nFile: ${commentPath}` : ''; - - return `You are on the branch \`${prBranch}\` for PR #${prNumber}. - -A user commented on this PR and mentioned you. Respond to their comment. -${pathContext} - -Their comment: ---- -${commentBody} ---- - -Read the comment carefully and respond accordingly. If they ask for code changes, make the changes, commit, and push. If they ask a question, reply with a PR comment. Default to surgical, targeted changes unless they clearly ask for something broader.`; -} - /** * Prompt for the respond-to-ci agent (llmist backend format β€” includes GitHub context). * Used by agents/base.ts when the trigger type is 'check-failure'. diff --git a/src/backends/adapter.ts b/src/backends/adapter.ts index efe9af9d..6680b8a8 100644 --- a/src/backends/adapter.ts +++ b/src/backends/adapter.ts @@ -83,13 +83,20 @@ async function buildBackendInput( // DB not available β€” fall back to disk-only partials } - const { systemPrompt, model, maxIterations, contextFiles } = await resolveModelConfig({ + const { + systemPrompt, + taskPrompt: taskPromptOverride, + model, + maxIterations, + contextFiles, + } = await resolveModelConfig({ agentType, project, config, repoDir, promptContext, dbPartials, + agentInput: input, }); const profile = getAgentProfile(agentType); @@ -123,7 +130,7 @@ async function buildBackendInput( config, repoDir, systemPrompt, - taskPrompt: profile.buildTaskPrompt(input), + taskPrompt: taskPromptOverride ?? profile.buildTaskPrompt(input), cliToolsDir, availableTools: profile.filterTools(getToolManifests()), contextInjections, @@ -263,7 +270,9 @@ export async function executeWithBackend( monitor?.stop(); } - postProcessResult(result, agentType, backend, input, identifier); + postProcessResult(result, agentType, backend, input, identifier, { + requiresPR: profile.requiresPR, + }); return { success: result.success, diff --git a/src/backends/agent-profiles.ts b/src/backends/agent-profiles.ts index 85b0ca83..765e61b1 100644 --- a/src/backends/agent-profiles.ts +++ b/src/backends/agent-profiles.ts @@ -1,97 +1,23 @@ -import { execFileSync } from 'node:child_process'; - import { type AgentCapabilities, getAgentCapabilities } from '../agents/shared/capabilities.js'; export type { AgentCapabilities } from '../agents/shared/capabilities.js'; +import type { FetchContextParams, PreExecuteParams } from '../agents/definitions/contextSteps.js'; import { - buildPRAgentGadgets, - buildReviewGadgets, - buildWorkItemGadgets, -} from '../agents/shared/gadgets.js'; -import { - formatPRComments, - formatPRDetails, - formatPRDiff, - formatPRIssueComments, - formatPRReviews, - readPRFileContents, -} from '../agents/shared/prFormatting.js'; -import { - buildCIResponsePrompt, - buildCommentResponsePrompt, - buildPRCommentResponsePrompt, - buildReviewPrompt, - buildWorkItemPrompt, -} from '../agents/shared/taskPrompts.js'; -import type { ContextFile } from '../agents/utils/setup.js'; -import { INITIAL_MESSAGES } from '../config/agentMessages.js'; -import { ListDirectory } from '../gadgets/ListDirectory.js'; -import { formatCheckStatus } from '../gadgets/github/core/getPRChecks.js'; -import { readWorkItem } from '../gadgets/pm/core/readWorkItem.js'; -import { githubClient } from '../github/client.js'; + type AgentDefinition, + CONTEXT_STEP_REGISTRY, + GADGET_BUILDER_REGISTRY, + PRE_EXECUTE_REGISTRY, + SDK_TOOLS_REGISTRY, + TOOL_SET_REGISTRY, + loadAgentDefinition, +} from '../agents/definitions/index.js'; +import { type TaskPromptContext, renderTaskPrompt } from '../agents/prompts/index.js'; import type { AgentInput } from '../types/index.js'; -import { parseRepoFullName } from '../utils/repo.js'; -import { resolveSquintDbPath } from '../utils/squintDb.js'; -import type { ContextInjection, LogWriter, ToolManifest } from './types.js'; - -// ============================================================================ -// Tool Name Sets -// ============================================================================ - -/** PM tools available to most agents */ -const PM_TOOLS = [ - 'ReadWorkItem', - 'PostComment', - 'UpdateWorkItem', - 'CreateWorkItem', - 'ListWorkItems', - 'AddChecklist', -]; - -/** PM checklist update β€” excluded from planning to prevent premature completion */ -const PM_CHECKLIST_TOOL = 'UpdateChecklistItem'; - -/** GitHub review tools for code review agents */ -const GITHUB_REVIEW_TOOLS = [ - 'GetPRDetails', - 'GetPRDiff', - 'GetPRChecks', - 'GetPRComments', - 'PostPRComment', - 'UpdatePRComment', - 'ReplyToReviewComment', - 'CreatePRReview', -]; - -/** GitHub CI tools for respond-to-ci agent (no CreatePR β€” pushes to existing branch) */ -const GITHUB_CI_TOOLS = [ - 'GetPRDetails', - 'GetPRDiff', - 'GetPRChecks', - 'PostPRComment', - 'UpdatePRComment', -]; - -const SESSION_TOOL = 'Finish'; - -const ALL_SDK_TOOLS = ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep']; -const READ_ONLY_SDK_TOOLS = ['Read', 'Bash', 'Glob', 'Grep']; +import type { ContextInjection, ToolManifest } from './types.js'; // ============================================================================ // AgentProfile Interface // ============================================================================ -interface FetchContextParams { - input: AgentInput; - repoDir: string; - contextFiles: ContextFile[]; - logWriter: LogWriter; -} - -interface PreExecuteParams { - input: AgentInput; - logWriter: LogWriter; -} - export interface AgentProfile { /** Filter the full set of tool manifests down to what this agent needs */ filterTools(allTools: ToolManifest[]): ToolManifest[]; @@ -103,6 +29,8 @@ export interface AgentProfile { needsGitHubToken: boolean; /** Whether to block git push in hooks (default: true β€” set false for agents on existing PR branches) */ blockGitPush?: boolean; + /** Whether the agent must create a PR for success (e.g., implementation) */ + requiresPR?: boolean; /** Fetch context injections for this agent type */ fetchContext(params: FetchContextParams): Promise; /** Build the task prompt for this agent type */ @@ -119,15 +47,7 @@ export interface AgentProfile { } // ============================================================================ -// Llmist Gadget Builders -// ============================================================================ -// All three builder functions below delegate to the shared gadget factories in -// agents/shared/gadgets.ts, which serve as the single source of truth for tool -// sets used by both the llmist backend and the Claude Code backend. -// ============================================================================ - -// ============================================================================ -// Context Fetching Helpers +// Helpers // ============================================================================ function filterToolsByNames(allTools: ToolManifest[], names: string[]): ToolManifest[] { @@ -135,472 +55,97 @@ function filterToolsByNames(allTools: ToolManifest[], names: string[]): ToolMani return allTools.filter((t) => nameSet.has(t.name)); } -function fetchDirectoryListing(repoDir: string): ContextInjection { - const listDirGadget = new ListDirectory(); - // Pass the absolute repoDir path so ListDirectory resolves correctly - // without requiring process.chdir(), which is a dangerous side effect. - const params = { - comment: 'Pre-fetching codebase structure for context', - directoryPath: repoDir, - maxDepth: 3, - includeGitIgnored: false, - }; +function resolveRegistry(registry: Record, key: string, label: string): T { + const value = registry[key]; + if (!value) throw new Error(`${label} '${key}' not found in registry`); + return value; +} - const result = listDirGadget.execute(params); +/** + * Extract all relevant fields from AgentInput into a flat context object + * for Eta task prompt template rendering. + */ +function buildTaskPromptContext(input: AgentInput): TaskPromptContext { return { - toolName: 'ListDirectory', - params, - result, - description: 'Pre-fetched codebase structure', + cardId: input.cardId || 'unknown', + commentText: input.triggerCommentText as string | undefined, + commentAuthor: (input.triggerCommentAuthor as string) || 'unknown', + prNumber: input.prNumber, + prBranch: input.prBranch, + commentBody: input.triggerCommentBody as string | undefined, + commentPath: (input.triggerCommentPath as string) || undefined, }; } -function fetchContextFileInjections(contextFiles: ContextFile[]): ContextInjection[] { - return contextFiles.map((file) => ({ - toolName: 'ReadFile', - params: { comment: `Pre-fetching ${file.path} for project context`, filePath: file.path }, - result: file.content, - description: `Pre-fetched ${file.path}`, - })); -} - -function fetchSquintOverview(repoDir: string): ContextInjection | null { - const squintDb = resolveSquintDbPath(repoDir); - if (!squintDb) return null; - - try { - const output = execFileSync('squint', ['overview', '-d', squintDb], { - encoding: 'utf-8', - timeout: 30_000, - }); - if (!output?.trim()) return null; - - return { - toolName: 'SquintOverview', - params: { comment: 'Pre-fetching Squint codebase overview for context', database: squintDb }, - result: output, - description: 'Pre-fetched Squint codebase overview', - }; - } catch { - return null; - } -} - -async function fetchWorkItemInjection(cardId: string): Promise { - try { - const cardData = await readWorkItem(cardId, true); - return { - toolName: 'ReadWorkItem', - params: { workItemId: cardId, includeComments: true }, - result: cardData, - description: 'Pre-fetched work item data', - }; - } catch { - return null; - } -} - -/** Fetch PR context injections (ported from review.ts:93-144) */ -async function fetchPRContextInjections( - owner: string, - repo: string, - prNumber: number, - repoDir: string, - logWriter: LogWriter, -): Promise<{ injections: ContextInjection[]; skippedFiles: string[] }> { - const injections: ContextInjection[] = []; - - logWriter('INFO', 'Fetching PR details, diff, and check status', { owner, repo, prNumber }); - - const prDetails = await githubClient.getPR(owner, repo, prNumber); - const prDiff = await githubClient.getPRDiff(owner, repo, prNumber); - const checkStatus = await githubClient.getCheckSuiteStatus(owner, repo, prDetails.headSha); - - const prDetailsFormatted = formatPRDetails(prDetails); - const diffFormatted = formatPRDiff(prDiff); - const checkStatusFormatted = formatCheckStatus(prNumber, checkStatus); - - injections.push({ - toolName: 'GetPRDetails', - params: { comment: 'Pre-fetching PR details for review context', owner, repo, prNumber }, - result: prDetailsFormatted, - description: 'Pre-fetched PR details', - }); - - injections.push({ - toolName: 'GetPRDiff', - params: { comment: 'Pre-fetching PR diff for code review', owner, repo, prNumber }, - result: diffFormatted, - description: 'Pre-fetched PR diff', - }); - - injections.push({ - toolName: 'GetPRChecks', - params: { comment: 'Pre-fetching CI check status for review', owner, repo, prNumber }, - result: checkStatusFormatted, - description: 'Pre-fetched CI check status', - }); - - // Read full contents of changed files - logWriter('INFO', 'Reading PR file contents', { fileCount: prDiff.length }); - const fileContents = await readPRFileContents(repoDir, prDiff); - logWriter('INFO', 'File contents loaded', { - included: fileContents.included.length, - skipped: fileContents.skipped.length, - }); - - for (const file of fileContents.included) { - injections.push({ - toolName: 'ReadFile', - params: { comment: `Pre-fetching ${file.path} for review`, filePath: file.path }, - result: `path=${file.path}\n\n${file.content}`, - description: `Pre-fetched ${file.path}`, - }); - } - - return { injections, skippedFiles: fileContents.skipped }; -} - // ============================================================================ -// Common Context Builders +// Profile Builder (YAML-driven) // ============================================================================ -/** Standard context for work-item-based agents: dirListing + contextFiles + squint + workItem */ -async function fetchWorkItemContext(params: FetchContextParams): Promise { - const injections: ContextInjection[] = []; - - injections.push(fetchDirectoryListing(params.repoDir)); - injections.push(...fetchContextFileInjections(params.contextFiles)); - - const squint = fetchSquintOverview(params.repoDir); - if (squint) injections.push(squint); - - if (params.input.cardId) { - const workItem = await fetchWorkItemInjection(params.input.cardId); - if (workItem) injections.push(workItem); - } - - return injections; -} - -/** PR review context: PR details + diff + checks + file contents + contextFiles + squint */ -async function fetchReviewContext(params: FetchContextParams): Promise { - const injections: ContextInjection[] = []; - - const repoFullName = params.input.repoFullName as string; - const prNumber = params.input.prNumber as number; - const { owner, repo } = parseRepoFullName(repoFullName); - - // PR context first (most relevant for review) - const { injections: prInjections } = await fetchPRContextInjections( - owner, - repo, - prNumber, - params.repoDir, - params.logWriter, - ); - injections.push(...prInjections); - - // Then context files and squint for codebase understanding - injections.push(...fetchContextFileInjections(params.contextFiles)); - - const squint = fetchSquintOverview(params.repoDir); - if (squint) injections.push(squint); - - return injections; -} - -/** CI context: PR details + diff + checks + dirListing + contextFiles + squint + optional workItem */ -async function fetchCIContext(params: FetchContextParams): Promise { - const injections: ContextInjection[] = []; - const repoFullName = params.input.repoFullName as string; - const prNumber = params.input.prNumber as number; - const { owner, repo } = parseRepoFullName(repoFullName); - - // PR context (details, diff, checks) β€” most relevant for CI fixing - const { injections: prInjections } = await fetchPRContextInjections( - owner, - repo, - prNumber, - params.repoDir, - params.logWriter, - ); - injections.push(...prInjections); - - // Codebase context - injections.push(fetchDirectoryListing(params.repoDir)); - injections.push(...fetchContextFileInjections(params.contextFiles)); - - const squint = fetchSquintOverview(params.repoDir); - if (squint) injections.push(squint); - - // Work item context (if triggered from a Trello card) - if (params.input.cardId) { - const workItem = await fetchWorkItemInjection(params.input.cardId); - if (workItem) injections.push(workItem); +function buildProfileFromDefinition(agentType: string, def: AgentDefinition): AgentProfile { + // Resolve tool names from YAML set references + const hasAllSet = def.tools.sets.includes('all'); + const toolNames: string[] = []; + if (!hasAllSet) { + for (const setName of def.tools.sets) { + const tools = TOOL_SET_REGISTRY[setName]; + if (tools) toolNames.push(...tools); + } } - return injections; -} - -/** PR comment response context: PR details + diff + conversation + dirListing + contextFiles + squint */ -async function fetchPRCommentResponseContext( - params: FetchContextParams, -): Promise { - const injections: ContextInjection[] = []; - const repoFullName = params.input.repoFullName as string; - const prNumber = params.input.prNumber as number; - const { owner, repo } = parseRepoFullName(repoFullName); - - // PR context (details, diff, checks) - const { injections: prInjections } = await fetchPRContextInjections( - owner, - repo, - prNumber, - params.repoDir, - params.logWriter, + const sdkTools = SDK_TOOLS_REGISTRY[def.tools.sdkTools]; + // taskPromptBuilder YAML value maps directly to the .eta template filename + // (validated by the Zod schema in AgentDefinitionSchema) + const taskTemplateName = def.strategies.taskPromptBuilder; + const caps = getAgentCapabilities(agentType); + const gadgetBuilderFn = resolveRegistry( + GADGET_BUILDER_REGISTRY, + def.strategies.gadgetBuilder, + 'gadgetBuilder', ); - injections.push(...prInjections); - - // Conversation context (review comments, reviews, issue comments) - params.logWriter('INFO', 'Fetching PR conversation context', { owner, repo, prNumber }); - - const [reviewComments, reviews, issueComments] = await Promise.all([ - githubClient.getPRReviewComments(owner, repo, prNumber), - githubClient.getPRReviews(owner, repo, prNumber), - githubClient.getPRIssueComments(owner, repo, prNumber), - ]); - - injections.push({ - toolName: 'GetPRComments', - params: { - comment: 'Pre-fetching PR review comments for conversation context', - owner, - repo, - prNumber, + const gadgetBuilderOptions = def.strategies.gadgetBuilderOptions; + const contextPipeline = def.strategies.contextPipeline; + + const profile: AgentProfile = { + filterTools: hasAllSet + ? (allTools) => allTools + : (allTools) => filterToolsByNames(allTools, toolNames), + sdkTools, + enableStopHooks: def.backend.enableStopHooks, + needsGitHubToken: def.backend.needsGitHubToken, + ...(def.backend.blockGitPush !== undefined && { blockGitPush: def.backend.blockGitPush }), + ...(def.backend.requiresPR && { requiresPR: true }), + fetchContext: async (params) => { + const injections: ContextInjection[] = []; + for (const step of contextPipeline) { + const stepFn = resolveRegistry(CONTEXT_STEP_REGISTRY, step, 'contextPipeline step'); + const result = await stepFn(params); + injections.push(...result); + } + return injections; }, - result: formatPRComments(reviewComments), - description: 'Pre-fetched PR review comments', - }); - - injections.push({ - toolName: 'GetPRComments', - params: { comment: 'Pre-fetching PR reviews for conversation context', owner, repo, prNumber }, - result: formatPRReviews(reviews), - description: 'Pre-fetched PR reviews', - }); - - injections.push({ - toolName: 'GetPRComments', - params: { - comment: 'Pre-fetching PR issue comments for conversation context', - owner, - repo, - prNumber, - }, - result: formatPRIssueComments(issueComments), - description: 'Pre-fetched PR issue comments', - }); - - // Codebase context - injections.push(fetchDirectoryListing(params.repoDir)); - injections.push(...fetchContextFileInjections(params.contextFiles)); - - const squint = fetchSquintOverview(params.repoDir); - if (squint) injections.push(squint); - - return injections; -} - -// ============================================================================ -// Task Prompt Builders (thin wrappers around shared/taskPrompts.ts) -// ============================================================================ - -function buildWorkItemTaskPrompt(input: AgentInput): string { - return buildWorkItemPrompt(input.cardId || 'unknown'); -} - -function buildCommentResponseTaskPrompt(input: AgentInput): string { - const commentText = input.triggerCommentText as string; - const commentAuthor = (input.triggerCommentAuthor as string) || 'unknown'; - return buildCommentResponsePrompt(input.cardId || 'unknown', commentText, commentAuthor); -} - -function buildReviewTaskPrompt(input: AgentInput): string { - return buildReviewPrompt(input.prNumber as number); -} + buildTaskPrompt: (input) => renderTaskPrompt(taskTemplateName, buildTaskPromptContext(input)), + capabilities: caps, + getLlmistGadgets: (at) => gadgetBuilderFn(getAgentCapabilities(at), gadgetBuilderOptions), + }; -function buildCITaskPrompt(input: AgentInput): string { - return buildCIResponsePrompt(input.prBranch as string, input.prNumber as number); -} + if (def.backend.preExecute) { + const preExecFn = resolveRegistry(PRE_EXECUTE_REGISTRY, def.backend.preExecute, 'preExecute'); + profile.preExecute = (params) => preExecFn(agentType, params); + } -function buildPRCommentResponseTaskPrompt(input: AgentInput): string { - return buildPRCommentResponsePrompt( - input.prBranch as string, - input.prNumber as number, - input.triggerCommentBody as string, - (input.triggerCommentPath as string) || undefined, - ); + return profile; } // ============================================================================ -// Agent Profiles -// ============================================================================ - -const splittingProfile: AgentProfile = { - filterTools: (allTools) => - filterToolsByNames(allTools, [...PM_TOOLS, PM_CHECKLIST_TOOL, SESSION_TOOL]), - sdkTools: ALL_SDK_TOOLS, - enableStopHooks: false, - needsGitHubToken: false, - fetchContext: fetchWorkItemContext, - buildTaskPrompt: buildWorkItemTaskPrompt, - capabilities: getAgentCapabilities('splitting'), - getLlmistGadgets: (agentType) => buildWorkItemGadgets(getAgentCapabilities(agentType)), -}; - -const planningProfile: AgentProfile = { - filterTools: (allTools) => filterToolsByNames(allTools, [...PM_TOOLS, SESSION_TOOL]), - sdkTools: READ_ONLY_SDK_TOOLS, - enableStopHooks: false, - needsGitHubToken: false, - fetchContext: fetchWorkItemContext, - buildTaskPrompt: buildWorkItemTaskPrompt, - capabilities: getAgentCapabilities('planning'), - getLlmistGadgets: (agentType) => buildWorkItemGadgets(getAgentCapabilities(agentType)), -}; - -const reviewProfile: AgentProfile = { - filterTools: (allTools) => filterToolsByNames(allTools, [...GITHUB_REVIEW_TOOLS, SESSION_TOOL]), - sdkTools: READ_ONLY_SDK_TOOLS, - enableStopHooks: false, - needsGitHubToken: true, - fetchContext: fetchReviewContext, - buildTaskPrompt: buildReviewTaskPrompt, - capabilities: getAgentCapabilities('review'), - getLlmistGadgets: (_agentType) => buildReviewGadgets(), - - async preExecute({ input, logWriter }: PreExecuteParams): Promise { - // Skip if ack comment already posted by router or webhook handler - if (input.ackCommentId) return; - - const repoFullName = input.repoFullName as string; - const prNumber = input.prNumber as number; - const { owner, repo } = parseRepoFullName(repoFullName); - - const message = (input.ackMessage as string | undefined) ?? INITIAL_MESSAGES.review; - logWriter('INFO', 'Posting initial review comment', { owner, repo, prNumber }); - await githubClient.createPRComment(owner, repo, prNumber, message); - }, -}; - -const respondToPlanningCommentProfile: AgentProfile = { - filterTools: (allTools) => - filterToolsByNames(allTools, [...PM_TOOLS, PM_CHECKLIST_TOOL, SESSION_TOOL]), - sdkTools: READ_ONLY_SDK_TOOLS, - enableStopHooks: false, - needsGitHubToken: false, - fetchContext: fetchWorkItemContext, - buildTaskPrompt: buildCommentResponseTaskPrompt, - capabilities: getAgentCapabilities('respond-to-planning-comment'), - getLlmistGadgets: (agentType) => buildWorkItemGadgets(getAgentCapabilities(agentType)), -}; - -const respondToCIProfile: AgentProfile = { - filterTools: (allTools) => - filterToolsByNames(allTools, [ - ...GITHUB_CI_TOOLS, - ...PM_TOOLS, - PM_CHECKLIST_TOOL, - SESSION_TOOL, - ]), - sdkTools: ALL_SDK_TOOLS, - enableStopHooks: true, - needsGitHubToken: true, - blockGitPush: false, - fetchContext: fetchCIContext, - buildTaskPrompt: buildCITaskPrompt, - capabilities: getAgentCapabilities('respond-to-ci'), - getLlmistGadgets: (_agentType) => buildPRAgentGadgets(), - - async preExecute({ input, logWriter }: PreExecuteParams): Promise { - // Skip if ack comment already posted by router or webhook handler - if (input.ackCommentId) return; - - const repoFullName = input.repoFullName as string; - const prNumber = input.prNumber as number; - const { owner, repo } = parseRepoFullName(repoFullName); - - const message = (input.ackMessage as string | undefined) ?? INITIAL_MESSAGES['respond-to-ci']; - logWriter('INFO', 'Posting initial CI fix comment', { owner, repo, prNumber }); - await githubClient.createPRComment(owner, repo, prNumber, message); - }, -}; - -const respondToReviewProfile: AgentProfile = { - filterTools: (allTools) => filterToolsByNames(allTools, [...GITHUB_REVIEW_TOOLS, SESSION_TOOL]), - sdkTools: ALL_SDK_TOOLS, - enableStopHooks: true, - needsGitHubToken: true, - blockGitPush: false, - fetchContext: fetchPRCommentResponseContext, - buildTaskPrompt: buildPRCommentResponseTaskPrompt, - capabilities: getAgentCapabilities('respond-to-review'), - getLlmistGadgets: (_agentType) => buildPRAgentGadgets({ includeReviewComments: true }), -}; - -const respondToPRCommentProfile: AgentProfile = { - filterTools: (allTools) => filterToolsByNames(allTools, [...GITHUB_REVIEW_TOOLS, SESSION_TOOL]), - sdkTools: ALL_SDK_TOOLS, - enableStopHooks: true, - needsGitHubToken: true, - blockGitPush: false, - fetchContext: fetchPRCommentResponseContext, - buildTaskPrompt: buildPRCommentResponseTaskPrompt, - capabilities: getAgentCapabilities('respond-to-pr-comment'), - getLlmistGadgets: (_agentType) => buildPRAgentGadgets({ includeReviewComments: true }), -}; - -const defaultProfile: AgentProfile = { - filterTools: (allTools) => allTools, - sdkTools: ALL_SDK_TOOLS, - enableStopHooks: true, - needsGitHubToken: false, - fetchContext: fetchWorkItemContext, - buildTaskPrompt: buildWorkItemTaskPrompt, - capabilities: getAgentCapabilities('debug'), - getLlmistGadgets: (agentType) => buildWorkItemGadgets(getAgentCapabilities(agentType)), -}; - -const implementationProfile: AgentProfile = { - ...defaultProfile, - needsGitHubToken: true, - capabilities: getAgentCapabilities('implementation'), - getLlmistGadgets: (agentType) => buildWorkItemGadgets(getAgentCapabilities(agentType)), -}; - -// ============================================================================ -// Profile Registry +// Public API // ============================================================================ -const PROFILE_REGISTRY: Record = { - splitting: splittingProfile, - planning: planningProfile, - implementation: implementationProfile, - review: reviewProfile, - 'respond-to-planning-comment': respondToPlanningCommentProfile, - 'respond-to-review': respondToReviewProfile, - 'respond-to-pr-comment': respondToPRCommentProfile, - 'respond-to-ci': respondToCIProfile, - debug: defaultProfile, -}; - export function getAgentProfile(agentType: string): AgentProfile { - const profile = PROFILE_REGISTRY[agentType]; - if (!profile) { - throw new Error( - `Unknown agent type '${agentType}' β€” add it to PROFILE_REGISTRY in agent-profiles.ts`, - ); + let def: AgentDefinition; + try { + def = loadAgentDefinition(agentType); + } catch (err) { + throw new Error(`Failed to load agent profile for '${agentType}'`, { cause: err }); } - return profile; + return buildProfileFromDefinition(agentType, def); } diff --git a/src/backends/llmist/index.ts b/src/backends/llmist/index.ts index 2bfa5cdb..1f13d11c 100644 --- a/src/backends/llmist/index.ts +++ b/src/backends/llmist/index.ts @@ -2,6 +2,7 @@ import os from 'node:os'; import { LLMist, type ModelSpec, createLogger } from 'llmist'; +import { loadAgentDefinition } from '../../agents/definitions/index.js'; import { type BuilderType, createConfiguredBuilder } from '../../agents/shared/builderFactory.js'; import { injectSyntheticCall } from '../../agents/shared/syntheticCalls.js'; import { runAgentLoop } from '../../agents/utils/agentLoop.js'; @@ -15,6 +16,11 @@ import { extractPRUrl } from '../../utils/prUrl.js'; import { getAgentProfile } from '../agent-profiles.js'; import type { AgentBackend, AgentBackendInput, AgentBackendResult } from '../types.js'; +/** Post-configure registry: maps YAML string references to builder transform functions */ +const POST_CONFIGURE_REGISTRY: Record BuilderType> = { + sequentialGadgetExecution: (b) => b.withGadgetExecutionMode('sequential'), +}; + /** * llmist backend β€” executes agents using the llmist SDK. * @@ -107,10 +113,16 @@ export class LlmistBackend implements AgentBackend { progressMonitor: progressReporter as Parameters< typeof createConfiguredBuilder >[0]['progressMonitor'], - // Implementation agent uses sequential execution to ensure file operations - // are properly ordered (e.g., FileSearchAndReplace then ReadFile on same file) - postConfigure: - agentType === 'implementation' ? (b) => b.withGadgetExecutionMode('sequential') : undefined, + // Post-configure hook from YAML definition (e.g., sequentialGadgetExecution for implementation) + postConfigure: (() => { + try { + const def = loadAgentDefinition(agentType); + const hookName = def.backend.postConfigure; + return hookName ? POST_CONFIGURE_REGISTRY[hookName] : undefined; + } catch { + return undefined; + } + })(), }); // Convert ContextInjection[] from the unified adapter into synthetic gadget calls. diff --git a/src/backends/postProcess.ts b/src/backends/postProcess.ts index 6894fa3b..a30b8663 100644 --- a/src/backends/postProcess.ts +++ b/src/backends/postProcess.ts @@ -3,7 +3,7 @@ import { logger } from '../utils/logging.js'; import type { AgentBackend, AgentBackendResult } from './types.js'; /** - * Post-process a backend result: validate PR creation for implementation agents + * Post-process a backend result: validate PR creation for agents that require it * and zero out cost for subscription-backed Claude Code sessions. */ export function postProcessResult( @@ -12,15 +12,16 @@ export function postProcessResult( backend: AgentBackend, input: AgentInput & { project: ProjectConfig }, identifier: string, + options?: { requiresPR?: boolean }, ): void { - // Validate PR creation for implementation agents - if (agentType === 'implementation' && result.success && !result.prUrl) { - logger.warn('Implementation agent completed without creating a PR', { + // Validate PR creation for agents that require it (e.g., implementation) + if (options?.requiresPR && result.success && !result.prUrl) { + logger.warn(`${agentType} agent completed without creating a PR`, { identifier, backend: backend.name, }); result.success = false; - result.error = 'Implementation completed but no PR was created'; + result.error = 'Agent completed but no PR was created'; } // Zero out cost for subscription-backed Claude Code sessions diff --git a/src/config/agentMessages.ts b/src/config/agentMessages.ts index 127e8e41..74057286 100644 --- a/src/config/agentMessages.ts +++ b/src/config/agentMessages.ts @@ -1,3 +1,38 @@ +import { getKnownAgentTypes, loadAgentDefinition } from '../agents/definitions/index.js'; + +// ============================================================================ +// Agent Labels, Role Hints, and Initial Messages β€” derived from YAML definitions +// ============================================================================ + +function buildRecords(): { + labels: Record; + roleHints: Record; + initialMessages: Record; +} { + const labels: Record = {}; + const roleHints: Record = {}; + const initialMessages: Record = {}; + + for (const agentType of getKnownAgentTypes()) { + const def = loadAgentDefinition(agentType); + labels[agentType] = { emoji: def.identity.emoji, label: def.identity.label }; + roleHints[agentType] = def.identity.roleHint; + initialMessages[agentType] = def.identity.initialMessage; + } + + return { labels, roleHints, initialMessages }; +} + +// Eager-load at module init (YAML files are on disk, read is fast) +let labels: Record; +let roleHints: Record; +let initialMessages: Record; +try { + ({ labels, roleHints, initialMessages } = buildRecords()); +} catch (err) { + throw new Error('Failed to load agent identity records from YAML definitions', { cause: err }); +} + /** * Agent-specific emoji and label for progress update headers. * @@ -5,17 +40,7 @@ * - progressModel.ts β€” LLM prompt to produce correct header * - statusUpdateConfig.ts β€” template fallback header */ -export const AGENT_LABELS: Record = { - splitting: { emoji: 'πŸ“‹', label: 'Splitting Update' }, - planning: { emoji: 'πŸ—ΊοΈ', label: 'Planning Update' }, - implementation: { emoji: 'πŸ§‘β€πŸ’»', label: 'Implementation Update' }, - review: { emoji: 'πŸ”', label: 'Code Review Update' }, - 'respond-to-planning-comment': { emoji: 'πŸ’¬', label: 'Planning Response Update' }, - 'respond-to-review': { emoji: 'πŸ”§', label: 'Review Response Update' }, - 'respond-to-pr-comment': { emoji: 'πŸ’¬', label: 'PR Comment Response Update' }, - 'respond-to-ci': { emoji: 'πŸ”§', label: 'CI Fix Update' }, - debug: { emoji: 'πŸ›', label: 'Debug Update' }, -}; +export const AGENT_LABELS: Record = labels; /** * Get the emoji and label for a given agent type. @@ -32,17 +57,7 @@ export function getAgentLabel(agentType: string): { emoji: string; label: string * - ackMessageGenerator.ts β€” contextual acknowledgment messages * - progressModel.ts β€” progress update generation */ -export const AGENT_ROLE_HINTS: Record = { - splitting: 'Breaks down a feature plan into smaller, ordered work items (subtasks)', - planning: 'Studies the codebase and designs a step-by-step implementation plan', - implementation: 'Writes code, runs tests, and prepares a pull request', - review: 'Reviews pull request changes for quality and correctness', - 'respond-to-planning-comment': 'Reads user feedback and updates the plan accordingly', - 'respond-to-review': 'Addresses code review feedback by making requested changes', - 'respond-to-pr-comment': 'Reads a PR comment and takes action', - 'respond-to-ci': 'Analyzes failed CI checks and works on a fix', - debug: 'Analyzes session logs to identify what went wrong', -}; +export const AGENT_ROLE_HINTS: Record = roleHints; /** * Human-readable initial messages per agent type. @@ -51,20 +66,4 @@ export const AGENT_ROLE_HINTS: Record = { * - ProgressMonitor (worker-side) β€” initial comment on work item * - Router acknowledgments β€” immediate ack before worker starts */ -export const INITIAL_MESSAGES: Record = { - splitting: '**πŸ“‹ Splitting plan** β€” Reading the plan and splitting it into ordered work items...', - planning: - '**πŸ—ΊοΈ Planning implementation** β€” Studying the codebase and designing a step-by-step plan...', - implementation: - '**πŸš€ Implementing changes** β€” Writing code, running tests, and preparing a PR...', - review: '**πŸ” Reviewing code** β€” Examining the PR changes for quality and correctness...', - 'respond-to-planning-comment': - '**πŸ’¬ Responding to feedback** β€” Reading your comment and updating the plan accordingly...', - 'respond-to-review': - '**πŸ”§ Addressing review feedback** β€” Making the requested changes from the code review...', - 'respond-to-pr-comment': - '**πŸ’¬ Responding to PR comment** β€” Reading your comment and taking action...', - 'respond-to-ci': - '**πŸ”§ Fixing CI failures** β€” Analyzing the failed checks and working on a fix...', - debug: '**πŸ› Analyzing session logs** β€” Reviewing what happened and identifying issues...', -}; +export const INITIAL_MESSAGES: Record = initialMessages; diff --git a/src/config/compactionConfig.ts b/src/config/compactionConfig.ts index 9d67d655..71534a61 100644 --- a/src/config/compactionConfig.ts +++ b/src/config/compactionConfig.ts @@ -1,4 +1,5 @@ import type { CompactionConfig, CompactionEvent } from 'llmist'; +import { loadAgentDefinition } from '../agents/definitions/index.js'; import { clearReadTracking } from '../gadgets/readTracking.js'; import { logger } from '../utils/logging.js'; @@ -62,6 +63,11 @@ Format as a brief narrative, with the failed approaches as a bullet list at the Previous conversation:`, }; +const COMPACTION_PRESET_REGISTRY: Record = { + implementation: IMPLEMENTATION_COMPACTION_BASE, + default: DEFAULT_COMPACTION_BASE, +}; + /** * Handle compaction event: log and clear read tracking. * @@ -89,13 +95,21 @@ function handleCompaction(event: CompactionEvent): void { /** * Get compaction configuration for a given agent type. + * Reads the compaction preset name from the YAML definition. * * @param agentType - Type of agent (e.g., "implementation", "splitting", "planning") * @returns Compaction configuration */ export function getCompactionConfig(agentType: string): CompactionConfig { - const baseConfig = - agentType === 'implementation' ? IMPLEMENTATION_COMPACTION_BASE : DEFAULT_COMPACTION_BASE; + let presetName = 'default'; + try { + const def = loadAgentDefinition(agentType); + presetName = def.compaction; + } catch { + // Unknown agent type β€” use default preset + } + + const baseConfig = COMPACTION_PRESET_REGISTRY[presetName] ?? DEFAULT_COMPACTION_BASE; return { ...baseConfig, onCompaction: handleCompaction, diff --git a/src/config/hintConfig.ts b/src/config/hintConfig.ts index 367bcee0..be1931ea 100644 --- a/src/config/hintConfig.ts +++ b/src/config/hintConfig.ts @@ -1,5 +1,6 @@ import { execSync } from 'node:child_process'; import type { TrailingMessage } from 'llmist'; +import { loadAgentDefinition } from '../agents/definitions/index.js'; import { formatDiagnosticStatus, getDiagnosticLoopFiles, @@ -7,38 +8,20 @@ import { } from '../gadgets/shared/diagnosticState.js'; import { formatTodoList, loadTodos } from '../gadgets/todo/storage.js'; -/** - * Agent-specific batch hints. - * Each agent type gets guidance relevant to its available gadgets. - */ -const AGENT_HINTS: Record = { - // Agents with file editing capabilities - implementation: - 'Complete the current todo in as few iterations as possible. Batch related edits together. Verify with Tmux after edits. NEVER mark acceptance criteria complete without passing verification.', - 'respond-to-review': - 'Address the current review comment fully before moving to the next. Batch related file edits together.', - 'respond-to-ci': - 'Fix CI failures with minimal, focused changes. Batch related file edits together.', - - // Read-only agents - review: - 'Focus on the current aspect of review before moving to the next. Read related files together.', - splitting: 'Gather all context needed for the current step before proceeding.', - planning: 'Complete the current planning step efficiently before moving to the next.', - debug: 'Analyze the current issue fully before moving to the next.', - - // Default fallback - default: 'Complete the current task efficiently before moving to the next.', -}; - /** * Get the agent-specific hint for batch processing. + * Reads from YAML definition; falls back to a default for unknown types. */ function getAgentHint(agentType?: string): string { - if (agentType && agentType in AGENT_HINTS) { - return AGENT_HINTS[agentType]; + if (agentType) { + try { + const def = loadAgentDefinition(agentType); + return def.hint; + } catch { + // Unknown agent type β€” fall through to default + } } - return AGENT_HINTS.default; + return 'Complete the current task efficiently before moving to the next.'; } /** @@ -99,59 +82,58 @@ function formatIterationStatus( } /** - * Get trailing message function for iteration tracking. - * - * Injects iteration budget awareness into each LLM call: - * - Always shows current iteration, remaining count, and percentage - * - Adds urgency indicator when running low on iterations - * - Includes agent-specific batch processing hints - * - For implementation agent: includes current todo list for visibility - * - * Note: Loop detection warnings are injected as separate user messages - * (see agentLoop.ts) rather than in trailing messages for higher visibility. - * - * Trailing messages are ephemeral - they appear in each request but don't - * persist to conversation history, keeping context clean. - * - * @param agentType - The type of agent (e.g., 'implementation', 'review') - * @returns Trailing message function - */ -/** - * Build the trailing message for the implementation agent. - * Includes diagnostics, todo progress, git status, PR status, and reminders. + * Build the full trailing message with all optional sections. */ -function buildImplementationTrailingMessage(timestamp: string, iterationStatus: string): string { +function buildFullTrailingMessage( + timestamp: string, + iterationStatus: string, + flags: { + includeDiagnostics?: boolean; + includeTodoProgress?: boolean; + includeGitStatus?: boolean; + includePRStatus?: boolean; + includeReminder?: boolean; + }, +): string { const sections: string[] = [timestamp, iterationStatus]; - if (hasAnyDiagnosticErrors()) { + if (flags.includeDiagnostics && hasAnyDiagnosticErrors()) { sections.push(formatDiagnosticStatus()); const loopWarning = formatDiagnosticLoopWarning(); if (loopWarning) sections.push(loopWarning); } - const todos = loadTodos(); - if (todos.length > 0) { - sections.push(`## Current Progress\n\n${formatTodoList(todos)}`); + if (flags.includeTodoProgress) { + const todos = loadTodos(); + if (todos.length > 0) { + sections.push(`## Current Progress\n\n${formatTodoList(todos)}`); + } } - const gitStatus = getGitStatus(); - sections.push( - gitStatus - ? `## Git Status\n\n\`\`\`\n${gitStatus}\n\`\`\`` - : '## Git Status\n\nNo uncommitted changes.', - ); + if (flags.includeGitStatus) { + const gitStatus = getGitStatus(); + sections.push( + gitStatus + ? `## Git Status\n\n\`\`\`\n${gitStatus}\n\`\`\`` + : '## Git Status\n\nNo uncommitted changes.', + ); + } - const prView = getPRView(); - sections.push( - prView - ? `## PR Status\n\n\`\`\`\n${prView}\n\`\`\`` - : '## PR Status\n\nNo PR exists for current branch.', - ); + if (flags.includePRStatus) { + const prView = getPRView(); + sections.push( + prView + ? `## PR Status\n\n\`\`\`\n${prView}\n\`\`\`` + : '## PR Status\n\nNo PR exists for current branch.', + ); + } - sections.push( - '## Reminder\n\nCall multiple gadgets in a single response when you know which ones you need. ' + - 'For example, read multiple related files at once, or make multiple independent edits together.', - ); + if (flags.includeReminder) { + sections.push( + '## Reminder\n\nCall multiple gadgets in a single response when you know which ones you need. ' + + 'For example, read multiple related files at once, or make multiple independent edits together.', + ); + } return sections.join('\n\n'); } @@ -185,25 +167,53 @@ function formatDiagnosticLoopWarning(): string | null { return lines.join('\n'); } +/** + * Get trailing message function for iteration tracking. + * + * Injects iteration budget awareness into each LLM call: + * - Always shows current iteration, remaining count, and percentage + * - Adds urgency indicator when running low on iterations + * - Includes agent-specific batch processing hints + * - Uses YAML trailingMessage flags to decide which extra sections to include + * + * Note: Loop detection warnings are injected as separate user messages + * (see agentLoop.ts) rather than in trailing messages for higher visibility. + * + * Trailing messages are ephemeral - they appear in each request but don't + * persist to conversation history, keeping context clean. + * + * @param agentType - The type of agent (e.g., 'implementation', 'review') + * @returns Trailing message function + */ export function getIterationTrailingMessage(agentType?: string): TrailingMessage { const batchHint = getAgentHint(agentType); + // Resolve trailing message flags from YAML definition + let flags: { + includeDiagnostics?: boolean; + includeTodoProgress?: boolean; + includeGitStatus?: boolean; + includePRStatus?: boolean; + includeReminder?: boolean; + } = {}; + + if (agentType) { + try { + const def = loadAgentDefinition(agentType); + flags = def.trailingMessage ?? {}; + } catch { + // Unknown agent type β€” use empty flags (basic message only) + } + } + + const hasAnyFlag = Object.values(flags).some(Boolean); + return (ctx) => { const timestamp = `**Timestamp:** ${getCurrentTimestamp()}`; const iterationStatus = formatIterationStatus(ctx.iteration, ctx.maxIterations, batchHint); - if (agentType === 'implementation') { - return buildImplementationTrailingMessage(timestamp, iterationStatus); - } - - if ( - (agentType === 'respond-to-review' || agentType === 'respond-to-ci') && - hasAnyDiagnosticErrors() - ) { - const sections = [timestamp, iterationStatus, formatDiagnosticStatus()]; - const loopWarning = formatDiagnosticLoopWarning(); - if (loopWarning) sections.push(loopWarning); - return sections.join('\n\n'); + if (hasAnyFlag) { + return buildFullTrailingMessage(timestamp, iterationStatus, flags); } return `${timestamp}\n\n${iterationStatus}`; diff --git a/src/config/schema.ts b/src/config/schema.ts index f4323ab8..2836a4ab 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -73,6 +73,7 @@ export const ProjectConfigSchema = z.object({ .optional(), prompts: z.record(z.string()).optional(), + taskPrompts: z.record(z.string()).optional(), model: z.string().optional(), agentModels: z.record(z.string()).optional(), cardBudgetUsd: z.number().positive().optional(), @@ -97,6 +98,7 @@ export const CascadeConfigSchema = z.object({ progressModel: z.string().default('openrouter:google/gemini-2.5-flash-lite'), progressIntervalMinutes: z.number().positive().default(5), prompts: z.record(z.string()).default({}), + taskPrompts: z.record(z.string()).default({}), }) .default({}), projects: z.array(ProjectConfigSchema).min(1), diff --git a/src/db/migrations/0016_add_task_prompt_column.sql b/src/db/migrations/0016_add_task_prompt_column.sql new file mode 100644 index 00000000..b96c0dbe --- /dev/null +++ b/src/db/migrations/0016_add_task_prompt_column.sql @@ -0,0 +1 @@ +ALTER TABLE "agent_configs" ADD COLUMN IF NOT EXISTS "task_prompt" TEXT; \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 9dd14544..f2e0c398 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1750000000000, "tag": "0015_rename_briefing_to_splitting", "breakpoints": false + }, + { + "idx": 16, + "version": "7", + "when": 1751000000000, + "tag": "0016_add_task_prompt_column", + "breakpoints": false } ] } diff --git a/src/db/repositories/configMapper.ts b/src/db/repositories/configMapper.ts index b85ef3d6..398f11fa 100644 --- a/src/db/repositories/configMapper.ts +++ b/src/db/repositories/configMapper.ts @@ -51,6 +51,7 @@ export interface AgentConfigRow { maxIterations: number | null; agentBackend: string | null; prompt: string | null; + taskPrompt: string | null; } export interface IntegrationRow { @@ -89,6 +90,7 @@ export interface ProjectConfigRaw { branchPrefix: string; pm: { type: string }; prompts?: Record; + taskPrompts?: Record; model?: string; agentModels?: Record; cardBudgetUsd?: number; @@ -139,19 +141,22 @@ export function buildAgentMaps(configs: AgentConfigRow[]): { models: Record; iterations: Record; prompts: Record; + taskPrompts: Record; backends: Record; } { const models: Record = {}; const iterations: Record = {}; const prompts: Record = {}; + const taskPrompts: Record = {}; const backends: Record = {}; for (const ac of configs) { if (ac.model) models[ac.agentType] = ac.model; if (ac.maxIterations != null) iterations[ac.agentType] = ac.maxIterations; if (ac.prompt) prompts[ac.agentType] = ac.prompt; + if (ac.taskPrompt) taskPrompts[ac.agentType] = ac.taskPrompt; if (ac.agentBackend) backends[ac.agentType] = ac.agentBackend; } - return { models, iterations, prompts, backends }; + return { models, iterations, prompts, taskPrompts, backends }; } export function orUndefined>(obj: T): T | undefined { @@ -206,7 +211,7 @@ export function mapDefaultsRow( row: DefaultsRow | undefined, globalAgentConfigs: AgentConfigRow[], ): Record { - const { models, iterations, prompts } = buildAgentMaps(globalAgentConfigs); + const { models, iterations, prompts, taskPrompts } = buildAgentMaps(globalAgentConfigs); return { model: row?.model ?? undefined, @@ -221,6 +226,7 @@ export function mapDefaultsRow( ? Number(row.progressIntervalMinutes) : undefined, prompts: orUndefined(prompts), + taskPrompts: orUndefined(taskPrompts), }; } @@ -255,7 +261,7 @@ export function mapProjectRow({ jiraTriggers, githubTriggers, }: MapProjectInput): ProjectConfigRaw { - const { models, prompts, backends } = buildAgentMaps(projectAgentConfigs); + const { models, prompts, taskPrompts, backends } = buildAgentMaps(projectAgentConfigs); // Derive PM type from integration config const pmType = jiraConfig ? 'jira' : 'trello'; @@ -269,6 +275,7 @@ export function mapProjectRow({ branchPrefix: row.branchPrefix ?? 'feature/', pm: { type: pmType }, prompts: orUndefined(prompts), + taskPrompts: orUndefined(taskPrompts), model: row.model ?? undefined, agentModels: orUndefined(models), cardBudgetUsd: row.cardBudgetUsd ? Number(row.cardBudgetUsd) : undefined, diff --git a/src/db/schema/agentConfigs.ts b/src/db/schema/agentConfigs.ts index 4bcbf9ea..3ae16368 100644 --- a/src/db/schema/agentConfigs.ts +++ b/src/db/schema/agentConfigs.ts @@ -13,6 +13,7 @@ export const agentConfigs = pgTable( maxIterations: integer('max_iterations'), agentBackend: text('agent_backend'), prompt: text('prompt'), + taskPrompt: text('task_prompt'), createdAt: timestamp('created_at').defaultNow(), updatedAt: timestamp('updated_at') .defaultNow() diff --git a/tests/unit/agents/definitions/loader.test.ts b/tests/unit/agents/definitions/loader.test.ts new file mode 100644 index 00000000..e8ecfe37 --- /dev/null +++ b/tests/unit/agents/definitions/loader.test.ts @@ -0,0 +1,362 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + clearDefinitionCache, + getKnownAgentTypes, + loadAgentDefinition, + loadAllAgentDefinitions, +} from '../../../../src/agents/definitions/loader.js'; +import { + CONTEXT_STEP_REGISTRY, + GADGET_BUILDER_REGISTRY, + SDK_TOOLS_REGISTRY, + TOOL_SET_REGISTRY, +} from '../../../../src/agents/definitions/strategies.js'; +import { getAgentCapabilities } from '../../../../src/agents/shared/capabilities.js'; + +const ALL_AGENT_TYPES = [ + 'debug', + 'implementation', + 'planning', + 'respond-to-ci', + 'respond-to-planning-comment', + 'respond-to-pr-comment', + 'respond-to-review', + 'review', + 'splitting', +]; + +describe('YAML agent definitions loader', () => { + afterEach(() => { + clearDefinitionCache(); + }); + + describe('getKnownAgentTypes', () => { + it('discovers all 9 agent types from YAML files', () => { + const types = getKnownAgentTypes(); + expect(types).toEqual(ALL_AGENT_TYPES); + }); + }); + + describe('loadAgentDefinition', () => { + it('loads and parses each agent definition without error', () => { + for (const agentType of ALL_AGENT_TYPES) { + expect(() => loadAgentDefinition(agentType)).not.toThrow(); + } + }); + + it('throws for unknown agent type', () => { + expect(() => loadAgentDefinition('nonexistent-agent')).toThrow('Agent definition not found'); + }); + + it('caches parsed definitions', () => { + const first = loadAgentDefinition('implementation'); + const second = loadAgentDefinition('implementation'); + expect(first).toBe(second); + }); + + it('returns fresh results after cache clear', () => { + const first = loadAgentDefinition('implementation'); + clearDefinitionCache(); + const second = loadAgentDefinition('implementation'); + expect(first).not.toBe(second); + expect(first).toEqual(second); + }); + }); + + describe('loadAllAgentDefinitions', () => { + it('returns a map with all 9 agent types', () => { + const all = loadAllAgentDefinitions(); + expect(all.size).toBe(9); + for (const agentType of ALL_AGENT_TYPES) { + expect(all.has(agentType)).toBe(true); + } + }); + }); + + describe('strategy references resolve correctly', () => { + it('all tool set references exist in TOOL_SET_REGISTRY', () => { + for (const agentType of ALL_AGENT_TYPES) { + const def = loadAgentDefinition(agentType); + for (const setName of def.tools.sets) { + expect( + setName === 'all' || setName in TOOL_SET_REGISTRY, + `${agentType}: tool set '${setName}' not in TOOL_SET_REGISTRY`, + ).toBe(true); + } + } + }); + + it('all sdkTools references exist in SDK_TOOLS_REGISTRY', () => { + for (const agentType of ALL_AGENT_TYPES) { + const def = loadAgentDefinition(agentType); + expect( + def.tools.sdkTools in SDK_TOOLS_REGISTRY, + `${agentType}: sdkTools '${def.tools.sdkTools}' not in SDK_TOOLS_REGISTRY`, + ).toBe(true); + } + }); + + it('all gadgetBuilder references exist in GADGET_BUILDER_REGISTRY', () => { + for (const agentType of ALL_AGENT_TYPES) { + const def = loadAgentDefinition(agentType); + expect( + def.strategies.gadgetBuilder in GADGET_BUILDER_REGISTRY, + `${agentType}: gadgetBuilder '${def.strategies.gadgetBuilder}' not in GADGET_BUILDER_REGISTRY`, + ).toBe(true); + } + }); + + it('all contextPipeline step references exist in CONTEXT_STEP_REGISTRY', () => { + for (const agentType of ALL_AGENT_TYPES) { + const def = loadAgentDefinition(agentType); + for (const step of def.strategies.contextPipeline) { + expect( + step in CONTEXT_STEP_REGISTRY, + `${agentType}: contextPipeline step '${step}' not in CONTEXT_STEP_REGISTRY`, + ).toBe(true); + } + } + }); + + it('all taskPromptBuilder values correspond to .eta template files', () => { + const { readdirSync } = require('node:fs'); + const { join, dirname } = require('node:path'); + const { fileURLToPath } = require('node:url'); + const taskTemplatesDir = join( + dirname(fileURLToPath(import.meta.url)), + '../../../../src/agents/prompts/task-templates', + ); + const templateFiles = new Set( + readdirSync(taskTemplatesDir) + .filter((f: string) => f.endsWith('.eta')) + .map((f: string) => f.replace(/\.eta$/, '')), + ); + + for (const agentType of ALL_AGENT_TYPES) { + const def = loadAgentDefinition(agentType); + expect( + templateFiles.has(def.strategies.taskPromptBuilder), + `${agentType}: taskPromptBuilder '${def.strategies.taskPromptBuilder}' has no matching .eta template file`, + ).toBe(true); + } + }); + }); + + describe('definition content spot checks', () => { + it('implementation has implementation compaction preset', () => { + const def = loadAgentDefinition('implementation'); + expect(def.compaction).toBe('implementation'); + }); + + it('implementation has postConfigure hook', () => { + const def = loadAgentDefinition('implementation'); + expect(def.backend.postConfigure).toBe('sequentialGadgetExecution'); + }); + + it('implementation has requiresPR flag', () => { + const def = loadAgentDefinition('implementation'); + expect(def.backend.requiresPR).toBe(true); + }); + + it('non-implementation agents do not have requiresPR', () => { + for (const agentType of ALL_AGENT_TYPES.filter((t) => t !== 'implementation')) { + const def = loadAgentDefinition(agentType); + expect(def.backend.requiresPR).toBeUndefined(); + } + }); + + it('work-item agents use standard context pipeline', () => { + const workItemAgents = ['implementation', 'splitting', 'planning', 'debug']; + for (const agentType of workItemAgents) { + const def = loadAgentDefinition(agentType); + expect(def.strategies.contextPipeline).toEqual([ + 'directoryListing', + 'contextFiles', + 'squint', + 'workItem', + ]); + } + }); + + it('review agent uses PR context pipeline without directoryListing', () => { + const def = loadAgentDefinition('review'); + expect(def.strategies.contextPipeline).toEqual(['prContext', 'contextFiles', 'squint']); + }); + + it('respond-to-ci uses combined PR + work-item pipeline', () => { + const def = loadAgentDefinition('respond-to-ci'); + expect(def.strategies.contextPipeline).toEqual([ + 'prContext', + 'directoryListing', + 'contextFiles', + 'squint', + 'workItem', + ]); + }); + + it('PR comment agents use conversation pipeline', () => { + const prCommentAgents = ['respond-to-review', 'respond-to-pr-comment']; + for (const agentType of prCommentAgents) { + const def = loadAgentDefinition(agentType); + expect(def.strategies.contextPipeline).toEqual([ + 'prContext', + 'prConversation', + 'directoryListing', + 'contextFiles', + 'squint', + ]); + } + }); + + it('review has preExecute hook', () => { + const def = loadAgentDefinition('review'); + expect(def.backend.preExecute).toBe('postInitialPRComment'); + }); + + it('respond-to-ci has preExecute hook', () => { + const def = loadAgentDefinition('respond-to-ci'); + expect(def.backend.preExecute).toBe('postInitialPRComment'); + }); + + it('planning is readOnly', () => { + const def = loadAgentDefinition('planning'); + expect(def.capabilities.isReadOnly).toBe(true); + expect(def.capabilities.canEditFiles).toBe(false); + expect(def.tools.sdkTools).toBe('readOnly'); + }); + + it('implementation has trailingMessage with all flags', () => { + const def = loadAgentDefinition('implementation'); + expect(def.trailingMessage).toEqual({ + includeDiagnostics: true, + includeTodoProgress: true, + includeGitStatus: true, + includePRStatus: true, + includeReminder: true, + }); + }); + + it('respond-to-review has diagnostics-only trailingMessage', () => { + const def = loadAgentDefinition('respond-to-review'); + expect(def.trailingMessage).toEqual({ + includeDiagnostics: true, + }); + }); + + it('respond-to-ci has diagnostics-only trailingMessage', () => { + const def = loadAgentDefinition('respond-to-ci'); + expect(def.trailingMessage).toEqual({ + includeDiagnostics: true, + }); + }); + + it('splitting has no trailingMessage', () => { + const def = loadAgentDefinition('splitting'); + expect(def.trailingMessage).toBeUndefined(); + }); + + it('respond-to-review includes review comment gadget options', () => { + const def = loadAgentDefinition('respond-to-review'); + expect(def.strategies.gadgetBuilderOptions).toEqual({ includeReviewComments: true }); + }); + + it('respond-to-pr-comment includes review comment gadget options', () => { + const def = loadAgentDefinition('respond-to-pr-comment'); + expect(def.strategies.gadgetBuilderOptions).toEqual({ includeReviewComments: true }); + }); + + it('debug uses "all" tool set', () => { + const def = loadAgentDefinition('debug'); + expect(def.tools.sets).toContain('all'); + }); + + it('all agents have non-empty identity fields', () => { + for (const agentType of ALL_AGENT_TYPES) { + const def = loadAgentDefinition(agentType); + expect(def.identity.emoji.length).toBeGreaterThan(0); + expect(def.identity.label.length).toBeGreaterThan(0); + expect(def.identity.roleHint.length).toBeGreaterThan(0); + expect(def.identity.initialMessage.length).toBeGreaterThan(0); + } + }); + + it('all agents have non-empty hints', () => { + for (const agentType of ALL_AGENT_TYPES) { + const def = loadAgentDefinition(agentType); + expect(def.hint.length).toBeGreaterThan(0); + } + }); + }); + + describe('roundtrip: YAML definition β†’ profile properties', () => { + it('implementation agent has full capabilities and stop hooks', () => { + const def = loadAgentDefinition('implementation'); + const caps = getAgentCapabilities('implementation'); + + expect(caps.canEditFiles).toBe(true); + expect(caps.canCreatePR).toBe(true); + expect(caps.canUpdateChecklists).toBe(true); + expect(caps.isReadOnly).toBe(false); + expect(def.backend.enableStopHooks).toBe(true); + expect(def.backend.needsGitHubToken).toBe(true); + expect(def.backend.preExecute).toBeUndefined(); + expect(def.backend.postConfigure).toBe('sequentialGadgetExecution'); + expect(SDK_TOOLS_REGISTRY[def.tools.sdkTools]).toBeDefined(); + }); + + it('review agent is read-only with preExecute hook', () => { + const def = loadAgentDefinition('review'); + const caps = getAgentCapabilities('review'); + + expect(caps.canEditFiles).toBe(false); + expect(caps.isReadOnly).toBe(true); + expect(def.backend.enableStopHooks).toBe(false); + expect(def.backend.needsGitHubToken).toBe(true); + expect(def.backend.preExecute).toBe('postInitialPRComment'); + }); + + it('respond-to-ci agent has preExecute and needsGitHubToken', () => { + const def = loadAgentDefinition('respond-to-ci'); + const caps = getAgentCapabilities('respond-to-ci'); + + expect(caps.canEditFiles).toBe(true); + expect(def.backend.needsGitHubToken).toBe(true); + expect(def.backend.preExecute).toBe('postInitialPRComment'); + }); + + it('all agent sdkTools references resolve to non-empty arrays', () => { + for (const agentType of ALL_AGENT_TYPES) { + const def = loadAgentDefinition(agentType); + const sdkTools = SDK_TOOLS_REGISTRY[def.tools.sdkTools]; + expect( + Array.isArray(sdkTools) && sdkTools.length > 0, + `${agentType}: sdkTools '${def.tools.sdkTools}' resolved to empty or non-array`, + ).toBe(true); + } + }); + + it('capabilities from getAgentCapabilities match YAML definition for all agents', () => { + for (const agentType of ALL_AGENT_TYPES) { + const def = loadAgentDefinition(agentType); + const caps = getAgentCapabilities(agentType); + + expect(caps.canEditFiles).toBe(def.capabilities.canEditFiles); + expect(caps.canCreatePR).toBe(def.capabilities.canCreatePR); + expect(caps.canUpdateChecklists).toBe(def.capabilities.canUpdateChecklists); + expect(caps.isReadOnly).toBe(def.capabilities.isReadOnly); + } + }); + }); + + describe('unknown agent type fallbacks', () => { + it('getAgentCapabilities returns full-access defaults for unknown type', () => { + const caps = getAgentCapabilities('nonexistent-agent-type'); + expect(caps).toEqual({ + canEditFiles: true, + canCreatePR: true, + canUpdateChecklists: true, + isReadOnly: false, + }); + }); + }); +}); diff --git a/tests/unit/agents/definitions/schema.test.ts b/tests/unit/agents/definitions/schema.test.ts new file mode 100644 index 00000000..a7e4327b --- /dev/null +++ b/tests/unit/agents/definitions/schema.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest'; +import { AgentDefinitionSchema } from '../../../../src/agents/definitions/schema.js'; + +describe('AgentDefinitionSchema', () => { + const validDefinition = { + identity: { + emoji: 'πŸ”§', + label: 'Test Agent', + roleHint: 'Does test things', + initialMessage: '**πŸ”§ Testing** β€” Running tests...', + }, + capabilities: { + canEditFiles: true, + canCreatePR: false, + canUpdateChecklists: true, + isReadOnly: false, + }, + tools: { + sets: ['pm', 'session'], + sdkTools: 'all', + }, + strategies: { + contextPipeline: ['directoryListing', 'contextFiles', 'squint', 'workItem'], + taskPromptBuilder: 'workItem', + gadgetBuilder: 'workItem', + }, + backend: { + enableStopHooks: false, + needsGitHubToken: false, + }, + compaction: 'default', + hint: 'Do the thing efficiently.', + }; + + it('parses a valid minimal definition', () => { + const result = AgentDefinitionSchema.safeParse(validDefinition); + expect(result.success).toBe(true); + }); + + it('parses a definition with all optional fields', () => { + const full = { + ...validDefinition, + strategies: { + ...validDefinition.strategies, + gadgetBuilderOptions: { includeReviewComments: true }, + }, + backend: { + ...validDefinition.backend, + blockGitPush: false, + preExecute: 'postInitialPRComment', + postConfigure: 'sequentialGadgetExecution', + }, + trailingMessage: { + includeDiagnostics: true, + includeTodoProgress: true, + includeGitStatus: true, + includePRStatus: true, + includeReminder: true, + }, + }; + + const result = AgentDefinitionSchema.safeParse(full); + expect(result.success).toBe(true); + }); + + it('rejects missing required fields', () => { + const { identity: _, ...missing } = validDefinition; + const result = AgentDefinitionSchema.safeParse(missing); + expect(result.success).toBe(false); + }); + + it('rejects invalid tool set names', () => { + const bad = { + ...validDefinition, + tools: { sets: ['invalid_set'], sdkTools: 'all' }, + }; + const result = AgentDefinitionSchema.safeParse(bad); + expect(result.success).toBe(false); + }); + + it('rejects invalid sdkTools values', () => { + const bad = { + ...validDefinition, + tools: { sets: ['pm'], sdkTools: 'invalid' }, + }; + const result = AgentDefinitionSchema.safeParse(bad); + expect(result.success).toBe(false); + }); + + it('rejects invalid strategy names', () => { + const bad = { + ...validDefinition, + strategies: { ...validDefinition.strategies, contextPipeline: ['nonexistentStep'] }, + }; + const result = AgentDefinitionSchema.safeParse(bad); + expect(result.success).toBe(false); + }); + + it('rejects invalid compaction preset names', () => { + const bad = { ...validDefinition, compaction: 'aggressive' }; + const result = AgentDefinitionSchema.safeParse(bad); + expect(result.success).toBe(false); + }); + + it('allows trailingMessage to be omitted', () => { + const result = AgentDefinitionSchema.safeParse(validDefinition); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.trailingMessage).toBeUndefined(); + } + }); + + it('rejects invalid preExecute hook names', () => { + const bad = { + ...validDefinition, + backend: { ...validDefinition.backend, preExecute: 'typoInHookName' }, + }; + const result = AgentDefinitionSchema.safeParse(bad); + expect(result.success).toBe(false); + }); + + it('accepts valid preExecute hook name', () => { + const good = { + ...validDefinition, + backend: { ...validDefinition.backend, preExecute: 'postInitialPRComment' }, + }; + const result = AgentDefinitionSchema.safeParse(good); + expect(result.success).toBe(true); + }); + + it('rejects invalid postConfigure hook names', () => { + const bad = { + ...validDefinition, + backend: { ...validDefinition.backend, postConfigure: 'nonexistentHook' }, + }; + const result = AgentDefinitionSchema.safeParse(bad); + expect(result.success).toBe(false); + }); + + it('accepts valid postConfigure hook name', () => { + const good = { + ...validDefinition, + backend: { ...validDefinition.backend, postConfigure: 'sequentialGadgetExecution' }, + }; + const result = AgentDefinitionSchema.safeParse(good); + expect(result.success).toBe(true); + }); + + it('accepts requiresPR boolean', () => { + const good = { + ...validDefinition, + backend: { ...validDefinition.backend, requiresPR: true }, + }; + const result = AgentDefinitionSchema.safeParse(good); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.backend.requiresPR).toBe(true); + } + }); + + it('allows requiresPR to be omitted', () => { + const result = AgentDefinitionSchema.safeParse(validDefinition); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.backend.requiresPR).toBeUndefined(); + } + }); + + it('validates contextPipeline step names', () => { + const good = { + ...validDefinition, + strategies: { + ...validDefinition.strategies, + contextPipeline: ['prContext', 'prConversation', 'directoryListing'], + }, + }; + const result = AgentDefinitionSchema.safeParse(good); + expect(result.success).toBe(true); + }); +}); diff --git a/tests/unit/agents/shared/modelResolution.test.ts b/tests/unit/agents/shared/modelResolution.test.ts index e22da634..e968d539 100644 --- a/tests/unit/agents/shared/modelResolution.test.ts +++ b/tests/unit/agents/shared/modelResolution.test.ts @@ -210,6 +210,117 @@ describe('resolveModelConfig', () => { }); }); + describe('task prompt override resolution', () => { + it('returns undefined taskPrompt when no override configured', async () => { + const result = await resolveModelConfig({ + agentType: 'splitting', + project: makeProject(), + config: makeConfig(), + repoDir: '/tmp/test', + }); + + expect(result.taskPrompt).toBeUndefined(); + }); + + it('renders project-level task prompt override', async () => { + const project = makeProject({ + taskPrompts: { splitting: 'Custom task for <%= it.cardId %>.' }, + }); + + const result = await resolveModelConfig({ + agentType: 'splitting', + project, + config: makeConfig(), + repoDir: '/tmp/test', + agentInput: { cardId: 'card-42' }, + }); + + expect(result.taskPrompt).toBe('Custom task for card-42.'); + }); + + it('renders task-specific variables from agentInput', async () => { + const project = makeProject({ + taskPrompts: { + 'respond-to-planning-comment': + 'Comment by @<%= it.commentAuthor %>: <%= it.commentText %>', + }, + }); + + const result = await resolveModelConfig({ + agentType: 'respond-to-planning-comment', + project, + config: makeConfig(), + repoDir: '/tmp/test', + agentInput: { + triggerCommentText: 'Add more tests', + triggerCommentAuthor: 'alice', + }, + }); + + expect(result.taskPrompt).toBe('Comment by @alice: Add more tests'); + }); + + it('renders PR-specific variables from agentInput in task prompt override', async () => { + const project = makeProject({ + taskPrompts: { + 'respond-to-pr-comment': + 'PR #<%= it.prNumber %>, file: <%= it.commentPath %>, body: <%= it.commentBody %>', + }, + }); + + const result = await resolveModelConfig({ + agentType: 'respond-to-pr-comment', + project, + config: makeConfig(), + repoDir: '/tmp/test', + agentInput: { + prNumber: 55, + triggerCommentBody: 'Fix this line', + triggerCommentPath: 'src/utils.ts', + }, + promptContext: { prNumber: 55 }, + }); + + expect(result.taskPrompt).toContain('PR #55'); + expect(result.taskPrompt).toContain('src/utils.ts'); + expect(result.taskPrompt).toContain('Fix this line'); + }); + + it('uses defaults-level task prompt when no project override', async () => { + const config = makeConfig({ + taskPrompts: { splitting: 'Default task prompt for <%= it.cardId %>.' }, + }); + + const result = await resolveModelConfig({ + agentType: 'splitting', + project: makeProject(), + config, + repoDir: '/tmp/test', + agentInput: { cardId: 'card-99' }, + }); + + expect(result.taskPrompt).toBe('Default task prompt for card-99.'); + }); + + it('prefers project task prompt over defaults', async () => { + const project = makeProject({ + taskPrompts: { splitting: 'Project task prompt.' }, + }); + const config = makeConfig({ + taskPrompts: { splitting: 'Defaults task prompt.' }, + }); + + const result = await resolveModelConfig({ + agentType: 'splitting', + project, + config, + repoDir: '/tmp/test', + }); + + expect(result.taskPrompt).toBe('Project task prompt.'); + }); + }); + describe('iterations resolution', () => { it('uses default maxIterations', async () => { const result = await resolveModelConfig({ diff --git a/tests/unit/agents/shared/taskPrompts.test.ts b/tests/unit/agents/shared/taskPrompts.test.ts index 7a484e18..a82435c4 100644 --- a/tests/unit/agents/shared/taskPrompts.test.ts +++ b/tests/unit/agents/shared/taskPrompts.test.ts @@ -1,117 +1,183 @@ import { describe, expect, it } from 'vitest'; +import { renderCustomPrompt, renderTaskPrompt } from '../../../../src/agents/prompts/index.js'; import { - buildCIResponsePrompt, buildCheckFailurePrompt, - buildCommentResponsePrompt, buildDebugPrompt, - buildPRCommentResponsePrompt, - buildReviewPrompt, - buildWorkItemPrompt, } from '../../../../src/agents/shared/taskPrompts.js'; -describe('buildWorkItemPrompt', () => { +// ============================================================================ +// .eta task prompt template tests (replaces the old TS function tests) +// ============================================================================ + +describe('workItem task template', () => { it('includes the card ID', () => { - const prompt = buildWorkItemPrompt('abc123'); + const prompt = renderTaskPrompt('workItem', { cardId: 'abc123' }); expect(prompt).toContain('abc123'); }); it('asks the agent to process the work item', () => { - const prompt = buildWorkItemPrompt('card-99'); + const prompt = renderTaskPrompt('workItem', { cardId: 'card-99' }); expect(prompt).toContain('work item'); }); }); -describe('buildCommentResponsePrompt', () => { +describe('commentResponse task template', () => { it('includes card ID, comment text, and author', () => { - const prompt = buildCommentResponsePrompt('card-42', 'Please add tests', 'alice'); + const prompt = renderTaskPrompt('commentResponse', { + cardId: 'card-42', + commentText: 'Please add tests', + commentAuthor: 'alice', + }); expect(prompt).toContain('card-42'); expect(prompt).toContain('Please add tests'); expect(prompt).toContain('@alice'); }); it('instructs surgical updates for plan changes', () => { - const prompt = buildCommentResponsePrompt('card-1', 'Fix the typo', 'bob'); + const prompt = renderTaskPrompt('commentResponse', { + cardId: 'card-1', + commentText: 'Fix the typo', + commentAuthor: 'bob', + }); expect(prompt).toContain('surgical'); }); it('mentions that work item data is pre-loaded', () => { - const prompt = buildCommentResponsePrompt('card-1', 'Update docs', 'carol'); + const prompt = renderTaskPrompt('commentResponse', { + cardId: 'card-1', + commentText: 'Update docs', + commentAuthor: 'carol', + }); expect(prompt).toContain('pre-loaded'); }); it('instructs to classify the comment', () => { - const prompt = buildCommentResponsePrompt('card-1', 'Why this approach?', 'dave'); + const prompt = renderTaskPrompt('commentResponse', { + cardId: 'card-1', + commentText: 'Why this approach?', + commentAuthor: 'dave', + }); expect(prompt).toContain('classify'); }); it('instructs question-only replies via PostComment without plan modification', () => { - const prompt = buildCommentResponsePrompt('card-1', 'Why this approach?', 'dave'); + const prompt = renderTaskPrompt('commentResponse', { + cardId: 'card-1', + commentText: 'Why this approach?', + commentAuthor: 'dave', + }); expect(prompt).toContain('question'); expect(prompt).toContain('PostComment'); expect(prompt).toContain('do not modify the plan'); }); it('defaults to plan updates when intent is ambiguous', () => { - const prompt = buildCommentResponsePrompt('card-1', 'Some comment', 'eve'); + const prompt = renderTaskPrompt('commentResponse', { + cardId: 'card-1', + commentText: 'Some comment', + commentAuthor: 'eve', + }); expect(prompt).toContain('Default to plan updates when intent is ambiguous'); }); }); -describe('buildReviewPrompt', () => { +describe('review task template', () => { it('includes the PR number', () => { - const prompt = buildReviewPrompt(42); + const prompt = renderTaskPrompt('review', { prNumber: 42 }); expect(prompt).toContain('PR #42'); }); it('instructs to use CreatePRReview', () => { - const prompt = buildReviewPrompt(7); + const prompt = renderTaskPrompt('review', { prNumber: 7 }); expect(prompt).toContain('CreatePRReview'); }); }); -describe('buildCIResponsePrompt', () => { +describe('ci task template', () => { it('includes branch and PR number', () => { - const prompt = buildCIResponsePrompt('fix/ci-errors', 99); + const prompt = renderTaskPrompt('ci', { prBranch: 'fix/ci-errors', prNumber: 99 }); expect(prompt).toContain('fix/ci-errors'); expect(prompt).toContain('PR #99'); }); it('mentions CI checks have failed', () => { - const prompt = buildCIResponsePrompt('main', 1); + const prompt = renderTaskPrompt('ci', { prBranch: 'main', prNumber: 1 }); expect(prompt).toContain('CI checks have failed'); }); }); -describe('buildPRCommentResponsePrompt', () => { +describe('prCommentResponse task template', () => { it('includes PR number, branch, and comment body', () => { - const prompt = buildPRCommentResponsePrompt('feat/new', 55, 'Can you fix the typo?'); + const prompt = renderTaskPrompt('prCommentResponse', { + prBranch: 'feat/new', + prNumber: 55, + commentBody: 'Can you fix the typo?', + }); expect(prompt).toContain('PR #55'); expect(prompt).toContain('feat/new'); expect(prompt).toContain('Can you fix the typo?'); }); it('includes file path when provided', () => { - const prompt = buildPRCommentResponsePrompt('feat/new', 55, 'Fix this line', 'src/utils.ts'); + const prompt = renderTaskPrompt('prCommentResponse', { + prBranch: 'feat/new', + prNumber: 55, + commentBody: 'Fix this line', + commentPath: 'src/utils.ts', + }); expect(prompt).toContain('src/utils.ts'); }); it('omits file path when not provided', () => { - const prompt = buildPRCommentResponsePrompt('feat/new', 55, 'Looks good overall!'); + const prompt = renderTaskPrompt('prCommentResponse', { + prBranch: 'feat/new', + prNumber: 55, + commentBody: 'Looks good overall!', + }); expect(prompt).not.toContain('File:'); }); it('omits file path when empty string provided', () => { - const prompt = buildPRCommentResponsePrompt('feat/new', 55, 'LGTM', ''); + const prompt = renderTaskPrompt('prCommentResponse', { + prBranch: 'feat/new', + prNumber: 55, + commentBody: 'LGTM', + commentPath: '', + }); expect(prompt).not.toContain('File:'); }); it('instructs surgical changes by default', () => { - const prompt = buildPRCommentResponsePrompt('main', 1, 'Please refactor'); + const prompt = renderTaskPrompt('prCommentResponse', { + prBranch: 'main', + prNumber: 1, + commentBody: 'Please refactor', + }); expect(prompt).toContain('surgical'); }); }); +// ============================================================================ +// Edge cases: DB partials and error handling +// ============================================================================ + +describe('renderTaskPrompt edge cases', () => { + it('renders DB task prompt override with partials via renderCustomPrompt', () => { + const dbPartials = new Map([['custom', 'DB partial content']]); + const result = renderCustomPrompt('Task: <%~ include("partials/custom") %>', {}, dbPartials); + expect(result).toContain('DB partial content'); + }); + + it('throws for nonexistent template name', () => { + expect(() => renderTaskPrompt('nonexistent-template', {})).toThrow(); + }); +}); + +// ============================================================================ +// Direct-call prompts (not part of YAML profile system) +// ============================================================================ + describe('buildCheckFailurePrompt', () => { const prContext = { prNumber: 33, diff --git a/tests/unit/backends/adapter.test.ts b/tests/unit/backends/adapter.test.ts index 2dedc413..bea8ec96 100644 --- a/tests/unit/backends/adapter.test.ts +++ b/tests/unit/backends/adapter.test.ts @@ -424,6 +424,7 @@ describe('executeWithBackend', () => { it('marks implementation agent as failed when no PR was created', async () => { setupMocks(); + mockGetAgentProfile.mockReturnValue(makeMockProfile({ requiresPR: true })); const backend = makeMockBackend(); vi.mocked(backend.execute).mockResolvedValue({ success: true, @@ -435,9 +436,9 @@ describe('executeWithBackend', () => { const result = await executeWithBackend(backend, 'implementation', input); expect(result.success).toBe(false); - expect(result.error).toBe('Implementation completed but no PR was created'); + expect(result.error).toBe('Agent completed but no PR was created'); expect(logger.warn).toHaveBeenCalledWith( - 'Implementation agent completed without creating a PR', + 'implementation agent completed without creating a PR', expect.objectContaining({ backend: 'test-backend' }), ); }); diff --git a/tests/unit/backends/agent-profiles.test.ts b/tests/unit/backends/agent-profiles.test.ts index 733c111f..0815d094 100644 --- a/tests/unit/backends/agent-profiles.test.ts +++ b/tests/unit/backends/agent-profiles.test.ts @@ -424,7 +424,7 @@ describe('getAgentProfile', () => { it('throws for unknown agent types', () => { expect(() => getAgentProfile('nonexistent-agent')).toThrow( - "Unknown agent type 'nonexistent-agent'", + "Failed to load agent profile for 'nonexistent-agent'", ); }); diff --git a/tests/unit/backends/llmist.test.ts b/tests/unit/backends/llmist.test.ts index 49416fc8..ea7ece72 100644 --- a/tests/unit/backends/llmist.test.ts +++ b/tests/unit/backends/llmist.test.ts @@ -4,7 +4,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('llmist', () => ({ LLMist: vi.fn().mockImplementation(() => ({})), createLogger: vi.fn(() => ({})), - type: undefined, +})); + +// Mock agents/definitions to break the circular dependency chain: +// backends/llmist β†’ definitions β†’ strategies β†’ gadgets β†’ pm/ β†’ webhook-handler +// β†’ triggers/agent-execution β†’ agents/registry β†’ new LlmistBackend() (still loading) +vi.mock('../../../src/agents/definitions/index.js', () => ({ + loadAgentDefinition: vi.fn(() => ({ backend: {} })), })); vi.mock('../../../src/backends/agent-profiles.js', () => ({ diff --git a/tests/unit/backends/postProcess.test.ts b/tests/unit/backends/postProcess.test.ts index 263c3d28..61ddc086 100644 --- a/tests/unit/backends/postProcess.test.ts +++ b/tests/unit/backends/postProcess.test.ts @@ -49,55 +49,63 @@ function makeInput(overrides?: Partial): AgentInput & { project: } describe('postProcessResult', () => { - describe('PR validation for implementation agents', () => { - it('marks as failed when implementation agent succeeds without prUrl', () => { + describe('PR validation for agents with requiresPR', () => { + it('marks as failed when requiresPR agent succeeds without prUrl', () => { const result = makeResult({ success: true, prUrl: undefined }); const backend = makeBackend(); const input = makeInput(); - postProcessResult(result, 'implementation', backend, input, 'implementation-card-123'); + postProcessResult(result, 'implementation', backend, input, 'implementation-card-123', { + requiresPR: true, + }); expect(result.success).toBe(false); - expect(result.error).toBe('Implementation completed but no PR was created'); + expect(result.error).toBe('Agent completed but no PR was created'); }); - it('logs warning when implementation agent succeeds without prUrl', () => { + it('logs warning when requiresPR agent succeeds without prUrl', () => { const result = makeResult({ success: true, prUrl: undefined }); const backend = makeBackend('my-backend'); const input = makeInput(); - postProcessResult(result, 'implementation', backend, input, 'impl-id'); + postProcessResult(result, 'implementation', backend, input, 'impl-id', { + requiresPR: true, + }); expect(logger.warn).toHaveBeenCalledWith( - 'Implementation agent completed without creating a PR', + 'implementation agent completed without creating a PR', { identifier: 'impl-id', backend: 'my-backend' }, ); }); - it('passes through when implementation agent succeeds with prUrl', () => { + it('passes through when requiresPR agent succeeds with prUrl', () => { const result = makeResult({ success: true, prUrl: 'https://github.com/o/r/pull/1' }); const backend = makeBackend(); const input = makeInput(); - postProcessResult(result, 'implementation', backend, input, 'impl-id'); + postProcessResult(result, 'implementation', backend, input, 'impl-id', { + requiresPR: true, + }); expect(result.success).toBe(true); expect(result.error).toBeUndefined(); }); - it('passes through when implementation agent already failed', () => { + it('passes through when requiresPR agent already failed', () => { const result = makeResult({ success: false, error: 'Budget exceeded' }); const backend = makeBackend(); const input = makeInput(); - postProcessResult(result, 'implementation', backend, input, 'impl-id'); + postProcessResult(result, 'implementation', backend, input, 'impl-id', { + requiresPR: true, + }); expect(result.success).toBe(false); expect(result.error).toBe('Budget exceeded'); expect(logger.warn).not.toHaveBeenCalled(); }); - it('does not validate PR creation for non-implementation agents', () => { + it('does not validate PR creation when requiresPR is not set', () => { const result = makeResult({ success: true, prUrl: undefined }); const backend = makeBackend(); const input = makeInput(); diff --git a/tests/unit/backends/progressModel.test.ts b/tests/unit/backends/progressModel.test.ts index c6b90b9f..288f53cf 100644 --- a/tests/unit/backends/progressModel.test.ts +++ b/tests/unit/backends/progressModel.test.ts @@ -1,13 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mockTextComplete = vi.fn(); -vi.mock('llmist', () => { - return { - LLMist: vi.fn().mockImplementation(() => ({ - text: { complete: mockTextComplete }, - })), - }; -}); +vi.mock('llmist', async (importOriginal) => ({ + ...(await importOriginal()), + LLMist: vi.fn().mockImplementation(() => ({ + text: { complete: mockTextComplete }, + })), +})); import { LLMist } from 'llmist'; import { type ProgressContext, callProgressModel } from '../../../src/backends/progressModel.js'; diff --git a/tests/unit/config/compactionConfig.test.ts b/tests/unit/config/compactionConfig.test.ts index 6f489108..8f7892ef 100644 --- a/tests/unit/config/compactionConfig.test.ts +++ b/tests/unit/config/compactionConfig.test.ts @@ -217,6 +217,17 @@ describe('config/compactionConfig', () => { } }); + it('returns default config for unknown agent type', () => { + const config = getCompactionConfig('nonexistent-agent-type'); + + 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); + expect(config.onCompaction).toBeTypeOf('function'); + }); + it('target percent is less than trigger threshold', () => { const agentTypes = ['implementation', 'splitting', 'planning'];