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/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/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/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/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-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 266a76dd..b10a26c0 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,19 @@ "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", - "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", @@ -63,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", @@ -79,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/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/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/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/prompts/index.ts b/src/agents/prompts/index.ts index c2c298b4..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 = [ - 'briefing', - '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 { @@ -37,7 +31,7 @@ export interface PromptContext { workItemNounPluralCap?: string; // "Cards" or "Issues" pmName?: string; // "Trello" or "JIRA" - // Briefing-specific + // Splitting-specific storiesListId?: string; processedLabelId?: string; @@ -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)) { @@ -189,8 +228,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)' }, @@ -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 BRIEFING_SYSTEM_PROMPT = loadTemplate('briefing'); -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/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/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/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/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/capabilities.ts b/src/agents/shared/capabilities.ts index 336ecae7..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 = { - briefing: { - 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/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/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/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/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/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/taskPrompts.ts b/src/agents/shared/taskPrompts.ts index 1ffc791c..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 - * (briefing, 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/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/src/backends/adapter.ts b/src/backends/adapter.ts index d7e13435..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, @@ -139,6 +146,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 +247,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, @@ -238,6 +259,7 @@ export async function executeWithBackend( onText: () => {}, }, runId, + llmistLogPath: fileLogger.llmistLogPath, }; monitor?.start(); @@ -248,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 9bb76e89..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 briefingProfile: 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('briefing'), - 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 = { - briefing: briefingProfile, - 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/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/backends/llmist/index.ts b/src/backends/llmist/index.ts index 8d3035e0..1f13d11c 100644 --- a/src/backends/llmist/index.ts +++ b/src/backends/llmist/index.ts @@ -1,37 +1,42 @@ -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 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'; +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'; -/** - * 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]), +/** Post-configure registry: maps YAML string references to builder transform functions */ +const POST_CONFIGURE_REGISTRY: Record BuilderType> = { + sequentialGadgetExecution: (b) => b.withGadgetExecutionMode('sequential'), }; /** - * 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 +46,135 @@ 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[] = []; + + // 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()}`); - const executor = specializedExecutors[input.agentType]; - const result = executor - ? await executor(fullInput) - : await executeAgent(input.agentType, fullInput); + // 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'], + // 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. + // 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/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/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/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/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/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..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 = { - briefing: { emoji: '📋', label: 'Briefing 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. @@ -25,6 +50,15 @@ 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 = roleHints; + /** * Human-readable initial messages per agent type. * @@ -32,21 +66,4 @@ export function getAgentLabel(agentType: string): { emoji: string; label: string * - ProgressMonitor (worker-side) — initial comment on work item * - 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...', - 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 723e6e9f..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'; @@ -33,7 +34,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%) @@ -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", "briefing", "planning") + * @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 e2ee6696..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.', - briefing: '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/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/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 2fb68a45..f2e0c398 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -5,100 +5,121 @@ { "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": 15, + "version": "7", + "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 new file mode 100644 index 00000000..398f11fa --- /dev/null +++ b/src/db/repositories/configMapper.ts @@ -0,0 +1,303 @@ +/** + * 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; + taskPrompt: 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; + taskPrompts?: 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; + 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, taskPrompts, 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, taskPrompts } = 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), + taskPrompts: orUndefined(taskPrompts), + }; +} + +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, taskPrompts, 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), + taskPrompts: orUndefined(taskPrompts), + 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/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/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/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/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/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/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/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/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/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/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/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..53085934 --- /dev/null +++ b/tests/integration/helpers/seed.ts @@ -0,0 +1,302 @@ +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'; + +/** + * 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; +} + +/** + * 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; +} 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/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/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/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/prompts.test.ts b/tests/unit/agents/prompts.test.ts index 79662cbd..668666b2 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'); @@ -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', () => { @@ -213,7 +225,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 +252,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/registry.test.ts b/tests/unit/agents/registry.test.ts index 69414e41..962e31e8 100644 --- a/tests/unit/agents/registry.test.ts +++ b/tests/unit/agents/registry.test.ts @@ -78,15 +78,12 @@ function makeMockBackend(name: string, supportsAll = true): AgentBackend { }; } -beforeEach(() => { - vi.clearAllMocks(); -}); - describe('runAgent', () => { it('resolves backend name from config', async () => { const backend = makeMockBackend('llmist'); mockResolveBackendName.mockReturnValue('llmist'); mockGetBackend.mockReturnValue(backend); + mockExecuteWithBackend.mockResolvedValue({ success: true, output: 'Done' }); await runAgent('implementation', makeInput()); @@ -120,26 +117,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/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/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/modelResolution.test.ts b/tests/unit/agents/shared/modelResolution.test.ts index bcdcc479..e968d539 100644 --- a/tests/unit/agents/shared/modelResolution.test.ts +++ b/tests/unit/agents/shared/modelResolution.test.ts @@ -41,14 +41,10 @@ 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({ - agentType: 'briefing', + agentType: 'splitting', project: makeProject(), config: makeConfig(), repoDir: '/tmp/test', @@ -60,46 +56,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 +110,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 +155,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 +168,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 +180,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', @@ -214,10 +210,121 @@ 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({ - agentType: 'briefing', + agentType: 'splitting', project: makeProject(), config: makeConfig({ maxIterations: 42 }), repoDir: '/tmp/test', @@ -228,12 +335,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/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/promptContext.test.ts b/tests/unit/agents/shared/promptContext.test.ts index 024767a2..e14a6d46 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', @@ -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/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/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(); - }); -}); 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/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 5887255a..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,22 +40,12 @@ 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 = ['briefing', 'planning', 'implementation']; + const types = ['splitting', 'planning', 'implementation']; mockGetValidAgentTypes.mockReturnValue(types); const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); @@ -75,10 +66,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 +85,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/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 96ed1170..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', @@ -83,7 +78,7 @@ const mockJiraProject = { jira: { projectKey: 'PROJ', baseUrl: 'https://test.atlassian.net', - statuses: { briefing: 'Briefing' }, + statuses: { splitting: 'Briefing' }, labels: { processing: 'my-processing', processed: 'my-processed', @@ -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 new file mode 100644 index 00000000..35e31a59 --- /dev/null +++ b/tests/unit/backends/accumulator.test.ts @@ -0,0 +1,199 @@ +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(() => { + 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/adapter.test.ts b/tests/unit/backends/adapter.test.ts index 50edda17..bea8ec96 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'); @@ -425,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, @@ -436,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' }), ); }); @@ -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 36acc6f8..0815d094 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,14 +109,33 @@ 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 mockGithub = vi.mocked(githubClient); +const mockExecFileSync = vi.mocked(execFileSync); +const mockResolveSquintDbPath = vi.mocked(resolveSquintDbPath); +const mockReadWorkItem = vi.mocked(readWorkItem); -beforeEach(() => { - vi.clearAllMocks(); -}); +const mockGithub = vi.mocked(githubClient); describe('getAgentProfile', () => { describe('respond-to-ci profile', () => { @@ -404,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'", ); }); @@ -430,7 +450,7 @@ describe('AgentProfile.getLlmistGadgets', () => { it('each profile has a getLlmistGadgets method', () => { const agentTypes = [ - 'briefing', + 'splitting', 'planning', 'implementation', 'review', @@ -560,9 +580,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'); @@ -575,3 +595,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('splitting fetchContext returns a ListDirectory injection with maxDepth:3', async () => { + mockResolveSquintDbPath.mockReturnValue(null); + const profile = getAgentProfile('splitting'); + 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('splitting'); + 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('splitting'); + 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('splitting'); + 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('splitting'); + 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('splitting'); + 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('splitting'); + 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('splitting'); + 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('splitting'); + 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('splitting'); + 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('splitting'); + 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('splitting'); + 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/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 fa0f921c..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(''); @@ -312,7 +308,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/githubPoster.test.ts b/tests/unit/backends/githubPoster.test.ts new file mode 100644 index 00000000..c25f2200 --- /dev/null +++ b/tests/unit/backends/githubPoster.test.ts @@ -0,0 +1,131 @@ +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); + +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/llmist.test.ts b/tests/unit/backends/llmist.test.ts index dc0fede7..ea7ece72 100644 --- a/tests/unit/backends/llmist.test.ts +++ b/tests/unit/backends/llmist.test.ts @@ -1,71 +1,121 @@ 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(() => ({})), })); -vi.mock('../../../src/agents/respond-to-review.js', () => ({ - executeRespondToReviewAgent: vi.fn(), +// 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/agents/respond-to-ci.js', () => ({ - executeRespondToCIAgent: vi.fn(), +vi.mock('../../../src/backends/agent-profiles.js', () => ({ + getAgentProfile: vi.fn(() => ({ + getLlmistGadgets: vi.fn(() => []), + })), })); -vi.mock('../../../src/agents/respond-to-pr-comment.js', () => ({ - executeRespondToPRCommentAgent: 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/review.js', () => ({ - executeReviewAgent: vi.fn(), +vi.mock('../../../src/agents/shared/syntheticCalls.js', () => ({ + injectSyntheticCall: vi.fn((builder) => builder), })); -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/agentLoop.js', () => ({ + runAgentLoop: vi.fn().mockResolvedValue({ + output: 'Agent completed', + iterations: 3, + gadgetCalls: 5, + cost: 0.05, + loopTerminated: false, + }), +})); + +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(); -}); - describe('LlmistBackend', () => { it('has name "llmist"', () => { const backend = new LlmistBackend(); @@ -80,77 +130,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 { createConfiguredBuilder } = await import( + '../../../src/agents/shared/builderFactory.js' + ); + const mockCreateConfiguredBuilder = vi.mocked(createConfiguredBuilder); - const backend = new LlmistBackend(); - await backend.execute(makeInput('respond-to-pr-comment')); + 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, + }), + ); }); }); diff --git a/tests/unit/backends/pmPoster.test.ts b/tests/unit/backends/pmPoster.test.ts new file mode 100644 index 00000000..9d2e6b74 --- /dev/null +++ b/tests/unit/backends/pmPoster.test.ts @@ -0,0 +1,180 @@ +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(() => { + // 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/postProcess.test.ts b/tests/unit/backends/postProcess.test.ts index b6906546..61ddc086 100644 --- a/tests/unit/backends/postProcess.test.ts +++ b/tests/unit/backends/postProcess.test.ts @@ -48,65 +48,69 @@ 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', () => { + 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(); - 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..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); @@ -816,10 +815,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/backends/progressModel.test.ts b/tests/unit/backends/progressModel.test.ts index b77a3033..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'; @@ -27,10 +26,6 @@ function makeContext(overrides: Partial = {}): ProgressContext }; } -beforeEach(() => { - vi.clearAllMocks(); -}); - describe('callProgressModel', () => { it('returns text output from LLM on success', async () => { mockTextComplete.mockResolvedValue( @@ -103,4 +98,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/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(); + }); + }); +}); 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 32ceee2d..8f7892ef 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'); @@ -38,7 +34,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 +49,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 +80,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 +196,7 @@ describe('config/compactionConfig', () => { it('all agent types return valid config structure', () => { const agentTypes = [ 'implementation', - 'briefing', + 'splitting', 'planning', 'debug', 'review', @@ -221,8 +217,19 @@ 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', 'briefing', 'planning']; + const agentTypes = ['implementation', 'splitting', 'planning']; for (const agentType of agentTypes) { const config = getCompactionConfig(agentType); @@ -233,7 +240,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/hintConfig.test.ts b/tests/unit/config/hintConfig.test.ts index 523f8e81..58b9d011 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,29 @@ 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(); + mockLoadTodos.mockReturnValue([]); + mockFormatTodoList.mockReturnValue(''); + mockExecSync.mockReturnValue(''); }); describe('respond-to-ci agent', () => { @@ -114,4 +132,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'); + }); + }); }); 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/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..0845ecf9 100644 --- a/tests/unit/config/statusUpdateConfig.test.ts +++ b/tests/unit/config/statusUpdateConfig.test.ts @@ -16,13 +16,9 @@ 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', 'briefing', 'planning', 'review']; + const agentTypes = ['implementation', 'splitting', 'planning', 'review']; for (const agentType of agentTypes) { const config = getStatusUpdateConfig(agentType); @@ -66,9 +62,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/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/configMapper.test.ts b/tests/unit/db/repositories/configMapper.test.ts new file mode 100644 index 00000000..1b964f27 --- /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: { splitting: '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: 'splitting', + 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({ splitting: '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/db/repositories/configRepository.test.ts b/tests/unit/db/repositories/configRepository.test.ts index aa25324d..e574d677 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, @@ -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 @@ -201,7 +197,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 +211,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 +284,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/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 372af90a..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 }); @@ -97,7 +95,7 @@ describe('runsRepository', () => { const result = await createRun({ projectId: 'proj-1', - agentType: 'briefing', + agentType: 'splitting', backend: 'claude-code', }); @@ -105,7 +103,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/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 6184db8b..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'); @@ -276,7 +272,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/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/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); + }); + }); +}); 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 89010cfd..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 // ======================================================================== @@ -41,7 +37,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/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/jira/adapter.test.ts b/tests/unit/pm/jira/adapter.test.ts index e5609f12..e3f52ebb 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', @@ -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/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..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); @@ -247,7 +246,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/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 074b8eec..d9d8613a 100644 --- a/tests/unit/router/ackMessageGenerator.test.ts +++ b/tests/unit/router/ackMessageGenerator.test.ts @@ -32,10 +32,15 @@ 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...', }, + 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'; @@ -46,10 +51,6 @@ import { generateAckMessage, } from '../../../src/router/ackMessageGenerator.js'; -beforeEach(() => { - vi.clearAllMocks(); -}); - // --------------------------------------------------------------------------- // Context extractors // --------------------------------------------------------------------------- @@ -319,10 +320,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/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 cee1bc9a..2a847339 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', @@ -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 1ed79fe1..6eb9f7b0 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', () => ({ @@ -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', @@ -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/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/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); diff --git a/tests/unit/router/trello.test.ts b/tests/unit/router/trello.test.ts index 420d4227..01a6335d 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', @@ -39,14 +39,10 @@ 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); - 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 +85,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/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 cf62381b..f750515e 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: {}, }, }, @@ -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 9fa58775..2f6bcd77 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' }, })); @@ -72,10 +72,6 @@ function createMockRegistry(): { register: ReturnType; handlers: o }; } -beforeEach(() => { - vi.clearAllMocks(); -}); - describe('registerBuiltInTriggers', () => { it('registers all expected trigger handlers', () => { const registry = createMockRegistry(); @@ -101,7 +97,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 +136,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..a171d1c6 100644 --- a/tests/unit/triggers/card-moved.test.ts +++ b/tests/unit/triggers/card-moved.test.ts @@ -40,33 +40,19 @@ 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'; +import { createMockProject } from '../../helpers/factories.js'; -describe('CardMovedToBriefingTrigger', () => { - const trigger = CardMovedToBriefingTrigger; +describe('CardMovedToSplittingTrigger', () => { + const trigger = CardMovedToSplittingTrigger; - const mockProject = { - id: 'test', - name: 'Test', - repo: 'owner/repo', - baseBranch: 'main', - branchPrefix: 'feature/', - trello: { - boardId: 'board123', - lists: { - briefing: 'briefing-list-id', - planning: 'planning-list-id', - todo: 'todo-list-id', - }, - labels: {}, - }, - }; + const mockProject = createMockProject(); - 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 +66,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 +75,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 +88,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 +98,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 +111,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 +152,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 +166,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 +174,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'); }); @@ -197,22 +183,7 @@ describe('CardMovedToBriefingTrigger', () => { describe('CardMovedToTodoTrigger', () => { const trigger = CardMovedToTodoTrigger; - const mockProject = { - id: 'test', - name: 'Test', - repo: 'owner/repo', - baseBranch: 'main', - branchPrefix: 'feature/', - trello: { - boardId: 'board123', - lists: { - briefing: 'briefing-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 e1b15ee7..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: { - briefing: 'briefing-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 adb31956..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: { - briefing: 'briefing-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 f2cb466a..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: { briefing: '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); }); @@ -259,7 +248,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); @@ -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 055fb959..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(); @@ -111,7 +107,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 +116,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..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: { briefing: 'b', planning: 'p', todo: 't' }, + 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/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..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,16 +39,11 @@ 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: { - briefing: 'briefing-list-id', + splitting: 'splitting-list-id', planning: 'planning-list-id', todo: 'todo-list-id', }, @@ -55,10 +51,6 @@ describe('ReadyToProcessLabelTrigger', () => { readyToProcess: 'ready-label-id', }, }, - }; - - beforeEach(() => { - vi.clearAllMocks(); }); describe('matches', () => { @@ -139,14 +131,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 +162,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 +237,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 +268,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..ea9aa26f 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; @@ -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 6337f232..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,26 +60,20 @@ 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: { - briefing: 'briefing-list-id', + splitting: 'splitting-list-id', planning: 'planning-list-id', todo: 'todo-list-id', merged: 'merged-list-id', }, labels: {}, }, - }; + }); beforeEach(() => { - vi.clearAllMocks(); vi.mocked(lookupWorkItemForPR).mockResolvedValue(null); }); @@ -321,7 +316,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..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: { - briefing: 'briefing-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 dc5bf582..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,26 +61,20 @@ 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: { - briefing: 'briefing-list-id', + splitting: 'splitting-list-id', planning: 'planning-list-id', todo: 'todo-list-id', done: 'done-list-id', }, labels: {}, }, - }; + }); beforeEach(() => { - vi.clearAllMocks(); vi.mocked(lookupWorkItemForPR).mockResolvedValue(null); }); @@ -656,7 +651,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..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: { - briefing: 'briefing-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/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..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: { - briefing: 'briefing-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/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/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/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/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 1d768615..1ad15db1 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,8 @@ const TRELLO_LIST_SLOTS = [ const TRELLO_LABEL_SLOTS = ['readyToProcess', 'processing', 'processed', 'error']; const JIRA_STATUS_SLOTS = [ - 'briefing', + 'splitting', + 'stories', 'planning', 'todo', 'inProgress', @@ -1660,12 +1661,6 @@ export function PMWizard({ Create Webhook -

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

{createWebhookMutation.isError && (

{createWebhookMutation.error.message}

)} 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',