diff --git a/src/agents/base.ts b/src/agents/base.ts deleted file mode 100644 index 2563227a..00000000 --- a/src/agents/base.ts +++ /dev/null @@ -1,219 +0,0 @@ -import type { ModelSpec } from 'llmist'; - -import { createProgressMonitor } from '../backends/progress.js'; -import { CUSTOM_MODELS } from '../config/customModels.js'; -import { getPMProvider } from '../pm/index.js'; -import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js'; -import { logger } from '../utils/logging.js'; -import { extractPRUrl } from '../utils/prUrl.js'; -import { type FileLogger, executeAgentLifecycle } from './shared/lifecycle.js'; -import { setupRepository as setupRepo } from './shared/repository.js'; -import { - createWorkItemAgentBuilder, - injectWorkItemSyntheticCalls, -} from './shared/workItemBuilder.js'; -import { buildAgentContext } from './shared/workItemContext.js'; -import type { AgentLogger } from './utils/logging.js'; - -export interface AgentContext { - project: ProjectConfig; - config: CascadeConfig; - cardId: string; - repoDir: string; -} - -export interface AgentRunner { - name: string; - run: (ctx: AgentContext) => Promise; -} - -// Re-export for backwards compatibility and test access -export { fetchImplementationSteps } from './shared/workItemContext.js'; - -// ============================================================================ -// Agent Execution -// ============================================================================ - -interface PRContext { - prNumber: number; - prBranch: string; - repoFullName: string; - headSha: string; -} - -function extractPRContext(input: AgentInput): PRContext | undefined { - if (input.triggerType !== 'check-failure') return undefined; - return { - prNumber: input.prNumber as number, - prBranch: input.prBranch as string, - repoFullName: input.repoFullName as string, - headSha: input.headSha as string, - }; -} - -function extractDebugContext(agentType: string, input: AgentInput) { - if (agentType !== 'debug' || !input.logDir) return undefined; - return { - logDir: input.logDir, - originalCardId: input.originalCardId as string, - originalCardName: input.originalCardName as string, - originalCardUrl: input.originalCardUrl as string, - detectedAgentType: input.detectedAgentType as string, - }; -} - -function getLoggerIdentifier( - agentType: string, - cardId: string | undefined, - prContext: PRContext | undefined, - debugCardId: string | undefined, -): string { - if (prContext) return `${agentType}-pr${prContext.prNumber}`; - return `${agentType}-${cardId || debugCardId}`; -} - -async function setupWorkingDirectory( - input: AgentInput, - project: ProjectConfig, - log: AgentLogger, - agentType: string, - prBranch?: string, -): Promise { - if (input.logDir && typeof input.logDir === 'string') { - log.info('Using log directory (no repo setup)', { logDir: input.logDir }); - return input.logDir; - } - - return setupRepo({ project, log, agentType, prBranch, warmTsCache: true }); -} - -export async function executeAgent( - agentType: string, - input: AgentInput & { project: ProjectConfig; config: CascadeConfig }, -): Promise { - const { project, config, cardId, interactive, autoAccept } = input; - const prContext = extractPRContext(input); - const isDebugAgent = input.logDir && typeof input.logDir === 'string'; - - if (!cardId && !prContext && !isDebugAgent) { - return { success: false, output: '', error: 'No card ID or PR context provided' }; - } - - const debugCardId = isDebugAgent ? (input.originalCardId as string) : undefined; - const identifier = getLoggerIdentifier(agentType, cardId, prContext, debugCardId); - - return executeAgentLifecycle({ - loggerIdentifier: identifier, - - onWatchdogTimeout: async (_fileLogger: FileLogger, runId?: string) => { - if (cardId) { - try { - const provider = getPMProvider(); - await provider.addComment( - cardId, - `⏱️ Agent timed out (watchdog).${runId ? ` Run ID: ${runId}` : ''}`, - ); - logger.info('Posted timeout comment to work item', { cardId, runId }); - } catch { - logger.warn('Failed to post timeout comment', { cardId, runId }); - } - } - }, - - setupRepoDir: (log) => - setupWorkingDirectory(input, project, log, agentType, prContext?.prBranch), - - buildContext: (repoDir, log) => { - const debugContext = extractDebugContext(agentType, input); - const commentContext = input.triggerCommentText - ? { text: input.triggerCommentText, author: input.triggerCommentAuthor || 'unknown' } - : undefined; - return buildAgentContext( - agentType, - cardId, - repoDir, - project, - config, - log, - input.triggerType, - prContext, - debugContext, - input.modelOverride, - commentContext, - ); - }, - - createBuilder: ({ - client, - ctx, - llmistLogger, - trackingContext, - fileLogger, - repoDir, - progressMonitor, - llmCallAccumulator, - runId, - }) => - createWorkItemAgentBuilder({ - client, - ctx, - llmistLogger, - trackingContext, - agentType, - logWriter: fileLogger.write.bind(fileLogger), - llmCallLogger: fileLogger.llmCallLogger, - repoDir, - progressMonitor: progressMonitor ?? undefined, - remainingBudgetUsd: input.remainingBudgetUsd as number | undefined, - llmCallAccumulator, - runId, - baseBranch: project.baseBranch, - projectId: project.id, - cardId, - }), - - injectSyntheticCalls: ({ builder, ctx, trackingContext, repoDir }) => - injectWorkItemSyntheticCalls( - builder, - cardId, - ctx.cardData, - ctx.contextFiles, - trackingContext, - repoDir, - ctx.implementationSteps, - ), - - createProgressMonitor: (fileLogger, repoDir) => - createProgressMonitor({ - logWriter: fileLogger.write.bind(fileLogger), - agentType, - taskDescription: cardId ? `Work item ${cardId}` : 'Unknown task', - progressModel: config.defaults.progressModel, - intervalMinutes: config.defaults.progressIntervalMinutes, - customModels: CUSTOM_MODELS as ModelSpec[], - repoDir, - trello: cardId ? { cardId } : undefined, - preSeededCommentId: input.ackCommentId as string | undefined, - }), - - interactive, - autoAccept, - customModels: CUSTOM_MODELS, - - postProcess: (output) => { - const prUrl = extractPRUrl(output); - return prUrl ? { prUrl } : {}; - }, - - runTracking: { - projectId: project.id, - cardId, - prNumber: prContext?.prNumber ?? (input.prNumber as number | undefined), - agentType, - backendName: 'llmist', - triggerType: input.triggerType, - }, - - squintDbUrl: project.squintDbUrl, - }); -} diff --git a/src/agents/index.ts b/src/agents/index.ts index 72c7ee27..a29a0fc6 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -1,3 +1,2 @@ -export { executeAgent, type AgentContext, type AgentRunner } from './base.js'; export { runAgent, registerBackend } from './registry.js'; export { getSystemPrompt } from './prompts/index.js'; diff --git a/src/agents/respond-to-ci.ts b/src/agents/respond-to-ci.ts deleted file mode 100644 index c11d99bb..00000000 --- a/src/agents/respond-to-ci.ts +++ /dev/null @@ -1,397 +0,0 @@ -import type { CheckSuiteStatus } from '../github/client.js'; -import { githubClient } from '../github/client.js'; -import type { AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js'; -import { logger } from '../utils/logging.js'; -import { runCommand as execCommand } from '../utils/repo.js'; -import { buildPRAgentGadgets } from './shared/gadgets.js'; -import { - type GitHubAgentContext, - type GitHubAgentDefinition, - type GitHubAgentInput, - type RepoIdentifier, - createInitialPRComment, - executeGitHubAgent, -} from './shared/githubAgent.js'; -import { resolveModelConfig } from './shared/modelResolution.js'; -import { formatPRDetails, formatPRDiff } from './shared/prFormatting.js'; -import { - injectContextFiles, - injectDirectoryListing, - injectSquintContext, - injectSyntheticCall, -} from './shared/syntheticCalls.js'; - -interface RespondToCIAgentInput extends GitHubAgentInput { - headSha: string; -} - -// ============================================================================ -// CI Data Formatting -// ============================================================================ - -function formatCheckStatus(checkStatus: CheckSuiteStatus): string { - const lines = ['## Check Suite Status', `Total checks: ${checkStatus.totalCount}`, '']; - - // Group by status/conclusion - const passed = checkStatus.checkRuns.filter( - (cr) => - cr.conclusion === 'success' || cr.conclusion === 'skipped' || cr.conclusion === 'neutral', - ); - const failed = checkStatus.checkRuns.filter( - (cr) => - cr.conclusion === 'failure' || - cr.conclusion === 'timed_out' || - cr.conclusion === 'action_required', - ); - const pending = checkStatus.checkRuns.filter((cr) => cr.status !== 'completed'); - - if (failed.length > 0) { - lines.push('### Failed Checks'); - for (const cr of failed) { - lines.push(`- **${cr.name}**: ${cr.conclusion}`); - } - lines.push(''); - } - - if (passed.length > 0) { - lines.push('### Passed Checks'); - for (const cr of passed) { - lines.push(`- ${cr.name}: ${cr.conclusion}`); - } - lines.push(''); - } - - if (pending.length > 0) { - lines.push('### Pending Checks'); - for (const cr of pending) { - lines.push(`- ${cr.name}: ${cr.status}`); - } - lines.push(''); - } - - return lines.join('\n'); -} - -// ============================================================================ -// CI Log Fetching -// ============================================================================ - -interface WorkflowRun { - databaseId: number; - name: string; - conclusion: string; - headSha: string; -} - -function truncateLogOutput(stdout: string, maxLines = 200): string { - const logLines = stdout.split('\n'); - if (logLines.length <= maxLines) { - return stdout; - } - const truncatedCount = logLines.length - maxLines; - return `[... truncated ${truncatedCount} lines ...]\n${logLines.slice(-maxLines).join('\n')}`; -} - -function formatCheckLogEntry(checkName: string, content: string, isCode = false): string { - if (isCode) { - return `## ${checkName}\n\n\`\`\`\n${content}\n\`\`\``; - } - return `## ${checkName}\n\n${content}`; -} - -async function fetchSingleRunLog( - runId: number, - owner: string, - repo: string, - repoDir: string, -): Promise<{ success: boolean; content: string }> { - const logResult = await execCommand( - 'gh', - ['run', 'view', String(runId), '--repo', `${owner}/${repo}`, '--log-failed'], - repoDir, - ); - - if (logResult.exitCode === 0 && logResult.stdout.trim()) { - return { success: true, content: truncateLogOutput(logResult.stdout) }; - } - return { success: false, content: logResult.stderr || 'No output' }; -} - -async function fetchFailedCheckLogs( - owner: string, - repo: string, - checkStatus: CheckSuiteStatus, - repoDir: string, - log: { - info: (msg: string, ctx?: Record) => void; - warn: (msg: string, ctx?: Record) => void; - }, -): Promise { - const failedRuns = checkStatus.checkRuns.filter( - (cr) => - cr.conclusion === 'failure' || - cr.conclusion === 'timed_out' || - cr.conclusion === 'action_required', - ); - - if (failedRuns.length === 0) { - return 'No failed check logs to display.'; - } - - log.info('Fetching failed check logs via gh CLI', { failedCount: failedRuns.length }); - - const listResult = await execCommand( - 'gh', - [ - 'run', - 'list', - '--repo', - `${owner}/${repo}`, - '--limit', - '20', - '--json', - 'databaseId,name,conclusion,headSha', - ], - repoDir, - ); - - if (listResult.exitCode !== 0) { - log.warn('Failed to list workflow runs', { stderr: listResult.stderr }); - return `Unable to fetch check logs: ${listResult.stderr}`; - } - - const runs = JSON.parse(listResult.stdout) as WorkflowRun[]; - const logs: string[] = []; - - for (const failedCheck of failedRuns) { - const logEntry = await processFailedCheck(failedCheck, runs, owner, repo, repoDir, log); - logs.push(logEntry); - } - - return logs.length > 0 ? logs.join('\n\n---\n\n') : 'No failed check logs available.'; -} - -async function processFailedCheck( - failedCheck: { name: string; conclusion: string | null }, - runs: WorkflowRun[], - owner: string, - repo: string, - repoDir: string, - log: { info: (msg: string, ctx?: Record) => void }, -): Promise { - const matchingRun = runs.find( - (r) => - r.name === failedCheck.name && (r.conclusion === 'failure' || r.conclusion === 'timed_out'), - ); - - if (!matchingRun) { - return formatCheckLogEntry( - failedCheck.name, - 'No matching workflow run found for log retrieval.', - ); - } - - log.info('Fetching logs for failed run', { - name: matchingRun.name, - id: matchingRun.databaseId, - }); - - const result = await fetchSingleRunLog(matchingRun.databaseId, owner, repo, repoDir); - - if (result.success) { - return formatCheckLogEntry(failedCheck.name, result.content, true); - } - return formatCheckLogEntry(failedCheck.name, `Unable to fetch logs: ${result.content}`); -} - -// ============================================================================ -// Context Building -// ============================================================================ - -interface CIContextData extends GitHubAgentContext { - contextFiles: Awaited>['contextFiles']; - prDetailsFormatted: string; - diffFormatted: string; - checkStatusFormatted: string; - failedLogsFormatted: string; -} - -async function buildCIContext( - owner: string, - repo: string, - prNumber: number, - prBranch: string, - headSha: string, - repoDir: string, - project: ProjectConfig, - config: CascadeConfig, - log: { - info: (msg: string, ctx?: Record) => void; - warn: (msg: string, ctx?: Record) => void; - }, - modelOverride?: string, -): Promise { - const { systemPrompt, model, maxIterations, contextFiles } = await resolveModelConfig({ - agentType: 'respond-to-ci', - project, - config, - repoDir, - modelOverride, - }); - - // Fetch PR details and diff - log.info('Fetching PR details and diff', { owner, repo, prNumber }); - const prDetails = await githubClient.getPR(owner, repo, prNumber); - const prDiff = await githubClient.getPRDiff(owner, repo, prNumber); - - // Get check suite status - log.info('Fetching check suite status', { owner, repo, headSha }); - const checkStatus = await githubClient.getCheckSuiteStatus(owner, repo, headSha); - - // Fetch failed check logs - const failedLogsFormatted = await fetchFailedCheckLogs(owner, repo, checkStatus, repoDir, log); - - // Format data - const prDetailsFormatted = formatPRDetails(prDetails); - const diffFormatted = formatPRDiff(prDiff); - const checkStatusFormatted = formatCheckStatus(checkStatus); - - // Build prompt - const prompt = buildCIPrompt(prBranch, prNumber, owner, repo); - - return { - systemPrompt, - model, - maxIterations, - contextFiles, - prDetailsFormatted, - diffFormatted, - checkStatusFormatted, - failedLogsFormatted, - prompt, - }; -} - -function buildCIPrompt(prBranch: string, prNumber: number, owner: string, repo: string): string { - return `You are on the branch \`${prBranch}\` for PR #${prNumber}. - -CI checks have failed. Analyze the failures and fix them. - -## GitHub Context - -Owner: ${owner} -Repo: ${repo} -PR Number: ${prNumber} - -Use these values when calling GitHub gadgets (GetPRDetails, PostPRComment, UpdatePRComment).`; -} - -// ============================================================================ -// Agent Definition -// ============================================================================ - -const ciAgentDefinition: GitHubAgentDefinition = { - agentType: 'respond-to-ci', - initialCommentDescription: 'Acknowledge CI failures', - timeoutMessage: '⚠️ CI fix agent timed out while attempting to fix failures.', - loggerPrefix: 'ci', - - getGadgets: () => buildPRAgentGadgets(), - - async preExecute(input: RespondToCIAgentInput, id: RepoIdentifier) { - const checkStatus = await githubClient.getCheckSuiteStatus(id.owner, id.repo, input.headSha); - const hasFailedChecks = checkStatus.checkRuns.some( - (cr) => - cr.conclusion === 'failure' || - cr.conclusion === 'timed_out' || - cr.conclusion === 'action_required', - ); - - if (!hasFailedChecks) { - logger.info('No failed checks found, skipping CI fix agent', { - prNumber: input.prNumber, - headSha: input.headSha, - totalChecks: checkStatus.totalCount, - }); - return { success: true, output: 'No failed checks to fix' }; - } - return null; - }, - - postInitialComment: (input, id, headerMessage) => - createInitialPRComment(input.prNumber, id, headerMessage), - - buildContext: ({ owner, repo }, input, repoDir, log) => - buildCIContext( - owner, - repo, - input.prNumber, - input.prBranch, - input.headSha, - repoDir, - input.project, - input.config, - log, - input.modelOverride, - ), - - async injectSyntheticCalls({ - builder, - ctx, - trackingContext, - repoDir, - id: { owner, repo }, - input, - }) { - let b = injectDirectoryListing(builder, trackingContext); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRDetails', - { comment: 'Pre-fetching PR details for context', owner, repo, prNumber: input.prNumber }, - ctx.prDetailsFormatted, - 'gc_pr_details', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRDiff', - { comment: 'Pre-fetching PR diff for context', owner, repo, prNumber: input.prNumber }, - ctx.diffFormatted, - 'gc_pr_diff', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetCheckStatus', - { comment: 'Pre-fetching CI check status', owner, repo, prNumber: input.prNumber }, - ctx.checkStatusFormatted, - 'gc_check_status', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetFailedCheckLogs', - { comment: 'Pre-fetching failed CI check logs', owner, repo, prNumber: input.prNumber }, - ctx.failedLogsFormatted, - 'gc_failed_logs', - ); - - b = injectContextFiles(b, trackingContext, ctx.contextFiles); - b = injectSquintContext(b, trackingContext, repoDir); - - return b; - }, -}; - -// ============================================================================ -// CI Agent Execution -// ============================================================================ - -export async function executeRespondToCIAgent(input: RespondToCIAgentInput): Promise { - return executeGitHubAgent(ciAgentDefinition, input); -} diff --git a/src/agents/respond-to-pr-comment.ts b/src/agents/respond-to-pr-comment.ts deleted file mode 100644 index a9494dbd..00000000 --- a/src/agents/respond-to-pr-comment.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { AgentResult } from '../types/index.js'; -import { buildPRAgentGadgets } from './shared/gadgets.js'; -import { type GitHubAgentDefinition, executeGitHubAgent } from './shared/githubAgent.js'; -import { - type PRResponseAgentInput, - type PRResponseContextData, - buildPRResponseContext, - buildPRResponsePrompt, - injectPRResponseSyntheticCalls, - postInitialPRResponseComment, -} from './shared/prResponseAgent.js'; -import { injectSyntheticCall } from './shared/syntheticCalls.js'; - -const respondToPRCommentDefinition: GitHubAgentDefinition< - PRResponseAgentInput, - PRResponseContextData -> = { - agentType: 'respond-to-pr-comment', - initialCommentDescription: 'Acknowledge PR comment request', - timeoutMessage: '⚠️ PR comment agent timed out while working on the request.', - loggerPrefix: 'pr-comment', - - getGadgets: () => buildPRAgentGadgets({ includeReviewComments: true }), - - postInitialComment: postInitialPRResponseComment, - - buildContext: ({ owner, repo }, input, repoDir, log) => - buildPRResponseContext( - owner, - repo, - input.prNumber, - input.prBranch, - repoDir, - input.project, - input.config, - log, - 'respond-to-pr-comment', - (prBranch, prNumber, o, r) => - buildPRResponsePrompt( - prBranch, - prNumber, - o, - r, - 'A user @mentioned you in a PR comment. Read their request and execute it.', - 'GetPRComments, ReplyToReviewComment, PostPRComment, UpdatePRComment', - ), - input.modelOverride, - ), - - async injectSyntheticCalls(params) { - return injectPRResponseSyntheticCalls(params, { - preSyntheticCalls: (builder, trackingContext, input) => - injectSyntheticCall( - builder, - trackingContext, - 'TriggeringComment', - { - comment: - 'The @mention comment that triggered this agent — this is your primary instruction', - commentId: input.triggerCommentId, - url: input.triggerCommentUrl, - path: input.triggerCommentPath || '(general PR comment)', - }, - input.triggerCommentBody, - 'gc_triggering_comment', - ), - commentDescriptions: { - prComments: 'Pre-fetching line-specific review comments for context', - prReviews: 'Pre-fetching review submissions for context', - prIssueComments: 'Pre-fetching general PR comments for context', - }, - }); - }, -}; - -export async function executeRespondToPRCommentAgent( - input: PRResponseAgentInput, -): Promise { - return executeGitHubAgent(respondToPRCommentDefinition, input); -} diff --git a/src/agents/respond-to-review.ts b/src/agents/respond-to-review.ts deleted file mode 100644 index 344a4fcd..00000000 --- a/src/agents/respond-to-review.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { AgentResult } from '../types/index.js'; -import { buildPRAgentGadgets } from './shared/gadgets.js'; -import { type GitHubAgentDefinition, executeGitHubAgent } from './shared/githubAgent.js'; -import { - type PRResponseAgentInput, - type PRResponseContextData, - buildPRResponseContext, - buildPRResponsePrompt, - injectPRResponseSyntheticCalls, - postInitialPRResponseComment, -} from './shared/prResponseAgent.js'; - -const respondToReviewDefinition: GitHubAgentDefinition< - PRResponseAgentInput, - PRResponseContextData -> = { - agentType: 'respond-to-review', - initialCommentDescription: 'Acknowledge review feedback', - timeoutMessage: '⚠️ Review agent timed out while addressing feedback.', - loggerPrefix: 'review', - - getGadgets: () => buildPRAgentGadgets({ includeReviewComments: true }), - - postInitialComment: postInitialPRResponseComment, - - buildContext: ({ owner, repo }, input, repoDir, log) => - buildPRResponseContext( - owner, - repo, - input.prNumber, - input.prBranch, - repoDir, - input.project, - input.config, - log, - 'respond-to-review', - (prBranch, prNumber, o, r) => - buildPRResponsePrompt( - prBranch, - prNumber, - o, - r, - 'Address the review comments and push your changes.', - 'GetPRComments, ReplyToReviewComment', - ), - input.modelOverride, - ), - - async injectSyntheticCalls(params) { - return injectPRResponseSyntheticCalls(params); - }, -}; - -export async function executeRespondToReviewAgent( - input: PRResponseAgentInput, -): Promise { - return executeGitHubAgent(respondToReviewDefinition, input); -} diff --git a/src/agents/review.ts b/src/agents/review.ts deleted file mode 100644 index f8761592..00000000 --- a/src/agents/review.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { Finish } from '../gadgets/Finish.js'; -import { ListDirectory } from '../gadgets/ListDirectory.js'; -import { ReadFile } from '../gadgets/ReadFile.js'; -import { Sleep } from '../gadgets/Sleep.js'; -import { - CreatePRReview, - GetPRChecks, - GetPRDetails, - GetPRDiff, - UpdatePRComment, - formatCheckStatus, -} from '../gadgets/github/index.js'; -import { Tmux } from '../gadgets/tmux.js'; -import { TodoDelete, TodoUpdateStatus, TodoUpsert } from '../gadgets/todo/index.js'; -import { githubClient } from '../github/client.js'; -import type { AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js'; -import { - type GitHubAgentContext, - type GitHubAgentDefinition, - type GitHubAgentInput, - createInitialPRComment, - executeGitHubAgent, -} from './shared/githubAgent.js'; -import { resolveModelConfig } from './shared/modelResolution.js'; -import { - type PRFileContents, - formatPRDetails, - formatPRDiff, - readPRFileContents, -} from './shared/prFormatting.js'; -import { - injectContextFiles, - injectSquintContext, - injectSyntheticCall, -} from './shared/syntheticCalls.js'; - -interface ReviewAgentInput extends GitHubAgentInput { - prNumber: number; - prBranch: string; - repoFullName: string; - project: ProjectConfig; - config: CascadeConfig; -} - -// ============================================================================ -// Context Building -// ============================================================================ - -interface ReviewContextData extends GitHubAgentContext { - contextFiles: Awaited>['contextFiles']; - prDetailsFormatted: string; - diffFormatted: string; - checkStatusFormatted: string; - fileContents: PRFileContents; -} - -async function buildReviewContext( - owner: string, - repo: string, - prNumber: number, - repoDir: string, - project: ProjectConfig, - config: CascadeConfig, - log: { info: (msg: string, ctx?: Record) => void }, - modelOverride?: string, -): Promise { - const { systemPrompt, model, maxIterations, contextFiles } = await resolveModelConfig({ - agentType: 'review', - project, - config, - repoDir, - modelOverride, - }); - - // Fetch PR details, diff, and check status - log.info('Fetching PR details, diff, and check status', { owner, repo, prNumber }); - const prDetails = await githubClient.getPR(owner, repo, prNumber); - const prDiff = await githubClient.getPRDiff(owner, repo, prNumber); - const checkStatus = await githubClient.getCheckSuiteStatus(owner, repo, prDetails.headSha); - - // Format PR data - const prDetailsFormatted = formatPRDetails(prDetails); - const diffFormatted = formatPRDiff(prDiff); - const checkStatusFormatted = formatCheckStatus(prNumber, checkStatus); - - // Read full contents of changed files (up to token limit) - log.info('Reading PR file contents', { fileCount: prDiff.length }); - const fileContents = await readPRFileContents(repoDir, prDiff); - log.info('File contents loaded', { - included: fileContents.included.length, - skipped: fileContents.skipped.length, - }); - - // Build prompt (include skipped files note if any) - const prompt = buildReviewPrompt(prNumber, owner, repo, fileContents.skipped); - - return { - systemPrompt, - model, - maxIterations, - contextFiles, - prDetailsFormatted, - diffFormatted, - checkStatusFormatted, - fileContents, - prompt, - }; -} - -function buildReviewPrompt( - prNumber: number, - owner: string, - repo: string, - skippedFiles: string[], -): string { - let prompt = `Review PR #${prNumber} in ${owner}/${repo}. - -Examine the code changes carefully and submit your review using CreatePRReview. - -## GitHub Context - -Owner: ${owner} -Repo: ${repo} -PR Number: ${prNumber} - -Use these values when calling GitHub gadgets (GetPRDetails, GetPRDiff, CreatePRReview).`; - - if (skippedFiles.length > 0) { - prompt += `\n\n## Files Not Pre-loaded - -The following files exceeded the token limit and were not pre-loaded. Use ReadFile if you need their full contents: -${skippedFiles.map((f) => `- ${f}`).join('\n')}`; - } - - return prompt; -} - -// ============================================================================ -// Agent Definition -// ============================================================================ - -const reviewAgentDefinition: GitHubAgentDefinition = { - agentType: 'review', - initialCommentDescription: 'Post initial review status comment', - timeoutMessage: '⚠️ Review agent timed out while reviewing the PR.', - loggerPrefix: 'review', - - getGadgets: () => [ - new ListDirectory(), - new ReadFile(), - new Tmux(), - new Sleep(), - new TodoUpsert(), - new TodoUpdateStatus(), - new TodoDelete(), - new GetPRDetails(), - new GetPRDiff(), - new GetPRChecks(), - new CreatePRReview(), - new UpdatePRComment(), - new Finish(), - ], - - postInitialComment: (input, id, headerMessage) => - createInitialPRComment(input.prNumber, id, headerMessage), - - buildContext: ({ owner, repo }, input, repoDir, log) => - buildReviewContext( - owner, - repo, - input.prNumber, - repoDir, - input.project, - input.config, - log, - input.modelOverride, - ), - - async injectSyntheticCalls({ - builder, - ctx, - trackingContext, - repoDir, - id: { owner, repo }, - input, - }) { - let b = injectSyntheticCall( - builder, - trackingContext, - 'GetPRDetails', - { - comment: 'Pre-fetching PR details for review context', - owner, - repo, - prNumber: input.prNumber, - }, - ctx.prDetailsFormatted, - 'gc_pr_details', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRDiff', - { comment: 'Pre-fetching PR diff for code review', owner, repo, prNumber: input.prNumber }, - ctx.diffFormatted, - 'gc_pr_diff', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRChecks', - { comment: 'Pre-fetching CI check status for review', owner, repo, prNumber: input.prNumber }, - ctx.checkStatusFormatted, - 'gc_pr_checks', - ); - - // Inject context files (CLAUDE.md, README.md, etc.) - b = injectContextFiles(b, trackingContext, ctx.contextFiles); - - // Inject Squint overview BEFORE file contents — agent sees architectural map - // before encountering specific file contents - b = injectSquintContext(b, trackingContext, repoDir); - - // Inject full contents of PR changed files (up to token limit) - for (let i = 0; i < ctx.fileContents.included.length; i++) { - const file = ctx.fileContents.included[i]; - b = injectSyntheticCall( - b, - trackingContext, - 'ReadFile', - { comment: `Pre-fetching ${file.path} for review`, filePath: file.path }, - `path=${file.path}\n\n${file.content}`, - `gc_file_${i + 1}`, - ); - } - - return b; - }, -}; - -// ============================================================================ -// Review Agent Execution -// ============================================================================ - -export async function executeReviewAgent(input: ReviewAgentInput): Promise { - return executeGitHubAgent(reviewAgentDefinition, input); -} diff --git a/src/agents/shared/cleanup.ts b/src/agents/shared/cleanup.ts index 27a88154..2eb07110 100644 --- a/src/agents/shared/cleanup.ts +++ b/src/agents/shared/cleanup.ts @@ -2,7 +2,7 @@ import { cleanupLogDirectory, cleanupLogFile } from '../../utils/fileLogger.js'; import { clearWatchdogCleanup } from '../../utils/lifecycle.js'; import { logger } from '../../utils/logging.js'; import { cleanupTempDir } from '../../utils/repo.js'; -import type { FileLogger } from './lifecycle.js'; +import type { FileLogger } from './executionPipeline.js'; /** * Clean up temporary resources after agent execution. diff --git a/src/agents/shared/githubAgent.ts b/src/agents/shared/githubAgent.ts deleted file mode 100644 index 94f71bc6..00000000 --- a/src/agents/shared/githubAgent.ts +++ /dev/null @@ -1,265 +0,0 @@ -import type { ModelSpec } from 'llmist'; - -import { createProgressMonitor } from '../../backends/progress.js'; -import { INITIAL_MESSAGES } from '../../config/agentMessages.js'; -import { CUSTOM_MODELS } from '../../config/customModels.js'; -import { recordInitialComment } from '../../gadgets/sessionState.js'; -import { githubClient, withGitHubToken } from '../../github/client.js'; -import { getPersonaToken } from '../../github/personas.js'; -import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../../types/index.js'; -import { logger } from '../../utils/logging.js'; -import { parseRepoFullName } from '../../utils/repo.js'; -import type { AgentLogger } from '../utils/logging.js'; -import type { TrackingContext } from '../utils/tracking.js'; -import { - type BuilderType, - type CreateBuilderOptions, - createConfiguredBuilder, -} from './builderFactory.js'; -import { type BaseAgentContext, executeAgentLifecycle } from './lifecycle.js'; -import { setupRepository } from './repository.js'; -import { injectSyntheticCall } from './syntheticCalls.js'; - -// ============================================================================ -// Types -// ============================================================================ - -export interface GitHubAgentInput extends AgentInput { - prNumber: number; - prBranch: string; - repoFullName: string; - project: ProjectConfig; - config: CascadeConfig; -} - -export interface RepoIdentifier { - owner: string; - repo: string; -} - -export interface InitialCommentResult { - id: number; - htmlUrl: string; - gadgetName: string; -} - -export interface GitHubAgentContext extends BaseAgentContext { - systemPrompt: string; -} - -export interface GitHubAgentDefinition< - TInput extends GitHubAgentInput, - TContext extends GitHubAgentContext, -> { - agentType: string; - /** Static header message — last-resort fallback when no ackMessage or INITIAL_MESSAGES entry. */ - headerMessage?: string; - initialCommentDescription: string; - timeoutMessage: string; - loggerPrefix: string; - - getGadgets(): CreateBuilderOptions['gadgets']; - - preExecute?(input: TInput, id: RepoIdentifier): Promise; - - postInitialComment( - input: TInput, - id: RepoIdentifier, - headerMessage: string, - ): Promise; - - buildContext( - id: RepoIdentifier, - input: TInput, - repoDir: string, - log: AgentLogger, - ): Promise; - - injectSyntheticCalls(params: { - builder: BuilderType; - ctx: TContext; - trackingContext: TrackingContext; - repoDir: string; - id: RepoIdentifier; - input: TInput; - }): Promise; - - wrapExecution?(input: TInput, runLifecycle: () => Promise): Promise; - - builderOptions?: Pick; -} - -// ============================================================================ -// Default Helpers -// ============================================================================ - -export async function createInitialPRComment( - prNumber: number, - id: RepoIdentifier, - headerMessage: string, -): Promise { - const comment = await githubClient.createPRComment(id.owner, id.repo, prNumber, headerMessage); - return { id: comment.id, htmlUrl: comment.htmlUrl, gadgetName: 'PostPRComment' }; -} - -// ============================================================================ -// Shared Execution -// ============================================================================ - -export async function executeGitHubAgent< - TInput extends GitHubAgentInput, - TContext extends GitHubAgentContext, ->(definition: GitHubAgentDefinition, input: TInput): Promise { - const { prNumber, prBranch, repoFullName, project, interactive, autoAccept } = input; - - let owner: string; - let repo: string; - try { - ({ owner, repo } = parseRepoFullName(repoFullName)); - } catch { - return { success: false, output: '', error: `Invalid repo format: ${repoFullName}` }; - } - const id: RepoIdentifier = { owner, repo }; - - if (definition.preExecute) { - const earlyResult = await definition.preExecute(input, id); - if (earlyResult) return earlyResult; - } - - // Resolve effective header: ackMessage (LLM-generated) > INITIAL_MESSAGES > definition fallback - const effectiveHeader = - (input.ackMessage as string | undefined) ?? - INITIAL_MESSAGES[definition.agentType] ?? - definition.headerMessage ?? - INITIAL_MESSAGES.implementation; - - // Pre-existing ack comment from router or webhook handler - const preExistingAckId = input.ackCommentId as number | undefined; - - const runLifecycle = () => - executeAgentLifecycle({ - loggerIdentifier: `${definition.loggerPrefix}-${prNumber}`, - - onWatchdogTimeout: async () => { - await githubClient.createPRComment(owner, repo, prNumber, definition.timeoutMessage); - logger.info('Posted timeout notice to PR', { prNumber }); - }, - - setupRepoDir: (log) => - setupRepository({ project, log, agentType: definition.agentType, prBranch }), - - buildContext: (repoDir, log) => definition.buildContext(id, input, repoDir, log), - - createBuilder: ({ - client, - ctx, - llmistLogger, - trackingContext, - fileLogger, - repoDir, - progressMonitor, - llmCallAccumulator, - runId, - }) => - createConfiguredBuilder({ - client, - agentType: definition.agentType, - model: ctx.model, - systemPrompt: ctx.systemPrompt, - maxIterations: ctx.maxIterations, - llmistLogger, - trackingContext, - logWriter: fileLogger.write.bind(fileLogger), - llmCallLogger: fileLogger.llmCallLogger, - repoDir, - gadgets: definition.getGadgets(), - progressMonitor: progressMonitor ?? undefined, - remainingBudgetUsd: input.remainingBudgetUsd as number | undefined, - llmCallAccumulator, - runId, - baseBranch: project.baseBranch, - projectId: project.id, - cardId: input.cardId, - ...definition.builderOptions, - }), - - injectSyntheticCalls: async ({ builder, ctx, trackingContext, repoDir }) => { - let initialCommentId: number; - let initialCommentHtmlUrl: string; - let gadgetName: string; - - if (preExistingAckId) { - // Ack comment already posted by router/webhook-handler — reuse it - recordInitialComment(preExistingAckId); - initialCommentId = preExistingAckId; - initialCommentHtmlUrl = `https://github.com/${owner}/${repo}/pull/${prNumber}#issuecomment-${preExistingAckId}`; - gadgetName = 'PostPRComment'; - } else { - // No pre-existing ack — post initial comment now - const initialComment = await definition.postInitialComment(input, id, effectiveHeader); - recordInitialComment(initialComment.id); - initialCommentId = initialComment.id; - initialCommentHtmlUrl = initialComment.htmlUrl; - gadgetName = initialComment.gadgetName; - } - - const withComment = injectSyntheticCall( - builder, - trackingContext, - gadgetName, - { - comment: definition.initialCommentDescription, - owner, - repo, - prNumber, - body: effectiveHeader, - }, - `Comment posted (id: ${initialCommentId}): ${initialCommentHtmlUrl}`, - 'gc_initial_comment', - ); - - return definition.injectSyntheticCalls({ - builder: withComment, - ctx, - trackingContext, - repoDir, - id, - input, - }); - }, - - createProgressMonitor: (fileLogger, _repoDir) => - createProgressMonitor({ - logWriter: fileLogger.write.bind(fileLogger), - agentType: definition.agentType, - taskDescription: `PR #${prNumber} in ${repoFullName}`, - progressModel: input.config.defaults.progressModel, - intervalMinutes: input.config.defaults.progressIntervalMinutes, - customModels: CUSTOM_MODELS as ModelSpec[], - github: { owner, repo, headerMessage: effectiveHeader }, - }), - - interactive, - autoAccept, - customModels: CUSTOM_MODELS, - - runTracking: { - projectId: project.id, - cardId: input.cardId, - prNumber, - agentType: definition.agentType, - backendName: 'llmist', - triggerType: input.triggerType, - }, - }); - - // Resolve the persona-based GitHub token (GITHUB_TOKEN_IMPLEMENTER or GITHUB_TOKEN_REVIEWER) - // for all PR interactions (comments, reviews). Individual agents can add further wrapping via wrapExecution. - const agentGitHubToken = await getPersonaToken(input.project.id, definition.agentType); - const scopedLifecycle = () => withGitHubToken(agentGitHubToken, runLifecycle); - - if (definition.wrapExecution) { - return definition.wrapExecution(input, scopedLifecycle); - } - return scopedLifecycle(); -} diff --git a/src/agents/shared/lifecycle.ts b/src/agents/shared/lifecycle.ts deleted file mode 100644 index 4d762dcf..00000000 --- a/src/agents/shared/lifecycle.ts +++ /dev/null @@ -1,323 +0,0 @@ -import fs from 'node:fs'; - -import { LLMist, type ModelSpec, createLogger } from 'llmist'; - -import type { ProgressMonitor } from '../../backends/progressMonitor.js'; -import { - type CompleteRunInput, - type LlmCallRecord, - storeLlmCallsBulk, - storeRunLogs, -} from '../../db/repositories/runsRepository.js'; -import { addBreadcrumb } from '../../sentry.js'; -import type { AgentResult } from '../../types/index.js'; -import { logger } from '../../utils/logging.js'; -import { runAgentLoop } from '../utils/agentLoop.js'; -import type { AccumulatedLlmCall } from '../utils/hooks.js'; -import { getLogLevel } from '../utils/index.js'; -import { createAgentLogger } from '../utils/logging.js'; -import { createTrackingContext } from '../utils/tracking.js'; -import type { BuilderType } from './builderFactory.js'; -import { - type AgentLogger, - type FileLogger, - type FinalizeRunOutcome, - type PipelineContext, - executeAgentPipeline, -} from './executionPipeline.js'; -import type { RunTrackingInput } from './runTracking.js'; -import { tryCompleteRun, tryCreateRun } from './runTracking.js'; - -export type { FileLogger, AgentLogger }; - -export interface BaseAgentContext { - model: string; - maxIterations: number; - prompt: string; -} - -export interface ExecuteAgentOptions { - /** Identifier for log file naming (e.g., "review-42", "ci-42") */ - loggerIdentifier: string; - - /** Called when the watchdog timer expires. FileLogger is already closed. */ - onWatchdogTimeout: (fileLogger: FileLogger, runId?: string) => Promise; - - /** Set up the working directory (clone repo, etc.) */ - setupRepoDir: (log: AgentLogger) => Promise; - - /** Build agent-specific context (model config, PR data, etc.) */ - buildContext: (repoDir: string, log: AgentLogger) => Promise; - - /** Create the configured agent builder with gadgets */ - createBuilder: (params: { - client: LLMist; - ctx: TContext; - llmistLogger: ReturnType; - trackingContext: ReturnType; - fileLogger: FileLogger; - repoDir: string; - progressMonitor: ProgressMonitor | null; - llmCallAccumulator: AccumulatedLlmCall[]; - /** Run ID for real-time LLM call logging (resolved before builder creation) */ - runId: string | undefined; - }) => BuilderType; - - /** Inject pre-fetched data as synthetic gadget calls */ - injectSyntheticCalls: (params: { - builder: BuilderType; - ctx: TContext; - trackingContext: ReturnType; - repoDir: string; - }) => Promise; - - /** Create a ProgressMonitor for time-based progress reporting */ - createProgressMonitor?: (fileLogger: FileLogger, repoDir: string) => ProgressMonitor | null; - - /** Whether to run in interactive mode */ - interactive?: boolean; - - /** Whether to auto-accept gadget calls */ - autoAccept?: boolean; - - /** Custom model definitions for LLMist */ - customModels?: ModelSpec[]; - - /** Extract additional fields from agent output (e.g., PR URL) */ - postProcess?: (output: string) => Partial; - - /** Run tracking configuration (if set, creates DB records) */ - runTracking?: RunTrackingInput; - - /** Remote Squint DB URL for projects that don't commit .squint.db */ - squintDbUrl?: string; -} - -// ============================================================================ -// Run Tracking Helpers -// ============================================================================ - -async function tryStoreLogsAndCalls( - runId: string, - fileLogger: FileLogger, - llmCallAccumulator: AccumulatedLlmCall[], - realtimeLoggingActive?: boolean, -): Promise { - try { - // Read log files from disk - const cascadeLog = fs.existsSync(fileLogger.logPath) - ? fs.readFileSync(fileLogger.logPath, 'utf-8') - : undefined; - const llmistLog = fs.existsSync(fileLogger.llmistLogPath) - ? fs.readFileSync(fileLogger.llmistLogPath, 'utf-8') - : undefined; - - await storeRunLogs(runId, cascadeLog, llmistLog); - - // Merge file-based request/response text with accumulator-based token/cost metrics - const llmLogFiles = fileLogger.llmCallLogger.getLogFiles(); - const requestFiles = new Map(); - const responseFiles = new Map(); - - for (const filePath of llmLogFiles) { - const basename = filePath.split('/').pop() ?? ''; - const match = basename.match(/^(\d+)\.(request|response)$/); - if (!match) continue; - const callNum = Number.parseInt(match[1], 10); - const content = fs.readFileSync(filePath, 'utf-8'); - if (match[2] === 'request') { - requestFiles.set(callNum, content); - } else { - responseFiles.set(callNum, content); - } - } - - // Build LLM call records by merging file content with accumulator metrics - const accumulatorMap = new Map(); - for (const acc of llmCallAccumulator) { - accumulatorMap.set(acc.callNumber, acc); - } - - const allCallNumbers = new Set([ - ...requestFiles.keys(), - ...responseFiles.keys(), - ...accumulatorMap.keys(), - ]); - - const calls: LlmCallRecord[] = []; - for (const callNumber of allCallNumbers) { - const acc = accumulatorMap.get(callNumber); - calls.push({ - runId, - callNumber, - request: requestFiles.get(callNumber), - response: responseFiles.get(callNumber), - inputTokens: acc?.inputTokens, - outputTokens: acc?.outputTokens, - cachedTokens: acc?.cachedTokens, - costUsd: acc?.costUsd, - durationMs: acc?.durationMs, - model: acc?.model, - }); - } - - // Skip bulk insert if real-time logging was active (calls already stored per-turn) - if (calls.length > 0 && !realtimeLoggingActive) { - await storeLlmCallsBulk(calls); - } - } catch (err) { - logger.warn('Failed to store run logs', { runId, error: String(err) }); - } -} - -async function finalizeRunWithLlmCalls( - runId: string | undefined, - fileLogger: FileLogger, - llmCallAccumulator: AccumulatedLlmCall[], - input: CompleteRunInput, - realtimeLoggingActive?: boolean, -): Promise { - if (!runId) return; - await tryStoreLogsAndCalls(runId, fileLogger, llmCallAccumulator, realtimeLoggingActive); - await tryCompleteRun(runId, input); -} - -// ============================================================================ -// Main Lifecycle -// ============================================================================ - -/** - * Shared agent execution lifecycle handling logger setup, watchdog, - * repository setup, LLMist agent creation, execution, and cleanup. - */ -export async function executeAgentLifecycle( - options: ExecuteAgentOptions, -): Promise { - const llmCallAccumulator: AccumulatedLlmCall[] = []; - - // Build the finalizeRun callback with access to llmCallAccumulator - const buildFinalizeRun = - (finalizeRunFn: typeof finalizeRunWithLlmCalls) => - async ( - runId: string | undefined, - fileLogger: FileLogger, - outcome: FinalizeRunOutcome, - ): Promise => { - const meta = outcome.metadata as { llmIterations?: number; gadgetCalls?: number } | undefined; - - const completeInput: CompleteRunInput = { - status: outcome.status, - durationMs: outcome.durationMs, - success: outcome.success, - error: outcome.error, - costUsd: outcome.costUsd, - prUrl: outcome.prUrl, - outputSummary: outcome.outputSummary, - llmIterations: meta?.llmIterations, - gadgetCalls: meta?.gadgetCalls, - }; - await finalizeRunFn(runId, fileLogger, llmCallAccumulator, completeInput, !!runId); - }; - - return executeAgentPipeline({ - loggerIdentifier: options.loggerIdentifier, - setupRepoDir: options.setupRepoDir, - squintDbUrl: options.squintDbUrl, - - onWatchdogTimeout: async (fileLogger, runId) => { - await options.onWatchdogTimeout(fileLogger, runId); - }, - - finalizeRun: buildFinalizeRun(finalizeRunWithLlmCalls), - - execute: async (ctx: PipelineContext) => { - const { repoDir, fileLogger, setRunId } = ctx; - - const log = createAgentLogger(fileLogger); - const ctx_ = await options.buildContext(repoDir, log); - - // Create run record now that we have model and maxIterations - let runId: string | undefined; - if (options.runTracking) { - runId = await tryCreateRun(options.runTracking, ctx_.model, ctx_.maxIterations); - if (runId) setRunId(runId); - } - - log.info('Starting llmist agent', { - model: ctx_.model, - maxIterations: ctx_.maxIterations, - promptLength: ctx_.prompt.length, - runId, - }); - - addBreadcrumb({ - category: 'agent', - message: `Starting ${options.loggerIdentifier}`, - data: { model: ctx_.model, maxIterations: ctx_.maxIterations, runId }, - }); - - process.env.LLMIST_LOG_FILE = fileLogger.llmistLogPath; - process.env.LLMIST_LOG_TEE = 'true'; - - const client = options.customModels - ? new LLMist({ customModels: options.customModels }) - : new LLMist(); - const llmistLogger = createLogger({ minLevel: getLogLevel() }); - const trackingContext = createTrackingContext(); - const progressMonitor = options.createProgressMonitor?.(fileLogger, repoDir) ?? null; - - let builder = options.createBuilder({ - client, - ctx: ctx_, - llmistLogger, - trackingContext, - fileLogger, - repoDir, - progressMonitor, - llmCallAccumulator, - runId, - }); - builder = await options.injectSyntheticCalls({ - builder, - ctx: ctx_, - trackingContext, - repoDir, - }); - - const agent = builder.ask(ctx_.prompt); - - progressMonitor?.start(); - let result: Awaited>; - try { - result = await runAgentLoop( - agent, - log, - trackingContext, - options.interactive === true, - options.autoAccept === true, - ); - } finally { - progressMonitor?.stop(); - } - - log.info('Agent completed', { - iterations: result.iterations, - gadgetCalls: result.gadgetCalls, - cost: result.cost, - loopTerminated: result.loopTerminated ?? false, - }); - - return { - success: !result.loopTerminated, - output: result.output, - error: result.loopTerminated ? 'Agent terminated due to persistent loop' : undefined, - cost: result.cost, - prUrl: options.postProcess?.(result.output)?.prUrl, - finalizeMetadata: { - llmIterations: result.iterations, - gadgetCalls: result.gadgetCalls, - }, - }; - }, - }); -} diff --git a/src/agents/shared/prResponseAgent.ts b/src/agents/shared/prResponseAgent.ts deleted file mode 100644 index 4f9a739b..00000000 --- a/src/agents/shared/prResponseAgent.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { githubClient } from '../../github/client.js'; -import type { CascadeConfig, ProjectConfig } from '../../types/index.js'; -import type { TrackingContext } from '../utils/tracking.js'; -import type { BuilderType } from './builderFactory.js'; -import type { GitHubAgentContext, GitHubAgentInput, RepoIdentifier } from './githubAgent.js'; -import { type InitialCommentResult, createInitialPRComment } from './githubAgent.js'; -import { resolveModelConfig } from './modelResolution.js'; -import { - formatPRComments, - formatPRDetails, - formatPRDiff, - formatPRIssueComments, - formatPRReviews, -} from './prFormatting.js'; -import { - injectContextFiles, - injectDirectoryListing, - injectSquintContext, - injectSyntheticCall, -} from './syntheticCalls.js'; - -// ============================================================================ -// Shared Types -// ============================================================================ - -export interface PRResponseAgentInput extends GitHubAgentInput { - triggerCommentId: number; - triggerCommentBody: string; - triggerCommentPath: string; - triggerCommentUrl: string; -} - -export interface PRResponseContextData extends GitHubAgentContext { - contextFiles: Awaited>['contextFiles']; - prDetailsFormatted: string; - commentsFormatted: string; - reviewsFormatted: string; - issueCommentsFormatted: string; - diffFormatted: string; -} - -// ============================================================================ -// Context Builder -// ============================================================================ - -export async function buildPRResponseContext( - owner: string, - repo: string, - prNumber: number, - prBranch: string, - repoDir: string, - project: ProjectConfig, - config: CascadeConfig, - log: { info: (msg: string, ctx?: Record) => void }, - agentType: string, - promptBuilder: (prBranch: string, prNumber: number, owner: string, repo: string) => string, - modelOverride?: string, -): Promise { - const { systemPrompt, model, maxIterations, contextFiles } = await resolveModelConfig({ - agentType, - project, - config, - repoDir, - modelOverride, - configKey: 'review', - }); - - log.info('Fetching PR details, comments, reviews, issue comments, and diff', { - owner, - repo, - prNumber, - }); - const prDetails = await githubClient.getPR(owner, repo, prNumber); - const prComments = await githubClient.getPRReviewComments(owner, repo, prNumber); - const prReviews = await githubClient.getPRReviews(owner, repo, prNumber); - const prIssueComments = await githubClient.getPRIssueComments(owner, repo, prNumber); - const prDiff = await githubClient.getPRDiff(owner, repo, prNumber); - - const prDetailsFormatted = formatPRDetails(prDetails); - const commentsFormatted = formatPRComments(prComments); - const reviewsFormatted = formatPRReviews(prReviews); - const issueCommentsFormatted = formatPRIssueComments(prIssueComments); - const diffFormatted = formatPRDiff(prDiff); - - const prompt = promptBuilder(prBranch, prNumber, owner, repo); - - return { - systemPrompt, - model, - maxIterations, - contextFiles, - prDetailsFormatted, - commentsFormatted, - reviewsFormatted, - issueCommentsFormatted, - diffFormatted, - prompt, - }; -} - -// ============================================================================ -// Prompt Builder -// ============================================================================ - -export function buildPRResponsePrompt( - prBranch: string, - prNumber: number, - owner: string, - repo: string, - instructionLine: string, - gadgetNames: string, -): string { - return `You are on the branch \`${prBranch}\` for PR #${prNumber}. - -${instructionLine} - -## GitHub Context - -Owner: ${owner} -Repo: ${repo} -PR Number: ${prNumber} - -Use these values when calling GitHub gadgets (${gadgetNames}).`; -} - -// ============================================================================ -// Initial Comment Handler -// ============================================================================ - -export async function postInitialPRResponseComment( - input: PRResponseAgentInput, - id: RepoIdentifier, - headerMessage: string, -): Promise { - return createInitialPRComment(input.prNumber, id, headerMessage); -} - -// ============================================================================ -// Synthetic Call Injection -// ============================================================================ - -/** Default comment descriptions used by respond-to-review. */ -const DEFAULT_COMMENT_DESCRIPTIONS = { - prComments: 'Pre-fetching line-specific review comments to address', - prReviews: 'Pre-fetching review submissions (approve/request changes with body text)', - prIssueComments: 'Pre-fetching general PR comments (issue-style conversation)', -}; - -export interface InjectPRResponseSyntheticCallsParams { - builder: BuilderType; - ctx: PRResponseContextData; - trackingContext: TrackingContext; - repoDir: string; - id: RepoIdentifier; - input: PRResponseAgentInput; -} - -export interface InjectPRResponseSyntheticCallsOptions { - /** Callback to inject additional synthetic calls before the standard PR data calls. */ - preSyntheticCalls?: ( - builder: BuilderType, - trackingContext: TrackingContext, - input: PRResponseAgentInput, - ) => BuilderType; - /** Override default comment descriptions for specific calls. */ - commentDescriptions?: Partial; -} - -export function injectPRResponseSyntheticCalls( - params: InjectPRResponseSyntheticCallsParams, - options?: InjectPRResponseSyntheticCallsOptions, -): BuilderType { - const { ctx, trackingContext, repoDir, input } = params; - const { owner, repo } = params.id; - const descriptions = { ...DEFAULT_COMMENT_DESCRIPTIONS, ...options?.commentDescriptions }; - - let b = injectDirectoryListing(params.builder, trackingContext); - - if (options?.preSyntheticCalls) { - b = options.preSyntheticCalls(b, trackingContext, input); - } - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRDetails', - { comment: 'Pre-fetching PR details for context', owner, repo, prNumber: input.prNumber }, - ctx.prDetailsFormatted, - 'gc_pr_details', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRComments', - { comment: descriptions.prComments, owner, repo, prNumber: input.prNumber }, - ctx.commentsFormatted, - 'gc_pr_comments', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRReviews', - { comment: descriptions.prReviews, owner, repo, prNumber: input.prNumber }, - ctx.reviewsFormatted, - 'gc_pr_reviews', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRIssueComments', - { comment: descriptions.prIssueComments, owner, repo, prNumber: input.prNumber }, - ctx.issueCommentsFormatted, - 'gc_pr_issue_comments', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRDiff', - { comment: 'Pre-fetching PR diff for context', owner, repo, prNumber: input.prNumber }, - ctx.diffFormatted, - 'gc_pr_diff', - ); - - b = injectContextFiles(b, trackingContext, ctx.contextFiles); - b = injectSquintContext(b, trackingContext, repoDir); - - return b; -} diff --git a/src/agents/shared/runTracking.ts b/src/agents/shared/runTracking.ts index 24c01b60..d0cc71ed 100644 --- a/src/agents/shared/runTracking.ts +++ b/src/agents/shared/runTracking.ts @@ -7,7 +7,7 @@ import { storeRunLogs, } from '../../db/repositories/runsRepository.js'; import { logger } from '../../utils/logging.js'; -import type { FileLogger } from './lifecycle.js'; +import type { FileLogger } from './executionPipeline.js'; // ============================================================================ // Run Tracking Configuration diff --git a/src/agents/shared/syntheticCalls.ts b/src/agents/shared/syntheticCalls.ts index b465e7e8..b3c5b8d0 100644 --- a/src/agents/shared/syntheticCalls.ts +++ b/src/agents/shared/syntheticCalls.ts @@ -1,7 +1,3 @@ -import { execFileSync } from 'node:child_process'; -import { ListDirectory } from '../../gadgets/ListDirectory.js'; -import { resolveSquintDbPath } from '../../utils/squintDb.js'; -import type { ContextFile } from '../utils/setup.js'; import { type TrackingContext, recordSyntheticInvocationId } from '../utils/tracking.js'; import type { BuilderType } from './builderFactory.js'; @@ -19,86 +15,3 @@ export function injectSyntheticCall( recordSyntheticInvocationId(trackingContext, invocationId); return builder.withSyntheticGadgetCall(gadgetName, params, result, invocationId); } - -/** - * Inject directory listing as synthetic ListDirectory call. - */ -export function injectDirectoryListing( - builder: BuilderType, - trackingContext: TrackingContext, - maxDepth = 3, -): BuilderType { - const listDirGadget = new ListDirectory(); - const listDirParams = { - comment: 'Pre-fetching codebase structure for context', - directoryPath: '.', - maxDepth, - includeGitIgnored: false, - }; - const listDirResult = listDirGadget.execute(listDirParams); - return injectSyntheticCall( - builder, - trackingContext, - 'ListDirectory', - listDirParams, - listDirResult, - 'gc_dir', - ); -} - -/** - * Inject context files (CLAUDE.md, AGENTS.md, etc.) as synthetic ReadFile calls. - */ -export function injectContextFiles( - builder: BuilderType, - trackingContext: TrackingContext, - contextFiles: ContextFile[], -): BuilderType { - let result = builder; - for (let i = 0; i < contextFiles.length; i++) { - const file = contextFiles[i]; - const invocationId = `gc_init_${i + 1}`; - result = injectSyntheticCall( - result, - trackingContext, - 'ReadFile', - { comment: `Pre-fetching ${file.path} for project context`, filePath: file.path }, - file.content, - invocationId, - ); - } - return result; -} - -/** - * Inject Squint overview if enabled (gives agent immediate codebase context). - */ -export function injectSquintContext( - builder: BuilderType, - trackingContext: TrackingContext, - repoDir: string, -): BuilderType { - const squintDb = resolveSquintDbPath(repoDir); - if (!squintDb) return builder; - - try { - const output = execFileSync('squint', ['overview', '-d', squintDb], { - encoding: 'utf-8', - timeout: 30_000, - }); - - if (!output || !output.trim()) return builder; - - return injectSyntheticCall( - builder, - trackingContext, - 'SquintOverview', - { comment: 'Pre-fetching Squint codebase overview for context', database: squintDb }, - output, - 'gc_squint_overview', - ); - } catch { - // Squint command failed, continue without it - return builder; - } -} diff --git a/src/agents/shared/workItemBuilder.ts b/src/agents/shared/workItemBuilder.ts deleted file mode 100644 index f5eeddd6..00000000 --- a/src/agents/shared/workItemBuilder.ts +++ /dev/null @@ -1,165 +0,0 @@ -import type { LLMist, createLogger } from 'llmist'; - -import type { ProgressMonitor } from '../../backends/progressMonitor.js'; -import type { LLMCallLogger } from '../../utils/llmLogging.js'; -import type { AccumulatedLlmCall } from '../utils/hooks.js'; -import type { TrackingContext } from '../utils/tracking.js'; -import { type BuilderType, createConfiguredBuilder } from './builderFactory.js'; -import { getAgentCapabilities } from './capabilities.js'; -import { buildWorkItemGadgets } from './gadgets.js'; -import { - injectContextFiles, - injectDirectoryListing, - injectSquintContext, - injectSyntheticCall, -} from './syntheticCalls.js'; -import type { AgentContextData } from './workItemContext.js'; - -import { - type Todo, - formatTodoList, - initTodoSession, - saveTodos, -} from '../../gadgets/todo/storage.js'; - -// ============================================================================ -// Gadget Helpers -// ============================================================================ - -export function getBaseAgentGadgets(agentType: string) { - return buildWorkItemGadgets(getAgentCapabilities(agentType)); -} - -// ============================================================================ -// Builder Creation -// ============================================================================ - -export interface CreateWorkItemAgentBuilderParams { - client: LLMist; - ctx: AgentContextData; - llmistLogger: ReturnType; - trackingContext: TrackingContext; - agentType: string; - logWriter: (level: string, message: string, context?: Record) => void; - llmCallLogger: LLMCallLogger; - repoDir: string; - progressMonitor?: ProgressMonitor; - remainingBudgetUsd?: number; - llmCallAccumulator?: AccumulatedLlmCall[]; - runId?: string; - baseBranch?: string; - projectId?: string; - cardId?: string; -} - -export function createWorkItemAgentBuilder(params: CreateWorkItemAgentBuilderParams): BuilderType { - const { - client, - ctx, - llmistLogger, - trackingContext, - agentType, - logWriter, - llmCallLogger, - repoDir, - progressMonitor, - remainingBudgetUsd, - llmCallAccumulator, - runId, - baseBranch, - projectId, - cardId, - } = params; - - return createConfiguredBuilder({ - client, - agentType, - model: ctx.model, - systemPrompt: ctx.systemPrompt, - maxIterations: ctx.maxIterations, - llmistLogger, - trackingContext, - logWriter, - llmCallLogger, - repoDir, - gadgets: getBaseAgentGadgets(agentType), - progressMonitor, - remainingBudgetUsd, - llmCallAccumulator, - runId, - baseBranch, - projectId, - cardId, - // Implementation agent uses sequential execution to ensure file operations - // are properly ordered (e.g., FileSearchAndReplace then ReadFile on same file) - postConfigure: - agentType === 'implementation' - ? (builder) => builder.withGadgetExecutionMode('sequential') - : undefined, - }); -} - -// ============================================================================ -// Synthetic Call Injection -// ============================================================================ - -export async function injectWorkItemSyntheticCalls( - initialBuilder: BuilderType, - cardId: string | undefined, - cardData: string, - contextFiles: AgentContextData['contextFiles'], - trackingContext: TrackingContext, - repoDir: string, - implementationSteps?: string[], -): Promise { - // Use maxDepth=5 to give agents better visibility into nested structures - let builder = injectDirectoryListing(initialBuilder, trackingContext, 5); - - // Inject context files (CLAUDE.md, AGENTS.md) — conventions first - builder = injectContextFiles(builder, trackingContext, contextFiles); - - // Inject Squint overview BEFORE card data — agent sees architectural map - // before encountering specific file paths from the card - builder = injectSquintContext(builder, trackingContext, repoDir); - - // Inject work item data as synthetic ReadWorkItem call (only if cardId exists) - if (cardId && cardData) { - builder = injectSyntheticCall( - builder, - trackingContext, - 'ReadWorkItem', - { workItemId: cardId, includeComments: true }, - cardData, - 'gc_card', - ); - } - - // Inject pre-populated todos LAST — strongest "start coding" signal - if (implementationSteps && implementationSteps.length > 0) { - initTodoSession(`impl-${Date.now()}`); - - const now = new Date().toISOString(); - const todos: Todo[] = implementationSteps.map((step, i) => ({ - id: String(i + 1), - content: step, - status: 'pending' as const, - createdAt: now, - updatedAt: now, - })); - saveTodos(todos); - - builder = injectSyntheticCall( - builder, - trackingContext, - 'TodoUpsert', - { - items: implementationSteps.map((step) => ({ content: step })), - comment: 'Pre-populated from Implementation Steps checklist', - }, - `➕ Created ${todos.length} todos.\n\n${formatTodoList(todos)}`, - 'gc_todos', - ); - } - - return builder; -} diff --git a/src/agents/shared/workItemContext.ts b/src/agents/shared/workItemContext.ts deleted file mode 100644 index d2a4f0cd..00000000 --- a/src/agents/shared/workItemContext.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { loadPartials } from '../../db/repositories/partialsRepository.js'; -import { readWorkItem } from '../../gadgets/pm/core/readWorkItem.js'; -import { getPMProvider } from '../../pm/index.js'; -import type { CascadeConfig, ProjectConfig } from '../../types/index.js'; -import { type ModelConfig, resolveModelConfig } from './modelResolution.js'; -import { buildPromptContext } from './promptContext.js'; -import { - buildCheckFailurePrompt, - buildCommentResponsePrompt, - buildDebugPrompt, - buildWorkItemPrompt, -} from './taskPrompts.js'; - -// ============================================================================ -// Types -// ============================================================================ - -export interface AgentContextData { - systemPrompt: string; - model: string; - maxIterations: number; - contextFiles: ModelConfig['contextFiles']; - cardData: string; - prompt: string; - implementationSteps?: string[]; -} - -// ============================================================================ -// Helpers -// ============================================================================ - -export async function fetchImplementationSteps(cardId: string): Promise { - try { - const provider = getPMProvider(); - const checklists = await provider.getChecklists(cardId); - const implChecklist = checklists.find((cl) => cl.name.includes('Implementation Steps')); - if (!implChecklist || implChecklist.items.length === 0) return undefined; - const incompleteItems = implChecklist.items.filter((item) => !item.complete); - return incompleteItems.length > 0 ? incompleteItems.map((item) => item.name) : undefined; - } catch { - return undefined; - } -} - -async function loadDbPartials(orgId: string): Promise | undefined> { - try { - return await loadPartials(orgId); - } catch { - // DB not available — fall back to disk-only partials - return undefined; - } -} - -function selectPrompt( - cardId: string | undefined, - commentContext?: { text: string; author: string }, - prContext?: { prNumber: number; prBranch: string; repoFullName: string; headSha: string }, - debugContext?: { - logDir: string; - originalCardName: string; - originalCardUrl: string; - detectedAgentType: string; - }, -): string { - if (commentContext) { - return buildCommentResponsePrompt(cardId ?? '', commentContext.text, commentContext.author); - } - if (prContext) return buildCheckFailurePrompt(prContext); - if (debugContext) return buildDebugPrompt(debugContext); - return buildWorkItemPrompt(cardId ?? ''); -} - -// ============================================================================ -// Main Context Builder -// ============================================================================ - -export async function buildAgentContext( - agentType: string, - cardId: string | undefined, - repoDir: string, - project: ProjectConfig, - config: CascadeConfig, - log: { info: (msg: string, ctx?: Record) => void }, - triggerType?: string, - prContext?: { prNumber: number; prBranch: string; repoFullName: string; headSha: string }, - debugContext?: { - logDir: string; - originalCardId: string; - originalCardName: string; - originalCardUrl: string; - detectedAgentType: string; - }, - modelOverride?: string, - commentContext?: { text: string; author: string }, -): Promise { - const promptContext = buildPromptContext(cardId, project, triggerType, prContext, debugContext); - const dbPartials = await loadDbPartials(project.orgId); - - // Some agents share model/iteration config with another agent type - const configKeyOverrides: Record = { - 'respond-to-planning-comment': 'planning', - }; - - const { systemPrompt, model, maxIterations, contextFiles } = await resolveModelConfig({ - agentType, - project, - config, - repoDir, - modelOverride, - promptContext, - configKey: configKeyOverrides[agentType], - dbPartials, - }); - - // Pre-fetch work item data for synthetic gadget call (only if cardId exists and not debug flow) - let cardData = ''; - if (cardId && !debugContext) { - log.info('Fetching work item data for context', { cardId }); - cardData = await readWorkItem(cardId, true); - } - - // Pre-fetch implementation steps for synthetic todo injection - let implementationSteps: string[] | undefined; - if (agentType === 'implementation' && cardId && !debugContext) { - implementationSteps = await fetchImplementationSteps(cardId); - } - - const prompt = selectPrompt(cardId, commentContext, prContext, debugContext); - - return { - systemPrompt, - model, - maxIterations, - contextFiles, - cardData, - prompt, - implementationSteps, - }; -} diff --git a/tests/unit/agents/fetchImplementationSteps.test.ts b/tests/unit/agents/fetchImplementationSteps.test.ts deleted file mode 100644 index 840ae1ba..00000000 --- a/tests/unit/agents/fetchImplementationSteps.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('../../../src/pm/index.js', () => ({ - getPMProvider: vi.fn(), -})); - -import { fetchImplementationSteps } from '../../../src/agents/base.js'; -import type { PMProvider } from '../../../src/pm/index.js'; -import { getPMProvider } from '../../../src/pm/index.js'; - -const mockPMProvider = { - getChecklists: vi.fn(), -}; - -describe('fetchImplementationSteps', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(getPMProvider).mockReturnValue(mockPMProvider as unknown as PMProvider); - }); - - it('extracts incomplete items from Implementation Steps checklist', async () => { - mockPMProvider.getChecklists.mockResolvedValue([ - { - id: 'cl1', - name: '📋 Implementation Steps', - items: [ - { id: 'ci1', name: 'Add helper function', complete: false }, - { id: 'ci2', name: 'Update prompt template', complete: false }, - { id: 'ci3', name: 'Write tests', complete: false }, - ], - }, - ]); - - const result = await fetchImplementationSteps('card1'); - - expect(result).toEqual(['Add helper function', 'Update prompt template', 'Write tests']); - expect(mockPMProvider.getChecklists).toHaveBeenCalledWith('card1'); - }); - - it('filters out already-complete items', async () => { - mockPMProvider.getChecklists.mockResolvedValue([ - { - id: 'cl1', - name: '📋 Implementation Steps', - items: [ - { id: 'ci1', name: 'Already done step', complete: true }, - { id: 'ci2', name: 'Remaining step', complete: false }, - ], - }, - ]); - - const result = await fetchImplementationSteps('card1'); - - expect(result).toEqual(['Remaining step']); - }); - - it('returns undefined when all items are complete', async () => { - mockPMProvider.getChecklists.mockResolvedValue([ - { - id: 'cl1', - name: '📋 Implementation Steps', - items: [ - { id: 'ci1', name: 'Done step 1', complete: true }, - { id: 'ci2', name: 'Done step 2', complete: true }, - ], - }, - ]); - - const result = await fetchImplementationSteps('card1'); - - expect(result).toBeUndefined(); - }); - - it('returns undefined when no Implementation Steps checklist exists', async () => { - mockPMProvider.getChecklists.mockResolvedValue([ - { - id: 'cl1', - name: '✅ Acceptance Criteria', - items: [{ id: 'ci1', name: 'Some criterion', complete: false }], - }, - ]); - - const result = await fetchImplementationSteps('card1'); - - expect(result).toBeUndefined(); - }); - - it('returns undefined when checklist has no items', async () => { - mockPMProvider.getChecklists.mockResolvedValue([ - { - id: 'cl1', - name: '📋 Implementation Steps', - items: [], - }, - ]); - - const result = await fetchImplementationSteps('card1'); - - expect(result).toBeUndefined(); - }); - - it('returns undefined when card has no checklists', async () => { - mockPMProvider.getChecklists.mockResolvedValue([]); - - const result = await fetchImplementationSteps('card1'); - - expect(result).toBeUndefined(); - }); - - it('returns undefined when API call fails', async () => { - mockPMProvider.getChecklists.mockRejectedValue(new Error('API error')); - - const result = await fetchImplementationSteps('card1'); - - expect(result).toBeUndefined(); - }); - - it('matches checklist by substring (handles emoji prefix)', async () => { - mockPMProvider.getChecklists.mockResolvedValue([ - { - id: 'cl1', - name: 'Some other checklist', - items: [{ id: 'ci1', name: 'Ignored', complete: false }], - }, - { - id: 'cl2', - name: '📋 Implementation Steps (Phase 1)', - items: [{ id: 'ci2', name: 'Phase 1 step', complete: false }], - }, - ]); - - const result = await fetchImplementationSteps('card1'); - - expect(result).toEqual(['Phase 1 step']); - }); -}); diff --git a/tests/unit/agents/shared/lifecycle.test.ts b/tests/unit/agents/shared/lifecycle.test.ts deleted file mode 100644 index 1ed04734..00000000 --- a/tests/unit/agents/shared/lifecycle.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -// Mock all external dependencies -vi.mock('../../../../src/agents/utils/agentLoop.js', () => ({ - runAgentLoop: vi.fn(), -})); - -vi.mock('../../../../src/utils/fileLogger.js', () => ({ - createFileLogger: vi.fn(), - cleanupLogFile: vi.fn(), - cleanupLogDirectory: vi.fn(), -})); - -vi.mock('../../../../src/agents/utils/logging.js', () => ({ - createAgentLogger: vi.fn(), -})); - -vi.mock('../../../../src/utils/cascadeEnv.js', () => ({ - loadCascadeEnv: vi.fn(), - unloadCascadeEnv: vi.fn(), -})); - -vi.mock('../../../../src/utils/repo.js', () => ({ - cleanupTempDir: vi.fn(), -})); - -vi.mock('../../../../src/utils/lifecycle.js', () => ({ - setWatchdogCleanup: vi.fn(), - clearWatchdogCleanup: vi.fn(), -})); - -vi.mock('../../../../src/db/repositories/runsRepository.js', () => ({ - createRun: vi.fn(), - completeRun: vi.fn(), - storeRunLogs: vi.fn(), - storeLlmCallsBulk: vi.fn(), -})); - -vi.mock('llmist', () => ({ - LLMist: vi.fn().mockImplementation(() => ({})), - createLogger: vi.fn().mockReturnValue({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), -})); - -vi.mock('../../../../src/agents/utils/tracking.js', () => ({ - createTrackingContext: vi.fn().mockReturnValue({}), -})); - -import { executeAgentLifecycle } from '../../../../src/agents/shared/lifecycle.js'; -import { runAgentLoop } from '../../../../src/agents/utils/agentLoop.js'; -import { createAgentLogger } from '../../../../src/agents/utils/logging.js'; -import { - completeRun, - createRun, - storeLlmCallsBulk, - storeRunLogs, -} from '../../../../src/db/repositories/runsRepository.js'; -import { loadCascadeEnv, unloadCascadeEnv } from '../../../../src/utils/cascadeEnv.js'; -import { - cleanupLogDirectory, - cleanupLogFile, - createFileLogger, -} from '../../../../src/utils/fileLogger.js'; -import { clearWatchdogCleanup } from '../../../../src/utils/lifecycle.js'; -import { cleanupTempDir } from '../../../../src/utils/repo.js'; - -const mockRunAgentLoop = vi.mocked(runAgentLoop); -const mockCreateFileLogger = vi.mocked(createFileLogger); -const mockCreateAgentLogger = vi.mocked(createAgentLogger); -const mockLoadCascadeEnv = vi.mocked(loadCascadeEnv); -const mockUnloadCascadeEnv = vi.mocked(unloadCascadeEnv); -const mockCleanupTempDir = vi.mocked(cleanupTempDir); -const mockCleanupLogFile = vi.mocked(cleanupLogFile); -const mockCleanupLogDirectory = vi.mocked(cleanupLogDirectory); -const mockClearWatchdogCleanup = vi.mocked(clearWatchdogCleanup); -const mockCreateRun = vi.mocked(createRun); -const mockCompleteRun = vi.mocked(completeRun); -const mockStoreRunLogs = vi.mocked(storeRunLogs); -const mockStoreLlmCallsBulk = vi.mocked(storeLlmCallsBulk); - -function setupMocks() { - const mockLoggerInstance = { - write: vi.fn(), - close: vi.fn(), - getZippedBuffer: vi.fn().mockResolvedValue(Buffer.from('logs')), - logPath: '/tmp/test.log', - llmistLogPath: '/tmp/test-llmist.log', - llmCallLogger: { - logDir: '/tmp/llm-calls', - getLogFiles: vi.fn().mockReturnValue([]), - }, - }; - mockCreateFileLogger.mockReturnValue(mockLoggerInstance as never); - mockCreateAgentLogger.mockReturnValue({ info: vi.fn(), warn: vi.fn(), error: vi.fn() } as never); - mockLoadCascadeEnv.mockReturnValue({}); - mockRunAgentLoop.mockResolvedValue({ - output: 'Task completed', - iterations: 5, - gadgetCalls: 10, - cost: 0.5, - loopTerminated: false, - } as never); - - return mockLoggerInstance; -} - -beforeEach(() => { - vi.clearAllMocks(); - process.env.CASCADE_LOCAL_MODE = ''; -}); - -describe('executeAgentLifecycle', () => { - it('returns durationMs in successful result', async () => { - setupMocks(); - - const result = await executeAgentLifecycle({ - loggerIdentifier: 'test-run', - onWatchdogTimeout: vi.fn(), - setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), - buildContext: vi.fn().mockResolvedValue({ - model: 'test-model', - maxIterations: 50, - prompt: 'Do something', - }), - createBuilder: vi.fn().mockReturnValue({ - ask: vi.fn().mockReturnValue({}), - } as never), - injectSyntheticCalls: vi.fn().mockImplementation(({ builder }) => Promise.resolve(builder)), - }); - - expect(result.success).toBe(true); - expect(result.durationMs).toBeDefined(); - expect(result.durationMs).toBeGreaterThanOrEqual(0); - expect(typeof result.durationMs).toBe('number'); - }); - - it('returns durationMs in error result', async () => { - setupMocks(); - - const result = await executeAgentLifecycle({ - loggerIdentifier: 'test-run', - onWatchdogTimeout: vi.fn(), - setupRepoDir: vi.fn().mockRejectedValue(new Error('Setup failed')), - buildContext: vi.fn().mockResolvedValue({ - model: 'test-model', - maxIterations: 50, - prompt: 'Do something', - }), - createBuilder: vi.fn().mockReturnValue({ - ask: vi.fn().mockReturnValue({}), - } as never), - injectSyntheticCalls: vi.fn().mockImplementation(({ builder }) => Promise.resolve(builder)), - }); - - expect(result.success).toBe(false); - expect(result.durationMs).toBeDefined(); - expect(result.durationMs).toBeGreaterThanOrEqual(0); - expect(typeof result.durationMs).toBe('number'); - }); - - it('returns durationMs when loop is terminated', async () => { - const loggerInstance = setupMocks(); - mockRunAgentLoop.mockResolvedValue({ - output: 'Loop detected', - iterations: 50, - gadgetCalls: 100, - cost: 2.0, - loopTerminated: true, - } as never); - - const result = await executeAgentLifecycle({ - loggerIdentifier: 'test-run', - onWatchdogTimeout: vi.fn(), - setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), - buildContext: vi.fn().mockResolvedValue({ - model: 'test-model', - maxIterations: 50, - prompt: 'Do something', - }), - createBuilder: vi.fn().mockReturnValue({ - ask: vi.fn().mockReturnValue({}), - } as never), - injectSyntheticCalls: vi.fn().mockImplementation(({ builder }) => Promise.resolve(builder)), - }); - - expect(result.success).toBe(false); - expect(result.error).toBe('Agent terminated due to persistent loop'); - expect(result.durationMs).toBeDefined(); - expect(result.durationMs).toBeGreaterThanOrEqual(0); - expect(typeof result.durationMs).toBe('number'); - }); - - it('passes durationMs to completeRun on success', async () => { - setupMocks(); - mockCreateRun.mockResolvedValue('run123'); - - await executeAgentLifecycle({ - loggerIdentifier: 'test-run', - onWatchdogTimeout: vi.fn(), - setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), - buildContext: vi.fn().mockResolvedValue({ - model: 'test-model', - maxIterations: 50, - prompt: 'Do something', - }), - createBuilder: vi.fn().mockReturnValue({ - ask: vi.fn().mockReturnValue({}), - } as never), - injectSyntheticCalls: vi.fn().mockImplementation(({ builder }) => Promise.resolve(builder)), - runTracking: { - projectId: 'test-project', - agentType: 'implementation', - backendName: 'llmist', - }, - }); - - expect(mockCompleteRun).toHaveBeenCalledWith( - 'run123', - expect.objectContaining({ - status: 'completed', - durationMs: expect.any(Number), - }), - ); - }); - - it('passes durationMs to completeRun on agent loop error', async () => { - const loggerInstance = setupMocks(); - mockCreateRun.mockResolvedValue('run123'); - mockRunAgentLoop.mockRejectedValue(new Error('Agent crashed')); - - await executeAgentLifecycle({ - loggerIdentifier: 'test-run', - onWatchdogTimeout: vi.fn(), - setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), - buildContext: vi.fn().mockResolvedValue({ - model: 'test-model', - maxIterations: 50, - prompt: 'Do something', - }), - createBuilder: vi.fn().mockReturnValue({ - ask: vi.fn().mockReturnValue({}), - } as never), - injectSyntheticCalls: vi.fn().mockImplementation(({ builder }) => Promise.resolve(builder)), - runTracking: { - projectId: 'test-project', - agentType: 'implementation', - backendName: 'llmist', - }, - }); - - expect(mockCompleteRun).toHaveBeenCalledWith( - 'run123', - expect.objectContaining({ - status: 'failed', - durationMs: expect.any(Number), - success: false, - }), - ); - }); -}); diff --git a/tests/unit/agents/shared/prResponseAgent.test.ts b/tests/unit/agents/shared/prResponseAgent.test.ts deleted file mode 100644 index 9e0cc7de..00000000 --- a/tests/unit/agents/shared/prResponseAgent.test.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('../../../../src/github/client.js', () => ({ - githubClient: { - getPR: vi.fn(), - getPRReviewComments: vi.fn(), - getPRReviews: vi.fn(), - getPRIssueComments: vi.fn(), - getPRDiff: vi.fn(), - updatePRComment: vi.fn(), - createPRComment: vi.fn(), - }, -})); - -vi.mock('../../../../src/agents/shared/modelResolution.js', () => ({ - resolveModelConfig: vi.fn(), -})); - -vi.mock('../../../../src/agents/shared/prFormatting.js', () => ({ - formatPRDetails: vi.fn((v) => `details:${v}`), - formatPRComments: vi.fn((v) => `comments:${v}`), - formatPRReviews: vi.fn((v) => `reviews:${v}`), - formatPRIssueComments: vi.fn((v) => `issueComments:${v}`), - formatPRDiff: vi.fn((v) => `diff:${v}`), -})); - -vi.mock('../../../../src/agents/shared/syntheticCalls.js', () => ({ - injectDirectoryListing: vi.fn((_b, _tc) => 'builder-after-dir'), - injectSyntheticCall: vi.fn((_b, _tc, name) => `builder-after-${name}`), - injectContextFiles: vi.fn((_b, _tc, _cf) => 'builder-after-context-files'), - injectSquintContext: vi.fn((_b, _tc, _rd) => 'builder-after-squint'), -})); - -vi.mock('../../../../src/agents/shared/githubAgent.js', () => ({ - createInitialPRComment: vi.fn(), -})); - -import { createInitialPRComment } from '../../../../src/agents/shared/githubAgent.js'; -import { resolveModelConfig } from '../../../../src/agents/shared/modelResolution.js'; -import { - type InjectPRResponseSyntheticCallsParams, - type PRResponseAgentInput, - type PRResponseContextData, - buildPRResponseContext, - buildPRResponsePrompt, - injectPRResponseSyntheticCalls, - postInitialPRResponseComment, -} from '../../../../src/agents/shared/prResponseAgent.js'; -import { - injectContextFiles, - injectDirectoryListing, - injectSquintContext, - injectSyntheticCall, -} from '../../../../src/agents/shared/syntheticCalls.js'; -import { githubClient } from '../../../../src/github/client.js'; - -const mockGithub = vi.mocked(githubClient); -const mockResolveModelConfig = vi.mocked(resolveModelConfig); -const mockCreateInitialPRComment = vi.mocked(createInitialPRComment); -const mockInjectDirectoryListing = vi.mocked(injectDirectoryListing); -const mockInjectSyntheticCall = vi.mocked(injectSyntheticCall); -const mockInjectContextFiles = vi.mocked(injectContextFiles); -const mockInjectSquintContext = vi.mocked(injectSquintContext); - -describe('prResponseAgent shared module', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - // ======================================================================== - // buildPRResponsePrompt - // ======================================================================== - - describe('buildPRResponsePrompt', () => { - it('generates prompt with the correct template values', () => { - const result = buildPRResponsePrompt( - 'feature/xyz', - 42, - 'myorg', - 'myrepo', - 'Address the review comments.', - 'GetPRComments, ReplyToReviewComment', - ); - - expect(result).toContain('`feature/xyz`'); - expect(result).toContain('PR #42'); - expect(result).toContain('Address the review comments.'); - expect(result).toContain('Owner: myorg'); - expect(result).toContain('Repo: myrepo'); - expect(result).toContain('PR Number: 42'); - expect(result).toContain('GetPRComments, ReplyToReviewComment'); - }); - - it('uses the instruction line and gadget names provided', () => { - const result = buildPRResponsePrompt( - 'fix/bug', - 7, - 'owner', - 'repo', - 'A user @mentioned you. Execute their request.', - 'PostPRComment, UpdatePRComment', - ); - - expect(result).toContain('A user @mentioned you. Execute their request.'); - expect(result).toContain('PostPRComment, UpdatePRComment'); - }); - }); - - // ======================================================================== - // postInitialPRResponseComment - // ======================================================================== - - describe('postInitialPRResponseComment', () => { - const id = { owner: 'org', repo: 'repo' }; - const baseInput = { - prNumber: 10, - prBranch: 'feat', - repoFullName: 'org/repo', - triggerCommentId: 1, - triggerCommentBody: 'body', - triggerCommentPath: 'path', - triggerCommentUrl: 'url', - } as PRResponseAgentInput; - - it('creates a new comment via createInitialPRComment', async () => { - mockCreateInitialPRComment.mockResolvedValue({ - id: 999, - htmlUrl: 'https://example.com/999', - gadgetName: 'PostPRComment', - }); - - const result = await postInitialPRResponseComment(baseInput, id, 'header'); - - expect(mockCreateInitialPRComment).toHaveBeenCalledWith(10, id, 'header'); - expect(result).toEqual({ - id: 999, - htmlUrl: 'https://example.com/999', - gadgetName: 'PostPRComment', - }); - }); - }); - - // ======================================================================== - // buildPRResponseContext - // ======================================================================== - - describe('buildPRResponseContext', () => { - const mockLog = { info: vi.fn() }; - - beforeEach(() => { - mockResolveModelConfig.mockResolvedValue({ - systemPrompt: 'sys', - model: 'gpt-4', - maxIterations: 10, - contextFiles: [{ path: 'CLAUDE.md', content: '# test' }], - }); - - mockGithub.getPR.mockResolvedValue('pr-raw' as never); - mockGithub.getPRReviewComments.mockResolvedValue('comments-raw' as never); - mockGithub.getPRReviews.mockResolvedValue('reviews-raw' as never); - mockGithub.getPRIssueComments.mockResolvedValue('issue-comments-raw' as never); - mockGithub.getPRDiff.mockResolvedValue('diff-raw' as never); - }); - - it('resolves model config with the correct agent type and configKey', async () => { - const promptBuilder = vi.fn().mockReturnValue('prompt'); - - await buildPRResponseContext( - 'org', - 'repo', - 42, - 'feat', - '/tmp/repo', - { id: 'proj' } as never, - { defaults: {} } as never, - mockLog, - 'respond-to-review', - promptBuilder, - ); - - expect(mockResolveModelConfig).toHaveBeenCalledWith({ - agentType: 'respond-to-review', - project: { id: 'proj' }, - config: { defaults: {} }, - repoDir: '/tmp/repo', - modelOverride: undefined, - configKey: 'review', - }); - }); - - it('fetches all 5 PR endpoints', async () => { - const promptBuilder = vi.fn().mockReturnValue('prompt'); - - await buildPRResponseContext( - 'org', - 'repo', - 42, - 'feat', - '/tmp/repo', - { id: 'proj' } as never, - { defaults: {} } as never, - mockLog, - 'respond-to-review', - promptBuilder, - ); - - expect(mockGithub.getPR).toHaveBeenCalledWith('org', 'repo', 42); - expect(mockGithub.getPRReviewComments).toHaveBeenCalledWith('org', 'repo', 42); - expect(mockGithub.getPRReviews).toHaveBeenCalledWith('org', 'repo', 42); - expect(mockGithub.getPRIssueComments).toHaveBeenCalledWith('org', 'repo', 42); - expect(mockGithub.getPRDiff).toHaveBeenCalledWith('org', 'repo', 42); - }); - - it('returns combined context data with formatted values', async () => { - const promptBuilder = vi.fn().mockReturnValue('my-prompt'); - - const result = await buildPRResponseContext( - 'org', - 'repo', - 42, - 'feat', - '/tmp/repo', - { id: 'proj' } as never, - { defaults: {} } as never, - mockLog, - 'respond-to-review', - promptBuilder, - ); - - expect(result).toEqual({ - systemPrompt: 'sys', - model: 'gpt-4', - maxIterations: 10, - contextFiles: [{ path: 'CLAUDE.md', content: '# test' }], - prDetailsFormatted: 'details:pr-raw', - commentsFormatted: 'comments:comments-raw', - reviewsFormatted: 'reviews:reviews-raw', - issueCommentsFormatted: 'issueComments:issue-comments-raw', - diffFormatted: 'diff:diff-raw', - prompt: 'my-prompt', - }); - }); - - it('passes modelOverride through to resolveModelConfig', async () => { - const promptBuilder = vi.fn().mockReturnValue('prompt'); - - await buildPRResponseContext( - 'org', - 'repo', - 42, - 'feat', - '/tmp/repo', - { id: 'proj' } as never, - { defaults: {} } as never, - mockLog, - 'respond-to-pr-comment', - promptBuilder, - 'custom-model', - ); - - expect(mockResolveModelConfig).toHaveBeenCalledWith( - expect.objectContaining({ - modelOverride: 'custom-model', - agentType: 'respond-to-pr-comment', - }), - ); - }); - }); - - // ======================================================================== - // injectPRResponseSyntheticCalls - // ======================================================================== - - describe('injectPRResponseSyntheticCalls', () => { - const baseParams: InjectPRResponseSyntheticCallsParams = { - builder: 'initial-builder' as never, - ctx: { - prDetailsFormatted: 'pd', - commentsFormatted: 'c', - reviewsFormatted: 'r', - issueCommentsFormatted: 'ic', - diffFormatted: 'd', - contextFiles: [], - systemPrompt: 'sys', - model: 'm', - maxIterations: 5, - prompt: 'p', - }, - trackingContext: {} as never, - repoDir: '/tmp/repo', - id: { owner: 'org', repo: 'repo' }, - input: { prNumber: 42 } as PRResponseAgentInput, - }; - - it('injects calls in correct order: dir → PR details → comments → reviews → issue comments → diff → context files → squint', () => { - injectPRResponseSyntheticCalls(baseParams); - - expect(mockInjectDirectoryListing).toHaveBeenCalledTimes(1); - - const syntheticNames = mockInjectSyntheticCall.mock.calls.map((c) => c[2]); - expect(syntheticNames).toEqual([ - 'GetPRDetails', - 'GetPRComments', - 'GetPRReviews', - 'GetPRIssueComments', - 'GetPRDiff', - ]); - - expect(mockInjectContextFiles).toHaveBeenCalledTimes(1); - expect(mockInjectSquintContext).toHaveBeenCalledTimes(1); - }); - - it('uses default comment descriptions (respond-to-review style)', () => { - injectPRResponseSyntheticCalls(baseParams); - - const commentsCall = mockInjectSyntheticCall.mock.calls.find((c) => c[2] === 'GetPRComments'); - expect(commentsCall?.[3]).toEqual( - expect.objectContaining({ - comment: 'Pre-fetching line-specific review comments to address', - }), - ); - - const reviewsCall = mockInjectSyntheticCall.mock.calls.find((c) => c[2] === 'GetPRReviews'); - expect(reviewsCall?.[3]).toEqual( - expect.objectContaining({ - comment: 'Pre-fetching review submissions (approve/request changes with body text)', - }), - ); - - const issueCommentsCall = mockInjectSyntheticCall.mock.calls.find( - (c) => c[2] === 'GetPRIssueComments', - ); - expect(issueCommentsCall?.[3]).toEqual( - expect.objectContaining({ - comment: 'Pre-fetching general PR comments (issue-style conversation)', - }), - ); - }); - - it('calls preSyntheticCalls callback before standard calls', () => { - const preSyntheticCalls = vi.fn().mockReturnValue('builder-after-pre'); - - injectPRResponseSyntheticCalls(baseParams, { preSyntheticCalls }); - - expect(preSyntheticCalls).toHaveBeenCalledTimes(1); - expect(preSyntheticCalls).toHaveBeenCalledWith( - 'builder-after-dir', - baseParams.trackingContext, - baseParams.input, - ); - }); - - it('overrides comment descriptions when provided', () => { - injectPRResponseSyntheticCalls(baseParams, { - commentDescriptions: { - prComments: 'Pre-fetching line-specific review comments for context', - prReviews: 'Pre-fetching review submissions for context', - prIssueComments: 'Pre-fetching general PR comments for context', - }, - }); - - const commentsCall = mockInjectSyntheticCall.mock.calls.find((c) => c[2] === 'GetPRComments'); - expect(commentsCall?.[3]).toEqual( - expect.objectContaining({ - comment: 'Pre-fetching line-specific review comments for context', - }), - ); - - const reviewsCall = mockInjectSyntheticCall.mock.calls.find((c) => c[2] === 'GetPRReviews'); - expect(reviewsCall?.[3]).toEqual( - expect.objectContaining({ comment: 'Pre-fetching review submissions for context' }), - ); - - const issueCommentsCall = mockInjectSyntheticCall.mock.calls.find( - (c) => c[2] === 'GetPRIssueComments', - ); - expect(issueCommentsCall?.[3]).toEqual( - expect.objectContaining({ comment: 'Pre-fetching general PR comments for context' }), - ); - }); - }); -}); diff --git a/tests/unit/agents/shared/syntheticCalls.test.ts b/tests/unit/agents/shared/syntheticCalls.test.ts index 80a2ab13..e7c94040 100644 --- a/tests/unit/agents/shared/syntheticCalls.test.ts +++ b/tests/unit/agents/shared/syntheticCalls.test.ts @@ -1,36 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('../../../../src/utils/squintDb.js', () => ({ - resolveSquintDbPath: vi.fn().mockReturnValue(null), -})); - vi.mock('../../../../src/agents/utils/tracking.js', () => ({ recordSyntheticInvocationId: vi.fn(), })); -vi.mock('node:child_process', () => ({ - execFileSync: vi.fn(), -})); - -// Mock ListDirectory gadget -vi.mock('../../../../src/gadgets/ListDirectory.js', () => ({ - ListDirectory: vi.fn().mockImplementation(() => ({ - execute: vi.fn().mockReturnValue('mocked directory listing output'), - })), -})); - -import { execFileSync } from 'node:child_process'; -import { - injectContextFiles, - injectDirectoryListing, - injectSquintContext, - injectSyntheticCall, -} from '../../../../src/agents/shared/syntheticCalls.js'; +import { injectSyntheticCall } from '../../../../src/agents/shared/syntheticCalls.js'; import { recordSyntheticInvocationId } from '../../../../src/agents/utils/tracking.js'; -import { resolveSquintDbPath } from '../../../../src/utils/squintDb.js'; -const mockResolveSquintDbPath = vi.mocked(resolveSquintDbPath); -const mockExecFileSync = vi.mocked(execFileSync); const mockRecordSyntheticInvocationId = vi.mocked(recordSyntheticInvocationId); function createMockBuilder() { @@ -59,7 +35,6 @@ function createTrackingContext() { beforeEach(() => { vi.clearAllMocks(); - mockResolveSquintDbPath.mockReturnValue(null); }); describe('injectSyntheticCall', () => { @@ -116,181 +91,3 @@ describe('injectSyntheticCall', () => { expect(result).toBe(builder); }); }); - -describe('injectDirectoryListing', () => { - it('calls injectSyntheticCall with ListDirectory gadget name', () => { - const builder = createMockBuilder(); - const ctx = createTrackingContext(); - - injectDirectoryListing(builder as never, ctx as never); - - expect(builder.withSyntheticGadgetCall).toHaveBeenCalledWith( - 'ListDirectory', - expect.objectContaining({ directoryPath: '.', maxDepth: 3 }), - 'mocked directory listing output', - 'gc_dir', - ); - }); - - it('uses custom maxDepth when provided', () => { - const builder = createMockBuilder(); - const ctx = createTrackingContext(); - - injectDirectoryListing(builder as never, ctx as never, 5); - - expect(builder.withSyntheticGadgetCall).toHaveBeenCalledWith( - 'ListDirectory', - expect.objectContaining({ maxDepth: 5 }), - expect.any(String), - 'gc_dir', - ); - }); - - it('records the invocation ID gc_dir', () => { - const builder = createMockBuilder(); - const ctx = createTrackingContext(); - - injectDirectoryListing(builder as never, ctx as never); - - expect(mockRecordSyntheticInvocationId).toHaveBeenCalledWith(ctx, 'gc_dir'); - }); -}); - -describe('injectContextFiles', () => { - it('injects multiple context files with sequential IDs', () => { - const builder = createMockBuilder(); - const ctx = createTrackingContext(); - const files = [ - { path: 'CLAUDE.md', content: '# Project docs' }, - { path: 'AGENTS.md', content: '# Agent docs' }, - ]; - - injectContextFiles(builder as never, ctx as never, files); - - expect(builder.withSyntheticGadgetCall).toHaveBeenCalledTimes(2); - expect(builder.withSyntheticGadgetCall).toHaveBeenCalledWith( - 'ReadFile', - expect.objectContaining({ filePath: 'CLAUDE.md' }), - '# Project docs', - 'gc_init_1', - ); - expect(builder.withSyntheticGadgetCall).toHaveBeenCalledWith( - 'ReadFile', - expect.objectContaining({ filePath: 'AGENTS.md' }), - '# Agent docs', - 'gc_init_2', - ); - }); - - it('returns builder unchanged when contextFiles is empty', () => { - const builder = createMockBuilder(); - const ctx = createTrackingContext(); - - const result = injectContextFiles(builder as never, ctx as never, []); - - expect(builder.withSyntheticGadgetCall).not.toHaveBeenCalled(); - expect(result).toBe(builder); - }); - - it('records synthetic invocation ID for each file', () => { - const builder = createMockBuilder(); - const ctx = createTrackingContext(); - const files = [ - { path: 'CLAUDE.md', content: 'docs' }, - { path: 'AGENTS.md', content: 'agents' }, - ]; - - injectContextFiles(builder as never, ctx as never, files); - - expect(mockRecordSyntheticInvocationId).toHaveBeenCalledWith(ctx, 'gc_init_1'); - expect(mockRecordSyntheticInvocationId).toHaveBeenCalledWith(ctx, 'gc_init_2'); - }); - - it('includes comment describing the file in ReadFile params', () => { - const builder = createMockBuilder(); - const ctx = createTrackingContext(); - const files = [{ path: 'CLAUDE.md', content: 'docs' }]; - - injectContextFiles(builder as never, ctx as never, files); - - expect(builder.withSyntheticGadgetCall).toHaveBeenCalledWith( - 'ReadFile', - expect.objectContaining({ comment: expect.stringContaining('CLAUDE.md') }), - 'docs', - 'gc_init_1', - ); - }); -}); - -describe('injectSquintContext', () => { - it('returns builder unchanged when squint DB not found', () => { - mockResolveSquintDbPath.mockReturnValue(null); - const builder = createMockBuilder(); - const ctx = createTrackingContext(); - - const result = injectSquintContext(builder as never, ctx as never, '/repo'); - - expect(result).toBe(builder); - expect(builder.withSyntheticGadgetCall).not.toHaveBeenCalled(); - }); - - it('calls squint overview command when DB is found', () => { - mockResolveSquintDbPath.mockReturnValue('/repo/.squint.db'); - mockExecFileSync.mockReturnValue('squint overview output' as never); - const builder = createMockBuilder(); - const ctx = createTrackingContext(); - - injectSquintContext(builder as never, ctx as never, '/repo'); - - expect(mockExecFileSync).toHaveBeenCalledWith( - 'squint', - ['overview', '-d', '/repo/.squint.db'], - { - encoding: 'utf-8', - timeout: 30_000, - }, - ); - }); - - it('injects squint overview as synthetic SquintOverview call', () => { - mockResolveSquintDbPath.mockReturnValue('/repo/.squint.db'); - mockExecFileSync.mockReturnValue('# Squint Overview\n- modules: 5' as never); - const builder = createMockBuilder(); - const ctx = createTrackingContext(); - - injectSquintContext(builder as never, ctx as never, '/repo'); - - expect(builder.withSyntheticGadgetCall).toHaveBeenCalledWith( - 'SquintOverview', - expect.objectContaining({ database: '/repo/.squint.db' }), - '# Squint Overview\n- modules: 5', - 'gc_squint_overview', - ); - }); - - it('returns builder unchanged when squint output is empty', () => { - mockResolveSquintDbPath.mockReturnValue('/repo/.squint.db'); - mockExecFileSync.mockReturnValue('' as never); - const builder = createMockBuilder(); - const ctx = createTrackingContext(); - - const result = injectSquintContext(builder as never, ctx as never, '/repo'); - - expect(result).toBe(builder); - expect(builder.withSyntheticGadgetCall).not.toHaveBeenCalled(); - }); - - it('returns builder unchanged when squint command throws', () => { - mockResolveSquintDbPath.mockReturnValue('/repo/.squint.db'); - mockExecFileSync.mockImplementation(() => { - throw new Error('squint not found'); - }); - const builder = createMockBuilder(); - const ctx = createTrackingContext(); - - const result = injectSquintContext(builder as never, ctx as never, '/repo'); - - expect(result).toBe(builder); - expect(builder.withSyntheticGadgetCall).not.toHaveBeenCalled(); - }); -});