diff --git a/.cascade/setup.sh b/.cascade/setup.sh index 12a6e254..3f9dedb5 100755 --- a/.cascade/setup.sh +++ b/.cascade/setup.sh @@ -245,7 +245,22 @@ if pg_isready -q 2>/dev/null; then fi # ============================================================================= -# 4. Run migrations +# 4. Create .env for local database (npm scripts use --env-file=.env) +# ============================================================================= +if pg_isready -q 2>/dev/null && [ ! -f .env ]; then + echo "" + echo "--- Creating .env for local database ---" + if [ "$OS" = "linux" ]; then + echo "DATABASE_URL=postgresql://postgres:postgres@localhost:5432/cascade" > .env + else + echo "DATABASE_URL=postgresql://localhost:5432/cascade" > .env + fi + echo "DATABASE_SSL=false" >> .env + log_info "Created .env with local DATABASE_URL" +fi + +# ============================================================================= +# 5. Run migrations # ============================================================================= echo "" echo "--- Database Migrations ---" diff --git a/Dockerfile.worker b/Dockerfile.worker index b0176a3a..100f4595 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -23,6 +23,7 @@ RUN npm install -g pnpm @zbigniewsobiecki/squint --force # (niu-browser-base already has: git, curl, ca-certificates, gnupg, postgresql-client) RUN apt-get update && apt-get install -y \ fd-find \ + jq \ ripgrep \ ed \ unzip \ diff --git a/drizzle.config.ts b/drizzle.config.ts index a3ebf870..cddc5c12 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ './src/db/schema/agentConfigs.ts', './src/db/schema/integrations.ts', './src/db/schema/runs.ts', + './src/db/schema/promptPartials.ts', ], out: './src/db/migrations', dialect: 'postgresql', diff --git a/package.json b/package.json index 316b930c..004e521f 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "db:push": "node --env-file=.env ./node_modules/.bin/drizzle-kit push", "db:studio": "node --env-file=.env ./node_modules/.bin/drizzle-kit studio", "db:seed": "node --env-file=.env --import tsx tools/seed-config-from-json.ts", - "db:bootstrap-journal": "node --env-file=.env --import tsx tools/db-bootstrap-journal.ts" + "db:bootstrap-journal": "node --env-file=.env --import tsx tools/db-bootstrap-journal.ts", + "db:seed-prompts": "node --env-file=.env --import tsx tools/seed-prompts.ts" }, "keywords": [ "trello", diff --git a/src/agents/base.ts b/src/agents/base.ts index 02420172..c0d73819 100644 --- a/src/agents/base.ts +++ b/src/agents/base.ts @@ -3,6 +3,7 @@ import { WriteFile } from '../gadgets/WriteFile.js'; import { type ProgressMonitor, createProgressMonitor } from '../backends/progress.js'; import { CUSTOM_MODELS } from '../config/customModels.js'; +import { loadPartials } from '../db/repositories/partialsRepository.js'; import { AstGrep } from '../gadgets/AstGrep.js'; import { FileMultiEdit } from '../gadgets/FileMultiEdit.js'; import { FileSearchAndReplace } from '../gadgets/FileSearchAndReplace.js'; @@ -96,13 +97,18 @@ export async function fetchImplementationSteps(cardId: string): Promise | undefined> { + try { + return await loadPartials(orgId); + } catch { + // DB not available — fall back to disk-only partials + return undefined; + } +} + +function buildPromptContext( 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?: { @@ -112,13 +118,10 @@ async function buildAgentContext( originalCardUrl: string; detectedAgentType: string; }, - modelOverride?: string, - commentContext?: { text: string; author: string }, -): Promise { - // Build prompt context for template rendering +): PromptContext { const pmProvider = getPMProvider(); const isJira = pmProvider.type === 'jira'; - const promptContext: PromptContext = { + return { cardId, cardUrl: cardId ? pmProvider.getWorkItemUrl(cardId) : undefined, projectId: project.id, @@ -147,6 +150,48 @@ async function buildAgentContext( debugListId: project.trello?.lists?.debug, }), }; +} + +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 buildPrompt(cardId ?? ''); +} + +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 = { @@ -161,6 +206,7 @@ async function buildAgentContext( modelOverride, promptContext, configKey: configKeyOverrides[agentType], + dbPartials, }); // Pre-fetch work item data for synthetic gadget call (only if cardId exists and not debug flow) @@ -176,17 +222,7 @@ async function buildAgentContext( implementationSteps = await fetchImplementationSteps(cardId); } - // Build different prompt based on flow - let prompt: string; - if (commentContext) { - prompt = buildCommentResponsePrompt(cardId ?? '', commentContext.text, commentContext.author); - } else if (prContext) { - prompt = buildCheckFailurePrompt(prContext); - } else if (debugContext) { - prompt = buildDebugPrompt(debugContext); - } else { - prompt = buildPrompt(cardId ?? ''); - } + const prompt = selectPrompt(cardId, commentContext, prContext, debugContext); return { systemPrompt, diff --git a/src/agents/prompts/index.ts b/src/agents/prompts/index.ts index 2086400c..4ab77cb9 100644 --- a/src/agents/prompts/index.ts +++ b/src/agents/prompts/index.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Eta } from 'eta'; @@ -9,6 +9,19 @@ const templatesDir = join(__dirname, 'templates'); // Initialize Eta with the templates directory const eta = new Eta({ views: templatesDir, autoEscape: false }); +// Valid agent types +const validTypes = [ + 'briefing', + 'planning', + 'implementation', + 'debug', + 'respond-to-review', + 'respond-to-ci', + 'respond-to-pr-comment', + 'respond-to-planning-comment', + 'review', +]; + // Template context interface export interface PromptContext { // Common @@ -63,26 +76,137 @@ function loadTemplate(agentType: string): string { return template; } -export function getSystemPrompt(agentType: string, context: PromptContext = {}): string { - const validTypes = [ - 'briefing', - 'planning', - 'implementation', - 'debug', - 'respond-to-review', - 'respond-to-ci', - 'respond-to-pr-comment', - 'respond-to-planning-comment', - 'review', - ]; +/** + * Resolve `<%~ include("partials/...") %>` directives by looking up DB partials first, + * falling back to disk. This pre-processing happens before Eta variable interpolation. + */ +export function resolveIncludes(template: string, dbPartials: Map): string { + return template.replace( + /<%~\s*include\(\s*"partials\/([^"]+)"\s*\)\s*%>/g, + (_match, name: string) => { + const dbContent = dbPartials.get(name); + if (dbContent !== undefined) return dbContent; + // Fall back to disk + const diskPath = join(templatesDir, 'partials', `${name}.eta`); + try { + return readFileSync(diskPath, 'utf-8'); + } catch { + throw new Error(`Partial not found: partials/${name}`); + } + }, + ); +} + +/** + * Render a DB-stored template with include resolution + Eta variable interpolation. + */ +export function renderCustomPrompt( + templateSource: string, + context: PromptContext = {}, + dbPartials?: Map, +): string { + const expanded = resolveIncludes(templateSource, dbPartials ?? new Map()); + return eta.renderString(expanded, context); +} + +/** + * Validate a template string for correct Eta syntax and resolvable includes. + */ +export function validateTemplate( + templateSource: string, + dbPartials?: Map, +): { valid: true } | { valid: false; error: string } { + try { + const expanded = resolveIncludes(templateSource, dbPartials ?? new Map()); + eta.renderString(expanded, {}); + return { valid: true }; + } catch (err) { + return { valid: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export function getSystemPrompt( + agentType: string, + context: PromptContext = {}, + dbPartials?: Map, +): string { if (!validTypes.includes(agentType)) { throw new Error(`Unknown agent type: ${agentType}`); } const template = loadTemplate(agentType); + if (dbPartials && dbPartials.size > 0) { + const expanded = resolveIncludes(template, dbPartials); + return eta.renderString(expanded, context); + } return eta.renderString(template, context); } +/** Returns the raw .eta template source from disk (before rendering). */ +export function getRawTemplate(agentType: string): string { + if (!validTypes.includes(agentType)) { + throw new Error(`Unknown agent type: ${agentType}`); + } + return loadTemplate(agentType); +} + +/** Returns the raw partial source from disk. */ +export function getRawPartial(name: string): string { + const diskPath = join(templatesDir, 'partials', `${name}.eta`); + return readFileSync(diskPath, 'utf-8'); +} + +/** Returns the list of valid agent types. */ +export function getValidAgentTypes(): string[] { + return [...validTypes]; +} + +/** Returns the list of available disk-based partial names. */ +export function getAvailablePartialNames(): string[] { + try { + const entries = readdirSync(join(templatesDir, 'partials')); + return entries + .filter((f) => f.endsWith('.eta')) + .map((f) => f.replace(/\.eta$/, '')) + .sort(); + } catch { + return []; + } +} + +/** Returns template variable info for documentation/reference. */ +export function getTemplateVariables(): Array<{ + name: string; + group: string; + description: string; +}> { + return [ + { name: 'cardId', group: 'Common', description: 'Work item ID' }, + { name: 'cardUrl', group: 'Common', description: 'Work item URL' }, + { name: 'projectId', group: 'Common', description: 'Project identifier' }, + { name: 'baseBranch', group: 'Common', description: 'Base branch name (e.g. main)' }, + { name: 'pmType', group: 'PM', description: 'PM type: trello or jira' }, + { name: 'workItemNoun', group: 'PM', description: 'card or issue' }, + { name: 'workItemNounPlural', group: 'PM', description: 'cards or issues' }, + { name: 'workItemNounCap', group: 'PM', description: 'Card or Issue' }, + { name: 'workItemNounPluralCap', group: 'PM', description: 'Cards or Issues' }, + { name: 'pmName', group: 'PM', description: 'Trello or JIRA' }, + { name: 'storiesListId', group: 'Briefing', description: 'Trello stories list ID' }, + { name: 'processedLabelId', group: 'Briefing', description: 'Trello processed label ID' }, + { name: 'prNumber', group: 'CI', description: 'Pull request number' }, + { name: 'prBranch', group: 'CI', description: 'Pull request branch name' }, + { name: 'repoFullName', group: 'CI', description: 'Repository full name (owner/repo)' }, + { name: 'headSha', group: 'CI', description: 'HEAD commit SHA' }, + { name: 'triggerType', group: 'CI', description: 'Trigger type identifier' }, + { name: 'logDir', group: 'Debug', description: 'Debug log directory path' }, + { name: 'originalCardId', group: 'Debug', description: 'Original card ID being debugged' }, + { name: 'originalCardName', group: 'Debug', description: 'Original card name' }, + { name: 'originalCardUrl', group: 'Debug', description: 'Original card URL' }, + { name: 'detectedAgentType', group: 'Debug', description: 'Agent type from session log' }, + { name: 'debugListId', group: 'Debug', description: 'Debug list ID for output cards' }, + ]; +} + // Export individual prompts for backwards compatibility (rendered without context) export const BRIEFING_SYSTEM_PROMPT = loadTemplate('briefing'); export const PLANNING_SYSTEM_PROMPT = loadTemplate('planning'); diff --git a/src/agents/shared/modelResolution.ts b/src/agents/shared/modelResolution.ts index 8463151d..30760c97 100644 --- a/src/agents/shared/modelResolution.ts +++ b/src/agents/shared/modelResolution.ts @@ -1,7 +1,7 @@ import type { CascadeConfig, ProjectConfig } from '../../types/index.js'; import { type ContextFile, readContextFiles } from '../utils/setup.js'; -import { type PromptContext, getSystemPrompt } from '../prompts/index.js'; +import { type PromptContext, getSystemPrompt, renderCustomPrompt } from '../prompts/index.js'; export interface ModelConfig { systemPrompt: string; @@ -19,14 +19,23 @@ export interface ResolveModelConfigOptions { promptContext?: PromptContext; /** Optional key override for model/iteration config lookup (e.g., respond-to-review uses 'review') */ configKey?: string; + /** DB partials for template include resolution */ + dbPartials?: Map; } export async function resolveModelConfig(options: ResolveModelConfigOptions): Promise { - const { agentType, project, config, repoDir, modelOverride, promptContext } = options; + const { agentType, project, config, repoDir, modelOverride, promptContext, dbPartials } = options; const configKey = options.configKey ?? agentType; - const systemPrompt = - project.prompts?.[agentType] || getSystemPrompt(agentType, promptContext ?? {}); + // Resolution chain: project prompt → defaults prompt → .eta file + const customPromptSource = project.prompts?.[agentType] ?? config.defaults.prompts?.[agentType]; + + let systemPrompt: string; + if (customPromptSource) { + systemPrompt = renderCustomPrompt(customPromptSource, promptContext ?? {}, dbPartials); + } else { + systemPrompt = getSystemPrompt(agentType, promptContext ?? {}, dbPartials); + } const model = modelOverride || diff --git a/src/api/router.ts b/src/api/router.ts index 0bb7e9d4..58326c62 100644 --- a/src/api/router.ts +++ b/src/api/router.ts @@ -4,6 +4,7 @@ import { credentialsRouter } from './routers/credentials.js'; import { defaultsRouter } from './routers/defaults.js'; import { organizationRouter } from './routers/organization.js'; import { projectsRouter } from './routers/projects.js'; +import { promptsRouter } from './routers/prompts.js'; import { runsRouter } from './routers/runs.js'; import { webhooksRouter } from './routers/webhooks.js'; import { router } from './trpc.js'; @@ -16,6 +17,7 @@ export const appRouter = router({ defaults: defaultsRouter, credentials: credentialsRouter, agentConfigs: agentConfigsRouter, + prompts: promptsRouter, webhooks: webhooksRouter, }); diff --git a/src/api/routers/agentConfigs.ts b/src/api/routers/agentConfigs.ts index 4b3c8112..abbbebfa 100644 --- a/src/api/routers/agentConfigs.ts +++ b/src/api/routers/agentConfigs.ts @@ -1,7 +1,10 @@ import { TRPCError } from '@trpc/server'; import { eq } from 'drizzle-orm'; import { z } from 'zod'; +import { validateTemplate } from '../../agents/prompts/index.js'; +import { CLAUDE_CODE_MODELS } from '../../backends/claude-code/models.js'; import { getDb } from '../../db/client.js'; +import { loadPartials } from '../../db/repositories/partialsRepository.js'; import { createAgentConfig, deleteAgentConfig, @@ -9,9 +12,25 @@ import { updateAgentConfig, } from '../../db/repositories/settingsRepository.js'; import { agentConfigs, projects } from '../../db/schema/index.js'; -import { protectedProcedure, router } from '../trpc.js'; +import { protectedProcedure, publicProcedure, router } from '../trpc.js'; + +async function validatePromptIfPresent(prompt: string | null | undefined) { + if (!prompt) return; + const dbPartials = await loadPartials(); + const result = validateTemplate(prompt, dbPartials); + if (!result.valid) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Invalid prompt template: ${result.error}`, + }); + } +} export const agentConfigsRouter = router({ + claudeCodeModels: publicProcedure.query(() => { + return CLAUDE_CODE_MODELS; + }), + list: protectedProcedure .input(z.object({ projectId: z.string().optional() }).optional()) .query(async ({ ctx, input }) => { @@ -54,6 +73,7 @@ export const agentConfigsRouter = router({ throw new TRPCError({ code: 'NOT_FOUND' }); } } + await validatePromptIfPresent(input.prompt); return createAgentConfig({ orgId: input.orgId ?? ctx.user.orgId, projectId: input.projectId, @@ -102,6 +122,7 @@ export const agentConfigsRouter = router({ } const { id, ...updates } = input; + await validatePromptIfPresent(updates.prompt); await updateAgentConfig(id, updates); }), diff --git a/src/api/routers/prompts.ts b/src/api/routers/prompts.ts new file mode 100644 index 00000000..0e478fd5 --- /dev/null +++ b/src/api/routers/prompts.ts @@ -0,0 +1,169 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; +import { + getAvailablePartialNames, + getRawPartial, + getRawTemplate, + getTemplateVariables, + getValidAgentTypes, + validateTemplate, +} from '../../agents/prompts/index.js'; +import { + deletePartial, + getPartial, + listPartials, + loadPartials, + upsertPartial, +} from '../../db/repositories/partialsRepository.js'; +import { protectedProcedure, router } from '../trpc.js'; + +export const promptsRouter = router({ + // ======================================================================== + // Template introspection (read-only) + // ======================================================================== + + agentTypes: protectedProcedure.query(() => { + return getValidAgentTypes(); + }), + + getDefault: protectedProcedure + .input(z.object({ agentType: z.string().min(1) })) + .query(({ input }) => { + try { + return { content: getRawTemplate(input.agentType) }; + } catch { + throw new TRPCError({ + code: 'NOT_FOUND', + message: `Unknown agent type: ${input.agentType}`, + }); + } + }), + + variables: protectedProcedure.query(() => { + return getTemplateVariables(); + }), + + validate: protectedProcedure + .input(z.object({ template: z.string() })) + .mutation(async ({ input }) => { + const dbPartials = await loadPartials(); + return validateTemplate(input.template, dbPartials); + }), + + // ======================================================================== + // Partial CRUD + // ======================================================================== + + listPartials: protectedProcedure.query(async () => { + const dbRows = await listPartials(); + const diskNames = getAvailablePartialNames(); + + // Merge: DB content takes priority, disk names fill gaps + const dbByName = new Map(dbRows.map((r) => [r.name, r])); + const result: Array<{ + name: string; + source: 'db' | 'disk'; + lines: number; + id?: number; + }> = []; + + // Add all disk partials with DB override info + for (const name of diskNames) { + const dbRow = dbByName.get(name); + if (dbRow) { + result.push({ + name, + source: 'db', + lines: dbRow.content.split('\n').length, + id: dbRow.id, + }); + dbByName.delete(name); + } else { + try { + const content = getRawPartial(name); + result.push({ + name, + source: 'disk', + lines: content.split('\n').length, + }); + } catch { + result.push({ name, source: 'disk', lines: 0 }); + } + } + } + + // Add DB-only partials (custom ones not on disk) + for (const [name, row] of dbByName) { + result.push({ + name, + source: 'db', + lines: row.content.split('\n').length, + id: row.id, + }); + } + + return result.sort((a, b) => a.name.localeCompare(b.name)); + }), + + getPartial: protectedProcedure + .input(z.object({ name: z.string().min(1) })) + .query(async ({ input }) => { + // Check DB first + const dbRow = await getPartial(input.name); + if (dbRow) { + return { name: input.name, content: dbRow.content, source: 'db' as const, id: dbRow.id }; + } + // Fall back to disk + try { + const content = getRawPartial(input.name); + return { name: input.name, content, source: 'disk' as const }; + } catch { + throw new TRPCError({ + code: 'NOT_FOUND', + message: `Partial not found: ${input.name}`, + }); + } + }), + + getDefaultPartial: protectedProcedure + .input(z.object({ name: z.string().min(1) })) + .query(({ input }) => { + try { + return { content: getRawPartial(input.name) }; + } catch { + throw new TRPCError({ + code: 'NOT_FOUND', + message: `No disk partial: ${input.name}`, + }); + } + }), + + upsertPartial: protectedProcedure + .input( + z.object({ + name: z.string().min(1), + content: z.string().min(1), + }), + ) + .mutation(async ({ input }) => { + // Validate content doesn't break templates + const dbPartials = await loadPartials(); + dbPartials.set(input.name, input.content); + // Simple check: content itself shouldn't have broken Eta syntax + const testResult = validateTemplate(`Test: ${input.content}`, dbPartials); + if (!testResult.valid) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Invalid partial content: ${testResult.error}`, + }); + } + + return upsertPartial({ name: input.name, content: input.content }); + }), + + deletePartial: protectedProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ input }) => { + await deletePartial(input.id); + }), +}); diff --git a/src/backends/adapter.ts b/src/backends/adapter.ts index 02c9425e..c5ee4792 100644 --- a/src/backends/adapter.ts +++ b/src/backends/adapter.ts @@ -7,6 +7,7 @@ import { setupRepository } from '../agents/shared/repository.js'; import { createAgentLogger } from '../agents/utils/logging.js'; import { CUSTOM_MODELS } from '../config/customModels.js'; import { getAgentCredential, getProjectSecrets } from '../config/provider.js'; +import { loadPartials } from '../db/repositories/partialsRepository.js'; import { type CompleteRunInput, completeRun, @@ -37,7 +38,7 @@ function getToolManifests(): ToolManifest[] { cliCommand: 'cascade-tools pm read-work-item', parameters: { workItemId: { type: 'string', required: true }, - includeComments: { type: 'boolean', default: true }, + 'include-comments': { type: 'boolean', default: true }, }, }, { @@ -90,8 +91,8 @@ function getToolManifests(): ToolManifest[] { cliCommand: 'cascade-tools pm update-checklist-item', parameters: { workItemId: { type: 'string', required: true }, - checkItemId: { type: 'string', required: true }, - complete: { type: 'boolean' }, + 'check-item-id': { type: 'string', required: true }, + state: { type: 'string', required: true, description: 'complete or incomplete' }, }, }, { @@ -273,12 +274,21 @@ async function buildBackendInput( pmType, }; + // 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, model, maxIterations, contextFiles } = await resolveModelConfig({ agentType, project, config, repoDir, promptContext, + dbPartials, }); const profile = getAgentProfile(agentType); diff --git a/src/backends/claude-code/index.ts b/src/backends/claude-code/index.ts index 5e3a87e7..c2fc737f 100644 --- a/src/backends/claude-code/index.ts +++ b/src/backends/claude-code/index.ts @@ -8,6 +8,7 @@ import type { SDKStatusMessage, SDKSystemMessage, } from '@anthropic-ai/claude-agent-sdk'; +import { logger } from '../../utils/logging.js'; import type { AgentBackend, AgentBackendInput, @@ -16,6 +17,7 @@ import type { ToolManifest, } from '../types.js'; import { buildHooks } from './hooks.js'; +import { CLAUDE_CODE_MODEL_IDS, DEFAULT_CLAUDE_CODE_MODEL } from './models.js'; /** * Build prompt guidance for CASCADE-specific CLI tools. @@ -83,10 +85,15 @@ export function buildSystemPrompt(systemPrompt: string, tools: ToolManifest[]): * The Claude Code SDK expects Anthropic model IDs. */ export function resolveClaudeModel(cascadeModel: string): string { + if (CLAUDE_CODE_MODEL_IDS.includes(cascadeModel)) return cascadeModel; if (cascadeModel.startsWith('claude-')) return cascadeModel; if (cascadeModel.startsWith('anthropic:')) return cascadeModel.replace('anthropic:', ''); - // Fallback for non-Claude models configured in CASCADE - return 'claude-sonnet-4-5-20250929'; + // Non-Claude model configured for Claude Code backend — warn and fall back + logger.warn('Non-Claude model configured for Claude Code backend, falling back to default', { + configured: cascadeModel, + fallback: DEFAULT_CLAUDE_CODE_MODEL, + }); + return DEFAULT_CLAUDE_CODE_MODEL; } /** @@ -289,6 +296,28 @@ function buildResult( return { success, output, cost, error, prUrl }; } +/** + * Process a task_notification system message: log and report completed tasks. + */ +function processTaskNotification( + sysMsg: { [key: string]: unknown }, + input: AgentBackendInput, +): void { + const taskMsg = sysMsg as unknown as { + task_id: string; + status: string; + summary: string; + }; + if (taskMsg.status === 'completed' && input.progressReporter.onTaskCompleted) { + input.progressReporter.onTaskCompleted(taskMsg.task_id, '', taskMsg.summary); + } + input.logWriter('INFO', 'Task notification', { + taskId: taskMsg.task_id, + status: taskMsg.status, + summary: taskMsg.summary, + }); +} + /** * Claude Code SDK backend for CASCADE. * @@ -357,10 +386,12 @@ export class ClaudeCodeBackend implements AgentBackend { await input.progressReporter.onIteration(turnCount, input.maxIterations); processAssistantMessage(assistantMsg, turnCount, input); } else if (message.type === 'system') { - processSystemMessage( - message as { subtype: string; [key: string]: unknown }, - input.logWriter, - ); + const sysMsg = message as { subtype: string; [key: string]: unknown }; + if (sysMsg.subtype === 'task_notification') { + processTaskNotification(sysMsg, input); + } else { + processSystemMessage(sysMsg, input.logWriter); + } } else if (message.type === 'result') { resultMessage = message as SDKResultMessage; } diff --git a/src/backends/claude-code/models.ts b/src/backends/claude-code/models.ts new file mode 100644 index 00000000..a937213b --- /dev/null +++ b/src/backends/claude-code/models.ts @@ -0,0 +1,9 @@ +export const CLAUDE_CODE_MODELS = [ + { value: 'claude-opus-4-6', label: 'Claude Opus 4.6' }, + { value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' }, + { value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' }, +] as const; + +export const CLAUDE_CODE_MODEL_IDS: string[] = CLAUDE_CODE_MODELS.map((m) => m.value); + +export const DEFAULT_CLAUDE_CODE_MODEL = 'claude-sonnet-4-5-20250929'; diff --git a/src/backends/progressModel.ts b/src/backends/progressModel.ts index 9a4495e4..d0da9acf 100644 --- a/src/backends/progressModel.ts +++ b/src/backends/progressModel.ts @@ -16,42 +16,73 @@ export interface ProgressContext { iteration: number; maxIterations: number; todos: Todo[]; - recentToolCalls: { name: string; timestamp: number }[]; + recentToolCalls: { name: string; detail?: string; timestamp: number }[]; + recentTextSnippets?: { text: string; timestamp: number }[]; + completedTasks?: { subject: string; summary: string; timestamp: number }[]; } -const PROGRESS_SYSTEM_PROMPT = `You are a progress reporter for an AI coding agent called CASCADE. Write a brief, informative progress update based on the agent's current state. Be concise (3-5 sentences max). Focus on what has been accomplished, what's currently in progress, and what remains. Use markdown formatting. Start with a bold header like "**implementation agent progress** (X min)". Do not include a progress bar — the system adds that separately.`; +const PROGRESS_SYSTEM_PROMPT = `You are a progress reporter for an AI coding agent called CASCADE. Write a brief, informative progress update based on the agent's current state. Be concise (3-5 sentences max). Focus on what has been accomplished, what's currently in progress, and what remains. Synthesize the agent's own commentary, tool call details (file paths, commands), and completed task summaries into a coherent narrative — do not just list tool names. Use markdown formatting. Start with a bold header like "**implementation agent progress** (X min)". Do not include a progress bar — the system adds that separately.`; function formatProgressUserPrompt(context: ProgressContext): string { - const { agentType, taskDescription, elapsedMinutes, iteration, todos, recentToolCalls } = context; - - const todoLines = - todos.length > 0 - ? todos - .map((t) => { - const icon = t.status === 'done' ? '✅' : t.status === 'in_progress' ? '🔄' : '⬜'; - return `${icon} ${t.content}`; - }) - .join('\n') - : '(no todos)'; - - const recentActivity = - recentToolCalls.length > 0 - ? recentToolCalls - .slice(-10) - .map((tc) => tc.name) - .join(', ') - : '(no recent activity)'; - - return `Agent: ${agentType} -Task: ${taskDescription.slice(0, 500)} -Time elapsed: ${Math.round(elapsedMinutes)} minutes -Iterations: ${iteration} - -## Todo List -${todoLines} - -## Recent Activity -${recentActivity}`; + const { + agentType, + taskDescription, + elapsedMinutes, + iteration, + todos, + recentToolCalls, + recentTextSnippets, + completedTasks, + } = context; + + const sections: string[] = [ + `Agent: ${agentType}`, + `Task: ${taskDescription.slice(0, 500)}`, + `Time elapsed: ${Math.round(elapsedMinutes)} minutes`, + `Iterations: ${iteration}`, + ]; + + // Todos — only include if there are any (avoids noise for claude-code backend) + if (todos.length > 0) { + const todoLines = todos + .map((t) => { + const icon = t.status === 'done' ? '✅' : t.status === 'in_progress' ? '🔄' : '⬜'; + return `${icon} ${t.content}`; + }) + .join('\n'); + sections.push(`\n## Todo List\n${todoLines}`); + } + + // Recent activity with tool details (file paths, commands) + if (recentToolCalls.length > 0) { + const activityLines = recentToolCalls + .slice(-10) + .map((tc) => (tc.detail ? `${tc.name}: ${tc.detail}` : tc.name)) + .join('\n'); + sections.push(`\n## Recent Activity\n${activityLines}`); + } + + // Agent commentary — what the LLM said it's doing + if (recentTextSnippets && recentTextSnippets.length > 0) { + const commentaryLines = recentTextSnippets + .slice(-5) + .map((s) => s.text) + .join('\n---\n'); + sections.push(`\n## Agent Commentary\n${commentaryLines}`); + } + + // Completed tasks — subagent task summaries + if (completedTasks && completedTasks.length > 0) { + const taskLines = completedTasks + .map((t) => { + const label = t.subject ? `**${t.subject}**: ` : ''; + return `- ${label}${t.summary}`; + }) + .join('\n'); + sections.push(`\n## Completed Tasks\n${taskLines}`); + } + + return sections.join('\n'); } /** diff --git a/src/backends/progressMonitor.ts b/src/backends/progressMonitor.ts index c8635551..272dd4c9 100644 --- a/src/backends/progressMonitor.ts +++ b/src/backends/progressMonitor.ts @@ -33,9 +33,30 @@ export interface ProgressMonitorConfig { } const RING_BUFFER_MAX = 20; +const TEXT_SNIPPETS_MAX = 10; +const COMPLETED_TASKS_MAX = 5; + +/** + * Extract a meaningful detail string from tool call params. + * Returns file paths, commands, or search patterns — the most useful + * context for progress reporting. + */ +function summarizeToolParams(_toolName: string, params?: Record): string { + if (!params) return ''; + if (params.file_path) return String(params.file_path); + if (params.filePath) return String(params.filePath); + if (params.command) return String(params.command).slice(0, 100); + if (params.pattern) { + const detail = String(params.pattern); + return params.path ? `${detail} in ${params.path}` : detail; + } + return ''; +} export class ProgressMonitor implements ProgressReporter { - private recentToolCalls: { name: string; timestamp: number }[] = []; + private recentToolCalls: { name: string; detail?: string; timestamp: number }[] = []; + private recentTextSnippets: { text: string; timestamp: number }[] = []; + private completedTasks: { subject: string; summary: string; timestamp: number }[] = []; private currentIteration = 0; private maxIterations = 0; private startTime = Date.now(); @@ -52,7 +73,12 @@ export class ProgressMonitor implements ProgressReporter { } onToolCall(toolName: string, params?: Record): void { - this.recentToolCalls.push({ name: toolName, timestamp: Date.now() }); + const detail = summarizeToolParams(toolName, params); + this.recentToolCalls.push({ + name: toolName, + detail: detail || undefined, + timestamp: Date.now(), + }); if (this.recentToolCalls.length > RING_BUFFER_MAX) { this.recentToolCalls.shift(); } @@ -60,9 +86,30 @@ export class ProgressMonitor implements ProgressReporter { } onText(content: string): void { + if (content.trim()) { + this.recentTextSnippets.push({ + text: content.slice(0, 200), + timestamp: Date.now(), + }); + if (this.recentTextSnippets.length > TEXT_SNIPPETS_MAX) { + this.recentTextSnippets.shift(); + } + } this.config.logWriter('INFO', 'Agent text output', { length: content.length }); } + onTaskCompleted(taskId: string, subject: string, summary: string): void { + this.completedTasks.push({ + subject, + summary: summary.slice(0, 300), + timestamp: Date.now(), + }); + if (this.completedTasks.length > COMPLETED_TASKS_MAX) { + this.completedTasks.shift(); + } + this.config.logWriter('INFO', 'Task completed', { taskId, subject }); + } + // ── Lifecycle ── start(): void { @@ -99,6 +146,8 @@ export class ProgressMonitor implements ProgressReporter { maxIterations: this.maxIterations, todos, recentToolCalls: [...this.recentToolCalls], + recentTextSnippets: [...this.recentTextSnippets], + completedTasks: [...this.completedTasks], }; let summary: string; diff --git a/src/backends/types.ts b/src/backends/types.ts index 3feeec93..4fd2e5cc 100644 --- a/src/backends/types.ts +++ b/src/backends/types.ts @@ -37,6 +37,7 @@ export interface ProgressReporter { onIteration(iteration: number, maxIterations: number): Promise; onToolCall(toolName: string, params?: Record): void; onText(content: string): void; + onTaskCompleted?(taskId: string, subject: string, summary: string): void; } /** diff --git a/src/cli/dashboard/agents/create.ts b/src/cli/dashboard/agents/create.ts index 195651d7..a21627d6 100644 --- a/src/cli/dashboard/agents/create.ts +++ b/src/cli/dashboard/agents/create.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import { Flags } from '@oclif/core'; import { DashboardCommand } from '../_shared/base.js'; @@ -14,20 +15,31 @@ export default class AgentsCreate extends DashboardCommand { model: Flags.string({ description: 'Model override' }), 'max-iterations': Flags.integer({ description: 'Max iterations override' }), backend: Flags.string({ description: 'Agent backend override' }), - prompt: Flags.string({ description: 'Custom prompt override' }), + prompt: Flags.string({ description: 'Custom prompt override (inline)' }), + 'prompt-file': Flags.string({ + description: 'Read prompt from file (use - for stdin)', + }), }; async run(): Promise { const { flags } = await this.parse(AgentsCreate); try { + let prompt = flags.prompt ?? null; + if (flags['prompt-file']) { + prompt = + flags['prompt-file'] === '-' + ? readFileSync(0, 'utf-8') + : readFileSync(flags['prompt-file'], 'utf-8'); + } + const result = await this.client.agentConfigs.create.mutate({ agentType: flags['agent-type'], projectId: flags['project-id'], model: flags.model, maxIterations: flags['max-iterations'], agentBackend: flags.backend, - prompt: flags.prompt, + prompt, }); if (flags.json) { diff --git a/src/cli/dashboard/agents/list.ts b/src/cli/dashboard/agents/list.ts index 06999ff7..b17987b5 100644 --- a/src/cli/dashboard/agents/list.ts +++ b/src/cli/dashboard/agents/list.ts @@ -29,6 +29,7 @@ export default class AgentsList extends DashboardCommand { { key: 'model', header: 'Model' }, { key: 'maxIterations', header: 'Max Iter' }, { key: 'agentBackend', header: 'Backend' }, + { key: 'prompt', header: 'Prompt', format: (v) => (v ? 'custom' : '-') }, ]); } catch (err) { this.handleError(err); diff --git a/src/cli/dashboard/agents/update.ts b/src/cli/dashboard/agents/update.ts index 0453fb48..599fa019 100644 --- a/src/cli/dashboard/agents/update.ts +++ b/src/cli/dashboard/agents/update.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import { Args, Flags } from '@oclif/core'; import { DashboardCommand } from '../_shared/base.js'; @@ -14,20 +15,31 @@ export default class AgentsUpdate extends DashboardCommand { model: Flags.string({ description: 'Model override' }), 'max-iterations': Flags.integer({ description: 'Max iterations override' }), backend: Flags.string({ description: 'Agent backend override' }), - prompt: Flags.string({ description: 'Custom prompt override' }), + prompt: Flags.string({ description: 'Custom prompt override (inline)' }), + 'prompt-file': Flags.string({ + description: 'Read prompt from file (use - for stdin)', + }), }; async run(): Promise { const { args, flags } = await this.parse(AgentsUpdate); try { + let prompt: string | null | undefined = flags.prompt; + if (flags['prompt-file']) { + prompt = + flags['prompt-file'] === '-' + ? readFileSync(0, 'utf-8') + : readFileSync(flags['prompt-file'], 'utf-8'); + } + await this.client.agentConfigs.update.mutate({ id: args.id, agentType: flags['agent-type'], model: flags.model, maxIterations: flags['max-iterations'], agentBackend: flags.backend, - prompt: flags.prompt, + prompt: prompt ?? null, }); if (flags.json) { diff --git a/src/cli/dashboard/prompts/default-partial.ts b/src/cli/dashboard/prompts/default-partial.ts new file mode 100644 index 00000000..445eee9a --- /dev/null +++ b/src/cli/dashboard/prompts/default-partial.ts @@ -0,0 +1,27 @@ +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class PromptsDefaultPartial extends DashboardCommand { + static override description = 'Print the disk-based default partial content.'; + + static override flags = { + ...DashboardCommand.baseFlags, + name: Flags.string({ + description: 'Partial name (e.g. git, tmux, test-protocol)', + required: true, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PromptsDefaultPartial); + + try { + const result = await this.client.prompts.getDefaultPartial.query({ + name: flags.name, + }); + process.stdout.write(result.content); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/prompts/default.ts b/src/cli/dashboard/prompts/default.ts new file mode 100644 index 00000000..7b4bd905 --- /dev/null +++ b/src/cli/dashboard/prompts/default.ts @@ -0,0 +1,29 @@ +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class PromptsDefault extends DashboardCommand { + static override description = 'Print the default .eta template for an agent type.'; + + static override flags = { + ...DashboardCommand.baseFlags, + 'agent-type': Flags.string({ + description: 'Agent type (e.g. implementation, review)', + required: true, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PromptsDefault); + + try { + const result = await this.client.prompts.getDefault.query({ + agentType: flags['agent-type'], + }); + + // Print raw template to stdout (for piping) + process.stdout.write(result.content); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/prompts/get-partial.ts b/src/cli/dashboard/prompts/get-partial.ts new file mode 100644 index 00000000..fbe1adc2 --- /dev/null +++ b/src/cli/dashboard/prompts/get-partial.ts @@ -0,0 +1,25 @@ +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class PromptsGetPartial extends DashboardCommand { + static override description = 'Print a partial (DB content or disk fallback).'; + + static override flags = { + ...DashboardCommand.baseFlags, + name: Flags.string({ + description: 'Partial name (e.g. git, tmux, test-protocol)', + required: true, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PromptsGetPartial); + + try { + const result = await this.client.prompts.getPartial.query({ name: flags.name }); + process.stdout.write(result.content); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/prompts/list-partials.ts b/src/cli/dashboard/prompts/list-partials.ts new file mode 100644 index 00000000..1f73dac2 --- /dev/null +++ b/src/cli/dashboard/prompts/list-partials.ts @@ -0,0 +1,30 @@ +import { DashboardCommand } from '../_shared/base.js'; + +export default class PromptsListPartials extends DashboardCommand { + static override description = 'List all prompt partials (DB and disk).'; + + static override flags = { + ...DashboardCommand.baseFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(PromptsListPartials); + + try { + const partials = await this.client.prompts.listPartials.query(); + + if (flags.json) { + this.outputJson(partials); + return; + } + + this.outputTable(partials as unknown as Record[], [ + { key: 'name', header: 'Name' }, + { key: 'source', header: 'Source' }, + { key: 'lines', header: 'Lines' }, + ]); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/prompts/reset-partial.ts b/src/cli/dashboard/prompts/reset-partial.ts new file mode 100644 index 00000000..45764509 --- /dev/null +++ b/src/cli/dashboard/prompts/reset-partial.ts @@ -0,0 +1,43 @@ +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class PromptsResetPartial extends DashboardCommand { + static override description = 'Delete a DB partial (revert to disk default).'; + + static override flags = { + ...DashboardCommand.baseFlags, + name: Flags.string({ + description: 'Partial name to reset', + required: true, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PromptsResetPartial); + + try { + // Get the partial to find its ID + const partial = await this.client.prompts.getPartial.query({ name: flags.name }); + + if (partial.source === 'disk') { + this.log(`Partial "${flags.name}" is already using disk default.`); + return; + } + + if (partial.id == null) { + this.error(`Cannot determine partial ID for "${flags.name}".`); + } + + await this.client.prompts.deletePartial.mutate({ id: partial.id }); + + if (flags.json) { + this.outputJson({ ok: true }); + return; + } + + this.log(`Partial "${flags.name}" reset to disk default.`); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/prompts/set-partial.ts b/src/cli/dashboard/prompts/set-partial.ts new file mode 100644 index 00000000..5d8fe73d --- /dev/null +++ b/src/cli/dashboard/prompts/set-partial.ts @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs'; +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class PromptsSetPartial extends DashboardCommand { + static override description = 'Create or update a partial from a file.'; + + static override flags = { + ...DashboardCommand.baseFlags, + name: Flags.string({ + description: 'Partial name (e.g. git, tmux, test-protocol)', + required: true, + }), + file: Flags.string({ + description: 'Path to partial file (use - for stdin)', + required: true, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PromptsSetPartial); + + try { + const content = + flags.file === '-' ? readFileSync(0, 'utf-8') : readFileSync(flags.file, 'utf-8'); + + const result = await this.client.prompts.upsertPartial.mutate({ + name: flags.name, + content, + }); + + if (flags.json) { + this.outputJson(result); + return; + } + + this.log(`Partial "${flags.name}" saved (id: ${result.id}).`); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/prompts/validate.ts b/src/cli/dashboard/prompts/validate.ts new file mode 100644 index 00000000..7ef4d653 --- /dev/null +++ b/src/cli/dashboard/prompts/validate.ts @@ -0,0 +1,39 @@ +import { readFileSync } from 'node:fs'; +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class PromptsValidate extends DashboardCommand { + static override description = 'Validate a prompt template file.'; + + static override flags = { + ...DashboardCommand.baseFlags, + file: Flags.string({ + description: 'Path to template file (use - for stdin)', + required: true, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PromptsValidate); + + try { + const template = + flags.file === '-' ? readFileSync(0, 'utf-8') : readFileSync(flags.file, 'utf-8'); + + const result = await this.client.prompts.validate.mutate({ template }); + + if (flags.json) { + this.outputJson(result); + return; + } + + if (result.valid) { + this.log('Template is valid.'); + } else { + this.error(`Template invalid: ${result.error}`); + } + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/prompts/variables.ts b/src/cli/dashboard/prompts/variables.ts new file mode 100644 index 00000000..e876ecbd --- /dev/null +++ b/src/cli/dashboard/prompts/variables.ts @@ -0,0 +1,30 @@ +import { DashboardCommand } from '../_shared/base.js'; + +export default class PromptsVariables extends DashboardCommand { + static override description = 'List available template variables.'; + + static override flags = { + ...DashboardCommand.baseFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(PromptsVariables); + + try { + const variables = await this.client.prompts.variables.query(); + + if (flags.json) { + this.outputJson(variables); + return; + } + + this.outputTable(variables as unknown as Record[], [ + { key: 'name', header: 'Variable' }, + { key: 'group', header: 'Group' }, + { key: 'description', header: 'Description' }, + ]); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/github/create-pr-review.ts b/src/cli/github/create-pr-review.ts index be5e4789..692a2a24 100644 --- a/src/cli/github/create-pr-review.ts +++ b/src/cli/github/create-pr-review.ts @@ -1,17 +1,14 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { createPRReview } from '../../gadgets/github/core/createPRReview.js'; import { CredentialScopedCommand } from '../base.js'; export default class CreatePRReviewCommand extends CredentialScopedCommand { static override description = 'Submit a code review on a GitHub pull request.'; - static override args = { - prNumber: Args.integer({ description: 'The pull request number', required: true }), - }; - static override flags = { owner: Flags.string({ description: 'Repository owner', required: true }), repo: Flags.string({ description: 'Repository name', required: true }), + prNumber: Flags.integer({ description: 'The pull request number', required: true }), event: Flags.string({ description: 'Review action', required: true, @@ -24,7 +21,7 @@ export default class CreatePRReviewCommand extends CredentialScopedCommand { }; async execute(): Promise { - const { args, flags } = await this.parse(CreatePRReviewCommand); + const { flags } = await this.parse(CreatePRReviewCommand); let comments: Array<{ path: string; line?: number; body: string }> | undefined; if (flags.comments) { @@ -38,7 +35,7 @@ export default class CreatePRReviewCommand extends CredentialScopedCommand { const result = await createPRReview({ owner: flags.owner, repo: flags.repo, - prNumber: args.prNumber, + prNumber: flags.prNumber, event: flags.event as 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT', body: flags.body, comments, diff --git a/src/cli/github/get-pr-checks.ts b/src/cli/github/get-pr-checks.ts index 6e49f9ab..82181478 100644 --- a/src/cli/github/get-pr-checks.ts +++ b/src/cli/github/get-pr-checks.ts @@ -1,22 +1,19 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { getPRChecks } from '../../gadgets/github/core/getPRChecks.js'; import { CredentialScopedCommand } from '../base.js'; export default class GetPRChecks extends CredentialScopedCommand { static override description = 'Get the CI check status for a GitHub pull request.'; - static override args = { - prNumber: Args.integer({ description: 'The pull request number', required: true }), - }; - static override flags = { owner: Flags.string({ description: 'Repository owner', required: true }), repo: Flags.string({ description: 'Repository name', required: true }), + prNumber: Flags.integer({ description: 'The pull request number', required: true }), }; async execute(): Promise { - const { args, flags } = await this.parse(GetPRChecks); - const result = await getPRChecks(flags.owner, flags.repo, args.prNumber); + const { flags } = await this.parse(GetPRChecks); + const result = await getPRChecks(flags.owner, flags.repo, flags.prNumber); this.log(JSON.stringify({ success: true, data: result })); } } diff --git a/src/cli/github/get-pr-comments.ts b/src/cli/github/get-pr-comments.ts index 9d3c7b73..bc195d5a 100644 --- a/src/cli/github/get-pr-comments.ts +++ b/src/cli/github/get-pr-comments.ts @@ -1,22 +1,19 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { getPRComments } from '../../gadgets/github/core/getPRComments.js'; import { CredentialScopedCommand } from '../base.js'; export default class GetPRComments extends CredentialScopedCommand { static override description = 'Get all review comments on a GitHub pull request.'; - static override args = { - prNumber: Args.integer({ description: 'The pull request number', required: true }), - }; - static override flags = { owner: Flags.string({ description: 'Repository owner', required: true }), repo: Flags.string({ description: 'Repository name', required: true }), + prNumber: Flags.integer({ description: 'The pull request number', required: true }), }; async execute(): Promise { - const { args, flags } = await this.parse(GetPRComments); - const result = await getPRComments(flags.owner, flags.repo, args.prNumber); + const { flags } = await this.parse(GetPRComments); + const result = await getPRComments(flags.owner, flags.repo, flags.prNumber); this.log(JSON.stringify({ success: true, data: result })); } } diff --git a/src/cli/github/get-pr-details.ts b/src/cli/github/get-pr-details.ts index 622c63f5..aec572e1 100644 --- a/src/cli/github/get-pr-details.ts +++ b/src/cli/github/get-pr-details.ts @@ -1,22 +1,19 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { getPRDetails } from '../../gadgets/github/core/getPRDetails.js'; import { CredentialScopedCommand } from '../base.js'; export default class GetPRDetails extends CredentialScopedCommand { static override description = 'Get details about a GitHub pull request.'; - static override args = { - prNumber: Args.integer({ description: 'The pull request number', required: true }), - }; - static override flags = { owner: Flags.string({ description: 'Repository owner', required: true }), repo: Flags.string({ description: 'Repository name', required: true }), + prNumber: Flags.integer({ description: 'The pull request number', required: true }), }; async execute(): Promise { - const { args, flags } = await this.parse(GetPRDetails); - const result = await getPRDetails(flags.owner, flags.repo, args.prNumber); + const { flags } = await this.parse(GetPRDetails); + const result = await getPRDetails(flags.owner, flags.repo, flags.prNumber); this.log(JSON.stringify({ success: true, data: result })); } } diff --git a/src/cli/github/get-pr-diff.ts b/src/cli/github/get-pr-diff.ts index 2d287f53..b311fb6d 100644 --- a/src/cli/github/get-pr-diff.ts +++ b/src/cli/github/get-pr-diff.ts @@ -1,22 +1,19 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { getPRDiff } from '../../gadgets/github/core/getPRDiff.js'; import { CredentialScopedCommand } from '../base.js'; export default class GetPRDiff extends CredentialScopedCommand { static override description = 'Get the unified diff of all file changes in a GitHub PR.'; - static override args = { - prNumber: Args.integer({ description: 'The pull request number', required: true }), - }; - static override flags = { owner: Flags.string({ description: 'Repository owner', required: true }), repo: Flags.string({ description: 'Repository name', required: true }), + prNumber: Flags.integer({ description: 'The pull request number', required: true }), }; async execute(): Promise { - const { args, flags } = await this.parse(GetPRDiff); - const result = await getPRDiff(flags.owner, flags.repo, args.prNumber); + const { flags } = await this.parse(GetPRDiff); + const result = await getPRDiff(flags.owner, flags.repo, flags.prNumber); this.log(JSON.stringify({ success: true, data: result })); } } diff --git a/src/cli/github/post-pr-comment.ts b/src/cli/github/post-pr-comment.ts index e5e7064b..cbb15950 100644 --- a/src/cli/github/post-pr-comment.ts +++ b/src/cli/github/post-pr-comment.ts @@ -1,23 +1,20 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { postPRComment } from '../../gadgets/github/core/postPRComment.js'; import { CredentialScopedCommand } from '../base.js'; export default class PostPRComment extends CredentialScopedCommand { static override description = 'Post a comment on a GitHub pull request.'; - static override args = { - prNumber: Args.integer({ description: 'The pull request number', required: true }), - }; - static override flags = { owner: Flags.string({ description: 'Repository owner', required: true }), repo: Flags.string({ description: 'Repository name', required: true }), + prNumber: Flags.integer({ description: 'The pull request number', required: true }), body: Flags.string({ description: 'Comment body (markdown supported)', required: true }), }; async execute(): Promise { - const { args, flags } = await this.parse(PostPRComment); - const result = await postPRComment(flags.owner, flags.repo, args.prNumber, flags.body); + const { flags } = await this.parse(PostPRComment); + const result = await postPRComment(flags.owner, flags.repo, flags.prNumber, flags.body); this.log(JSON.stringify({ success: true, data: result })); } } diff --git a/src/cli/github/reply-to-review-comment.ts b/src/cli/github/reply-to-review-comment.ts index 26473162..3b8f6e26 100644 --- a/src/cli/github/reply-to-review-comment.ts +++ b/src/cli/github/reply-to-review-comment.ts @@ -1,27 +1,24 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { replyToReviewComment } from '../../gadgets/github/core/replyToReviewComment.js'; import { CredentialScopedCommand } from '../base.js'; export default class ReplyToReviewComment extends CredentialScopedCommand { static override description = 'Reply to a specific review comment on a GitHub pull request.'; - static override args = { - prNumber: Args.integer({ description: 'The pull request number', required: true }), - }; - static override flags = { owner: Flags.string({ description: 'Repository owner', required: true }), repo: Flags.string({ description: 'Repository name', required: true }), + prNumber: Flags.integer({ description: 'The pull request number', required: true }), 'comment-id': Flags.integer({ description: 'The comment ID to reply to', required: true }), body: Flags.string({ description: 'Reply message (markdown supported)', required: true }), }; async execute(): Promise { - const { args, flags } = await this.parse(ReplyToReviewComment); + const { flags } = await this.parse(ReplyToReviewComment); const result = await replyToReviewComment( flags.owner, flags.repo, - args.prNumber, + flags.prNumber, flags['comment-id'], flags.body, ); diff --git a/src/cli/github/update-pr-comment.ts b/src/cli/github/update-pr-comment.ts index 4c47bb4c..70f6a1d4 100644 --- a/src/cli/github/update-pr-comment.ts +++ b/src/cli/github/update-pr-comment.ts @@ -1,23 +1,20 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { updatePRComment } from '../../gadgets/github/core/updatePRComment.js'; import { CredentialScopedCommand } from '../base.js'; export default class UpdatePRComment extends CredentialScopedCommand { static override description = 'Update an existing comment on a GitHub pull request.'; - static override args = { - commentId: Args.integer({ description: 'The comment ID', required: true }), - }; - static override flags = { owner: Flags.string({ description: 'Repository owner', required: true }), repo: Flags.string({ description: 'Repository name', required: true }), + commentId: Flags.integer({ description: 'The comment ID', required: true }), body: Flags.string({ description: 'New comment body (markdown supported)', required: true }), }; async execute(): Promise { - const { args, flags } = await this.parse(UpdatePRComment); - const result = await updatePRComment(flags.owner, flags.repo, args.commentId, flags.body); + const { flags } = await this.parse(UpdatePRComment); + const result = await updatePRComment(flags.owner, flags.repo, flags.commentId, flags.body); this.log(JSON.stringify({ success: true, data: result })); } } diff --git a/src/cli/pm/add-checklist.ts b/src/cli/pm/add-checklist.ts index 2dbfe743..aa271d2e 100644 --- a/src/cli/pm/add-checklist.ts +++ b/src/cli/pm/add-checklist.ts @@ -1,15 +1,12 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { addChecklist } from '../../gadgets/pm/core/addChecklist.js'; import { CredentialScopedCommand } from '../base.js'; export default class AddChecklist extends CredentialScopedCommand { static override description = 'Add a checklist with items to a work item.'; - static override args = { - workItemId: Args.string({ description: 'The work item ID', required: true }), - }; - static override flags = { + workItemId: Flags.string({ description: 'The work item ID', required: true }), name: Flags.string({ description: 'Checklist name', required: true }), items: Flags.string({ description: 'Checklist items (can be specified multiple times)', @@ -19,9 +16,9 @@ export default class AddChecklist extends CredentialScopedCommand { }; async execute(): Promise { - const { args, flags } = await this.parse(AddChecklist); + const { flags } = await this.parse(AddChecklist); const result = await addChecklist({ - workItemId: args.workItemId, + workItemId: flags.workItemId, checklistName: flags.name, items: flags.items, }); diff --git a/src/cli/pm/create-work-item.ts b/src/cli/pm/create-work-item.ts index d2390911..e29c23b4 100644 --- a/src/cli/pm/create-work-item.ts +++ b/src/cli/pm/create-work-item.ts @@ -1,23 +1,23 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { createWorkItem } from '../../gadgets/pm/core/createWorkItem.js'; import { CredentialScopedCommand } from '../base.js'; export default class CreateWorkItem extends CredentialScopedCommand { static override description = 'Create a new work item in a container (list/project).'; - static override args = { - containerId: Args.string({ description: 'The container ID (list or project)', required: true }), - }; - static override flags = { + containerId: Flags.string({ + description: 'The container ID (list or project)', + required: true, + }), title: Flags.string({ description: 'Work item title', required: true }), description: Flags.string({ description: 'Work item description (markdown supported)' }), }; async execute(): Promise { - const { args, flags } = await this.parse(CreateWorkItem); + const { flags } = await this.parse(CreateWorkItem); const result = await createWorkItem({ - containerId: args.containerId, + containerId: flags.containerId, title: flags.title, description: flags.description, }); diff --git a/src/cli/pm/list-work-items.ts b/src/cli/pm/list-work-items.ts index 65b7ce40..c22667f2 100644 --- a/src/cli/pm/list-work-items.ts +++ b/src/cli/pm/list-work-items.ts @@ -1,17 +1,20 @@ -import { Args } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { listWorkItems } from '../../gadgets/pm/core/listWorkItems.js'; import { CredentialScopedCommand } from '../base.js'; export default class ListWorkItems extends CredentialScopedCommand { static override description = 'List all work items in a container (list/project).'; - static override args = { - containerId: Args.string({ description: 'The container ID (list or project)', required: true }), + static override flags = { + containerId: Flags.string({ + description: 'The container ID (list or project)', + required: true, + }), }; async execute(): Promise { - const { args } = await this.parse(ListWorkItems); - const result = await listWorkItems(args.containerId); + const { flags } = await this.parse(ListWorkItems); + const result = await listWorkItems(flags.containerId); this.log(JSON.stringify({ success: true, data: result })); } } diff --git a/src/cli/pm/post-comment.ts b/src/cli/pm/post-comment.ts index 757e9a4f..aa50f0a8 100644 --- a/src/cli/pm/post-comment.ts +++ b/src/cli/pm/post-comment.ts @@ -1,21 +1,18 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { postComment } from '../../gadgets/pm/core/postComment.js'; import { CredentialScopedCommand } from '../base.js'; export default class PostComment extends CredentialScopedCommand { static override description = 'Post a comment to a work item.'; - static override args = { - workItemId: Args.string({ description: 'The work item ID', required: true }), - }; - static override flags = { + workItemId: Flags.string({ description: 'The work item ID', required: true }), text: Flags.string({ description: 'The comment text (supports markdown)', required: true }), }; async execute(): Promise { - const { args, flags } = await this.parse(PostComment); - const result = await postComment(args.workItemId, flags.text); + const { flags } = await this.parse(PostComment); + const result = await postComment(flags.workItemId, flags.text); this.log(JSON.stringify({ success: true, data: result })); } } diff --git a/src/cli/pm/read-work-item.ts b/src/cli/pm/read-work-item.ts index 47503ec1..7273db48 100644 --- a/src/cli/pm/read-work-item.ts +++ b/src/cli/pm/read-work-item.ts @@ -1,4 +1,4 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { readWorkItem } from '../../gadgets/pm/core/readWorkItem.js'; import { CredentialScopedCommand } from '../base.js'; @@ -6,11 +6,8 @@ export default class ReadWorkItem extends CredentialScopedCommand { static override description = 'Read a work item with its title, description, comments, checklists, and attachments.'; - static override args = { - workItemId: Args.string({ description: 'The work item ID', required: true }), - }; - static override flags = { + workItemId: Flags.string({ description: 'The work item ID', required: true }), 'include-comments': Flags.boolean({ description: 'Include comments in the response', default: true, @@ -19,8 +16,8 @@ export default class ReadWorkItem extends CredentialScopedCommand { }; async execute(): Promise { - const { args, flags } = await this.parse(ReadWorkItem); - const result = await readWorkItem(args.workItemId, flags['include-comments']); + const { flags } = await this.parse(ReadWorkItem); + const result = await readWorkItem(flags.workItemId, flags['include-comments']); this.log(JSON.stringify({ success: true, data: result })); } } diff --git a/src/cli/pm/update-checklist-item.ts b/src/cli/pm/update-checklist-item.ts index d49901cb..600169b5 100644 --- a/src/cli/pm/update-checklist-item.ts +++ b/src/cli/pm/update-checklist-item.ts @@ -1,15 +1,12 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { updateChecklistItem } from '../../gadgets/pm/core/updateChecklistItem.js'; import { CredentialScopedCommand } from '../base.js'; export default class UpdateChecklistItem extends CredentialScopedCommand { static override description = 'Update a checklist item state on a work item.'; - static override args = { - workItemId: Args.string({ description: 'The work item ID', required: true }), - }; - static override flags = { + workItemId: Flags.string({ description: 'The work item ID', required: true }), 'check-item-id': Flags.string({ description: 'The checklist item ID', required: true }), state: Flags.string({ description: 'The new state', @@ -19,9 +16,9 @@ export default class UpdateChecklistItem extends CredentialScopedCommand { }; async execute(): Promise { - const { args, flags } = await this.parse(UpdateChecklistItem); + const { flags } = await this.parse(UpdateChecklistItem); const result = await updateChecklistItem( - args.workItemId, + flags.workItemId, flags['check-item-id'], flags.state === 'complete', ); diff --git a/src/cli/pm/update-work-item.ts b/src/cli/pm/update-work-item.ts index 4bfc5547..f704471c 100644 --- a/src/cli/pm/update-work-item.ts +++ b/src/cli/pm/update-work-item.ts @@ -1,15 +1,12 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { updateWorkItem } from '../../gadgets/pm/core/updateWorkItem.js'; import { CredentialScopedCommand } from '../base.js'; export default class UpdateWorkItem extends CredentialScopedCommand { static override description = 'Update a work item title, description, or labels.'; - static override args = { - workItemId: Args.string({ description: 'The work item ID', required: true }), - }; - static override flags = { + workItemId: Flags.string({ description: 'The work item ID', required: true }), title: Flags.string({ description: 'New title' }), description: Flags.string({ description: 'New description (markdown supported)' }), 'add-label-ids': Flags.string({ @@ -18,9 +15,9 @@ export default class UpdateWorkItem extends CredentialScopedCommand { }; async execute(): Promise { - const { args, flags } = await this.parse(UpdateWorkItem); + const { flags } = await this.parse(UpdateWorkItem); const result = await updateWorkItem({ - workItemId: args.workItemId, + workItemId: flags.workItemId, title: flags.title, description: flags.description, addLabelIds: flags['add-label-ids']?.split(','), diff --git a/src/config/schema.ts b/src/config/schema.ts index 0efe1142..a40103d6 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -70,6 +70,7 @@ export const CascadeConfigSchema = z.object({ agentBackend: z.string().default('llmist'), progressModel: z.string().default('openrouter:google/gemini-2.5-flash-lite'), progressIntervalMinutes: z.number().positive().default(5), + prompts: z.record(z.string()).default({}), }) .default({}), projects: z.array(ProjectConfigSchema).min(1), diff --git a/src/db/migrations/0008_prompt_partials.sql b/src/db/migrations/0008_prompt_partials.sql new file mode 100644 index 00000000..d49be9db --- /dev/null +++ b/src/db/migrations/0008_prompt_partials.sql @@ -0,0 +1,21 @@ +-- Prompt partials: store reusable template partials in the database +-- These override the on-disk .eta partial files in src/agents/prompts/templates/partials/ + +CREATE TABLE prompt_partials ( + id SERIAL PRIMARY KEY, + org_id TEXT REFERENCES organizations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT now(), + updated_at TIMESTAMP DEFAULT now() +); + +-- Global partials (org_id IS NULL): one per name +CREATE UNIQUE INDEX uq_prompt_partials_global + ON prompt_partials (name) + WHERE org_id IS NULL; + +-- Org-scoped partials: one per (org_id, name) +CREATE UNIQUE INDEX uq_prompt_partials_org + ON prompt_partials (org_id, name) + WHERE org_id IS NOT NULL; diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 1eee21ee..79648f25 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1742000000000, "tag": "0007_remove_flyio_columns", "breakpoints": false + }, + { + "idx": 7, + "version": "7", + "when": 1743000000000, + "tag": "0008_prompt_partials", + "breakpoints": false } ] } diff --git a/src/db/repositories/configRepository.ts b/src/db/repositories/configRepository.ts index c2837844..b8d38c8c 100644 --- a/src/db/repositories/configRepository.ts +++ b/src/db/repositories/configRepository.ts @@ -58,7 +58,7 @@ function orUndefined>(obj: T): T | undefined { } function mapDefaultsRow(row: DefaultsRow | undefined, globalAgentConfigs: AgentConfigRow[]) { - const { models, iterations } = buildAgentMaps(globalAgentConfigs); + const { models, iterations, prompts } = buildAgentMaps(globalAgentConfigs); return { model: row?.model ?? undefined, @@ -72,6 +72,7 @@ function mapDefaultsRow(row: DefaultsRow | undefined, globalAgentConfigs: AgentC progressIntervalMinutes: row?.progressIntervalMinutes ? Number(row.progressIntervalMinutes) : undefined, + prompts: orUndefined(prompts), }; } diff --git a/src/db/repositories/partialsRepository.ts b/src/db/repositories/partialsRepository.ts new file mode 100644 index 00000000..3212c858 --- /dev/null +++ b/src/db/repositories/partialsRepository.ts @@ -0,0 +1,119 @@ +import { and, eq, isNull } from 'drizzle-orm'; +import { getDb } from '../client.js'; +import { promptPartials } from '../schema/index.js'; + +export type PartialRow = typeof promptPartials.$inferSelect; + +/** Returns true if the error indicates the prompt_partials table doesn't exist yet. */ +function isTableMissing(err: unknown): boolean { + return err instanceof Error && err.message.includes('prompt_partials'); +} + +export async function loadPartials(orgId?: string): Promise> { + try { + const db = getDb(); + // Load global partials (org_id IS NULL) + const globalRows = await db.select().from(promptPartials).where(isNull(promptPartials.orgId)); + + const result = new Map(); + for (const row of globalRows) { + result.set(row.name, row.content); + } + + // If org-scoped, overlay org partials on top of globals + if (orgId) { + const orgRows = await db.select().from(promptPartials).where(eq(promptPartials.orgId, orgId)); + for (const row of orgRows) { + result.set(row.name, row.content); + } + } + + return result; + } catch (err) { + if (isTableMissing(err)) return new Map(); + throw err; + } +} + +export async function listPartials(orgId?: string): Promise { + try { + const db = getDb(); + if (orgId) { + // Return both global and org-scoped + return await db + .select() + .from(promptPartials) + .where(isNull(promptPartials.orgId)) + .then(async (globals) => { + const orgRows = await db + .select() + .from(promptPartials) + .where(eq(promptPartials.orgId, orgId)); + return [...globals, ...orgRows]; + }); + } + return await db.select().from(promptPartials).where(isNull(promptPartials.orgId)); + } catch (err) { + if (isTableMissing(err)) return []; + throw err; + } +} + +export async function getPartial(name: string, orgId?: string): Promise { + try { + const db = getDb(); + // Try org-scoped first, then global + if (orgId) { + const [orgRow] = await db + .select() + .from(promptPartials) + .where(and(eq(promptPartials.orgId, orgId), eq(promptPartials.name, name))); + if (orgRow) return orgRow; + } + const [globalRow] = await db + .select() + .from(promptPartials) + .where(and(isNull(promptPartials.orgId), eq(promptPartials.name, name))); + return globalRow ?? null; + } catch (err) { + if (isTableMissing(err)) return null; + throw err; + } +} + +export async function upsertPartial(data: { + orgId?: string | null; + name: string; + content: string; +}): Promise { + const db = getDb(); + const whereCondition = data.orgId + ? and(eq(promptPartials.orgId, data.orgId), eq(promptPartials.name, data.name)) + : and(isNull(promptPartials.orgId), eq(promptPartials.name, data.name)); + + const [existing] = await db.select().from(promptPartials).where(whereCondition); + + if (existing) { + const [updated] = await db + .update(promptPartials) + .set({ content: data.content, updatedAt: new Date() }) + .where(eq(promptPartials.id, existing.id)) + .returning(); + return updated; + } + + const [inserted] = await db + .insert(promptPartials) + .values({ + orgId: data.orgId ?? null, + name: data.name, + content: data.content, + }) + .returning(); + return inserted; +} + +export async function deletePartial(id: number): Promise { + const db = getDb(); + await db.delete(promptPartials).where(eq(promptPartials.id, id)); +} diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index f00ac855..401fa00b 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -5,4 +5,5 @@ export { agentConfigs } from './agentConfigs.js'; export { projectIntegrations } from './integrations.js'; export { projects } from './projects.js'; export { agentRunLlmCalls, agentRunLogs, agentRuns, debugAnalyses } from './runs.js'; +export { promptPartials } from './promptPartials.js'; export { sessions, users } from './users.js'; diff --git a/src/db/schema/promptPartials.ts b/src/db/schema/promptPartials.ts new file mode 100644 index 00000000..60b49e8c --- /dev/null +++ b/src/db/schema/promptPartials.ts @@ -0,0 +1,13 @@ +import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'; +import { organizations } from './organizations.js'; + +export const promptPartials = pgTable('prompt_partials', { + id: serial('id').primaryKey(), + orgId: text('org_id').references(() => organizations.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + content: text('content').notNull(), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => new Date()), +}); diff --git a/src/gadgets/pm/core/readWorkItem.ts b/src/gadgets/pm/core/readWorkItem.ts index 2ddb8f46..37dfeb0a 100644 --- a/src/gadgets/pm/core/readWorkItem.ts +++ b/src/gadgets/pm/core/readWorkItem.ts @@ -1,5 +1,78 @@ import { getPMProvider } from '../../../pm/index.js'; +interface Label { + name: string; + color?: string; +} + +interface ChecklistItem { + id: string; + name: string; + complete: boolean; +} + +interface Checklist { + id: string; + name: string; + items: ChecklistItem[]; +} + +interface Attachment { + name: string; + url: string; + date?: string; +} + +interface Comment { + author: { name: string }; + date: string; + text: string; +} + +function formatLabels(labels: Label[]): string { + if (labels.length === 0) return ''; + const items = labels.map((l) => `- ${l.name}${l.color ? ` (${l.color})` : ''}`).join('\n'); + return `## Labels\n\n${items}\n\n`; +} + +function formatChecklists(checklists: Checklist[]): string { + if (checklists.length === 0) return ''; + let result = '## Checklists\n\n'; + for (const checklist of checklists) { + result += `### ${checklist.name} [checklistId: ${checklist.id}]\n\n`; + for (const item of checklist.items) { + const checkbox = item.complete ? '[x]' : '[ ]'; + result += `- ${checkbox} ${item.name} [checkItemId: ${item.id}]\n`; + } + result += '\n'; + } + return result; +} + +function formatAttachments(attachments: Attachment[]): string { + if (attachments.length === 0) return ''; + let result = '## Attachments\n\n'; + for (const att of attachments) { + result += `- [${att.name}](${att.url})`; + if (att.date) { + result += ` (${new Date(att.date).toISOString()})`; + } + result += '\n'; + } + return `${result}\n`; +} + +function formatComments(comments: Comment[]): string { + if (comments.length === 0) return '## Comments\n\n(No comments)\n\n'; + let result = `## Comments (${comments.length})\n\n`; + for (const comment of comments.slice().reverse()) { + const date = new Date(comment.date).toISOString(); + result += `### ${comment.author.name} (${date})\n\n`; + result += `${comment.text}\n\n`; + } + return result; +} + export async function readWorkItem(workItemId: string, includeComments = true): Promise { try { const provider = getPMProvider(); @@ -10,47 +83,13 @@ export async function readWorkItem(workItemId: string, includeComments = true): ]); let result = `# ${item.title}\n\n**URL:** ${item.url}\n\n## Description\n\n${item.description || '(No description)'}\n\n`; - - if (item.labels.length > 0) { - result += `## Labels\n\n${item.labels.map((l) => `- ${l.name}${l.color ? ` (${l.color})` : ''}`).join('\n')}\n\n`; - } - - if (checklists.length > 0) { - result += '## Checklists\n\n'; - for (const checklist of checklists) { - result += `### ${checklist.name} [checklistId: ${checklist.id}]\n\n`; - for (const item of checklist.items) { - const checkbox = item.complete ? '[x]' : '[ ]'; - result += `- ${checkbox} ${item.name} [checkItemId: ${item.id}]\n`; - } - result += '\n'; - } - } - - if (attachments.length > 0) { - result += '## Attachments\n\n'; - for (const att of attachments) { - result += `- [${att.name}](${att.url})`; - if (att.date) { - result += ` (${new Date(att.date).toISOString()})`; - } - result += '\n'; - } - result += '\n'; - } + result += formatLabels(item.labels); + result += formatChecklists(checklists); + result += formatAttachments(attachments); if (includeComments) { const comments = await provider.getWorkItemComments(workItemId); - if (comments.length === 0) { - result += '## Comments\n\n(No comments)\n\n'; - } else { - result += `## Comments (${comments.length})\n\n`; - for (const comment of comments.slice().reverse()) { - const date = new Date(comment.date).toISOString(); - result += `### ${comment.author.name} (${date})\n\n`; - result += `${comment.text}\n\n`; - } - } + result += formatComments(comments); } return result; diff --git a/src/github/client.ts b/src/github/client.ts index d3fe0b56..69b5dc75 100644 --- a/src/github/client.ts +++ b/src/github/client.ts @@ -257,29 +257,51 @@ export const githubClient = { }, async getCheckSuiteStatus(owner: string, repo: string, ref: string): Promise { - logger.debug('Fetching check runs for ref', { owner, repo, ref }); - const { data } = await getClient().checks.listForRef({ + logger.debug('Fetching workflow runs for ref', { owner, repo, ref }); + const client = getClient(); + + // Use Actions API (workflow runs + jobs) instead of Checks API, + // because fine-grained PATs cannot access the Checks API. + const { data: runsData } = await client.actions.listWorkflowRunsForRepo({ owner, repo, - ref, + head_sha: ref, per_page: 100, }); - const checkRuns = data.check_runs.map((cr) => ({ - name: cr.name, - status: cr.status, - conclusion: cr.conclusion, - })); + // Fetch jobs for each workflow run to get per-job granularity + const jobResults = await Promise.all( + runsData.workflow_runs.map((run) => + client.actions.listJobsForWorkflowRun({ + owner, + repo, + run_id: run.id, + per_page: 100, + }), + ), + ); - // All checks pass if every completed check has success/skipped/neutral conclusion - const allPassing = checkRuns.every( - (cr) => - cr.status === 'completed' && - (cr.conclusion === 'success' || cr.conclusion === 'skipped' || cr.conclusion === 'neutral'), + const checkRuns = jobResults.flatMap(({ data }) => + data.jobs.map((job) => ({ + name: job.name, + status: job.status, + conclusion: job.conclusion, + })), ); + // All checks pass if every completed check has success/skipped/neutral conclusion + const allPassing = + checkRuns.length > 0 && + checkRuns.every( + (cr) => + cr.status === 'completed' && + (cr.conclusion === 'success' || + cr.conclusion === 'skipped' || + cr.conclusion === 'neutral'), + ); + return { - totalCount: data.total_count, + totalCount: checkRuns.length, checkRuns, allPassing, }; diff --git a/src/pm/jira/adf.ts b/src/pm/jira/adf.ts index 8f8107a4..7ff76901 100644 --- a/src/pm/jira/adf.ts +++ b/src/pm/jira/adf.ts @@ -9,6 +9,41 @@ * Convert a simple markdown string to ADF document. * Handles paragraphs, headings, bullet lists, bold, inline code, and code blocks. */ +function parseCodeBlock(lines: string[], startIndex: number): { node: unknown; nextIndex: number } { + const lang = lines[startIndex].slice(3).trim(); + const codeLines: string[] = []; + let i = startIndex + 1; + while (i < lines.length && !lines[i].startsWith('```')) { + codeLines.push(lines[i]); + i++; + } + return { + node: { + type: 'codeBlock', + attrs: lang ? { language: lang } : {}, + content: [{ type: 'text', text: codeLines.join('\n') }], + }, + nextIndex: i + 1, // skip closing ``` + }; +} + +function parseBulletList( + lines: string[], + startIndex: number, +): { node: unknown; nextIndex: number } { + const items: unknown[] = []; + let i = startIndex; + while (i < lines.length && lines[i].match(/^[-*]\s+/)) { + const itemText = lines[i].replace(/^[-*]\s+/, ''); + items.push({ + type: 'listItem', + content: [{ type: 'paragraph', content: inlineToAdf(itemText) }], + }); + i++; + } + return { node: { type: 'bulletList', content: items }, nextIndex: i }; +} + export function markdownToAdf(markdown: string): unknown { const lines = markdown.split('\n'); const content: unknown[] = []; @@ -17,25 +52,13 @@ export function markdownToAdf(markdown: string): unknown { while (i < lines.length) { const line = lines[i]; - // Code block if (line.startsWith('```')) { - const lang = line.slice(3).trim(); - const codeLines: string[] = []; - i++; - while (i < lines.length && !lines[i].startsWith('```')) { - codeLines.push(lines[i]); - i++; - } - i++; // skip closing ``` - content.push({ - type: 'codeBlock', - attrs: lang ? { language: lang } : {}, - content: [{ type: 'text', text: codeLines.join('\n') }], - }); + const result = parseCodeBlock(lines, i); + content.push(result.node); + i = result.nextIndex; continue; } - // Heading const headingMatch = line.match(/^(#{1,6})\s+(.+)/); if (headingMatch) { content.push({ @@ -47,28 +70,18 @@ export function markdownToAdf(markdown: string): unknown { continue; } - // Bullet list if (line.match(/^[-*]\s+/)) { - const items: unknown[] = []; - while (i < lines.length && lines[i].match(/^[-*]\s+/)) { - const itemText = lines[i].replace(/^[-*]\s+/, ''); - items.push({ - type: 'listItem', - content: [{ type: 'paragraph', content: inlineToAdf(itemText) }], - }); - i++; - } - content.push({ type: 'bulletList', content: items }); + const result = parseBulletList(lines, i); + content.push(result.node); + i = result.nextIndex; continue; } - // Empty line if (line.trim() === '') { i++; continue; } - // Regular paragraph content.push({ type: 'paragraph', content: inlineToAdf(line), @@ -128,63 +141,45 @@ function inlineToAdf(text: string): unknown[] { * Convert ADF document to plain text. * Used when reading JIRA issue descriptions/comments. */ +interface AdfNode { + type?: string; + content?: unknown[]; + text?: string; + attrs?: Record; +} + +function convertAdfNode(n: AdfNode): string[] { + switch (n.type) { + case 'paragraph': + return [adfToPlainText(n), '']; + case 'heading': { + const level = (n.attrs?.level as number) ?? 1; + return [`${'#'.repeat(level)} ${adfToPlainText(n)}`, '']; + } + case 'bulletList': + return [...(n.content ?? []).map((item) => `- ${adfToPlainText(item)}`), '']; + case 'listItem': + return [adfToPlainText(n)]; + case 'codeBlock': + return ['```', adfToPlainText(n), '```', '']; + case 'text': + return [n.text ?? '']; + default: + return [adfToPlainText(n)]; + } +} + export function adfToPlainText(adf: unknown): string { if (!adf || typeof adf !== 'object') return ''; - const doc = adf as { type?: string; content?: unknown[]; text?: string }; - - if (doc.type === 'text') { - return doc.text ?? ''; - } + const doc = adf as AdfNode; - if (!doc.content || !Array.isArray(doc.content)) { - return doc.text ?? ''; - } + if (doc.type === 'text') return doc.text ?? ''; + if (!doc.content || !Array.isArray(doc.content)) return doc.text ?? ''; const parts: string[] = []; for (const node of doc.content) { - const n = node as { - type?: string; - content?: unknown[]; - text?: string; - attrs?: Record; - }; - - switch (n.type) { - case 'paragraph': - parts.push(adfToPlainText(n)); - parts.push(''); - break; - case 'heading': { - const level = (n.attrs?.level as number) ?? 1; - parts.push(`${'#'.repeat(level)} ${adfToPlainText(n)}`); - parts.push(''); - break; - } - case 'bulletList': - if (n.content) { - for (const item of n.content) { - parts.push(`- ${adfToPlainText(item)}`); - } - } - parts.push(''); - break; - case 'listItem': - parts.push(adfToPlainText(n)); - break; - case 'codeBlock': - parts.push('```'); - parts.push(adfToPlainText(n)); - parts.push('```'); - parts.push(''); - break; - case 'text': - parts.push(n.text ?? ''); - break; - default: - parts.push(adfToPlainText(n)); - break; - } + parts.push(...convertAdfNode(node as AdfNode)); } return parts diff --git a/src/triggers/shared/debug-runner.ts b/src/triggers/shared/debug-runner.ts index 575456ef..07e5a84e 100644 --- a/src/triggers/shared/debug-runner.ts +++ b/src/triggers/shared/debug-runner.ts @@ -104,6 +104,35 @@ function parseDebugOutput(output: string): { * 5. Post summary comment on original Trello card * 6. Cleanup temp directory */ +function resolveCardUrl(cardId: string): string { + try { + const provider = getPMProvider(); + return provider.getWorkItemUrl(cardId); + } catch { + return `https://trello.com/c/${cardId}`; + } +} + +async function postDebugComment( + cardId: string, + analyzedRunId: string, + parsed: ReturnType, +): Promise { + try { + const provider = getPMProvider(); + const rootCauseText = parsed.rootCause + ? `**Root Cause:** ${parsed.rootCause.slice(0, 200)}\n\n` + : ''; + const comment = `🔍 **Debug Analysis** (run: ${analyzedRunId.slice(0, 8)})\n\n${parsed.summary}\n\n${rootCauseText}_Full analysis stored in database._`; + await provider.addComment(cardId, comment); + } catch (err) { + logger.warn('Failed to post debug summary comment', { + cardId, + error: String(err), + }); + } +} + export async function triggerDebugAnalysis( analyzedRunId: string, project: ProjectConfig, @@ -126,22 +155,11 @@ export async function triggerDebugAnalysis( try { logDir = await extractLogsToTempDir(analyzedRunId); - const originalCardName = cardId ? `Card ${cardId}` : 'Unknown card'; - let originalCardUrl = ''; - if (cardId) { - try { - const provider = getPMProvider(); - originalCardUrl = provider.getWorkItemUrl(cardId); - } catch { - originalCardUrl = `https://trello.com/c/${cardId}`; - } - } - const agentResult: AgentResult = await runAgent('debug', { logDir, originalCardId: cardId, - originalCardName, - originalCardUrl, + originalCardName: cardId ? `Card ${cardId}` : 'Unknown card', + originalCardUrl: cardId ? resolveCardUrl(cardId) : '', detectedAgentType: run.agentType, project, config, @@ -160,21 +178,8 @@ export async function triggerDebugAnalysis( severity: run.status === 'timed_out' ? 'timeout' : 'failure', }); - // Post summary comment on original work item if (cardId && parsed.summary) { - try { - const provider = getPMProvider(); - const rootCauseText = parsed.rootCause - ? `**Root Cause:** ${parsed.rootCause.slice(0, 200)}\n\n` - : ''; - const comment = `🔍 **Debug Analysis** (run: ${analyzedRunId.slice(0, 8)})\n\n${parsed.summary}\n\n${rootCauseText}_Full analysis stored in database._`; - await provider.addComment(cardId, comment); - } catch (err) { - logger.warn('Failed to post debug summary comment', { - cardId, - error: String(err), - }); - } + await postDebugComment(cardId, analyzedRunId, parsed); } logger.info('Debug analysis completed', { diff --git a/src/triggers/trello/webhook-handler.ts b/src/triggers/trello/webhook-handler.ts index 0d15b25d..8742ff11 100644 --- a/src/triggers/trello/webhook-handler.ts +++ b/src/triggers/trello/webhook-handler.ts @@ -179,6 +179,50 @@ function tryQueueWebhook(payload: TrelloWebhookPayload): boolean { return true; } +async function handleMatchedTrigger( + registry: TriggerRegistry, + payload: TrelloWebhookPayload, + actionType: string | undefined, + project: ProjectConfig, + config: CascadeConfig, + pmProvider: ReturnType, +): Promise { + const ctx: TriggerContext = { project, source: 'trello', payload }; + const result = await registry.dispatch(ctx); + if (!result) { + logger.info('No trigger matched for webhook', { actionType }); + return; + } + + const cardId = result.cardId ?? result.workItemId; + if (cardId && isCardActive(cardId)) { + logger.info('Card already being processed, skipping', { cardId }); + return; + } + + logger.info('Trigger matched', { agentType: result.agentType, cardId }); + setProcessing(true); + startWatchdog(config.defaults.watchdogTimeoutMs); + + const pmConfig = resolveProjectPMConfig(project); + const lifecycle = new PMLifecycleManager(pmProvider, pmConfig); + + try { + await executeAgent(result, project, config); + } catch (err) { + logger.error('Failed to process webhook', { error: String(err) }); + if (cardId) { + await lifecycle.handleError(cardId, String(err)); + } + } finally { + if (cardId) { + clearCardActive(cardId); + } + setProcessing(false); + processNextQueuedWebhook(registry); + } +} + export async function processTrelloWebhook( payload: unknown, registry: TriggerRegistry, @@ -214,41 +258,8 @@ export async function processTrelloWebhook( const pmProvider = createPMProvider(project); await withTrelloCredentials({ apiKey: trelloApiKey, token: trelloToken }, () => - withPMProvider(pmProvider, async () => { - const ctx: TriggerContext = { project, source: 'trello', payload }; - const result = await registry.dispatch(ctx); - if (!result) { - logger.info('No trigger matched for webhook', { actionType }); - return; - } - - const cardId = result.cardId ?? result.workItemId; - if (cardId && isCardActive(cardId)) { - logger.info('Card already being processed, skipping', { cardId }); - return; - } - - logger.info('Trigger matched', { agentType: result.agentType, cardId }); - setProcessing(true); - startWatchdog(config.defaults.watchdogTimeoutMs); - - const pmConfig = resolveProjectPMConfig(project); - const lifecycle = new PMLifecycleManager(pmProvider, pmConfig); - - try { - await executeAgent(result, project, config); - } catch (err) { - logger.error('Failed to process webhook', { error: String(err) }); - if (cardId) { - await lifecycle.handleError(cardId, String(err)); - } - } finally { - if (cardId) { - clearCardActive(cardId); - } - setProcessing(false); - processNextQueuedWebhook(registry); - } - }), + withPMProvider(pmProvider, () => + handleMatchedTrigger(registry, payload, actionType, project, config, pmProvider), + ), ); } diff --git a/tests/unit/agents/prompts.test.ts b/tests/unit/agents/prompts.test.ts index 195156b9..b029897e 100644 --- a/tests/unit/agents/prompts.test.ts +++ b/tests/unit/agents/prompts.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it } from 'vitest'; -import { getSystemPrompt } from '../../../src/agents/prompts/index.js'; +import { + getAvailablePartialNames, + getRawPartial, + getRawTemplate, + getSystemPrompt, + getTemplateVariables, + getValidAgentTypes, + renderCustomPrompt, + resolveIncludes, + validateTemplate, +} from '../../../src/agents/prompts/index.js'; describe('getSystemPrompt', () => { it('returns briefing prompt for briefing agent', () => { @@ -38,6 +48,14 @@ describe('getSystemPrompt', () => { expect(prompt).toContain('STORIES_LIST_ID: NOT_CONFIGURED'); expect(prompt).toContain('PROCESSED_LABEL_ID: NOT_CONFIGURED'); }); + + it('applies DB partials when provided', () => { + const partials = new Map([['git', '## Custom Git Instructions\nUse rebase workflow.']]); + const prompt = getSystemPrompt('implementation', {}, partials); + // The custom partial content should be present instead of disk default + expect(prompt).toContain('Custom Git Instructions'); + expect(prompt).toContain('Use rebase workflow'); + }); }); describe('system prompts content', () => { @@ -60,3 +78,195 @@ describe('system prompts content', () => { expect(prompt).toContain('conventional commits'); }); }); + +describe('resolveIncludes', () => { + it('resolves include from DB partials', () => { + const template = 'Before <%~ include("partials/git") %> After'; + const dbPartials = new Map([['git', 'DB GIT CONTENT']]); + const result = resolveIncludes(template, dbPartials); + expect(result).toBe('Before DB GIT CONTENT After'); + }); + + it('falls back to disk when partial not in DB', () => { + const template = '<%~ include("partials/git") %>'; + const result = resolveIncludes(template, new Map()); + // Should resolve from disk — the git partial exists on disk + expect(result).toBeTruthy(); + expect(result).not.toContain('include('); + }); + + it('throws when partial not found in DB or disk', () => { + const template = '<%~ include("partials/nonexistent-partial-xyz") %>'; + expect(() => resolveIncludes(template, new Map())).toThrow( + 'Partial not found: partials/nonexistent-partial-xyz', + ); + }); + + it('resolves multiple includes', () => { + const template = 'A <%~ include("partials/one") %> B <%~ include("partials/two") %> C'; + const dbPartials = new Map([ + ['one', 'FIRST'], + ['two', 'SECOND'], + ]); + const result = resolveIncludes(template, dbPartials); + expect(result).toBe('A FIRST B SECOND C'); + }); + + it('returns template unchanged when no includes', () => { + const template = 'No includes here, just plain text.'; + const result = resolveIncludes(template, new Map()); + expect(result).toBe(template); + }); + + it('prefers DB partial over disk', () => { + const template = '<%~ include("partials/git") %>'; + const dbPartials = new Map([['git', 'OVERRIDE']]); + const result = resolveIncludes(template, dbPartials); + expect(result).toBe('OVERRIDE'); + }); +}); + +describe('renderCustomPrompt', () => { + it('renders Eta variables', () => { + const template = 'Branch: <%= it.baseBranch %>'; + const result = renderCustomPrompt(template, { baseBranch: 'main' }); + expect(result).toBe('Branch: main'); + }); + + it('resolves includes and renders variables', () => { + const template = 'Branch: <%= it.baseBranch %>\n<%~ include("partials/custom") %>'; + const dbPartials = new Map([['custom', 'Project: <%= it.projectId %>']]); + const result = renderCustomPrompt(template, { baseBranch: 'dev', projectId: 'p1' }, dbPartials); + expect(result).toContain('Branch: dev'); + expect(result).toContain('Project: p1'); + }); + + it('handles empty context', () => { + const template = 'Hello world'; + const result = renderCustomPrompt(template); + expect(result).toBe('Hello world'); + }); + + it('renders undefined variables as "undefined"', () => { + const template = 'Value: [<%= it.baseBranch %>]'; + const result = renderCustomPrompt(template, {}); + // Eta renders undefined context values as the literal string "undefined" + expect(result).toBe('Value: [undefined]'); + }); +}); + +describe('validateTemplate', () => { + it('returns valid for correct Eta syntax', () => { + const result = validateTemplate('Hello <%= it.baseBranch %>'); + expect(result).toEqual({ valid: true }); + }); + + it('returns valid for template with includes (DB partials)', () => { + const dbPartials = new Map([['test', 'Partial content']]); + const result = validateTemplate('<%~ include("partials/test") %>', dbPartials); + expect(result).toEqual({ valid: true }); + }); + + it('returns invalid for broken Eta syntax', () => { + const result = validateTemplate('<% if (true) { %>'); + expect(result.valid).toBe(false); + expect('error' in result && result.error).toBeTruthy(); + }); + + it('returns invalid for missing partial', () => { + const result = validateTemplate('<%~ include("partials/does-not-exist-xyz") %>'); + expect(result.valid).toBe(false); + }); +}); + +describe('getRawTemplate', () => { + it('returns raw .eta template content', () => { + const raw = getRawTemplate('briefing'); + expect(raw).toContain('<%'); + expect(raw).toBeTruthy(); + }); + + it('throws for unknown agent type', () => { + expect(() => getRawTemplate('unknown-type')).toThrow('Unknown agent type: unknown-type'); + }); +}); + +describe('getRawPartial', () => { + it('returns raw partial content from disk', () => { + const content = getRawPartial('git'); + expect(content).toBeTruthy(); + expect(typeof content).toBe('string'); + }); + + it('throws for nonexistent partial', () => { + expect(() => getRawPartial('nonexistent-xyz')).toThrow(); + }); +}); + +describe('getValidAgentTypes', () => { + it('returns an array of agent type strings', () => { + const types = getValidAgentTypes(); + expect(Array.isArray(types)).toBe(true); + expect(types.length).toBeGreaterThan(0); + expect(types).toContain('briefing'); + expect(types).toContain('implementation'); + expect(types).toContain('review'); + }); + + it('returns a copy (not the original array)', () => { + const a = getValidAgentTypes(); + const b = getValidAgentTypes(); + expect(a).not.toBe(b); + expect(a).toEqual(b); + }); +}); + +describe('getAvailablePartialNames', () => { + it('returns an array of partial names', () => { + const names = getAvailablePartialNames(); + expect(Array.isArray(names)).toBe(true); + expect(names.length).toBeGreaterThan(0); + expect(names).toContain('git'); + }); + + it('returns names sorted alphabetically', () => { + const names = getAvailablePartialNames(); + const sorted = [...names].sort(); + expect(names).toEqual(sorted); + }); + + it('returns names without .eta extension', () => { + const names = getAvailablePartialNames(); + for (const name of names) { + expect(name).not.toContain('.eta'); + } + }); +}); + +describe('getTemplateVariables', () => { + it('returns an array of variable definitions', () => { + const vars = getTemplateVariables(); + expect(Array.isArray(vars)).toBe(true); + expect(vars.length).toBeGreaterThan(0); + }); + + it('each variable has name, group, and description', () => { + const vars = getTemplateVariables(); + for (const v of vars) { + expect(v).toHaveProperty('name'); + expect(v).toHaveProperty('group'); + expect(v).toHaveProperty('description'); + expect(typeof v.name).toBe('string'); + expect(typeof v.group).toBe('string'); + expect(typeof v.description).toBe('string'); + } + }); + + it('includes common variables', () => { + const vars = getTemplateVariables(); + const names = vars.map((v) => v.name); + expect(names).toContain('baseBranch'); + expect(names).toContain('cardId'); + expect(names).toContain('projectId'); + }); +}); diff --git a/tests/unit/agents/shared/modelResolution.test.ts b/tests/unit/agents/shared/modelResolution.test.ts new file mode 100644 index 00000000..bcdcc479 --- /dev/null +++ b/tests/unit/agents/shared/modelResolution.test.ts @@ -0,0 +1,245 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CascadeConfig, ProjectConfig } from '../../../../src/types/index.js'; + +// Mock readContextFiles +vi.mock('../../../../src/agents/utils/setup.js', () => ({ + readContextFiles: vi.fn().mockResolvedValue([]), +})); + +import { resolveModelConfig } from '../../../../src/agents/shared/modelResolution.js'; + +function makeProject(overrides: Partial = {}): ProjectConfig { + return { + id: 'test-proj', + orgId: 'org-1', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'trello' }, + ...overrides, + } as ProjectConfig; +} + +function makeConfig(overrides: Partial = {}): CascadeConfig { + return { + defaults: { + model: 'default-model', + agentModels: {}, + maxIterations: 50, + agentIterations: {}, + watchdogTimeoutMs: 1800000, + cardBudgetUsd: 5, + agentBackend: 'llmist', + progressModel: 'progress-model', + progressIntervalMinutes: 5, + prompts: {}, + ...overrides, + }, + projects: [], + }; +} + +describe('resolveModelConfig', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('prompt resolution chain', () => { + it('uses .eta file when no custom prompts configured', async () => { + const result = await resolveModelConfig({ + agentType: 'briefing', + project: makeProject(), + config: makeConfig(), + repoDir: '/tmp/test', + }); + + expect(result.systemPrompt).toContain('product manager'); + expect(result.systemPrompt).toContain('DO NOT IMPLEMENT'); + }); + + it('uses project prompt when configured', async () => { + const project = makeProject({ + prompts: { briefing: 'You are a custom briefing agent for <%= it.baseBranch %>.' }, + }); + + const result = await resolveModelConfig({ + agentType: 'briefing', + project, + config: makeConfig(), + repoDir: '/tmp/test', + promptContext: { baseBranch: 'develop' }, + }); + + expect(result.systemPrompt).toBe('You are a custom briefing agent for develop.'); + }); + + it('uses defaults prompt when no project prompt', async () => { + const config = makeConfig({ + prompts: { briefing: 'Global custom briefing for <%= it.projectId %>.' }, + }); + + const result = await resolveModelConfig({ + agentType: 'briefing', + project: makeProject(), + config, + repoDir: '/tmp/test', + promptContext: { projectId: 'p1' }, + }); + + expect(result.systemPrompt).toBe('Global custom briefing for p1.'); + }); + + it('prefers project prompt over defaults prompt', async () => { + const project = makeProject({ + prompts: { briefing: 'Project-level prompt.' }, + }); + const config = makeConfig({ + prompts: { briefing: 'Defaults-level prompt.' }, + }); + + const result = await resolveModelConfig({ + agentType: 'briefing', + project, + config, + repoDir: '/tmp/test', + }); + + expect(result.systemPrompt).toBe('Project-level prompt.'); + }); + + it('falls back to .eta when agent type has no custom prompt', async () => { + const config = makeConfig({ + prompts: { planning: 'Only planning has a custom prompt.' }, + }); + + const result = await resolveModelConfig({ + agentType: 'briefing', + project: makeProject(), + config, + repoDir: '/tmp/test', + }); + + // Should fall back to .eta file for briefing + expect(result.systemPrompt).toContain('product manager'); + }); + + it('resolves includes in custom prompts via dbPartials', async () => { + const project = makeProject({ + prompts: { briefing: 'Custom: <%~ include("partials/custom") %>' }, + }); + const dbPartials = new Map([['custom', 'Injected partial content']]); + + const result = await resolveModelConfig({ + agentType: 'briefing', + project, + config: makeConfig(), + repoDir: '/tmp/test', + dbPartials, + }); + + expect(result.systemPrompt).toContain('Injected partial content'); + }); + + it('passes dbPartials to .eta file rendering', async () => { + const dbPartials = new Map([['git', 'Custom git instructions from DB']]); + + const result = await resolveModelConfig({ + agentType: 'implementation', + project: makeProject(), + config: makeConfig(), + repoDir: '/tmp/test', + dbPartials, + }); + + expect(result.systemPrompt).toContain('Custom git instructions from DB'); + }); + }); + + describe('model resolution', () => { + it('uses default model when no overrides', async () => { + const result = await resolveModelConfig({ + agentType: 'briefing', + project: makeProject(), + config: makeConfig({ model: 'my-default' }), + repoDir: '/tmp/test', + }); + + expect(result.model).toBe('my-default'); + }); + + it('prefers modelOverride over project and default', async () => { + const project = makeProject({ model: 'project-model' }); + + const result = await resolveModelConfig({ + agentType: 'briefing', + project, + config: makeConfig({ model: 'default-model' }), + repoDir: '/tmp/test', + modelOverride: 'override-model', + }); + + expect(result.model).toBe('override-model'); + }); + + it('uses agent-specific model from project', async () => { + const project = makeProject({ + agentModels: { briefing: 'agent-specific-model' }, + }); + + const result = await resolveModelConfig({ + agentType: 'briefing', + project, + config: makeConfig(), + repoDir: '/tmp/test', + }); + + expect(result.model).toBe('agent-specific-model'); + }); + + it('uses configKey for model lookup when provided', async () => { + const config = makeConfig({ + agentModels: { review: 'review-model' }, + }); + + const result = await resolveModelConfig({ + agentType: 'respond-to-review', + project: makeProject(), + config, + repoDir: '/tmp/test', + configKey: 'review', + }); + + expect(result.model).toBe('review-model'); + }); + }); + + describe('iterations resolution', () => { + it('uses default maxIterations', async () => { + const result = await resolveModelConfig({ + agentType: 'briefing', + project: makeProject(), + config: makeConfig({ maxIterations: 42 }), + repoDir: '/tmp/test', + }); + + expect(result.maxIterations).toBe(42); + }); + + it('uses agent-specific iterations', async () => { + const config = makeConfig({ + agentIterations: { briefing: 10 }, + maxIterations: 50, + }); + + const result = await resolveModelConfig({ + agentType: 'briefing', + project: makeProject(), + config, + repoDir: '/tmp/test', + }); + + expect(result.maxIterations).toBe(10); + }); + }); +}); diff --git a/tests/unit/api/routers/prompts.test.ts b/tests/unit/api/routers/prompts.test.ts new file mode 100644 index 00000000..5c4c0cb0 --- /dev/null +++ b/tests/unit/api/routers/prompts.test.ts @@ -0,0 +1,272 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TRPCContext } from '../../../../src/api/trpc.js'; + +// Mock prompt functions +const mockGetValidAgentTypes = vi.fn(); +const mockGetRawTemplate = vi.fn(); +const mockGetTemplateVariables = vi.fn(); +const mockValidateTemplate = vi.fn(); +const mockGetAvailablePartialNames = vi.fn(); +const mockGetRawPartial = vi.fn(); + +vi.mock('../../../../src/agents/prompts/index.js', () => ({ + getValidAgentTypes: (...args: unknown[]) => mockGetValidAgentTypes(...args), + getRawTemplate: (...args: unknown[]) => mockGetRawTemplate(...args), + getTemplateVariables: (...args: unknown[]) => mockGetTemplateVariables(...args), + validateTemplate: (...args: unknown[]) => mockValidateTemplate(...args), + getAvailablePartialNames: (...args: unknown[]) => mockGetAvailablePartialNames(...args), + getRawPartial: (...args: unknown[]) => mockGetRawPartial(...args), +})); + +// Mock partials repository +const mockLoadPartials = vi.fn(); +const mockListPartials = vi.fn(); +const mockGetPartial = vi.fn(); +const mockUpsertPartial = vi.fn(); +const mockDeletePartial = vi.fn(); + +vi.mock('../../../../src/db/repositories/partialsRepository.js', () => ({ + loadPartials: (...args: unknown[]) => mockLoadPartials(...args), + listPartials: (...args: unknown[]) => mockListPartials(...args), + getPartial: (...args: unknown[]) => mockGetPartial(...args), + upsertPartial: (...args: unknown[]) => mockUpsertPartial(...args), + deletePartial: (...args: unknown[]) => mockDeletePartial(...args), +})); + +import { promptsRouter } from '../../../../src/api/routers/prompts.js'; + +function createCaller(ctx: TRPCContext) { + return promptsRouter.createCaller(ctx); +} + +const mockUser = { + id: 'user-1', + orgId: 'org-1', + email: 'test@example.com', + name: 'Test', + role: 'admin', +}; + +describe('promptsRouter', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('agentTypes', () => { + it('returns list of agent types', async () => { + const types = ['briefing', 'planning', 'implementation']; + mockGetValidAgentTypes.mockReturnValue(types); + const caller = createCaller({ user: mockUser }); + + const result = await caller.agentTypes(); + + expect(result).toEqual(types); + expect(mockGetValidAgentTypes).toHaveBeenCalled(); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null }); + await expect(caller.agentTypes()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + }); + + describe('getDefault', () => { + it('returns raw template for valid agent type', async () => { + mockGetRawTemplate.mockReturnValue('Template content: <%= it.baseBranch %>'); + const caller = createCaller({ user: mockUser }); + + const result = await caller.getDefault({ agentType: 'briefing' }); + + expect(result).toEqual({ content: 'Template content: <%= it.baseBranch %>' }); + expect(mockGetRawTemplate).toHaveBeenCalledWith('briefing'); + }); + + it('throws NOT_FOUND for unknown agent type', async () => { + mockGetRawTemplate.mockImplementation(() => { + throw new Error('Unknown'); + }); + const caller = createCaller({ user: mockUser }); + + await expect(caller.getDefault({ agentType: 'unknown' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null }); + await expect(caller.getDefault({ agentType: 'briefing' })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); + + describe('variables', () => { + it('returns template variables', async () => { + const vars = [{ name: 'baseBranch', group: 'Common', description: 'Base branch' }]; + mockGetTemplateVariables.mockReturnValue(vars); + const caller = createCaller({ user: mockUser }); + + const result = await caller.variables(); + + expect(result).toEqual(vars); + }); + }); + + describe('validate', () => { + it('returns valid for correct template', async () => { + mockLoadPartials.mockResolvedValue(new Map()); + mockValidateTemplate.mockReturnValue({ valid: true }); + const caller = createCaller({ user: mockUser }); + + const result = await caller.validate({ template: 'Hello <%= it.name %>' }); + + expect(result).toEqual({ valid: true }); + expect(mockLoadPartials).toHaveBeenCalled(); + }); + + it('returns invalid with error for bad template', async () => { + mockLoadPartials.mockResolvedValue(new Map()); + mockValidateTemplate.mockReturnValue({ valid: false, error: 'Syntax error' }); + const caller = createCaller({ user: mockUser }); + + const result = await caller.validate({ template: '<% broken' }); + + expect(result).toEqual({ valid: false, error: 'Syntax error' }); + }); + }); + + describe('listPartials', () => { + it('merges DB and disk partials', async () => { + mockListPartials.mockResolvedValue([ + { id: 1, name: 'git', content: 'DB git\ncontent', orgId: null }, + ]); + mockGetAvailablePartialNames.mockReturnValue(['git', 'tmux']); + mockGetRawPartial.mockImplementation((name: string) => { + if (name === 'tmux') return 'Tmux content\nline 2\nline 3'; + throw new Error('Not found'); + }); + const caller = createCaller({ user: mockUser }); + + const result = await caller.listPartials(); + + expect(result).toEqual([ + { name: 'git', source: 'db', lines: 2, id: 1 }, + { name: 'tmux', source: 'disk', lines: 3 }, + ]); + }); + + it('includes DB-only partials not on disk', async () => { + mockListPartials.mockResolvedValue([ + { id: 5, name: 'custom-partial', content: 'Custom\ncontent', orgId: null }, + ]); + mockGetAvailablePartialNames.mockReturnValue([]); + const caller = createCaller({ user: mockUser }); + + const result = await caller.listPartials(); + + expect(result).toEqual([{ name: 'custom-partial', source: 'db', lines: 2, id: 5 }]); + }); + }); + + describe('getPartial', () => { + it('returns DB partial when available', async () => { + mockGetPartial.mockResolvedValue({ id: 1, name: 'git', content: 'DB content' }); + const caller = createCaller({ user: mockUser }); + + const result = await caller.getPartial({ name: 'git' }); + + expect(result).toEqual({ name: 'git', content: 'DB content', source: 'db', id: 1 }); + }); + + it('falls back to disk when no DB partial', async () => { + mockGetPartial.mockResolvedValue(null); + mockGetRawPartial.mockReturnValue('Disk content'); + const caller = createCaller({ user: mockUser }); + + const result = await caller.getPartial({ name: 'git' }); + + expect(result).toEqual({ name: 'git', content: 'Disk content', source: 'disk' }); + }); + + it('throws NOT_FOUND when partial not in DB or disk', async () => { + mockGetPartial.mockResolvedValue(null); + mockGetRawPartial.mockImplementation(() => { + throw new Error('Not found'); + }); + const caller = createCaller({ user: mockUser }); + + await expect(caller.getPartial({ name: 'nonexistent' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + }); + + describe('getDefaultPartial', () => { + it('returns disk partial content', async () => { + mockGetRawPartial.mockReturnValue('Default content'); + const caller = createCaller({ user: mockUser }); + + const result = await caller.getDefaultPartial({ name: 'git' }); + + expect(result).toEqual({ content: 'Default content' }); + }); + + it('throws NOT_FOUND when no disk partial', async () => { + mockGetRawPartial.mockImplementation(() => { + throw new Error('Not found'); + }); + const caller = createCaller({ user: mockUser }); + + await expect(caller.getDefaultPartial({ name: 'nonexistent' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + }); + + describe('upsertPartial', () => { + it('upserts valid partial content', async () => { + mockLoadPartials.mockResolvedValue(new Map()); + mockValidateTemplate.mockReturnValue({ valid: true }); + mockUpsertPartial.mockResolvedValue({ + id: 1, + name: 'git', + content: 'New content', + orgId: null, + }); + const caller = createCaller({ user: mockUser }); + + const result = await caller.upsertPartial({ name: 'git', content: 'New content' }); + + expect(result).toMatchObject({ name: 'git', content: 'New content' }); + expect(mockUpsertPartial).toHaveBeenCalledWith({ name: 'git', content: 'New content' }); + }); + + it('rejects invalid partial content', async () => { + mockLoadPartials.mockResolvedValue(new Map()); + mockValidateTemplate.mockReturnValue({ valid: false, error: 'Bad syntax' }); + const caller = createCaller({ user: mockUser }); + + await expect( + caller.upsertPartial({ name: 'git', content: '<% broken' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + }); + + describe('deletePartial', () => { + it('deletes a partial by id', async () => { + mockDeletePartial.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser }); + + await caller.deletePartial({ id: 1 }); + + expect(mockDeletePartial).toHaveBeenCalledWith(1); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null }); + await expect(caller.deletePartial({ id: 1 })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); +}); diff --git a/tests/unit/backends/claude-code.test.ts b/tests/unit/backends/claude-code.test.ts index d9e048b7..ceb97a02 100644 --- a/tests/unit/backends/claude-code.test.ts +++ b/tests/unit/backends/claude-code.test.ts @@ -5,6 +5,10 @@ vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ query: vi.fn(), })); +vi.mock('../../../src/utils/logging.js', () => ({ + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + import { existsSync, mkdtempSync, readFileSync, statSync } from 'node:fs'; import { rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -19,7 +23,13 @@ import { ensureOnboardingFlag, resolveClaudeModel, } from '../../../src/backends/claude-code/index.js'; +import { + CLAUDE_CODE_MODELS, + CLAUDE_CODE_MODEL_IDS, + DEFAULT_CLAUDE_CODE_MODEL, +} from '../../../src/backends/claude-code/models.js'; import type { AgentBackendInput, ToolManifest } from '../../../src/backends/types.js'; +import { logger } from '../../../src/utils/logging.js'; const mockQuery = vi.mocked(query); @@ -137,9 +147,35 @@ describe('buildSystemPrompt', () => { }); }); +describe('CLAUDE_CODE_MODELS constants', () => { + it('contains three models', () => { + expect(CLAUDE_CODE_MODELS).toHaveLength(3); + }); + + it('has value/label pairs', () => { + for (const m of CLAUDE_CODE_MODELS) { + expect(m.value).toBeTruthy(); + expect(m.label).toBeTruthy(); + } + }); + + it('CLAUDE_CODE_MODEL_IDS matches model values', () => { + expect(CLAUDE_CODE_MODEL_IDS).toEqual(CLAUDE_CODE_MODELS.map((m) => m.value)); + }); + + it('DEFAULT_CLAUDE_CODE_MODEL is a known model ID', () => { + expect(CLAUDE_CODE_MODEL_IDS).toContain(DEFAULT_CLAUDE_CODE_MODEL); + }); +}); + describe('resolveClaudeModel', () => { - it('passes through claude-* models', () => { + it('passes through known Claude Code model IDs', () => { + expect(resolveClaudeModel('claude-opus-4-6')).toBe('claude-opus-4-6'); expect(resolveClaudeModel('claude-sonnet-4-5-20250929')).toBe('claude-sonnet-4-5-20250929'); + expect(resolveClaudeModel('claude-haiku-4-5-20251001')).toBe('claude-haiku-4-5-20251001'); + }); + + it('passes through other claude-* models', () => { expect(resolveClaudeModel('claude-opus-4-20250514')).toBe('claude-opus-4-20250514'); }); @@ -149,11 +185,26 @@ describe('resolveClaudeModel', () => { ); }); - it('falls back to sonnet for non-Claude models', () => { + it('falls back to default for non-Claude models', () => { expect(resolveClaudeModel('openrouter:google/gemini-3-flash-preview')).toBe( - 'claude-sonnet-4-5-20250929', + DEFAULT_CLAUDE_CODE_MODEL, ); - expect(resolveClaudeModel('gpt-4o')).toBe('claude-sonnet-4-5-20250929'); + expect(resolveClaudeModel('gpt-4o')).toBe(DEFAULT_CLAUDE_CODE_MODEL); + }); + + it('logs a warning when falling back', () => { + vi.mocked(logger.warn).mockClear(); + resolveClaudeModel('gpt-4o'); + expect(logger.warn).toHaveBeenCalledWith( + 'Non-Claude model configured for Claude Code backend, falling back to default', + { configured: 'gpt-4o', fallback: DEFAULT_CLAUDE_CODE_MODEL }, + ); + }); + + it('does not warn for valid Claude models', () => { + vi.mocked(logger.warn).mockClear(); + resolveClaudeModel('claude-sonnet-4-5-20250929'); + expect(logger.warn).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/config/provider.test.ts b/tests/unit/config/provider.test.ts new file mode 100644 index 00000000..070c9dbb --- /dev/null +++ b/tests/unit/config/provider.test.ts @@ -0,0 +1,531 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock DB repositories first (must be before imports) +vi.mock('../../../src/db/repositories/configRepository.js', () => ({ + loadConfigFromDb: vi.fn(), + findProjectByBoardIdFromDb: vi.fn(), + findProjectByRepoFromDb: vi.fn(), + findProjectByJiraProjectKeyFromDb: vi.fn(), + findProjectByIdFromDb: vi.fn(), +})); + +vi.mock('../../../src/db/repositories/credentialsRepository.js', () => ({ + resolveCredential: vi.fn(), + resolveAgentCredential: vi.fn(), + resolveAllCredentials: vi.fn(), +})); + +// Mock configCache +vi.mock('../../../src/config/configCache.js', () => ({ + configCache: { + getConfig: vi.fn(), + setConfig: vi.fn(), + getProjectByBoardId: vi.fn(), + setProjectByBoardId: vi.fn(), + getProjectByRepo: vi.fn(), + setProjectByRepo: vi.fn(), + getProjectByJiraKey: vi.fn(), + setProjectByJiraKey: vi.fn(), + getOrgIdForProject: vi.fn(), + setOrgIdForProject: vi.fn(), + getSecrets: vi.fn(), + setSecrets: vi.fn(), + invalidate: vi.fn(), + }, +})); + +import { configCache } from '../../../src/config/configCache.js'; +import { + findProjectByBoardId, + findProjectById, + findProjectByJiraProjectKey, + findProjectByRepo, + getAgentCredential, + getProjectSecret, + getProjectSecretOrNull, + getProjectSecrets, + invalidateConfigCache, + loadConfig, +} from '../../../src/config/provider.js'; +import { + findProjectByBoardIdFromDb, + findProjectByIdFromDb, + findProjectByJiraProjectKeyFromDb, + findProjectByRepoFromDb, + loadConfigFromDb, +} from '../../../src/db/repositories/configRepository.js'; +import { + resolveAgentCredential, + resolveAllCredentials, + resolveCredential, +} from '../../../src/db/repositories/credentialsRepository.js'; +import type { CascadeConfig, ProjectConfig } from '../../../src/types/index.js'; + +// --------------------------------------------------------------------------- +// Test data +// --------------------------------------------------------------------------- + +const mockConfig: CascadeConfig = { + defaults: { + model: 'test-model', + maxIterations: 50, + }, + projects: [ + { + id: 'proj1', + orgId: 'org1', + name: 'Project One', + repo: 'owner/repo1', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'trello' }, + trello: { + boardId: 'board123', + lists: { todo: 'list-todo' }, + labels: { processing: 'label-proc' }, + }, + }, + ] as ProjectConfig[], +}; + +const mockProject: ProjectConfig = { + id: 'proj1', + orgId: 'org1', + name: 'Project One', + repo: 'owner/repo1', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'trello' }, + trello: { + boardId: 'board123', + lists: { todo: 'list-todo' }, + labels: { processing: 'label-proc' }, + }, +}; + +describe('config/provider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('loadConfig', () => { + it('returns cached config when available', async () => { + vi.mocked(configCache.getConfig).mockReturnValue(mockConfig); + + const result = await loadConfig(); + + expect(result).toBe(mockConfig); + expect(loadConfigFromDb).not.toHaveBeenCalled(); + expect(configCache.setConfig).not.toHaveBeenCalled(); + }); + + it('loads config from DB when not cached', async () => { + vi.mocked(configCache.getConfig).mockReturnValue(null); + vi.mocked(loadConfigFromDb).mockResolvedValue(mockConfig); + + const result = await loadConfig(); + + expect(result).toBe(mockConfig); + expect(loadConfigFromDb).toHaveBeenCalledTimes(1); + expect(configCache.setConfig).toHaveBeenCalledWith(mockConfig); + }); + + it('caches loaded config for subsequent calls', async () => { + vi.mocked(configCache.getConfig).mockReturnValue(null); + vi.mocked(loadConfigFromDb).mockResolvedValue(mockConfig); + + await loadConfig(); + + expect(configCache.setConfig).toHaveBeenCalledWith(mockConfig); + }); + }); + + describe('findProjectByBoardId', () => { + it('returns cached project when available', async () => { + vi.mocked(configCache.getProjectByBoardId).mockReturnValue(mockProject); + + const result = await findProjectByBoardId('board123'); + + expect(result).toBe(mockProject); + expect(findProjectByBoardIdFromDb).not.toHaveBeenCalled(); + expect(configCache.setProjectByBoardId).not.toHaveBeenCalled(); + }); + + it('returns cached undefined when explicitly cached as not found', async () => { + vi.mocked(configCache.getProjectByBoardId).mockReturnValue(undefined); + + const result = await findProjectByBoardId('nonexistent'); + + expect(result).toBeUndefined(); + expect(findProjectByBoardIdFromDb).not.toHaveBeenCalled(); + }); + + it('loads project from DB when not cached', async () => { + vi.mocked(configCache.getProjectByBoardId).mockReturnValue(null); + vi.mocked(findProjectByBoardIdFromDb).mockResolvedValue(mockProject); + + const result = await findProjectByBoardId('board123'); + + expect(result).toBe(mockProject); + expect(findProjectByBoardIdFromDb).toHaveBeenCalledWith('board123'); + expect(configCache.setProjectByBoardId).toHaveBeenCalledWith('board123', mockProject); + }); + + it('caches undefined when project not found', async () => { + vi.mocked(configCache.getProjectByBoardId).mockReturnValue(null); + vi.mocked(findProjectByBoardIdFromDb).mockResolvedValue(undefined); + + const result = await findProjectByBoardId('nonexistent'); + + expect(result).toBeUndefined(); + expect(configCache.setProjectByBoardId).toHaveBeenCalledWith('nonexistent', undefined); + }); + }); + + describe('findProjectByRepo', () => { + it('returns cached project when available', async () => { + vi.mocked(configCache.getProjectByRepo).mockReturnValue(mockProject); + + const result = await findProjectByRepo('owner/repo1'); + + expect(result).toBe(mockProject); + expect(findProjectByRepoFromDb).not.toHaveBeenCalled(); + }); + + it('loads project from DB when not cached', async () => { + vi.mocked(configCache.getProjectByRepo).mockReturnValue(null); + vi.mocked(findProjectByRepoFromDb).mockResolvedValue(mockProject); + + const result = await findProjectByRepo('owner/repo1'); + + expect(result).toBe(mockProject); + expect(findProjectByRepoFromDb).toHaveBeenCalledWith('owner/repo1'); + expect(configCache.setProjectByRepo).toHaveBeenCalledWith('owner/repo1', mockProject); + }); + + it('caches undefined when project not found', async () => { + vi.mocked(configCache.getProjectByRepo).mockReturnValue(null); + vi.mocked(findProjectByRepoFromDb).mockResolvedValue(undefined); + + const result = await findProjectByRepo('owner/unknown'); + + expect(result).toBeUndefined(); + expect(configCache.setProjectByRepo).toHaveBeenCalledWith('owner/unknown', undefined); + }); + }); + + describe('findProjectByJiraProjectKey', () => { + it('returns cached project when available', async () => { + vi.mocked(configCache.getProjectByJiraKey).mockReturnValue(mockProject); + + const result = await findProjectByJiraProjectKey('PROJ'); + + expect(result).toBe(mockProject); + expect(findProjectByJiraProjectKeyFromDb).not.toHaveBeenCalled(); + }); + + it('loads project from DB when not cached', async () => { + vi.mocked(configCache.getProjectByJiraKey).mockReturnValue(null); + vi.mocked(findProjectByJiraProjectKeyFromDb).mockResolvedValue(mockProject); + + const result = await findProjectByJiraProjectKey('PROJ'); + + expect(result).toBe(mockProject); + expect(findProjectByJiraProjectKeyFromDb).toHaveBeenCalledWith('PROJ'); + expect(configCache.setProjectByJiraKey).toHaveBeenCalledWith('PROJ', mockProject); + }); + + it('caches undefined when project not found', async () => { + vi.mocked(configCache.getProjectByJiraKey).mockReturnValue(null); + vi.mocked(findProjectByJiraProjectKeyFromDb).mockResolvedValue(undefined); + + const result = await findProjectByJiraProjectKey('NONEXIST'); + + expect(result).toBeUndefined(); + expect(configCache.setProjectByJiraKey).toHaveBeenCalledWith('NONEXIST', undefined); + }); + }); + + describe('findProjectById', () => { + it('does not use cache for by-id lookups', async () => { + vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject); + + const result = await findProjectById('proj1'); + + expect(result).toBe(mockProject); + expect(findProjectByIdFromDb).toHaveBeenCalledWith('proj1'); + // No cache interactions + expect(configCache.getProjectByBoardId).not.toHaveBeenCalled(); + expect(configCache.setProjectByBoardId).not.toHaveBeenCalled(); + }); + + it('returns undefined when project not found', async () => { + vi.mocked(findProjectByIdFromDb).mockResolvedValue(undefined); + + const result = await findProjectById('nonexistent'); + + expect(result).toBeUndefined(); + }); + }); + + describe('getProjectSecret', () => { + beforeEach(() => { + // Mock getOrgIdForProject helper + vi.mocked(configCache.getOrgIdForProject).mockReturnValue(null); + vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject); + }); + + it('returns cached secret when available', async () => { + vi.mocked(configCache.getSecrets).mockReturnValue({ + GITHUB_TOKEN: 'ghp_cached', + }); + + const result = await getProjectSecret('proj1', 'GITHUB_TOKEN'); + + expect(result).toBe('ghp_cached'); + expect(resolveCredential).not.toHaveBeenCalled(); + }); + + it('resolves from credentials repository when not cached', async () => { + vi.mocked(configCache.getSecrets).mockReturnValue(null); + vi.mocked(resolveCredential).mockResolvedValue('ghp_resolved'); + + const result = await getProjectSecret('proj1', 'GITHUB_TOKEN'); + + expect(result).toBe('ghp_resolved'); + expect(resolveCredential).toHaveBeenCalledWith('proj1', 'org1', 'GITHUB_TOKEN'); + }); + + it('caches org ID for project on first resolution', async () => { + vi.mocked(configCache.getSecrets).mockReturnValue(null); + vi.mocked(configCache.getOrgIdForProject).mockReturnValue(null); + vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject); + vi.mocked(resolveCredential).mockResolvedValue('ghp_token'); + + await getProjectSecret('proj1', 'GITHUB_TOKEN'); + + expect(configCache.setOrgIdForProject).toHaveBeenCalledWith('proj1', 'org1'); + }); + + it('reuses cached org ID for subsequent secret resolutions', async () => { + vi.mocked(configCache.getSecrets).mockReturnValue(null); + vi.mocked(configCache.getOrgIdForProject).mockReturnValue('org1'); + vi.mocked(resolveCredential).mockResolvedValue('ghp_token'); + + await getProjectSecret('proj1', 'GITHUB_TOKEN'); + + expect(findProjectByIdFromDb).not.toHaveBeenCalled(); + expect(resolveCredential).toHaveBeenCalledWith('proj1', 'org1', 'GITHUB_TOKEN'); + }); + + it('throws error when secret not found', async () => { + vi.mocked(configCache.getSecrets).mockReturnValue(null); + vi.mocked(configCache.getOrgIdForProject).mockReturnValue('org1'); + vi.mocked(resolveCredential).mockResolvedValue(null); + + await expect(getProjectSecret('proj1', 'MISSING_KEY')).rejects.toThrow( + "Secret 'MISSING_KEY' not found for project 'proj1' in database", + ); + }); + + it('uses default org ID when project has no orgId', async () => { + const projectNoOrg = { ...mockProject, orgId: undefined }; + vi.mocked(configCache.getSecrets).mockReturnValue(null); + vi.mocked(configCache.getOrgIdForProject).mockReturnValue(null); + vi.mocked(findProjectByIdFromDb).mockResolvedValue(projectNoOrg); + vi.mocked(resolveCredential).mockResolvedValue('ghp_token'); + + await getProjectSecret('proj1', 'GITHUB_TOKEN'); + + expect(configCache.setOrgIdForProject).toHaveBeenCalledWith('proj1', 'default'); + expect(resolveCredential).toHaveBeenCalledWith('proj1', 'default', 'GITHUB_TOKEN'); + }); + }); + + describe('getProjectSecretOrNull', () => { + beforeEach(() => { + vi.mocked(configCache.getOrgIdForProject).mockReturnValue('org1'); + }); + + it('returns secret when found', async () => { + vi.mocked(configCache.getSecrets).mockReturnValue({ KEY: 'value' }); + + const result = await getProjectSecretOrNull('proj1', 'KEY'); + + expect(result).toBe('value'); + }); + + it('returns null when secret not found', async () => { + vi.mocked(configCache.getSecrets).mockReturnValue(null); + vi.mocked(resolveCredential).mockResolvedValue(null); + + const result = await getProjectSecretOrNull('proj1', 'MISSING'); + + expect(result).toBeNull(); + }); + + it('returns null when getProjectSecret throws', async () => { + vi.mocked(configCache.getSecrets).mockReturnValue(null); + vi.mocked(resolveCredential).mockRejectedValue(new Error('DB error')); + + const result = await getProjectSecretOrNull('proj1', 'KEY'); + + expect(result).toBeNull(); + }); + }); + + describe('getProjectSecrets', () => { + beforeEach(() => { + vi.mocked(configCache.getOrgIdForProject).mockReturnValue(null); + vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject); + }); + + it('returns cached secrets when available', async () => { + const secrets = { GITHUB_TOKEN: 'ghp_123', TRELLO_API_KEY: 'trello_abc' }; + vi.mocked(configCache.getSecrets).mockReturnValue(secrets); + + const result = await getProjectSecrets('proj1'); + + expect(result).toBe(secrets); + expect(resolveAllCredentials).not.toHaveBeenCalled(); + }); + + it('loads all credentials from repository when not cached', async () => { + const secrets = { GITHUB_TOKEN: 'ghp_123', TRELLO_API_KEY: 'trello_abc' }; + vi.mocked(configCache.getSecrets).mockReturnValue(null); + vi.mocked(resolveAllCredentials).mockResolvedValue(secrets); + + const result = await getProjectSecrets('proj1'); + + expect(result).toEqual(secrets); + expect(resolveAllCredentials).toHaveBeenCalledWith('proj1', 'org1'); + expect(configCache.setSecrets).toHaveBeenCalledWith('proj1', secrets); + }); + + it('caches resolved secrets for future access', async () => { + const secrets = { KEY: 'value' }; + vi.mocked(configCache.getSecrets).mockReturnValue(null); + vi.mocked(configCache.getOrgIdForProject).mockReturnValue('org1'); + vi.mocked(resolveAllCredentials).mockResolvedValue(secrets); + + await getProjectSecrets('proj1'); + + expect(configCache.setSecrets).toHaveBeenCalledWith('proj1', secrets); + }); + + it('resolves org ID once and caches it', async () => { + vi.mocked(configCache.getSecrets).mockReturnValue(null); + vi.mocked(configCache.getOrgIdForProject).mockReturnValue(null); + vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject); + vi.mocked(resolveAllCredentials).mockResolvedValue({}); + + await getProjectSecrets('proj1'); + + expect(configCache.setOrgIdForProject).toHaveBeenCalledWith('proj1', 'org1'); + }); + }); + + describe('getAgentCredential', () => { + beforeEach(() => { + vi.mocked(configCache.getOrgIdForProject).mockReturnValue(null); + vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject); + }); + + it('resolves agent-specific credential', async () => { + vi.mocked(resolveAgentCredential).mockResolvedValue('ghp_agent_token'); + + const result = await getAgentCredential('proj1', 'review', 'GITHUB_TOKEN'); + + expect(result).toBe('ghp_agent_token'); + expect(resolveAgentCredential).toHaveBeenCalledWith( + 'proj1', + 'org1', + 'review', + 'GITHUB_TOKEN', + ); + }); + + it('returns null when agent credential not found', async () => { + vi.mocked(resolveAgentCredential).mockResolvedValue(null); + + const result = await getAgentCredential('proj1', 'review', 'MISSING_KEY'); + + expect(result).toBeNull(); + }); + + it('caches org ID for subsequent agent credential resolutions', async () => { + vi.mocked(configCache.getOrgIdForProject).mockReturnValue(null); + vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject); + vi.mocked(resolveAgentCredential).mockResolvedValue('token'); + + await getAgentCredential('proj1', 'review', 'GITHUB_TOKEN'); + + expect(configCache.setOrgIdForProject).toHaveBeenCalledWith('proj1', 'org1'); + }); + + it('uses cached org ID when available', async () => { + vi.mocked(configCache.getOrgIdForProject).mockReturnValue('org1'); + vi.mocked(resolveAgentCredential).mockResolvedValue('token'); + + await getAgentCredential('proj1', 'review', 'GITHUB_TOKEN'); + + expect(findProjectByIdFromDb).not.toHaveBeenCalled(); + expect(resolveAgentCredential).toHaveBeenCalledWith( + 'proj1', + 'org1', + 'review', + 'GITHUB_TOKEN', + ); + }); + + it('resolves for different agent types independently', async () => { + vi.mocked(configCache.getOrgIdForProject).mockReturnValue('org1'); + vi.mocked(resolveAgentCredential) + .mockResolvedValueOnce('token_review') + .mockResolvedValueOnce('token_impl'); + + const result1 = await getAgentCredential('proj1', 'review', 'GITHUB_TOKEN'); + const result2 = await getAgentCredential('proj1', 'implementation', 'GITHUB_TOKEN'); + + expect(result1).toBe('token_review'); + expect(result2).toBe('token_impl'); + expect(resolveAgentCredential).toHaveBeenNthCalledWith( + 1, + 'proj1', + 'org1', + 'review', + 'GITHUB_TOKEN', + ); + expect(resolveAgentCredential).toHaveBeenNthCalledWith( + 2, + 'proj1', + 'org1', + 'implementation', + 'GITHUB_TOKEN', + ); + }); + }); + + describe('invalidateConfigCache', () => { + it('calls configCache.invalidate', () => { + invalidateConfigCache(); + + expect(configCache.invalidate).toHaveBeenCalledTimes(1); + }); + + it('clears all cached data', () => { + // Setup caches + vi.mocked(configCache.getConfig).mockReturnValue(mockConfig); + vi.mocked(configCache.getProjectByBoardId).mockReturnValue(mockProject); + vi.mocked(configCache.getSecrets).mockReturnValue({ KEY: 'val' }); + + invalidateConfigCache(); + + expect(configCache.invalidate).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/github/client.test.ts b/tests/unit/github/client.test.ts index fe893223..e5e12cce 100644 --- a/tests/unit/github/client.test.ts +++ b/tests/unit/github/client.test.ts @@ -22,6 +22,11 @@ const mockChecks = { listForRef: vi.fn(), }; +const mockActions = { + listWorkflowRunsForRepo: vi.fn(), + listJobsForWorkflowRun: vi.fn(), +}; + const mockRepos = { getBranch: vi.fn(), }; @@ -35,6 +40,7 @@ vi.mock('@octokit/rest', () => ({ pulls: mockPulls, issues: mockIssues, checks: mockChecks, + actions: mockActions, repos: mockRepos, users: mockUsers, })), @@ -397,15 +403,26 @@ describe('githubClient', () => { }); describe('getCheckSuiteStatus', () => { + function mockWorkflowRuns( + runs: { id: number }[], + jobsMap: Record, + ) { + mockActions.listWorkflowRunsForRepo.mockResolvedValue({ + data: { workflow_runs: runs }, + }); + mockActions.listJobsForWorkflowRun.mockImplementation(({ run_id }: { run_id: number }) => { + return Promise.resolve({ + data: { jobs: jobsMap[run_id] ?? [] }, + }); + }); + } + it('returns status with all passing', async () => { - mockChecks.listForRef.mockResolvedValue({ - data: { - total_count: 2, - check_runs: [ - { name: 'lint', status: 'completed', conclusion: 'success' }, - { name: 'test', status: 'completed', conclusion: 'success' }, - ], - }, + mockWorkflowRuns([{ id: 1 }], { + 1: [ + { name: 'lint', status: 'completed', conclusion: 'success' }, + { name: 'test', status: 'completed', conclusion: 'success' }, + ], }); const result = await withGitHubToken('test-token', () => @@ -418,14 +435,11 @@ describe('githubClient', () => { }); it('returns allPassing false when some checks fail', async () => { - mockChecks.listForRef.mockResolvedValue({ - data: { - total_count: 2, - check_runs: [ - { name: 'lint', status: 'completed', conclusion: 'success' }, - { name: 'test', status: 'completed', conclusion: 'failure' }, - ], - }, + mockWorkflowRuns([{ id: 1 }], { + 1: [ + { name: 'lint', status: 'completed', conclusion: 'success' }, + { name: 'test', status: 'completed', conclusion: 'failure' }, + ], }); const result = await withGitHubToken('test-token', () => @@ -436,14 +450,11 @@ describe('githubClient', () => { }); it('treats skipped and neutral as passing', async () => { - mockChecks.listForRef.mockResolvedValue({ - data: { - total_count: 2, - check_runs: [ - { name: 'lint', status: 'completed', conclusion: 'skipped' }, - { name: 'test', status: 'completed', conclusion: 'neutral' }, - ], - }, + mockWorkflowRuns([{ id: 1 }], { + 1: [ + { name: 'lint', status: 'completed', conclusion: 'skipped' }, + { name: 'test', status: 'completed', conclusion: 'neutral' }, + ], }); const result = await withGitHubToken('test-token', () => @@ -454,11 +465,8 @@ describe('githubClient', () => { }); it('returns allPassing false when checks are still in_progress', async () => { - mockChecks.listForRef.mockResolvedValue({ - data: { - total_count: 1, - check_runs: [{ name: 'test', status: 'in_progress', conclusion: null }], - }, + mockWorkflowRuns([{ id: 1 }], { + 1: [{ name: 'test', status: 'in_progress', conclusion: null }], }); const result = await withGitHubToken('test-token', () => @@ -467,6 +475,32 @@ describe('githubClient', () => { expect(result.allPassing).toBe(false); }); + + it('returns allPassing false when no workflow runs exist', async () => { + mockWorkflowRuns([], {}); + + const result = await withGitHubToken('test-token', () => + githubClient.getCheckSuiteStatus('owner', 'repo', 'sha123'), + ); + + expect(result.allPassing).toBe(false); + expect(result.totalCount).toBe(0); + }); + + it('aggregates jobs across multiple workflow runs', async () => { + mockWorkflowRuns([{ id: 1 }, { id: 2 }], { + 1: [{ name: 'lint', status: 'completed', conclusion: 'success' }], + 2: [{ name: 'test', status: 'completed', conclusion: 'success' }], + }); + + const result = await withGitHubToken('test-token', () => + githubClient.getCheckSuiteStatus('owner', 'repo', 'sha123'), + ); + + expect(result.allPassing).toBe(true); + expect(result.totalCount).toBe(2); + expect(result.checkRuns).toHaveLength(2); + }); }); describe('getPRDiff', () => { diff --git a/tests/unit/pm/jira/adf.test.ts b/tests/unit/pm/jira/adf.test.ts new file mode 100644 index 00000000..28a21550 --- /dev/null +++ b/tests/unit/pm/jira/adf.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from 'vitest'; +import { adfToPlainText, markdownToAdf } from '../../../../src/pm/jira/adf.js'; + +describe('markdownToAdf', () => { + it('converts a simple paragraph', () => { + const result = markdownToAdf('Hello world'); + expect(result).toEqual({ + type: 'doc', + version: 1, + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello world' }] }], + }); + }); + + it('converts headings', () => { + const result = markdownToAdf('## My Heading') as { content: unknown[] }; + expect(result.content[0]).toEqual({ + type: 'heading', + attrs: { level: 2 }, + content: [{ type: 'text', text: 'My Heading' }], + }); + }); + + it('converts different heading levels', () => { + for (let level = 1; level <= 6; level++) { + const md = `${'#'.repeat(level)} Heading ${level}`; + const result = markdownToAdf(md) as { content: Array<{ attrs: { level: number } }> }; + expect(result.content[0].attrs.level).toBe(level); + } + }); + + it('converts bullet lists', () => { + const result = markdownToAdf('- Item 1\n- Item 2\n- Item 3') as { content: unknown[] }; + const list = result.content[0] as { type: string; content: unknown[] }; + expect(list.type).toBe('bulletList'); + expect(list.content).toHaveLength(3); + }); + + it('converts bullet lists with * marker', () => { + const result = markdownToAdf('* Item 1\n* Item 2') as { content: unknown[] }; + const list = result.content[0] as { type: string; content: unknown[] }; + expect(list.type).toBe('bulletList'); + expect(list.content).toHaveLength(2); + }); + + it('converts code blocks', () => { + const md = '```typescript\nconst x = 1;\n```'; + const result = markdownToAdf(md) as { content: unknown[] }; + expect(result.content[0]).toEqual({ + type: 'codeBlock', + attrs: { language: 'typescript' }, + content: [{ type: 'text', text: 'const x = 1;' }], + }); + }); + + it('converts code blocks without language', () => { + const md = '```\nsome code\n```'; + const result = markdownToAdf(md) as { content: unknown[] }; + const block = result.content[0] as { attrs: Record }; + expect(block.attrs).toEqual({}); + }); + + it('converts bold text', () => { + const result = markdownToAdf('Hello **bold** world') as { content: unknown[] }; + const para = result.content[0] as { content: unknown[] }; + expect(para.content).toContainEqual({ + type: 'text', + text: 'bold', + marks: [{ type: 'strong' }], + }); + }); + + it('converts inline code', () => { + const result = markdownToAdf('Use `code` here') as { content: unknown[] }; + const para = result.content[0] as { content: unknown[] }; + expect(para.content).toContainEqual({ + type: 'text', + text: 'code', + marks: [{ type: 'code' }], + }); + }); + + it('skips empty lines', () => { + const result = markdownToAdf('Line 1\n\nLine 2') as { content: unknown[] }; + expect(result.content).toHaveLength(2); + expect(result.content[0]).toMatchObject({ type: 'paragraph' }); + expect(result.content[1]).toMatchObject({ type: 'paragraph' }); + }); + + it('returns empty paragraph for empty input', () => { + const result = markdownToAdf('') as { content: unknown[] }; + expect(result.content).toEqual([{ type: 'paragraph', content: [] }]); + }); + + it('handles mixed content', () => { + const md = '# Title\n\nSome text\n\n- Item 1\n- Item 2\n\n```js\nconsole.log(1);\n```'; + const result = markdownToAdf(md) as { content: Array<{ type: string }> }; + const types = result.content.map((n) => n.type); + expect(types).toEqual(['heading', 'paragraph', 'bulletList', 'codeBlock']); + }); +}); + +describe('adfToPlainText', () => { + it('converts a simple paragraph', () => { + const adf = { + type: 'doc', + version: 1, + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Hello world' }], + }, + ], + }; + expect(adfToPlainText(adf)).toBe('Hello world'); + }); + + it('converts headings', () => { + const adf = { + type: 'doc', + version: 1, + content: [ + { + type: 'heading', + attrs: { level: 2 }, + content: [{ type: 'text', text: 'Title' }], + }, + ], + }; + expect(adfToPlainText(adf)).toBe('## Title'); + }); + + it('converts bullet lists', () => { + const adf = { + type: 'doc', + version: 1, + content: [ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 1' }] }], + }, + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 2' }] }], + }, + ], + }, + ], + }; + const result = adfToPlainText(adf); + expect(result).toContain('- Item 1'); + expect(result).toContain('- Item 2'); + }); + + it('converts code blocks', () => { + const adf = { + type: 'doc', + version: 1, + content: [ + { + type: 'codeBlock', + content: [{ type: 'text', text: 'const x = 1;' }], + }, + ], + }; + const result = adfToPlainText(adf); + expect(result).toContain('```'); + expect(result).toContain('const x = 1;'); + }); + + it('returns empty string for null/undefined', () => { + expect(adfToPlainText(null)).toBe(''); + expect(adfToPlainText(undefined)).toBe(''); + }); + + it('returns empty string for non-object', () => { + expect(adfToPlainText('string')).toBe(''); + expect(adfToPlainText(42)).toBe(''); + }); + + it('returns text from text node', () => { + expect(adfToPlainText({ type: 'text', text: 'Hello' })).toBe('Hello'); + }); + + it('returns empty string when text is missing', () => { + expect(adfToPlainText({ type: 'text' })).toBe(''); + }); + + it('handles nested content', () => { + const adf = { + type: 'doc', + version: 1, + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Hello ' }, + { type: 'text', text: 'world' }, + ], + }, + ], + }; + expect(adfToPlainText(adf)).toBe('Hello \nworld'); + }); + + it('collapses triple+ newlines', () => { + const adf = { + type: 'doc', + version: 1, + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'Line 1' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'Line 2' }] }, + ], + }; + const result = adfToPlainText(adf); + expect(result).not.toMatch(/\n{3,}/); + }); +}); + +describe('roundtrip: markdownToAdf -> adfToPlainText', () => { + it('preserves simple text', () => { + const md = 'Hello world'; + const adf = markdownToAdf(md); + const result = adfToPlainText(adf); + expect(result).toBe('Hello world'); + }); + + it('preserves headings', () => { + const md = '## My Title'; + const adf = markdownToAdf(md); + const result = adfToPlainText(adf); + expect(result).toBe('## My Title'); + }); + + it('preserves bullet list content', () => { + const md = '- First\n- Second'; + const adf = markdownToAdf(md); + const result = adfToPlainText(adf); + expect(result).toContain('First'); + expect(result).toContain('Second'); + }); + + it('preserves code block content', () => { + const md = '```\ncode here\n```'; + const adf = markdownToAdf(md); + const result = adfToPlainText(adf); + expect(result).toContain('code here'); + }); +}); diff --git a/tools/seed-prompts.ts b/tools/seed-prompts.ts new file mode 100644 index 00000000..93d4da70 --- /dev/null +++ b/tools/seed-prompts.ts @@ -0,0 +1,83 @@ +#!/usr/bin/env tsx +/** + * Seed database with prompt templates and partials from disk .eta files. + * + * Reads all .eta templates from src/agents/prompts/templates/ and inserts them + * as global agent_configs rows (prompt column). Reads all partials from + * src/agents/prompts/templates/partials/ and inserts them as prompt_partials rows. + * + * Uses upsert semantics — safe to re-run. + * + * Usage: + * npx tsx tools/seed-prompts.ts + * + * Requires DATABASE_URL to be set. + */ + +import { and, eq, isNull } from 'drizzle-orm'; +import { + getAvailablePartialNames, + getRawPartial, + getRawTemplate, + getValidAgentTypes, +} from '../src/agents/prompts/index.js'; +import { closeDb, getDb } from '../src/db/client.js'; +import { upsertPartial } from '../src/db/repositories/partialsRepository.js'; +import { agentConfigs } from '../src/db/schema/index.js'; + +async function seedTemplates() { + const db = getDb(); + const agentTypes = getValidAgentTypes(); + + console.log(`Seeding ${agentTypes.length} agent prompt templates...`); + + for (const agentType of agentTypes) { + const content = getRawTemplate(agentType); + + // Update-first approach: try updating existing non-project row, insert if none affected. + // The unique constraint uq_agent_configs_global is on (agent_type) WHERE project_id IS NULL, + // so we match any row with this agent_type and no project (regardless of org_id). + const updated = await db + .update(agentConfigs) + .set({ prompt: content, updatedAt: new Date() }) + .where(and(eq(agentConfigs.agentType, agentType), isNull(agentConfigs.projectId))) + .returning({ id: agentConfigs.id }); + + if (updated.length > 0) { + console.log(` Updated: ${agentType}`); + } else { + await db.insert(agentConfigs).values({ + agentType, + prompt: content, + }); + console.log(` Created: ${agentType}`); + } + } +} + +async function seedPartials() { + const partialNames = getAvailablePartialNames(); + + console.log(`Seeding ${partialNames.length} prompt partials...`); + + for (const name of partialNames) { + const content = getRawPartial(name); + await upsertPartial({ name, content }); + console.log(` Upserted: ${name}`); + } +} + +async function main() { + try { + await seedTemplates(); + await seedPartials(); + console.log('\nDone.'); + } catch (err) { + console.error('Error seeding prompts:', err); + process.exit(1); + } finally { + await closeDb(); + } +} + +main(); diff --git a/web/src/components/layout/sidebar.tsx b/web/src/components/layout/sidebar.tsx index 6810f8e0..36a4e970 100644 --- a/web/src/components/layout/sidebar.tsx +++ b/web/src/components/layout/sidebar.tsx @@ -1,7 +1,15 @@ import { Separator } from '@/components/ui/separator.js'; import { cn } from '@/lib/utils.js'; import { Link, useRouterState } from '@tanstack/react-router'; -import { Activity, Bot, FolderGit2, KeyRound, LayoutDashboard, Settings } from 'lucide-react'; +import { + Activity, + Bot, + FileText, + FolderGit2, + KeyRound, + LayoutDashboard, + Settings, +} from 'lucide-react'; interface SidebarProps { user: { name: string; email: string } | undefined; @@ -16,6 +24,7 @@ const settingsNav = [ { to: '/settings/general' as const, label: 'General', icon: Settings }, { to: '/settings/credentials' as const, label: 'Credentials', icon: KeyRound }, { to: '/settings/agents' as const, label: 'Agent Configs', icon: Bot }, + { to: '/settings/prompts' as const, label: 'Prompts', icon: FileText }, ]; function NavLink({ diff --git a/web/src/components/projects/project-agent-configs.tsx b/web/src/components/projects/project-agent-configs.tsx index 13b8ff8c..eb7e51aa 100644 --- a/web/src/components/projects/project-agent-configs.tsx +++ b/web/src/components/projects/project-agent-configs.tsx @@ -1,3 +1,4 @@ +import { ModelField } from '@/components/settings/model-field.js'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog.js'; import { Input } from '@/components/ui/input.js'; import { Label } from '@/components/ui/label.js'; @@ -16,9 +17,9 @@ import { TableHeader, TableRow, } from '@/components/ui/table.js'; -import { Textarea } from '@/components/ui/textarea.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; import { Pencil, Plus, Trash2 } from 'lucide-react'; import { useState } from 'react'; @@ -130,13 +131,14 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { Model Max Iterations Backend + Prompt {configs.length === 0 && ( - + No project-scoped agent configs @@ -147,6 +149,15 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { {config.model ?? '-'} {config.maxIterations ?? '-'} {config.agentBackend ?? '-'} + + {config.prompt ? ( + + custom + + ) : ( + '-' + )} +