From c8aed5f8816228d1819bdba0e6fcddac042beb2c Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Mon, 23 Mar 2026 20:36:33 +0000 Subject: [PATCH] refactor(backends): decompose adapter.ts into sidecarManager, progressLifecycle, secretOrchestrator --- src/backends/adapter.ts | 451 +----------------- src/backends/progressLifecycle.ts | 61 +++ src/backends/secretOrchestrator.ts | 250 ++++++++++ src/backends/sidecarManager.ts | 179 +++++++ tests/unit/backends/progressLifecycle.test.ts | 284 +++++++++++ .../unit/backends/secretOrchestrator.test.ts | 136 ++++++ tests/unit/backends/sidecarManager.test.ts | 380 +++++++++++++++ 7 files changed, 1296 insertions(+), 445 deletions(-) create mode 100644 src/backends/progressLifecycle.ts create mode 100644 src/backends/secretOrchestrator.ts create mode 100644 src/backends/sidecarManager.ts create mode 100644 tests/unit/backends/progressLifecycle.test.ts create mode 100644 tests/unit/backends/secretOrchestrator.test.ts create mode 100644 tests/unit/backends/sidecarManager.test.ts diff --git a/src/backends/adapter.ts b/src/backends/adapter.ts index 006c578a..de3442f9 100644 --- a/src/backends/adapter.ts +++ b/src/backends/adapter.ts @@ -1,50 +1,16 @@ -import { unlinkSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import type { ModelSpec } from 'llmist'; - -import { needsGitStateStopHooks } from '../agents/definitions/index.js'; import { getAgentProfile } from '../agents/definitions/profiles.js'; -import { getToolManifests } from '../agents/definitions/toolManifests.js'; -import type { PromptContext } from '../agents/prompts/index.js'; -import { - type LogWriter, - type PipelineContext, - executeAgentPipeline, -} from '../agents/shared/executionPipeline.js'; -import { resolveModelConfig } from '../agents/shared/modelResolution.js'; -import { buildPromptContext } from '../agents/shared/promptContext.js'; +import { type PipelineContext, executeAgentPipeline } from '../agents/shared/executionPipeline.js'; import { setupRepository } from '../agents/shared/repository.js'; import { finalizeEngineRun, tryCreateRun } from '../agents/shared/runTracking.js'; import { createAgentLogger } from '../agents/utils/logging.js'; -import { CUSTOM_MODELS } from '../config/customModels.js'; -import { mergeEngineSettings } from '../config/engineSettings.js'; -import { loadPartials } from '../db/repositories/partialsRepository.js'; -import { - PM_WRITE_SIDECAR_ENV_VAR, - PR_SIDECAR_ENV_VAR, - PUSHED_CHANGES_SIDECAR_ENV_VAR, - REVIEW_SIDECAR_ENV_VAR, - clearInitialComment, - recordInitialComment, - recordPRCreation, - recordReviewSubmission, -} from '../gadgets/sessionState.js'; -import { withGitHubToken } from '../github/client.js'; +import { recordInitialComment } from '../gadgets/sessionState.js'; import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js'; -import { logger } from '../utils/logging.js'; -import { getDashboardUrl } from '../utils/runLink.js'; import { readCompletionEvidence } from './completion.js'; -import { createNativeToolRuntimeArtifacts } from './nativeToolRuntime.js'; import { postProcessResult } from './postProcess.js'; import { createProgressMonitor } from './progress.js'; -import { - augmentProjectSecrets, - injectGitHubAckCommentId, - injectProgressCommentId, - resolveGitHubToken, -} from './secretBuilder.js'; +import { buildProgressMonitorConfig, isGitHubAckComment } from './progressLifecycle.js'; +import { injectRunLinkSecrets, resolvePartialExecutionPlan } from './secretOrchestrator.js'; +import { cleanupTempFile, hydrateNativeToolSidecars } from './sidecarManager.js'; import type { AgentEngine, AgentExecutionPlan } from './types.js'; /** @@ -67,395 +33,6 @@ async function resolveRepoDir( }); } -function createCompletionArtifacts( - profile: Awaited>, - agentType: string, - needsNativeToolRuntime: boolean, - input: AgentInput, - projectSecrets: Record, -) { - const reviewSidecarPath = - agentType === 'review' - ? join(tmpdir(), `cascade-review-sidecar-${process.pid}-${Date.now()}.json`) - : undefined; - if (reviewSidecarPath) { - projectSecrets[REVIEW_SIDECAR_ENV_VAR] = reviewSidecarPath; - } - - const prSidecarPath = - needsNativeToolRuntime && profile.finishHooks.requiresPR - ? join(tmpdir(), `cascade-pr-sidecar-${process.pid}-${Date.now()}.json`) - : undefined; - if (prSidecarPath) { - projectSecrets[PR_SIDECAR_ENV_VAR] = prSidecarPath; - } - - const pushedChangesSidecarPath = - needsNativeToolRuntime && profile.finishHooks.requiresPushedChanges - ? join(tmpdir(), `cascade-pushed-changes-sidecar-${process.pid}-${Date.now()}.json`) - : undefined; - if (pushedChangesSidecarPath) { - projectSecrets[PUSHED_CHANGES_SIDECAR_ENV_VAR] = pushedChangesSidecarPath; - } - - const pmWriteSidecarPath = - needsNativeToolRuntime && profile.finishHooks.requiresPMWrite - ? join(tmpdir(), `cascade-pm-write-sidecar-${process.pid}-${Date.now()}.json`) - : undefined; - if (pmWriteSidecarPath) { - projectSecrets[PM_WRITE_SIDECAR_ENV_VAR] = pmWriteSidecarPath; - } - - if (Object.keys(profile.finishHooks).length > 0) { - projectSecrets.CASCADE_FINISH_HOOKS = JSON.stringify(profile.finishHooks); - } - if (input.headSha) { - projectSecrets.CASCADE_INITIAL_HEAD_SHA = input.headSha as string; - } - - return { - prSidecarPath, - pushedChangesSidecarPath, - reviewSidecarPath, - pmWriteSidecarPath, - }; -} - -/** - * Build the execution plan by resolving model config, fetching context, etc. - * Uses agent profiles to customize tools, context, and prompts per agent type. - */ -async function buildExecutionPlan( - agentType: string, - input: AgentInput & { project: ProjectConfig; config: CascadeConfig }, - repoDir: string, - logWriter: LogWriter, - _log: ReturnType, - gitHubToken: string | undefined, - isGitHubAck: boolean, - engineId: string, - engine: AgentEngine, -): Promise< - Omit & { - reviewSidecarPath?: string; - prSidecarPath?: string; - pushedChangesSidecarPath?: string; - pmWriteSidecarPath?: string; - nativeToolRuntimeCleanup?: () => void; - } -> { - const { project, config, workItemId } = input; - - // PR context from check-failure trigger - const prContext = - input.prNumber !== undefined - ? { - prNumber: input.prNumber as number, - prBranch: input.prBranch as string, - repoFullName: input.repoFullName as string, - headSha: input.headSha as string, - } - : undefined; - - const promptContext: PromptContext = buildPromptContext( - workItemId, - project, - input.triggerType, - prContext, - undefined, - repoDir, - ); - - // Load DB partials for template include resolution - let dbPartials: Map | undefined; - try { - dbPartials = await loadPartials(project.orgId); - } catch { - // DB not available — fall back to disk-only partials - } - - const { - systemPrompt, - taskPrompt: taskPromptOverride, - model: rawModel, - maxIterations, - contextFiles, - } = await resolveModelConfig({ - agentType, - project, - config, - repoDir, - promptContext, - dbPartials, - agentInput: input, - }); - - // Allow the engine to resolve/validate the model string (e.g. strip provider prefix) - const model = engine.resolveModel ? engine.resolveModel(rawModel) : rawModel; - - const profile = await getAgentProfile(agentType); - - // Use profile to fetch agent-specific context injections - const contextInjections = await profile.fetchContext({ - input, - repoDir, - contextFiles, - logWriter, - project, - }); - - const cliToolsDir = new URL('../../bin', import.meta.url).pathname; - const needsNativeToolRuntime = ['claude-code', 'codex', 'opencode'].includes(engineId); - const nativeToolRuntime = needsNativeToolRuntime ? createNativeToolRuntimeArtifacts() : undefined; - - // Build per-project secrets with CASCADE env var injections - const projectSecrets = await augmentProjectSecrets(project, agentType, input); - - // Inject pre-seeded progress comment ID so the subprocess finds it at startup - injectProgressCommentId( - projectSecrets, - workItemId, - input.ackCommentId as string | number | undefined, - ); - - // Inject GitHub ack comment ID so the subprocess can delete it after review submission - injectGitHubAckCommentId( - projectSecrets, - input.ackCommentId as string | number | undefined, - isGitHubAck, - ); - - const { reviewSidecarPath, prSidecarPath, pushedChangesSidecarPath, pmWriteSidecarPath } = - createCompletionArtifacts(profile, agentType, needsNativeToolRuntime, input, projectSecrets); - - const completionRequirements = { - requiresPR: profile.finishHooks.requiresPR, - requiresReview: profile.finishHooks.requiresReview, - requiresPushedChanges: profile.finishHooks.requiresPushedChanges, - requiresPMWrite: profile.finishHooks.requiresPMWrite, - prSidecarPath, - reviewSidecarPath, - pushedChangesSidecarPath, - pmWriteSidecarPath, - maxContinuationTurns: 2, - }; - - // Override GITHUB_TOKEN in subprocess secrets with agent-scoped token - if (gitHubToken && profile.needsGitHubToken) { - projectSecrets.GITHUB_TOKEN = gitHubToken; - } - - // Merge engine settings: agent-config settings override project-level settings. - // When no per-agent settings exist for this agent type, project-level settings are used unchanged. - const agentLevelEngineSettings = project.agentEngineSettings?.[agentType]; - const mergedEngineSettings = mergeEngineSettings( - project.engineSettings, - agentLevelEngineSettings, - ); - - return { - agentType, - project, - config, - repoDir, - systemPrompt, - taskPrompt: taskPromptOverride ?? profile.buildTaskPrompt(input), - cliToolsDir, - nativeToolShimDir: nativeToolRuntime?.shimDir, - availableTools: profile.filterTools(getToolManifests()), - contextInjections, - maxIterations, - budgetUsd: input.remainingBudgetUsd as number | undefined, - model, - logWriter, - agentInput: input, - nativeToolCapabilities: profile.allCapabilities, - completionRequirements, - enableStopHooks: needsGitStateStopHooks(profile.finishHooks), - blockGitPush: profile.finishHooks.blockGitPush, - engineSettings: mergedEngineSettings, - ...(Object.keys(projectSecrets).length > 0 && { projectSecrets }), - reviewSidecarPath, - prSidecarPath, - pushedChangesSidecarPath, - pmWriteSidecarPath, - nativeToolRuntimeCleanup: nativeToolRuntime?.cleanup, - }; -} - -/** - * Read the review sidecar file written by `cascade-tools scm create-pr-review` - * and hydrate session state so `postReviewSummaryToPM()` can post to the PM. - * - * Only needed for the claude-code backend where tools run as child processes - * and cannot update the parent process's module-level session state directly. - */ -async function hydrateReviewSidecar(sidecarPath: string): Promise { - try { - const sidecar = readCompletionEvidence({ reviewSidecarPath: sidecarPath }); - if (sidecar.reviewBody && sidecar.reviewUrl) { - recordReviewSubmission(sidecar.reviewUrl, sidecar.reviewBody, sidecar.reviewEvent); - logger.info('Hydrated review sidecar from subprocess', { - event: sidecar.reviewEvent, - bodyLength: sidecar.reviewBody.length, - }); - } else { - logger.warn('Review sidecar missing required fields', { - hasBody: !!sidecar.reviewBody, - hasReviewUrl: !!sidecar.reviewUrl, - }); - } - // If the subprocess already deleted the ack comment, clear it from session state - // so the GitHubProgressPoster post-agent callback does not attempt a redundant delete. - if (sidecar.ackCommentDeleted) { - clearInitialComment(); - } - } catch (err) { - // Sidecar not written by subprocess (agent may have failed before review) or malformed. - logger.warn('Failed to read review sidecar', { path: sidecarPath, error: String(err) }); - } -} - -async function hydratePrSidecar(sidecarPath: string): Promise<{ - prUrl?: string; - prEvidence?: { source: 'native-tool-sidecar'; authoritative: true; command: string }; -}> { - try { - const sidecar = readCompletionEvidence({ prSidecarPath: sidecarPath }); - if (sidecar.prUrl) { - recordPRCreation(sidecar.prUrl); - logger.info('Hydrated PR sidecar from subprocess', { - command: sidecar.prCommand ?? 'cascade-tools scm create-pr', - prUrl: sidecar.prUrl, - }); - return { - prUrl: sidecar.prUrl, - prEvidence: { - source: 'native-tool-sidecar', - authoritative: true, - command: sidecar.prCommand ?? 'cascade-tools scm create-pr', - }, - }; - } - logger.warn('PR sidecar missing required fields', { - hasPrUrl: !!sidecar.prUrl, - }); - } catch (err) { - logger.warn('Failed to read PR sidecar', { path: sidecarPath, error: String(err) }); - } - - return {}; -} - -/** - * Build progress-monitor config from pipeline inputs. - */ -function buildProgressMonitorConfig( - input: AgentInput & { config: CascadeConfig; project: ProjectConfig }, - agentType: string, - logWriter: LogWriter, - repoDir: string | null, - isGitHubAck: boolean, - engineId: string, - model: string, -) { - const { workItemId } = input; - - // Build run link config when the project has run links enabled and dashboard URL is set - const runLink = - input.project.runLinksEnabled && getDashboardUrl() - ? { - engineLabel: engineId, - model, - projectId: input.project.id, - workItemId: workItemId ?? undefined, - } - : undefined; - - return { - logWriter, - agentType, - taskDescription: workItemId ? `Work item ${workItemId}` : 'Unknown task', - progressModel: input.project.progressModel, - intervalMinutes: input.project.progressIntervalMinutes, - customModels: CUSTOM_MODELS as ModelSpec[], - repoDir: repoDir ?? undefined, - trello: workItemId ? { workItemId } : undefined, - preSeededCommentId: isGitHubAck ? undefined : (input.ackCommentId as string | undefined), - runLink, - ...(input.prNumber && input.repoFullName - ? { - github: { - owner: input.repoFullName.split('/')[0], - repo: input.repoFullName.split('/')[1], - }, - } - : {}), - }; -} - -function isGitHubAckComment(input: AgentInput): boolean { - return Boolean(input.prNumber && input.repoFullName && typeof input.ackCommentId === 'number'); -} - -function cleanupTempFile(path: string | undefined): void { - if (!path) return; - try { - unlinkSync(path); - } catch { - // Best-effort cleanup - } -} - -async function resolvePartialExecutionPlan( - engine: AgentEngine, - agentType: string, - input: AgentInput & { project: ProjectConfig; config: CascadeConfig }, - repoDir: string, - logWriter: LogWriter, - log: ReturnType, -): Promise>> { - const profile = await getAgentProfile(agentType); - const gitHubToken = await resolveGitHubToken(profile, input.project.id, agentType); - const isGitHubAck = isGitHubAckComment(input); - const buildPartial = () => - buildExecutionPlan( - agentType, - input, - repoDir, - logWriter, - log, - gitHubToken, - isGitHubAck, - engine.definition.id, - engine, - ); - - const partialInput = gitHubToken - ? await withGitHubToken(gitHubToken, buildPartial) - : await buildPartial(); - - return partialInput; -} - -async function hydrateNativeToolSidecars( - result: Awaited>, - prSidecarPath?: string, - reviewSidecarPath?: string, -): Promise { - if (prSidecarPath) { - const hydratedPr = await hydratePrSidecar(prSidecarPath); - if (hydratedPr.prUrl) { - result.prUrl = hydratedPr.prUrl; - result.prEvidence = hydratedPr.prEvidence; - } - } - - if (reviewSidecarPath) { - await hydrateReviewSidecar(reviewSidecarPath); - } -} - export async function executeWithEngine( engine: AgentEngine, agentType: string, @@ -540,23 +117,7 @@ export async function executeWithEngine( } // Inject run link env vars into project secrets for subprocess agents (claude-code/codex) - if (input.project.runLinksEnabled) { - partialInput.projectSecrets ??= {}; - const dashboardUrl = getDashboardUrl(); - if (dashboardUrl) { - partialInput.projectSecrets.CASCADE_RUN_LINKS_ENABLED = 'true'; - partialInput.projectSecrets.CASCADE_DASHBOARD_URL = dashboardUrl; - partialInput.projectSecrets.CASCADE_ENGINE_LABEL = engine.definition.id; - partialInput.projectSecrets.CASCADE_MODEL = partialInput.model ?? ''; - partialInput.projectSecrets.CASCADE_PROJECT_ID = input.project.id; - if (workItemId) { - partialInput.projectSecrets.CASCADE_WORK_ITEM_ID = workItemId; - } - if (runId) { - partialInput.projectSecrets.CASCADE_RUN_ID = runId; - } - } - } + injectRunLinkSecrets(partialInput, input.project, engine.definition.id, workItemId, runId); const executionPlan: AgentExecutionPlan = { ...partialInput, diff --git a/src/backends/progressLifecycle.ts b/src/backends/progressLifecycle.ts new file mode 100644 index 00000000..9f05567f --- /dev/null +++ b/src/backends/progressLifecycle.ts @@ -0,0 +1,61 @@ +import type { ModelSpec } from 'llmist'; + +import type { LogWriter } from '../agents/shared/executionPipeline.js'; +import { CUSTOM_MODELS } from '../config/customModels.js'; +import type { AgentInput, CascadeConfig, ProjectConfig } from '../types/index.js'; +import { getDashboardUrl } from '../utils/runLink.js'; + +/** + * Determine whether the incoming ack comment is a GitHub PR comment (numeric ID). + * Used to route between GitHub progress posting and PM progress posting. + */ +export function isGitHubAckComment(input: AgentInput): boolean { + return Boolean(input.prNumber && input.repoFullName && typeof input.ackCommentId === 'number'); +} + +/** + * Build progress-monitor config from pipeline inputs. + */ +export function buildProgressMonitorConfig( + input: AgentInput & { config: CascadeConfig; project: ProjectConfig }, + agentType: string, + logWriter: LogWriter, + repoDir: string | null, + isGitHubAck: boolean, + engineId: string, + model: string, +) { + const { workItemId } = input; + + // Build run link config when the project has run links enabled and dashboard URL is set + const runLink = + input.project.runLinksEnabled && getDashboardUrl() + ? { + engineLabel: engineId, + model, + projectId: input.project.id, + workItemId: workItemId ?? undefined, + } + : undefined; + + return { + logWriter, + agentType, + taskDescription: workItemId ? `Work item ${workItemId}` : 'Unknown task', + progressModel: input.project.progressModel, + intervalMinutes: input.project.progressIntervalMinutes, + customModels: CUSTOM_MODELS as ModelSpec[], + repoDir: repoDir ?? undefined, + trello: workItemId ? { workItemId } : undefined, + preSeededCommentId: isGitHubAck ? undefined : (input.ackCommentId as string | undefined), + runLink, + ...(input.prNumber && input.repoFullName + ? { + github: { + owner: input.repoFullName.split('/')[0], + repo: input.repoFullName.split('/')[1], + }, + } + : {}), + }; +} diff --git a/src/backends/secretOrchestrator.ts b/src/backends/secretOrchestrator.ts new file mode 100644 index 00000000..d14beb6d --- /dev/null +++ b/src/backends/secretOrchestrator.ts @@ -0,0 +1,250 @@ +import { needsGitStateStopHooks } from '../agents/definitions/index.js'; +import { getAgentProfile } from '../agents/definitions/profiles.js'; +import { getToolManifests } from '../agents/definitions/toolManifests.js'; +import type { PromptContext } from '../agents/prompts/index.js'; +import type { LogWriter } from '../agents/shared/executionPipeline.js'; +import { resolveModelConfig } from '../agents/shared/modelResolution.js'; +import { buildPromptContext } from '../agents/shared/promptContext.js'; +import type { createAgentLogger } from '../agents/utils/logging.js'; +import { mergeEngineSettings } from '../config/engineSettings.js'; +import { loadPartials } from '../db/repositories/partialsRepository.js'; +import { withGitHubToken } from '../github/client.js'; +import type { AgentInput, CascadeConfig, ProjectConfig } from '../types/index.js'; +import { getDashboardUrl } from '../utils/runLink.js'; +import { createNativeToolRuntimeArtifacts } from './nativeToolRuntime.js'; +import { isGitHubAckComment } from './progressLifecycle.js'; +import { + augmentProjectSecrets, + injectGitHubAckCommentId, + injectProgressCommentId, + resolveGitHubToken, +} from './secretBuilder.js'; +import { createCompletionArtifacts } from './sidecarManager.js'; +import type { AgentEngine, AgentExecutionPlan } from './types.js'; + +/** + * Build the execution plan by resolving model config, fetching context, etc. + * Uses agent profiles to customize tools, context, and prompts per agent type. + */ +export async function buildExecutionPlan( + agentType: string, + input: AgentInput & { project: ProjectConfig; config: CascadeConfig }, + repoDir: string, + logWriter: LogWriter, + _log: ReturnType, + gitHubToken: string | undefined, + isGitHubAck: boolean, + engineId: string, + engine: AgentEngine, +): Promise< + Omit & { + reviewSidecarPath?: string; + prSidecarPath?: string; + pushedChangesSidecarPath?: string; + pmWriteSidecarPath?: string; + nativeToolRuntimeCleanup?: () => void; + } +> { + const { project, config, workItemId } = input; + + // PR context from check-failure trigger + const prContext = + input.prNumber !== undefined + ? { + prNumber: input.prNumber as number, + prBranch: input.prBranch as string, + repoFullName: input.repoFullName as string, + headSha: input.headSha as string, + } + : undefined; + + const promptContext: PromptContext = buildPromptContext( + workItemId, + project, + input.triggerType, + prContext, + undefined, + repoDir, + ); + + // Load DB partials for template include resolution + let dbPartials: Map | undefined; + try { + dbPartials = await loadPartials(project.orgId); + } catch { + // DB not available — fall back to disk-only partials + } + + const { + systemPrompt, + taskPrompt: taskPromptOverride, + model: rawModel, + maxIterations, + contextFiles, + } = await resolveModelConfig({ + agentType, + project, + config, + repoDir, + promptContext, + dbPartials, + agentInput: input, + }); + + // Allow the engine to resolve/validate the model string (e.g. strip provider prefix) + const model = engine.resolveModel ? engine.resolveModel(rawModel) : rawModel; + + const profile = await getAgentProfile(agentType); + + // Use profile to fetch agent-specific context injections + const contextInjections = await profile.fetchContext({ + input, + repoDir, + contextFiles, + logWriter, + project, + }); + + const cliToolsDir = new URL('../../bin', import.meta.url).pathname; + const needsNativeToolRuntime = ['claude-code', 'codex', 'opencode'].includes(engineId); + const nativeToolRuntime = needsNativeToolRuntime ? createNativeToolRuntimeArtifacts() : undefined; + + // Build per-project secrets with CASCADE env var injections + const projectSecrets = await augmentProjectSecrets(project, agentType, input); + + // Inject pre-seeded progress comment ID so the subprocess finds it at startup + injectProgressCommentId( + projectSecrets, + workItemId, + input.ackCommentId as string | number | undefined, + ); + + // Inject GitHub ack comment ID so the subprocess can delete it after review submission + injectGitHubAckCommentId( + projectSecrets, + input.ackCommentId as string | number | undefined, + isGitHubAck, + ); + + const { reviewSidecarPath, prSidecarPath, pushedChangesSidecarPath, pmWriteSidecarPath } = + createCompletionArtifacts(profile, agentType, needsNativeToolRuntime, input, projectSecrets); + + const completionRequirements = { + requiresPR: profile.finishHooks.requiresPR, + requiresReview: profile.finishHooks.requiresReview, + requiresPushedChanges: profile.finishHooks.requiresPushedChanges, + requiresPMWrite: profile.finishHooks.requiresPMWrite, + prSidecarPath, + reviewSidecarPath, + pushedChangesSidecarPath, + pmWriteSidecarPath, + maxContinuationTurns: 2, + }; + + // Override GITHUB_TOKEN in subprocess secrets with agent-scoped token + if (gitHubToken && profile.needsGitHubToken) { + projectSecrets.GITHUB_TOKEN = gitHubToken; + } + + // Merge engine settings: agent-config settings override project-level settings. + // When no per-agent settings exist for this agent type, project-level settings are used unchanged. + const agentLevelEngineSettings = project.agentEngineSettings?.[agentType]; + const mergedEngineSettings = mergeEngineSettings( + project.engineSettings, + agentLevelEngineSettings, + ); + + return { + agentType, + project, + config, + repoDir, + systemPrompt, + taskPrompt: taskPromptOverride ?? profile.buildTaskPrompt(input), + cliToolsDir, + nativeToolShimDir: nativeToolRuntime?.shimDir, + availableTools: profile.filterTools(getToolManifests()), + contextInjections, + maxIterations, + budgetUsd: input.remainingBudgetUsd as number | undefined, + model, + logWriter, + agentInput: input, + nativeToolCapabilities: profile.allCapabilities, + completionRequirements, + enableStopHooks: needsGitStateStopHooks(profile.finishHooks), + blockGitPush: profile.finishHooks.blockGitPush, + engineSettings: mergedEngineSettings, + ...(Object.keys(projectSecrets).length > 0 && { projectSecrets }), + reviewSidecarPath, + prSidecarPath, + pushedChangesSidecarPath, + pmWriteSidecarPath, + nativeToolRuntimeCleanup: nativeToolRuntime?.cleanup, + }; +} + +/** + * Resolve the partial execution plan including GitHub token resolution and + * all project secret injection. Wraps buildExecutionPlan with token handling. + */ +export async function resolvePartialExecutionPlan( + engine: AgentEngine, + agentType: string, + input: AgentInput & { project: ProjectConfig; config: CascadeConfig }, + repoDir: string, + logWriter: LogWriter, + log: ReturnType, +): Promise>> { + const profile = await getAgentProfile(agentType); + const gitHubToken = await resolveGitHubToken(profile, input.project.id, agentType); + const isGitHubAck = isGitHubAckComment(input); + const buildPartial = () => + buildExecutionPlan( + agentType, + input, + repoDir, + logWriter, + log, + gitHubToken, + isGitHubAck, + engine.definition.id, + engine, + ); + + const partialInput = gitHubToken + ? await withGitHubToken(gitHubToken, buildPartial) + : await buildPartial(); + + return partialInput; +} + +/** + * Inject run-link env vars into project secrets for subprocess agents. + * Called after the run ID is known so it can be included in the secrets. + */ +export function injectRunLinkSecrets( + partialInput: { projectSecrets?: Record; model?: string }, + project: ProjectConfig, + engineId: string, + workItemId: string | undefined, + runId: string | undefined, +): void { + if (!project.runLinksEnabled) return; + + const dashboardUrl = getDashboardUrl(); + if (!dashboardUrl) return; + + partialInput.projectSecrets ??= {}; + partialInput.projectSecrets.CASCADE_RUN_LINKS_ENABLED = 'true'; + partialInput.projectSecrets.CASCADE_DASHBOARD_URL = dashboardUrl; + partialInput.projectSecrets.CASCADE_ENGINE_LABEL = engineId; + partialInput.projectSecrets.CASCADE_MODEL = partialInput.model ?? ''; + partialInput.projectSecrets.CASCADE_PROJECT_ID = project.id; + if (workItemId) { + partialInput.projectSecrets.CASCADE_WORK_ITEM_ID = workItemId; + } + if (runId) { + partialInput.projectSecrets.CASCADE_RUN_ID = runId; + } +} diff --git a/src/backends/sidecarManager.ts b/src/backends/sidecarManager.ts new file mode 100644 index 00000000..9f2ede8b --- /dev/null +++ b/src/backends/sidecarManager.ts @@ -0,0 +1,179 @@ +import { unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { getAgentProfile } from '../agents/definitions/profiles.js'; +import { + PM_WRITE_SIDECAR_ENV_VAR, + PR_SIDECAR_ENV_VAR, + PUSHED_CHANGES_SIDECAR_ENV_VAR, + REVIEW_SIDECAR_ENV_VAR, + clearInitialComment, + recordPRCreation, + recordReviewSubmission, +} from '../gadgets/sessionState.js'; +import type { AgentInput } from '../types/index.js'; +import { logger } from '../utils/logging.js'; +import { readCompletionEvidence } from './completion.js'; +import type { AgentEngineResult } from './types.js'; + +/** + * Create temp-file paths for all completion sidecars and inject them into + * projectSecrets so the subprocess can write to them at runtime. + */ +export function createCompletionArtifacts( + profile: Awaited>, + agentType: string, + needsNativeToolRuntime: boolean, + input: AgentInput, + projectSecrets: Record, +): { + prSidecarPath: string | undefined; + pushedChangesSidecarPath: string | undefined; + reviewSidecarPath: string | undefined; + pmWriteSidecarPath: string | undefined; +} { + const reviewSidecarPath = + agentType === 'review' + ? join(tmpdir(), `cascade-review-sidecar-${process.pid}-${Date.now()}.json`) + : undefined; + if (reviewSidecarPath) { + projectSecrets[REVIEW_SIDECAR_ENV_VAR] = reviewSidecarPath; + } + + const prSidecarPath = + needsNativeToolRuntime && profile.finishHooks.requiresPR + ? join(tmpdir(), `cascade-pr-sidecar-${process.pid}-${Date.now()}.json`) + : undefined; + if (prSidecarPath) { + projectSecrets[PR_SIDECAR_ENV_VAR] = prSidecarPath; + } + + const pushedChangesSidecarPath = + needsNativeToolRuntime && profile.finishHooks.requiresPushedChanges + ? join(tmpdir(), `cascade-pushed-changes-sidecar-${process.pid}-${Date.now()}.json`) + : undefined; + if (pushedChangesSidecarPath) { + projectSecrets[PUSHED_CHANGES_SIDECAR_ENV_VAR] = pushedChangesSidecarPath; + } + + const pmWriteSidecarPath = + needsNativeToolRuntime && profile.finishHooks.requiresPMWrite + ? join(tmpdir(), `cascade-pm-write-sidecar-${process.pid}-${Date.now()}.json`) + : undefined; + if (pmWriteSidecarPath) { + projectSecrets[PM_WRITE_SIDECAR_ENV_VAR] = pmWriteSidecarPath; + } + + if (Object.keys(profile.finishHooks).length > 0) { + projectSecrets.CASCADE_FINISH_HOOKS = JSON.stringify(profile.finishHooks); + } + if (input.headSha) { + projectSecrets.CASCADE_INITIAL_HEAD_SHA = input.headSha as string; + } + + return { + prSidecarPath, + pushedChangesSidecarPath, + reviewSidecarPath, + pmWriteSidecarPath, + }; +} + +/** + * Read the review sidecar file written by `cascade-tools scm create-pr-review` + * and hydrate session state so `postReviewSummaryToPM()` can post to the PM. + * + * Only needed for the claude-code backend where tools run as child processes + * and cannot update the parent process's module-level session state directly. + */ +export async function hydrateReviewSidecar(sidecarPath: string): Promise { + try { + const sidecar = readCompletionEvidence({ reviewSidecarPath: sidecarPath }); + if (sidecar.reviewBody && sidecar.reviewUrl) { + recordReviewSubmission(sidecar.reviewUrl, sidecar.reviewBody, sidecar.reviewEvent); + logger.info('Hydrated review sidecar from subprocess', { + event: sidecar.reviewEvent, + bodyLength: sidecar.reviewBody.length, + }); + } else { + logger.warn('Review sidecar missing required fields', { + hasBody: !!sidecar.reviewBody, + hasReviewUrl: !!sidecar.reviewUrl, + }); + } + // If the subprocess already deleted the ack comment, clear it from session state + // so the GitHubProgressPoster post-agent callback does not attempt a redundant delete. + if (sidecar.ackCommentDeleted) { + clearInitialComment(); + } + } catch (err) { + // Sidecar not written by subprocess (agent may have failed before review) or malformed. + logger.warn('Failed to read review sidecar', { path: sidecarPath, error: String(err) }); + } +} + +export async function hydratePrSidecar(sidecarPath: string): Promise<{ + prUrl?: string; + prEvidence?: { source: 'native-tool-sidecar'; authoritative: true; command: string }; +}> { + try { + const sidecar = readCompletionEvidence({ prSidecarPath: sidecarPath }); + if (sidecar.prUrl) { + recordPRCreation(sidecar.prUrl); + logger.info('Hydrated PR sidecar from subprocess', { + command: sidecar.prCommand ?? 'cascade-tools scm create-pr', + prUrl: sidecar.prUrl, + }); + return { + prUrl: sidecar.prUrl, + prEvidence: { + source: 'native-tool-sidecar', + authoritative: true, + command: sidecar.prCommand ?? 'cascade-tools scm create-pr', + }, + }; + } + logger.warn('PR sidecar missing required fields', { + hasPrUrl: !!sidecar.prUrl, + }); + } catch (err) { + logger.warn('Failed to read PR sidecar', { path: sidecarPath, error: String(err) }); + } + + return {}; +} + +/** + * Hydrate native tool sidecars (PR and review) after engine execution. + * Updates the result in-place with any authoritative PR evidence. + */ +export async function hydrateNativeToolSidecars( + result: AgentEngineResult, + prSidecarPath?: string, + reviewSidecarPath?: string, +): Promise { + if (prSidecarPath) { + const hydratedPr = await hydratePrSidecar(prSidecarPath); + if (hydratedPr.prUrl) { + result.prUrl = hydratedPr.prUrl; + result.prEvidence = hydratedPr.prEvidence; + } + } + + if (reviewSidecarPath) { + await hydrateReviewSidecar(reviewSidecarPath); + } +} + +/** + * Best-effort cleanup of a temp file. Ignores errors silently. + */ +export function cleanupTempFile(path: string | undefined): void { + if (!path) return; + try { + unlinkSync(path); + } catch { + // Best-effort cleanup + } +} diff --git a/tests/unit/backends/progressLifecycle.test.ts b/tests/unit/backends/progressLifecycle.test.ts new file mode 100644 index 00000000..649e897e --- /dev/null +++ b/tests/unit/backends/progressLifecycle.test.ts @@ -0,0 +1,284 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/config/customModels.js', () => ({ + CUSTOM_MODELS: [], +})); + +vi.mock('../../../src/utils/runLink.js', () => ({ + getDashboardUrl: vi.fn(), +})); + +import type { LogWriter } from '../../../src/agents/shared/executionPipeline.js'; +import { + buildProgressMonitorConfig, + isGitHubAckComment, +} from '../../../src/backends/progressLifecycle.js'; +import type { AgentInput, CascadeConfig, ProjectConfig } from '../../../src/types/index.js'; +import { getDashboardUrl } from '../../../src/utils/runLink.js'; + +const mockGetDashboardUrl = vi.mocked(getDashboardUrl); + +function makeProject(overrides?: Partial): ProjectConfig { + return { + id: 'test-project', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + trello: { boardId: 'b1', lists: {}, labels: {} }, + ...overrides, + }; +} + +function makeConfig(): CascadeConfig { + return { projects: [] }; +} + +function makeInput( + overrides?: Partial, +): AgentInput & { config: CascadeConfig; project: ProjectConfig } { + return { + workItemId: 'card123', + project: makeProject(), + config: makeConfig(), + ...overrides, + } as AgentInput & { config: CascadeConfig; project: ProjectConfig }; +} + +const mockLogWriter = {} as LogWriter; + +beforeEach(() => { + mockGetDashboardUrl.mockReturnValue(undefined); +}); + +describe('isGitHubAckComment', () => { + it('returns true when prNumber, repoFullName, and numeric ackCommentId are present', () => { + const input = makeInput({ prNumber: 42, repoFullName: 'acme/widgets', ackCommentId: 12345 }); + expect(isGitHubAckComment(input)).toBe(true); + }); + + it('returns false when ackCommentId is a string (PM comment)', () => { + const input = makeInput({ + prNumber: 42, + repoFullName: 'acme/widgets', + ackCommentId: 'trello-comment', + }); + expect(isGitHubAckComment(input)).toBe(false); + }); + + it('returns false when prNumber is absent', () => { + const input = makeInput({ repoFullName: 'acme/widgets', ackCommentId: 12345 }); + expect(isGitHubAckComment(input)).toBe(false); + }); + + it('returns false when repoFullName is absent', () => { + const input = makeInput({ prNumber: 42, ackCommentId: 12345 }); + expect(isGitHubAckComment(input)).toBe(false); + }); + + it('returns false when ackCommentId is absent', () => { + const input = makeInput({ prNumber: 42, repoFullName: 'acme/widgets' }); + expect(isGitHubAckComment(input)).toBe(false); + }); + + it('returns true when ackCommentId is zero (typeof 0 === "number")', () => { + // Note: ackCommentId of 0 still passes typeof check, so returns true + const input = makeInput({ prNumber: 42, repoFullName: 'acme/widgets', ackCommentId: 0 }); + expect(isGitHubAckComment(input)).toBe(true); + }); +}); + +describe('buildProgressMonitorConfig', () => { + it('sets taskDescription from workItemId', () => { + const input = makeInput({ workItemId: 'card-abc' }); + const config = buildProgressMonitorConfig( + input, + 'implementation', + mockLogWriter, + '/repo', + false, + 'test-engine', + 'model-x', + ); + expect(config.taskDescription).toBe('Work item card-abc'); + }); + + it('falls back to "Unknown task" when workItemId is absent', () => { + const input = makeInput({ workItemId: undefined }); + const config = buildProgressMonitorConfig( + input, + 'implementation', + mockLogWriter, + '/repo', + false, + 'test-engine', + 'model-x', + ); + expect(config.taskDescription).toBe('Unknown task'); + }); + + it('sets trello config when workItemId is present', () => { + const input = makeInput({ workItemId: 'card-abc' }); + const config = buildProgressMonitorConfig( + input, + 'implementation', + mockLogWriter, + '/repo', + false, + 'test-engine', + 'model-x', + ); + expect(config.trello).toEqual({ workItemId: 'card-abc' }); + }); + + it('sets trello to undefined when workItemId is absent', () => { + const input = makeInput({ workItemId: undefined }); + const config = buildProgressMonitorConfig( + input, + 'implementation', + mockLogWriter, + '/repo', + false, + 'test-engine', + 'model-x', + ); + expect(config.trello).toBeUndefined(); + }); + + it('sets preSeededCommentId when isGitHubAck is false and ackCommentId is a string', () => { + const input = makeInput({ ackCommentId: 'trello-comment-abc' }); + const config = buildProgressMonitorConfig( + input, + 'implementation', + mockLogWriter, + '/repo', + false, + 'test-engine', + 'model-x', + ); + expect(config.preSeededCommentId).toBe('trello-comment-abc'); + }); + + it('sets preSeededCommentId to undefined when isGitHubAck is true', () => { + const input = makeInput({ ackCommentId: 12345 }); + const config = buildProgressMonitorConfig( + input, + 'review', + mockLogWriter, + '/repo', + true, + 'test-engine', + 'model-x', + ); + expect(config.preSeededCommentId).toBeUndefined(); + }); + + it('includes github config when prNumber and repoFullName are present', () => { + const input = makeInput({ prNumber: 42, repoFullName: 'acme/widgets' }); + const config = buildProgressMonitorConfig( + input, + 'review', + mockLogWriter, + '/repo', + true, + 'test-engine', + 'model-x', + ); + expect(config.github).toEqual({ owner: 'acme', repo: 'widgets' }); + }); + + it('does not include github config when prNumber is absent', () => { + const input = makeInput({ repoFullName: 'acme/widgets' }); + const config = buildProgressMonitorConfig( + input, + 'implementation', + mockLogWriter, + '/repo', + false, + 'test-engine', + 'model-x', + ); + expect(config.github).toBeUndefined(); + }); + + it('does not include runLink when runLinksEnabled is false', () => { + mockGetDashboardUrl.mockReturnValue('https://dashboard.example.com'); + const input = makeInput({ project: makeProject({ runLinksEnabled: false }) }); + const config = buildProgressMonitorConfig( + input, + 'implementation', + mockLogWriter, + '/repo', + false, + 'test-engine', + 'model-x', + ); + expect(config.runLink).toBeUndefined(); + }); + + it('does not include runLink when dashboardUrl is absent', () => { + mockGetDashboardUrl.mockReturnValue(undefined); + const input = makeInput({ project: makeProject({ runLinksEnabled: true }) }); + const config = buildProgressMonitorConfig( + input, + 'implementation', + mockLogWriter, + '/repo', + false, + 'test-engine', + 'model-x', + ); + expect(config.runLink).toBeUndefined(); + }); + + it('includes runLink when runLinksEnabled and dashboardUrl are both set', () => { + mockGetDashboardUrl.mockReturnValue('https://dashboard.example.com'); + const input = makeInput({ + workItemId: 'card-abc', + project: makeProject({ runLinksEnabled: true }), + }); + const config = buildProgressMonitorConfig( + input, + 'implementation', + mockLogWriter, + '/repo', + false, + 'my-engine', + 'some-model', + ); + expect(config.runLink).toEqual({ + engineLabel: 'my-engine', + model: 'some-model', + projectId: 'test-project', + workItemId: 'card-abc', + }); + }); + + it('passes through repoDir to config', () => { + const input = makeInput(); + const config = buildProgressMonitorConfig( + input, + 'implementation', + mockLogWriter, + '/my/repo', + false, + 'test-engine', + 'model-x', + ); + expect(config.repoDir).toBe('/my/repo'); + }); + + it('converts null repoDir to undefined', () => { + const input = makeInput(); + const config = buildProgressMonitorConfig( + input, + 'implementation', + mockLogWriter, + null, + false, + 'test-engine', + 'model-x', + ); + expect(config.repoDir).toBeUndefined(); + }); +}); diff --git a/tests/unit/backends/secretOrchestrator.test.ts b/tests/unit/backends/secretOrchestrator.test.ts new file mode 100644 index 00000000..923fca17 --- /dev/null +++ b/tests/unit/backends/secretOrchestrator.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/utils/runLink.js', () => ({ + getDashboardUrl: vi.fn(), +})); + +import { injectRunLinkSecrets } from '../../../src/backends/secretOrchestrator.js'; +import type { ProjectConfig } from '../../../src/types/index.js'; +import { getDashboardUrl } from '../../../src/utils/runLink.js'; + +const mockGetDashboardUrl = vi.mocked(getDashboardUrl); + +function makeProject(overrides?: Partial): ProjectConfig { + return { + id: 'test-project', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + trello: { boardId: 'b1', lists: {}, labels: {} }, + ...overrides, + }; +} + +beforeEach(() => { + mockGetDashboardUrl.mockReturnValue(undefined); +}); + +describe('injectRunLinkSecrets', () => { + it('does nothing when runLinksEnabled is false', () => { + mockGetDashboardUrl.mockReturnValue('https://dashboard.example.com'); + const project = makeProject({ runLinksEnabled: false }); + const partialInput: { projectSecrets?: Record; model?: string } = { + model: 'test-model', + }; + + injectRunLinkSecrets(partialInput, project, 'claude-code', 'card123', 'run-id-1'); + + expect(partialInput.projectSecrets).toBeUndefined(); + }); + + it('does nothing when dashboardUrl is absent', () => { + mockGetDashboardUrl.mockReturnValue(undefined); + const project = makeProject({ runLinksEnabled: true }); + const partialInput: { projectSecrets?: Record; model?: string } = { + model: 'test-model', + }; + + injectRunLinkSecrets(partialInput, project, 'claude-code', 'card123', 'run-id-1'); + + expect(partialInput.projectSecrets).toBeUndefined(); + }); + + it('injects all run link secrets when enabled', () => { + mockGetDashboardUrl.mockReturnValue('https://dashboard.example.com'); + const project = makeProject({ runLinksEnabled: true, id: 'my-project' }); + const partialInput: { projectSecrets?: Record; model?: string } = { + model: 'claude-3-sonnet', + }; + + injectRunLinkSecrets(partialInput, project, 'claude-code', 'card-abc', 'run-uuid-123'); + + expect(partialInput.projectSecrets).toEqual( + expect.objectContaining({ + CASCADE_RUN_LINKS_ENABLED: 'true', + CASCADE_DASHBOARD_URL: 'https://dashboard.example.com', + CASCADE_ENGINE_LABEL: 'claude-code', + CASCADE_MODEL: 'claude-3-sonnet', + CASCADE_PROJECT_ID: 'my-project', + CASCADE_WORK_ITEM_ID: 'card-abc', + CASCADE_RUN_ID: 'run-uuid-123', + }), + ); + }); + + it('skips CASCADE_WORK_ITEM_ID when workItemId is undefined', () => { + mockGetDashboardUrl.mockReturnValue('https://dashboard.example.com'); + const project = makeProject({ runLinksEnabled: true }); + const partialInput: { projectSecrets?: Record; model?: string } = { + model: 'test-model', + }; + + injectRunLinkSecrets(partialInput, project, 'claude-code', undefined, 'run-id-1'); + + expect(partialInput.projectSecrets?.CASCADE_WORK_ITEM_ID).toBeUndefined(); + }); + + it('skips CASCADE_RUN_ID when runId is undefined', () => { + mockGetDashboardUrl.mockReturnValue('https://dashboard.example.com'); + const project = makeProject({ runLinksEnabled: true }); + const partialInput: { projectSecrets?: Record; model?: string } = { + model: 'test-model', + }; + + injectRunLinkSecrets(partialInput, project, 'claude-code', 'card123', undefined); + + expect(partialInput.projectSecrets?.CASCADE_RUN_ID).toBeUndefined(); + }); + + it('initializes projectSecrets when undefined', () => { + mockGetDashboardUrl.mockReturnValue('https://dashboard.example.com'); + const project = makeProject({ runLinksEnabled: true }); + const partialInput: { projectSecrets?: Record; model?: string } = { + model: 'test-model', + }; + expect(partialInput.projectSecrets).toBeUndefined(); + + injectRunLinkSecrets(partialInput, project, 'claude-code', 'card123', 'run-id-1'); + + expect(partialInput.projectSecrets).toBeDefined(); + }); + + it('merges into existing projectSecrets', () => { + mockGetDashboardUrl.mockReturnValue('https://dashboard.example.com'); + const project = makeProject({ runLinksEnabled: true }); + const partialInput: { projectSecrets?: Record; model?: string } = { + model: 'test-model', + projectSecrets: { EXISTING_KEY: 'existing-value' }, + }; + + injectRunLinkSecrets(partialInput, project, 'claude-code', 'card123', 'run-id-1'); + + expect(partialInput.projectSecrets?.EXISTING_KEY).toBe('existing-value'); + expect(partialInput.projectSecrets?.CASCADE_RUN_LINKS_ENABLED).toBe('true'); + }); + + it('uses empty string for model when model is undefined', () => { + mockGetDashboardUrl.mockReturnValue('https://dashboard.example.com'); + const project = makeProject({ runLinksEnabled: true }); + const partialInput: { projectSecrets?: Record; model?: string } = {}; + + injectRunLinkSecrets(partialInput, project, 'claude-code', 'card123', 'run-id-1'); + + expect(partialInput.projectSecrets?.CASCADE_MODEL).toBe(''); + }); +}); diff --git a/tests/unit/backends/sidecarManager.test.ts b/tests/unit/backends/sidecarManager.test.ts new file mode 100644 index 00000000..a8d94cb4 --- /dev/null +++ b/tests/unit/backends/sidecarManager.test.ts @@ -0,0 +1,380 @@ +import { existsSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/gadgets/sessionState.js', () => ({ + REVIEW_SIDECAR_ENV_VAR: 'CASCADE_REVIEW_SIDECAR_PATH', + PR_SIDECAR_ENV_VAR: 'CASCADE_PR_SIDECAR_PATH', + PUSHED_CHANGES_SIDECAR_ENV_VAR: 'CASCADE_PUSHED_CHANGES_SIDECAR_PATH', + PM_WRITE_SIDECAR_ENV_VAR: 'CASCADE_PM_WRITE_SIDECAR_PATH', + clearInitialComment: vi.fn(), + recordPRCreation: vi.fn(), + recordReviewSubmission: vi.fn(), +})); + +vi.mock('../../../src/utils/logging.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import type { AgentProfile } from '../../../src/agents/definitions/profiles.js'; +import { + cleanupTempFile, + createCompletionArtifacts, + hydrateNativeToolSidecars, + hydratePrSidecar, + hydrateReviewSidecar, +} from '../../../src/backends/sidecarManager.js'; +import { + clearInitialComment, + recordPRCreation, + recordReviewSubmission, +} from '../../../src/gadgets/sessionState.js'; +import type { AgentInput } from '../../../src/types/index.js'; + +const mockRecordPRCreation = vi.mocked(recordPRCreation); +const mockRecordReviewSubmission = vi.mocked(recordReviewSubmission); +const mockClearInitialComment = vi.mocked(clearInitialComment); + +function makeProfile(overrides?: Partial): AgentProfile { + return { + filterTools: (tools) => tools, + allCapabilities: ['fs:read'], + needsGitHubToken: false, + finishHooks: {}, + fetchContext: vi.fn().mockResolvedValue([]), + buildTaskPrompt: () => 'Process the work item', + capabilities: { required: ['fs:read'], optional: [] }, + ...overrides, + }; +} + +function makeSidecarPath(name: string): string { + return join(tmpdir(), `test-${name}-${process.pid}-${Date.now()}.json`); +} + +describe('createCompletionArtifacts', () => { + it('creates a review sidecar path for review agent type', () => { + const profile = makeProfile(); + const projectSecrets: Record = {}; + + const result = createCompletionArtifacts( + profile, + 'review', + false, + {} as AgentInput, + projectSecrets, + ); + + expect(result.reviewSidecarPath).toMatch(/cascade-review-sidecar-\d+-\d+\.json$/); + expect(projectSecrets.CASCADE_REVIEW_SIDECAR_PATH).toBe(result.reviewSidecarPath); + }); + + it('does not create review sidecar for non-review agents', () => { + const profile = makeProfile(); + const projectSecrets: Record = {}; + + const result = createCompletionArtifacts( + profile, + 'implementation', + false, + {} as AgentInput, + projectSecrets, + ); + + expect(result.reviewSidecarPath).toBeUndefined(); + expect(projectSecrets.CASCADE_REVIEW_SIDECAR_PATH).toBeUndefined(); + }); + + it('creates a PR sidecar path when requiresPR and needsNativeToolRuntime', () => { + const profile = makeProfile({ finishHooks: { requiresPR: true } }); + const projectSecrets: Record = {}; + + const result = createCompletionArtifacts( + profile, + 'implementation', + true, + {} as AgentInput, + projectSecrets, + ); + + expect(result.prSidecarPath).toMatch(/cascade-pr-sidecar-\d+-\d+\.json$/); + expect(projectSecrets.CASCADE_PR_SIDECAR_PATH).toBe(result.prSidecarPath); + }); + + it('does not create PR sidecar when needsNativeToolRuntime is false', () => { + const profile = makeProfile({ finishHooks: { requiresPR: true } }); + const projectSecrets: Record = {}; + + const result = createCompletionArtifacts( + profile, + 'implementation', + false, + {} as AgentInput, + projectSecrets, + ); + + expect(result.prSidecarPath).toBeUndefined(); + expect(projectSecrets.CASCADE_PR_SIDECAR_PATH).toBeUndefined(); + }); + + it('creates a pushed-changes sidecar when requiresPushedChanges and needsNativeToolRuntime', () => { + const profile = makeProfile({ finishHooks: { requiresPushedChanges: true } }); + const projectSecrets: Record = {}; + + const result = createCompletionArtifacts( + profile, + 'respond-to-review', + true, + {} as AgentInput, + projectSecrets, + ); + + expect(result.pushedChangesSidecarPath).toMatch( + /cascade-pushed-changes-sidecar-\d+-\d+\.json$/, + ); + expect(projectSecrets.CASCADE_PUSHED_CHANGES_SIDECAR_PATH).toBe( + result.pushedChangesSidecarPath, + ); + }); + + it('creates a PM write sidecar when requiresPMWrite and needsNativeToolRuntime', () => { + const profile = makeProfile({ finishHooks: { requiresPMWrite: true } }); + const projectSecrets: Record = {}; + + const result = createCompletionArtifacts( + profile, + 'splitting', + true, + {} as AgentInput, + projectSecrets, + ); + + expect(result.pmWriteSidecarPath).toMatch(/cascade-pm-write-sidecar-\d+-\d+\.json$/); + expect(projectSecrets.CASCADE_PM_WRITE_SIDECAR_PATH).toBe(result.pmWriteSidecarPath); + }); + + it('injects CASCADE_FINISH_HOOKS when finishHooks has entries', () => { + const finishHooks = { requiresPR: true, requiresReview: true }; + const profile = makeProfile({ finishHooks }); + const projectSecrets: Record = {}; + + createCompletionArtifacts(profile, 'implementation', true, {} as AgentInput, projectSecrets); + + expect(projectSecrets.CASCADE_FINISH_HOOKS).toBe(JSON.stringify(finishHooks)); + }); + + it('does not inject CASCADE_FINISH_HOOKS when finishHooks is empty', () => { + const profile = makeProfile({ finishHooks: {} }); + const projectSecrets: Record = {}; + + createCompletionArtifacts(profile, 'implementation', true, {} as AgentInput, projectSecrets); + + expect(projectSecrets.CASCADE_FINISH_HOOKS).toBeUndefined(); + }); + + it('injects CASCADE_INITIAL_HEAD_SHA when input.headSha is set', () => { + const profile = makeProfile(); + const projectSecrets: Record = {}; + const input = { headSha: 'abc123' } as AgentInput; + + createCompletionArtifacts(profile, 'review', false, input, projectSecrets); + + expect(projectSecrets.CASCADE_INITIAL_HEAD_SHA).toBe('abc123'); + }); + + it('does not inject CASCADE_INITIAL_HEAD_SHA when headSha is absent', () => { + const profile = makeProfile(); + const projectSecrets: Record = {}; + + createCompletionArtifacts(profile, 'review', false, {} as AgentInput, projectSecrets); + + expect(projectSecrets.CASCADE_INITIAL_HEAD_SHA).toBeUndefined(); + }); +}); + +describe('hydrateReviewSidecar', () => { + it('calls recordReviewSubmission when sidecar has reviewUrl and reviewBody', async () => { + const sidecarPath = makeSidecarPath('review'); + writeFileSync( + sidecarPath, + JSON.stringify({ + reviewUrl: 'https://github.com/o/r/pull/1#pullrequestreview-99', + event: 'REQUEST_CHANGES', + body: 'Please fix the null check', + }), + ); + + await hydrateReviewSidecar(sidecarPath); + + expect(mockRecordReviewSubmission).toHaveBeenCalledWith( + 'https://github.com/o/r/pull/1#pullrequestreview-99', + 'Please fix the null check', + 'REQUEST_CHANGES', + ); + }); + + it('calls clearInitialComment when sidecar has ackCommentDeleted: true', async () => { + const sidecarPath = makeSidecarPath('review-ack'); + writeFileSync( + sidecarPath, + JSON.stringify({ + reviewUrl: 'https://github.com/o/r/pull/1#pullrequestreview-42', + event: 'APPROVE', + body: 'LGTM', + ackCommentDeleted: true, + }), + ); + + await hydrateReviewSidecar(sidecarPath); + + expect(mockClearInitialComment).toHaveBeenCalled(); + }); + + it('does not call clearInitialComment when ackCommentDeleted is absent', async () => { + const sidecarPath = makeSidecarPath('review-no-ack'); + writeFileSync( + sidecarPath, + JSON.stringify({ + reviewUrl: 'https://github.com/o/r/pull/1#pullrequestreview-42', + event: 'APPROVE', + body: 'LGTM', + }), + ); + + await hydrateReviewSidecar(sidecarPath); + + expect(mockClearInitialComment).not.toHaveBeenCalled(); + }); + + it('does not throw when sidecar file does not exist', async () => { + await expect(hydrateReviewSidecar('/nonexistent/path.json')).resolves.not.toThrow(); + }); + + it('does not call recordReviewSubmission when sidecar has no body', async () => { + const sidecarPath = makeSidecarPath('review-missing-body'); + writeFileSync( + sidecarPath, + JSON.stringify({ + reviewUrl: 'https://github.com/o/r/pull/1#pullrequestreview-99', + event: 'APPROVE', + }), + ); + + await hydrateReviewSidecar(sidecarPath); + + expect(mockRecordReviewSubmission).not.toHaveBeenCalled(); + }); +}); + +describe('hydratePrSidecar', () => { + it('calls recordPRCreation and returns prUrl when sidecar has prUrl', async () => { + const sidecarPath = makeSidecarPath('pr'); + writeFileSync( + sidecarPath, + JSON.stringify({ + source: 'cascade-tools scm create-pr', + prUrl: 'https://github.com/o/r/pull/88', + prNumber: 88, + }), + ); + + const result = await hydratePrSidecar(sidecarPath); + + expect(mockRecordPRCreation).toHaveBeenCalledWith('https://github.com/o/r/pull/88'); + expect(result.prUrl).toBe('https://github.com/o/r/pull/88'); + expect(result.prEvidence?.source).toBe('native-tool-sidecar'); + expect(result.prEvidence?.authoritative).toBe(true); + }); + + it('returns empty object when sidecar file does not exist', async () => { + const result = await hydratePrSidecar('/nonexistent/path.json'); + + expect(result).toEqual({}); + expect(mockRecordPRCreation).not.toHaveBeenCalled(); + }); + + it('returns empty object when sidecar has no prUrl', async () => { + const sidecarPath = makeSidecarPath('pr-no-url'); + writeFileSync(sidecarPath, JSON.stringify({ prNumber: 5 })); + + const result = await hydratePrSidecar(sidecarPath); + + expect(result).toEqual({}); + expect(mockRecordPRCreation).not.toHaveBeenCalled(); + }); +}); + +describe('hydrateNativeToolSidecars', () => { + it('hydrates PR sidecar and updates result.prUrl', async () => { + const sidecarPath = makeSidecarPath('nt-pr'); + writeFileSync( + sidecarPath, + JSON.stringify({ + prUrl: 'https://github.com/o/r/pull/77', + }), + ); + const result = { success: true, output: 'Done' } as ReturnType; + + await hydrateNativeToolSidecars(result, sidecarPath, undefined); + + expect(result.prUrl).toBe('https://github.com/o/r/pull/77'); + }); + + it('hydrates review sidecar and calls recordReviewSubmission', async () => { + const sidecarPath = makeSidecarPath('nt-review'); + writeFileSync( + sidecarPath, + JSON.stringify({ + reviewUrl: 'https://github.com/o/r/pull/1#pullrequestreview-55', + event: 'APPROVE', + body: 'Looks good!', + }), + ); + const result = { success: true, output: 'Done' }; + + await hydrateNativeToolSidecars(result, undefined, sidecarPath); + + expect(mockRecordReviewSubmission).toHaveBeenCalledWith( + 'https://github.com/o/r/pull/1#pullrequestreview-55', + 'Looks good!', + 'APPROVE', + ); + }); + + it('does nothing when both sidecar paths are undefined', async () => { + const result = { success: true, output: 'Done', prUrl: 'existing-url' }; + + await hydrateNativeToolSidecars(result, undefined, undefined); + + expect(result.prUrl).toBe('existing-url'); + expect(mockRecordPRCreation).not.toHaveBeenCalled(); + expect(mockRecordReviewSubmission).not.toHaveBeenCalled(); + }); +}); + +describe('cleanupTempFile', () => { + it('deletes the file when it exists', () => { + const filePath = makeSidecarPath('cleanup'); + writeFileSync(filePath, '{}'); + expect(existsSync(filePath)).toBe(true); + + cleanupTempFile(filePath); + + expect(existsSync(filePath)).toBe(false); + }); + + it('does nothing when path is undefined', () => { + // Should not throw + expect(() => cleanupTempFile(undefined)).not.toThrow(); + }); + + it('does not throw when file does not exist', () => { + expect(() => cleanupTempFile('/nonexistent/path.json')).not.toThrow(); + }); +});