diff --git a/src/core/config.ts b/src/core/config.ts index 8eba7402..bbbbaeb5 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,12 +1,33 @@ +/** + * Configuration loading and resolution for ADHD.ai. + * Coordinates env resolution, file loading, merging, and validation. + */ + import { access } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { + buildEnvBase, + buildEnvNotifications, + buildEnvPolling, +} from "./config/env"; +import { + resolveCron, + resolveNotifications, + resolveProjects, +} from "./config/merging"; +import { + assertNoProjectNotifications, + assertNoProjectPolling, + validateCron, + validateNotifications, + validatePolling, + validateProject, + validateProjects, +} from "./config/validation"; import type { AdhdAiRootConfig, CronConfig, - CronJobConfig, - CronJobSchedule, - CronScheduleDayOfWeek, DeepPartial, NotificationConfig, PollingConfig, @@ -40,7 +61,12 @@ export async function loadConfig(cwd: string): Promise { assertNoProjectPolling(root.projects); assertNoProjectNotifications(root.projects); const projects = resolveProjects(envBase, root); - const polling = resolvePolling(envPolling, root.polling); + const polling: PollingConfig = { + intervalMs: envPolling.intervalMs, + maxCycles: envPolling.maxCycles ?? undefined, + exitWhenIdle: envPolling.exitWhenIdle ?? true, + staleRunTimeoutMs: envPolling.staleRunTimeoutMs ?? 3600000, + }; const cron = resolveCron(root.cron); const notifications = resolveNotifications( envNotifications, @@ -60,93 +86,6 @@ export function getProjectById( return config.projects.find((project) => project.id === projectId); } -function buildEnvBase(cwd: string): ProjectRuntimeConfig { - const env = process.env; - const workspacePath = env.PIV_WORKSPACE_PATH ?? cwd; - const sandbox = normalizeSandboxValue(env.CODEX_SANDBOX); - const codexHome = normalizeOptionalValue(env.CODEX_HOME); - return { - workspacePath, - executionPath: env.PIV_EXECUTION_PATH ?? workspacePath, - repo: { - owner: env.GITHUB_REPO_OWNER ?? "", - name: env.GITHUB_REPO_NAME ?? "", - baseBranch: env.GITHUB_BASE_BRANCH ?? "main", - }, - linear: { - apiKey: env.LINEAR_API_KEY ?? "", - apiUrl: env.LINEAR_API_URL ?? "https://api.linear.app/graphql", - projectId: normalizeOptionalValue(env.LINEAR_PROJECT_ID), - teamId: env.LINEAR_TEAM_ID, - requiredLabel: env.LINEAR_REQUIRED_LABEL, - pollLimit: Number(env.PIV_POLL_LIMIT ?? "10"), - statusMap: { - assigned: env.LINEAR_STATUS_ASSIGNED ?? "Todo", - planning: env.LINEAR_STATUS_PLANNING ?? "In Progress", - implementing: env.LINEAR_STATUS_IMPLEMENTING ?? "In Progress", - pr_created: env.LINEAR_STATUS_PR_CREATED ?? "In Review", - reviewing: env.LINEAR_STATUS_REVIEWING ?? "In Review", - testing: env.LINEAR_STATUS_TESTING ?? "In Review", - blocked: env.LINEAR_STATUS_BLOCKED ?? "Canceled", - done: env.LINEAR_STATUS_DONE ?? "Done", - }, - labelMap: { - pr_created: env.LINEAR_LABEL_PR_CREATED ?? "PR Created", - reviewing: env.LINEAR_LABEL_REVIEWING ?? "Reviewing", - testing: env.LINEAR_LABEL_TESTING ?? "Testing", - }, - autoCreateLabels: env.LINEAR_AUTO_CREATE_LABELS !== "0", - }, - github: { - useGhCli: true, - defaultBugLabel: env.GITHUB_BUG_LABEL ?? "bug", - }, - codex: { - binary: env.CODEX_BINARY ?? "codex", - streamLogs: env.PIV_DEV_MODE === "1" || env.PIV_PRINT_CODEX_LOGS === "1", - model: env.CODEX_MODEL, - models: { - plan: env.CODEX_MODEL_PLAN, - implement: env.CODEX_MODEL_IMPLEMENT, - reviewTest: env.CODEX_MODEL_REVIEW_TEST, - }, - sandbox, - codexHome, - }, - skills: { - plan: path.join(cwd, "skills", "piv-plan", "SKILL.md"), - implement: path.join(cwd, "skills", "piv-implement", "SKILL.md"), - reviewTest: path.join(cwd, "skills", "piv-review-test", "SKILL.md"), - }, - agent: { - backend: normalizeAgentBackend(env.AGENT_BACKEND), - }, - dryRun: env.PIV_DRY_RUN === "1", - }; -} - -function buildEnvPolling(): PollingConfig { - const env = process.env; - return { - intervalMs: Number(env.PIV_POLL_INTERVAL_MS ?? "30000"), - maxCycles: parseOptionalPositiveInt(env.PIV_MAX_POLL_CYCLES), - exitWhenIdle: env.PIV_EXIT_WHEN_IDLE !== "0", - staleRunTimeoutMs: Number(env.PIV_STALE_RUN_TIMEOUT_MS ?? "3600000"), - }; -} - -function buildEnvNotifications(): ResolvedNotificationConfig { - const env = process.env; - return { - email: { - enabled: false, - resendApiKey: normalizeOptionalValue(env.RESEND_API_KEY), - from: normalizeOptionalValue(env.RESEND_FROM), - to: parseRecipientsFromEnv(env.RESEND_TO), - }, - }; -} - async function loadConfigOverride(cwd: string): Promise { for (const configFile of [DEFAULT_CONFIG_FILE, LEGACY_CONFIG_FILE]) { const configPath = path.join(cwd, configFile); @@ -179,611 +118,3 @@ function normalizeOverrideToRoot(override: AnyOverride): AdhdAiRootConfig { ], }; } - -function resolveProjects( - base: ProjectRuntimeConfig, - root: AdhdAiRootConfig, -): ResolvedProjectConfig[] { - const projectSpecs = - root.projects.length > 0 ? root.projects : [{ id: "default" }]; - const rootDefaults = stripProjects(root); - const resolved = projectSpecs.map((project) => - resolveProject(base, rootDefaults, project), - ); - return resolved; -} - -function stripProjects( - root: AdhdAiRootConfig, -): DeepPartial { - const { - projects: _, - polling: __, - cron: ___, - notifications: ____, - ...rest - } = root; - return rest; -} - -function resolvePolling( - base: PollingConfig, - override: DeepPartial | undefined, -): PollingConfig { - return { - ...base, - ...(override ?? {}), - }; -} - -function resolveCron( - override: DeepPartial | undefined, -): CronConfig { - const jobs = override?.jobs ?? []; - return { - jobs: jobs.map((job, index) => resolveCronJob(job, index)), - }; -} - -function resolveNotifications( - base: ResolvedNotificationConfig, - override: DeepPartial | undefined, -): ResolvedNotificationConfig { - const email = override?.email; - const resendApiKey = - typeof email?.resendApiKey === "string" - ? normalizeOptionalValue(email.resendApiKey) - : base.email.resendApiKey; - const from = - typeof email?.from === "string" - ? normalizeOptionalValue(email.from) - : base.email.from; - const to = normalizeRecipientsOverride(email?.to) ?? base.email.to; - const enabled = resolveNotificationEnabled(email?.enabled, resendApiKey); - - return { - email: { - enabled, - resendApiKey, - from, - to, - }, - }; -} - -function resolveNotificationEnabled( - input: unknown, - resendApiKey: string | undefined, -): boolean { - if (input === undefined) { - return Boolean(resendApiKey); - } - if (input === true) { - return true; - } - if (input === false) { - return false; - } - throw new Error("notifications.email.enabled must be a boolean"); -} - -function normalizeRecipientsOverride(input: unknown): string[] | undefined { - if (input === undefined) { - return undefined; - } - if (!Array.isArray(input)) { - throw new Error("notifications.email.to must be an array of email strings"); - } - - const recipients = input.map((value, index) => { - if (typeof value !== "string") { - throw new Error( - `notifications.email.to[${index}] must be an email string`, - ); - } - return value.trim(); - }); - return recipients.filter((recipient) => recipient.length > 0); -} - -function parseRecipientsFromEnv(input: string | undefined): string[] { - if (!input) { - return []; - } - return input - .split(",") - .map((value) => value.trim()) - .filter((value) => value.length > 0); -} - -function resolveCronJob( - job: DeepPartial, - index: number, -): CronJobConfig { - if (!job || typeof job !== "object") { - throw new Error(`cron.jobs[${index}] must be an object`); - } - if (typeof job.id !== "string" || job.id.trim() === "") { - throw new Error(`cron.jobs[${index}].id is required`); - } - - return { - id: job.id.trim(), - name: - typeof job.name === "string" && job.name.trim() - ? job.name.trim() - : undefined, - enabled: job.enabled === undefined ? true : job.enabled === true, - schedule: resolveCronSchedule(job.schedule, index), - run: resolveCronRun(job.run, index), - }; -} - -function resolveCronSchedule( - schedule: DeepPartial | undefined, - index: number, -): CronJobSchedule { - if (!schedule || typeof schedule !== "object") { - throw new Error(`cron.jobs[${index}].schedule is required`); - } - if (schedule.frequency === "minute") { - return { - frequency: "minute", - every: parseOptionalPositiveIntStrict( - schedule.every, - `cron.jobs[${index}].schedule.every`, - ), - }; - } - if (schedule.frequency === "hourly") { - return { - frequency: "hourly", - every: parseOptionalPositiveIntStrict( - schedule.every, - `cron.jobs[${index}].schedule.every`, - ), - minute: parseOptionalPositiveIntStrict( - schedule.minute, - `cron.jobs[${index}].schedule.minute`, - true, - ), - }; - } - if (schedule.frequency === "daily") { - if (typeof schedule.time !== "string") { - throw new Error(`cron.jobs[${index}].schedule.time is required`); - } - return { - frequency: "daily", - time: schedule.time, - }; - } - if (schedule.frequency === "weekly") { - if (typeof schedule.time !== "string") { - throw new Error(`cron.jobs[${index}].schedule.time is required`); - } - if (typeof schedule.dayOfWeek !== "string") { - throw new Error(`cron.jobs[${index}].schedule.dayOfWeek is required`); - } - return { - frequency: "weekly", - dayOfWeek: schedule.dayOfWeek as CronScheduleDayOfWeek, - time: schedule.time, - }; - } - - throw new Error( - `cron.jobs[${index}].schedule.frequency must be one of minute, hourly, daily, weekly`, - ); -} - -function resolveCronRun( - run: DeepPartial | undefined, - index: number, -): RunOptions { - if (!run || typeof run !== "object") { - return {}; - } - const projectId = - typeof run.projectId === "string" - ? normalizeOptionalValue(run.projectId) - : undefined; - const issueArg = - typeof run.issueArg === "string" - ? normalizeOptionalValue(run.issueArg) - : undefined; - const pollIntervalMs = parseOptionalPositiveIntStrict( - run.pollIntervalMs, - `cron.jobs[${index}].run.pollIntervalMs`, - ); - const maxPollCycles = parseOptionalPositiveIntStrict( - run.maxPollCycles, - `cron.jobs[${index}].run.maxPollCycles`, - ); - const exitWhenIdle = - run.exitWhenIdle === undefined - ? undefined - : run.exitWhenIdle === true - ? true - : run.exitWhenIdle === false - ? false - : invalidCronRunBoolean( - `cron.jobs[${index}].run.exitWhenIdle must be a boolean`, - ); - const allProjects = - run.allProjects === undefined - ? undefined - : run.allProjects === true - ? true - : run.allProjects === false - ? false - : invalidCronRunBoolean( - `cron.jobs[${index}].run.allProjects must be a boolean`, - ); - const poll = - run.poll === undefined - ? undefined - : run.poll === true - ? true - : run.poll === false - ? false - : invalidCronRunBoolean( - `cron.jobs[${index}].run.poll must be a boolean`, - ); - - return { - issueArg, - projectId, - allProjects, - poll, - pollIntervalMs, - maxPollCycles, - exitWhenIdle, - }; -} - -function invalidCronRunBoolean(_message: string): never { - throw new Error(_message); -} - -function parseOptionalPositiveIntStrict( - input: unknown, - field: string, - allowZero = false, -): number | undefined { - if (input === undefined) { - return undefined; - } - if (typeof input !== "number" || !Number.isInteger(input)) { - throw new Error(`${field} must be an integer`); - } - if (allowZero) { - if (input < 0) { - throw new Error(`${field} must be zero or a positive integer`); - } - return input; - } - if (input <= 0) { - throw new Error(`${field} must be a positive integer`); - } - return input; -} - -function resolveProject( - base: ProjectRuntimeConfig, - rootDefaults: DeepPartial, - project: ProjectConfig, -): ResolvedProjectConfig { - const mergedRuntime = mergeRuntime(base, rootDefaults, project); - const id = project.id.trim(); - const name = project.name?.trim() || id; - - return { - ...mergedRuntime, - id, - name, - }; -} - -function mergeRuntime( - base: ProjectRuntimeConfig, - rootDefaults: DeepPartial, - project: ProjectConfig, -): ProjectRuntimeConfig { - const workspacePath = - project.workspacePath ?? rootDefaults.workspacePath ?? base.workspacePath; - const executionPath = - project.executionPath ?? - rootDefaults.executionPath ?? - project.workspacePath ?? - rootDefaults.workspacePath ?? - base.executionPath; - - return { - workspacePath, - executionPath, - repo: { - ...base.repo, - ...(rootDefaults.repo ?? {}), - ...(project.repo ?? {}), - }, - linear: { - ...base.linear, - ...(rootDefaults.linear ?? {}), - ...(project.linear ?? {}), - statusMap: { - ...base.linear.statusMap, - ...(rootDefaults.linear?.statusMap ?? {}), - ...(project.linear?.statusMap ?? {}), - }, - labelMap: { - ...base.linear.labelMap, - ...(rootDefaults.linear?.labelMap ?? {}), - ...(project.linear?.labelMap ?? {}), - }, - }, - github: { - ...base.github, - ...(rootDefaults.github ?? {}), - ...(project.github ?? {}), - }, - codex: { - ...base.codex, - ...(rootDefaults.codex ?? {}), - ...(project.codex ?? {}), - }, - skills: { - ...base.skills, - ...(rootDefaults.skills ?? {}), - ...(project.skills ?? {}), - }, - dryRun: project.dryRun ?? rootDefaults.dryRun ?? base.dryRun, - }; -} - -function parseOptionalPositiveInt( - value: string | undefined, -): number | undefined { - if (!value) { - return undefined; - } - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed <= 0) { - return undefined; - } - return parsed; -} - -function validateProjects(projects: ResolvedProjectConfig[]): void { - if (projects.length === 0) { - throw new Error("At least one project configuration is required"); - } - - const seen = new Set(); - for (const project of projects) { - if (!project.id) { - throw new Error("Project id cannot be empty"); - } - if (seen.has(project.id)) { - throw new Error(`Duplicate project id: ${project.id}`); - } - seen.add(project.id); - validateProject(project); - } -} - -function normalizeOptionalValue(input: string | undefined): string | undefined { - if (!input) { - return undefined; - } - const value = input.trim(); - return value ? value : undefined; -} - -function normalizeSandboxValue( - input: string | undefined, -): ProjectRuntimeConfig["codex"]["sandbox"] | undefined { - if (!input) { - return undefined; - } - - const value = input.trim().toLowerCase(); - if (!value || value === "off" || value === "none" || value === "0") { - return undefined; - } - - if (value === "read-only") { - return "read-only"; - } - if (value === "workspace-write") { - return "workspace-write"; - } - if (value === "danger-full-access") { - return "danger-full-access"; - } - - throw new Error( - `Invalid CODEX_SANDBOX value '${input}'. Use read-only, workspace-write, danger-full-access, or leave empty.`, - ); -} - -function validateProject(project: ResolvedProjectConfig): void { - if (!project.linear.apiKey) { - throw new Error(`LINEAR_API_KEY is required for project '${project.id}'`); - } - if (!project.executionPath) { - throw new Error(`Execution path is required for project '${project.id}'`); - } - - const requiredStateIds = Object.entries(project.linear.statusMap).filter( - ([, value]) => !value, - ); - if (requiredStateIds.length > 0) { - throw new Error( - `Missing Linear status ids for project '${project.id}': ${requiredStateIds - .map(([key]) => key) - .join(", ")}`, - ); - } -} - -function validatePolling(polling: PollingConfig): void { - if (!Number.isInteger(polling.intervalMs) || polling.intervalMs <= 0) { - throw new Error("Polling interval must be a positive integer"); - } - if ( - polling.maxCycles !== undefined && - (!Number.isInteger(polling.maxCycles) || polling.maxCycles <= 0) - ) { - throw new Error("Polling max cycles must be a positive integer"); - } - if ( - !Number.isInteger(polling.staleRunTimeoutMs) || - polling.staleRunTimeoutMs <= 0 - ) { - throw new Error("Polling stale run timeout must be a positive integer"); - } -} - -function validateCron(cron: CronConfig): void { - const seen = new Set(); - for (const job of cron.jobs) { - if (seen.has(job.id)) { - throw new Error(`Duplicate cron job id: ${job.id}`); - } - seen.add(job.id); - validateCronSchedule(job.id, job.schedule); - validateCronRun(job.id, job.run); - } -} - -function validateNotifications( - notifications: ResolvedNotificationConfig, -): void { - const { email } = notifications; - if (!email.enabled) { - return; - } - if (!email.resendApiKey) { - throw new Error( - "notifications.email.resendApiKey (or RESEND_API_KEY) is required when email notifications are enabled", - ); - } - if (!email.from) { - throw new Error( - "notifications.email.from (or RESEND_FROM) is required when email notifications are enabled", - ); - } - if (email.to.length === 0) { - throw new Error( - "notifications.email.to (or RESEND_TO) must include at least one recipient when email notifications are enabled", - ); - } -} - -function validateCronSchedule(jobId: string, schedule: CronJobSchedule): void { - if (schedule.frequency === "minute") { - const every = schedule.every ?? 1; - if (!Number.isInteger(every) || every <= 0 || every > 59) { - throw new Error( - `Cron job '${jobId}' minute schedule.every must be between 1 and 59`, - ); - } - return; - } - if (schedule.frequency === "hourly") { - const every = schedule.every ?? 1; - const minute = schedule.minute ?? 0; - if (!Number.isInteger(every) || every <= 0 || every > 24) { - throw new Error( - `Cron job '${jobId}' hourly schedule.every must be between 1 and 24`, - ); - } - if (!Number.isInteger(minute) || minute < 0 || minute > 59) { - throw new Error( - `Cron job '${jobId}' hourly schedule.minute must be between 0 and 59`, - ); - } - return; - } - if (schedule.frequency === "daily") { - assertValidTime(jobId, schedule.time); - return; - } - assertValidDayOfWeek(jobId, schedule.dayOfWeek); - assertValidTime(jobId, schedule.time); -} - -function validateCronRun(jobId: string, run: RunOptions): void { - if (run.projectId && run.allProjects) { - throw new Error( - `Cron job '${jobId}' run cannot use projectId with allProjects`, - ); - } -} - -function assertValidTime(jobId: string, time: string): void { - if (!/^\d{2}:\d{2}$/.test(time)) { - throw new Error(`Cron job '${jobId}' time must be in HH:mm 24-hour format`); - } - const [hourRaw, minuteRaw] = time.split(":"); - const hour = Number(hourRaw); - const minute = Number(minuteRaw); - if ( - !Number.isInteger(hour) || - !Number.isInteger(minute) || - hour < 0 || - hour > 23 || - minute < 0 || - minute > 59 - ) { - throw new Error(`Cron job '${jobId}' time must be in HH:mm 24-hour format`); - } -} - -function assertValidDayOfWeek( - jobId: string, - dayOfWeek: CronScheduleDayOfWeek, -): void { - const allowed = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; - if (!allowed.includes(dayOfWeek)) { - throw new Error( - `Cron job '${jobId}' weekly dayOfWeek must be one of: ${allowed.join(", ")}`, - ); - } -} - -function assertNoProjectPolling(projects: ProjectConfig[]): void { - for (const project of projects) { - if ("polling" in (project as unknown as Record)) { - throw new Error( - `Project-level polling config is not supported for project '${project.id}'. Configure polling once at root level.`, - ); - } - } -} - -function assertNoProjectNotifications(projects: ProjectConfig[]): void { - for (const project of projects) { - if ("notifications" in (project as unknown as Record)) { - throw new Error( - `Project-level notifications config is not supported for project '${project.id}'. Configure notifications once at root level.`, - ); - } - } -} - -function normalizeAgentBackend( - value: string | undefined, -): "codex" | "claude-code" | undefined { - if (!value) { - return undefined; - } - const normalized = value.trim().toLowerCase(); - if (normalized === "codex" || normalized === "claude-code") { - return normalized; - } - throw new Error( - `Invalid AGENT_BACKEND value: '${value}'. Must be 'codex' or 'claude-code'.`, - ); -} diff --git a/src/core/config/env.ts b/src/core/config/env.ts new file mode 100644 index 00000000..611a7aea --- /dev/null +++ b/src/core/config/env.ts @@ -0,0 +1,174 @@ +/** + * Environment variable resolution for ADHD.ai configuration. + * Reads env vars and converts to strongly-typed config values. + */ + +import path from "node:path"; +import type { + PollingConfig, + ProjectRuntimeConfig, + ResolvedNotificationConfig, +} from "../types"; + +export function buildEnvBase(cwd: string): ProjectRuntimeConfig { + const env = process.env; + const workspacePath = env.PIV_WORKSPACE_PATH ?? cwd; + const sandbox = normalizeSandboxValue(env.CODEX_SANDBOX); + const codexHome = normalizeOptionalValue(env.CODEX_HOME); + + return { + workspacePath, + executionPath: env.PIV_EXECUTION_PATH ?? workspacePath, + repo: { + owner: env.GITHUB_REPO_OWNER ?? "", + name: env.GITHUB_REPO_NAME ?? "", + baseBranch: env.GITHUB_BASE_BRANCH ?? "main", + }, + linear: { + apiKey: env.LINEAR_API_KEY ?? "", + apiUrl: env.LINEAR_API_URL ?? "https://api.linear.app/graphql", + projectId: normalizeOptionalValue(env.LINEAR_PROJECT_ID), + teamId: env.LINEAR_TEAM_ID, + requiredLabel: env.LINEAR_REQUIRED_LABEL, + pollLimit: Number(env.PIV_POLL_LIMIT ?? "10"), + statusMap: { + assigned: env.LINEAR_STATUS_ASSIGNED ?? "Todo", + planning: env.LINEAR_STATUS_PLANNING ?? "In Progress", + implementing: env.LINEAR_STATUS_IMPLEMENTING ?? "In Progress", + pr_created: env.LINEAR_STATUS_PR_CREATED ?? "In Review", + reviewing: env.LINEAR_STATUS_REVIEWING ?? "In Review", + testing: env.LINEAR_STATUS_TESTING ?? "In Review", + blocked: env.LINEAR_STATUS_BLOCKED ?? "Canceled", + done: env.LINEAR_STATUS_DONE ?? "Done", + }, + labelMap: { + pr_created: env.LINEAR_LABEL_PR_CREATED ?? "PR Created", + reviewing: env.LINEAR_LABEL_REVIEWING ?? "Reviewing", + testing: env.LINEAR_LABEL_TESTING ?? "Testing", + }, + autoCreateLabels: env.LINEAR_AUTO_CREATE_LABELS !== "0", + }, + github: { + useGhCli: true, + defaultBugLabel: env.GITHUB_BUG_LABEL ?? "bug", + }, + codex: { + binary: env.CODEX_BINARY ?? "codex", + streamLogs: env.PIV_DEV_MODE === "1" || env.PIV_PRINT_CODEX_LOGS === "1", + model: env.CODEX_MODEL, + models: { + plan: env.CODEX_MODEL_PLAN, + implement: env.CODEX_MODEL_IMPLEMENT, + reviewTest: env.CODEX_MODEL_REVIEW_TEST, + }, + sandbox, + codexHome, + }, + skills: { + plan: path.join(cwd, "skills", "piv-plan", "SKILL.md"), + implement: path.join(cwd, "skills", "piv-implement", "SKILL.md"), + reviewTest: path.join(cwd, "skills", "piv-review-test", "SKILL.md"), + }, + agent: { + backend: normalizeAgentBackend(env.AGENT_BACKEND), + }, + dryRun: env.PIV_DRY_RUN === "1", + }; +} + +export function buildEnvPolling(): PollingConfig { + const env = process.env; + return { + intervalMs: Number(env.PIV_POLL_INTERVAL_MS ?? "30000"), + maxCycles: parseOptionalPositiveInt(env.PIV_MAX_POLL_CYCLES), + exitWhenIdle: env.PIV_EXIT_WHEN_IDLE !== "0", + staleRunTimeoutMs: Number(env.PIV_STALE_RUN_TIMEOUT_MS ?? "3600000"), + }; +} + +export function buildEnvNotifications(): ResolvedNotificationConfig { + const env = process.env; + return { + email: { + enabled: false, + resendApiKey: normalizeOptionalValue(env.RESEND_API_KEY), + from: normalizeOptionalValue(env.RESEND_FROM), + to: parseRecipientsFromEnv(env.RESEND_TO), + }, + }; +} + +export function normalizeOptionalValue( + input: string | undefined, +): string | undefined { + if (!input) { + return undefined; + } + const value = input.trim(); + return value ? value : undefined; +} + +export function normalizeSandboxValue( + input: string | undefined, +): ProjectRuntimeConfig["codex"]["sandbox"] | undefined { + if (!input) { + return undefined; + } + + const value = input.trim().toLowerCase(); + if (!value || value === "off" || value === "none" || value === "0") { + return undefined; + } + + if (value === "read-only") { + return "read-only"; + } + if (value === "workspace-write") { + return "workspace-write"; + } + if (value === "danger-full-access") { + return "danger-full-access"; + } + + throw new Error( + `Invalid CODEX_SANDBOX value '${input}'. Use read-only, workspace-write, danger-full-access, or leave empty.`, + ); +} + +export function normalizeAgentBackend( + value: string | undefined, +): "codex" | "claude-code" | undefined { + if (!value) { + return undefined; + } + const normalized = value.trim().toLowerCase(); + if (normalized === "codex" || normalized === "claude-code") { + return normalized; + } + throw new Error( + `Invalid AGENT_BACKEND value: '${value}'. Must be 'codex' or 'claude-code'.`, + ); +} + +export function parseRecipientsFromEnv(input: string | undefined): string[] { + if (!input) { + return []; + } + return input + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0); +} + +export function parseOptionalPositiveInt( + value: string | undefined, +): number | undefined { + if (!value) { + return undefined; + } + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + return undefined; + } + return parsed; +} diff --git a/src/core/config/index.ts b/src/core/config/index.ts new file mode 100644 index 00000000..56e7d5b4 --- /dev/null +++ b/src/core/config/index.ts @@ -0,0 +1,7 @@ +/** + * Config module exports for ADHD.ai. + */ + +export * from "./env"; +export * from "./validation"; +export * from "./merging"; diff --git a/src/core/config/merging.ts b/src/core/config/merging.ts new file mode 100644 index 00000000..524ee017 --- /dev/null +++ b/src/core/config/merging.ts @@ -0,0 +1,394 @@ +/** + * Configuration merging utilities for ADHD.ai. + * Handles deep merging of config overrides and project resolution. + */ + +import type { + AdhdAiRootConfig, + CronConfig, + CronJobConfig, + CronJobSchedule, + CronScheduleDayOfWeek, + DeepPartial, + NotificationConfig, + PollingConfig, + ProjectConfig, + ProjectRuntimeConfig, + ResolvedNotificationConfig, + ResolvedProjectConfig, + RunOptions, +} from "../types"; + +export function resolveProjects( + base: ProjectRuntimeConfig, + root: AdhdAiRootConfig, +): ResolvedProjectConfig[] { + const projectSpecs = + root.projects.length > 0 ? root.projects : [{ id: "default" }]; + const rootDefaults = stripProjects(root); + const resolved = projectSpecs.map((project) => + resolveProject(base, rootDefaults, project), + ); + return resolved; +} + +export function stripProjects( + root: AdhdAiRootConfig, +): DeepPartial { + const { + projects: _, + polling: __, + cron: ___, + notifications: ____, + ...rest + } = root; + return rest; +} + +export function resolveProject( + base: ProjectRuntimeConfig, + rootDefaults: DeepPartial, + project: ProjectConfig, +): ResolvedProjectConfig { + const mergedRuntime = mergeRuntime(base, rootDefaults, project); + const id = project.id.trim(); + const name = project.name?.trim() || id; + + return { + ...mergedRuntime, + id, + name, + }; +} + +export function mergeRuntime( + base: ProjectRuntimeConfig, + rootDefaults: DeepPartial, + project: ProjectConfig, +): ProjectRuntimeConfig { + const workspacePath = + project.workspacePath ?? rootDefaults.workspacePath ?? base.workspacePath; + const executionPath = + project.executionPath ?? + rootDefaults.executionPath ?? + project.workspacePath ?? + rootDefaults.workspacePath ?? + base.executionPath; + + return { + workspacePath, + executionPath, + repo: { + ...base.repo, + ...(rootDefaults.repo ?? {}), + ...(project.repo ?? {}), + }, + linear: { + ...base.linear, + ...(rootDefaults.linear ?? {}), + ...(project.linear ?? {}), + statusMap: { + ...base.linear.statusMap, + ...(rootDefaults.linear?.statusMap ?? {}), + ...(project.linear?.statusMap ?? {}), + }, + labelMap: { + ...base.linear.labelMap, + ...(rootDefaults.linear?.labelMap ?? {}), + ...(project.linear?.labelMap ?? {}), + }, + }, + github: { + ...base.github, + ...(rootDefaults.github ?? {}), + ...(project.github ?? {}), + }, + codex: { + ...base.codex, + ...(rootDefaults.codex ?? {}), + ...(project.codex ?? {}), + }, + skills: { + ...base.skills, + ...(rootDefaults.skills ?? {}), + ...(project.skills ?? {}), + }, + agent: { + ...base.agent, + ...(rootDefaults.agent ?? {}), + ...(project.agent ?? {}), + }, + dryRun: project.dryRun ?? rootDefaults.dryRun ?? base.dryRun, + }; +} + +export function resolvePolling( + envPolling: { + intervalMs: number; + maxCycles?: number; + exitWhenIdle?: boolean; + staleRunTimeoutMs?: number; + }, + rootPolling?: DeepPartial, +): PollingConfig { + return { + intervalMs: envPolling.intervalMs, + maxCycles: rootPolling?.maxCycles ?? envPolling.maxCycles ?? undefined, + exitWhenIdle: rootPolling?.exitWhenIdle ?? envPolling.exitWhenIdle ?? true, + staleRunTimeoutMs: + rootPolling?.staleRunTimeoutMs ?? envPolling.staleRunTimeoutMs ?? 3600000, + }; +} + +export function resolveCron(override?: DeepPartial): CronConfig { + const jobs = override?.jobs ?? []; + return { + jobs: jobs.map((job, index) => resolveCronJob(job, index)), + }; +} + +export function resolveNotifications( + base: ResolvedNotificationConfig, + override?: DeepPartial, +): ResolvedNotificationConfig { + const email = override?.email; + const resendApiKey = + typeof email?.resendApiKey === "string" + ? normalizeOptionalString(email.resendApiKey) + : base.email.resendApiKey; + const from = + typeof email?.from === "string" + ? normalizeOptionalString(email.from) + : base.email.from; + const to = normalizeRecipientsOverride(email?.to) ?? base.email.to; + const enabled = resolveNotificationEnabled(email?.enabled, resendApiKey); + + return { + email: { + enabled, + resendApiKey, + from, + to, + }, + }; +} + +export function resolveNotificationEnabled( + input: unknown, + resendApiKey: string | undefined, +): boolean { + if (input === undefined) { + return Boolean(resendApiKey); + } + if (input === true) { + return true; + } + if (input === false) { + return false; + } + throw new Error("notifications.email.enabled must be a boolean"); +} + +export function normalizeRecipientsOverride( + input: unknown, +): string[] | undefined { + if (input === undefined) { + return undefined; + } + if (!Array.isArray(input)) { + throw new Error("notifications.email.to must be an array of email strings"); + } + + const recipients = input.map((value, index) => { + if (typeof value !== "string") { + throw new Error( + `notifications.email.to[${index}] must be an email string`, + ); + } + return value.trim(); + }); + return recipients.filter((recipient) => recipient.length > 0); +} + +export function resolveCronJob( + job: DeepPartial, + index: number, +): CronJobConfig { + if (!job || typeof job !== "object") { + throw new Error(`cron.jobs[${index}] must be an object`); + } + if (typeof job.id !== "string" || job.id.trim() === "") { + throw new Error(`cron.jobs[${index}].id is required`); + } + + return { + id: job.id.trim(), + name: + typeof job.name === "string" && job.name.trim() + ? job.name.trim() + : undefined, + enabled: job.enabled === undefined ? true : job.enabled === true, + schedule: resolveCronSchedule(job.schedule, index), + run: resolveCronRun(job.run, index), + }; +} + +export function resolveCronSchedule( + schedule: DeepPartial | undefined, + index: number, +): CronJobSchedule { + if (!schedule || typeof schedule !== "object") { + throw new Error(`cron.jobs[${index}].schedule is required`); + } + if (schedule.frequency === "minute") { + return { + frequency: "minute", + every: parseOptionalPositiveIntStrict( + schedule.every, + `cron.jobs[${index}].schedule.every`, + ), + }; + } + if (schedule.frequency === "hourly") { + return { + frequency: "hourly", + every: parseOptionalPositiveIntStrict( + schedule.every, + `cron.jobs[${index}].schedule.every`, + ), + minute: parseOptionalPositiveIntStrict( + schedule.minute, + `cron.jobs[${index}].schedule.minute`, + true, + ), + }; + } + if (schedule.frequency === "daily") { + if (typeof schedule.time !== "string") { + throw new Error(`cron.jobs[${index}].schedule.time is required`); + } + return { + frequency: "daily", + time: schedule.time, + }; + } + if (schedule.frequency === "weekly") { + if (typeof schedule.time !== "string") { + throw new Error(`cron.jobs[${index}].schedule.time is required`); + } + if (typeof schedule.dayOfWeek !== "string") { + throw new Error(`cron.jobs[${index}].schedule.dayOfWeek is required`); + } + return { + frequency: "weekly", + dayOfWeek: schedule.dayOfWeek as CronScheduleDayOfWeek, + time: schedule.time, + }; + } + + throw new Error( + `cron.jobs[${index}].schedule.frequency must be one of minute, hourly, daily, weekly`, + ); +} + +export function resolveCronRun( + run: DeepPartial | undefined, + index: number, +): RunOptions { + if (!run || typeof run !== "object") { + return {}; + } + const projectId = + typeof run.projectId === "string" + ? normalizeOptionalString(run.projectId) + : undefined; + const issueArg = + typeof run.issueArg === "string" + ? normalizeOptionalString(run.issueArg) + : undefined; + const pollIntervalMs = parseOptionalPositiveIntStrict( + run.pollIntervalMs, + `cron.jobs[${index}].run.pollIntervalMs`, + ); + const maxPollCycles = parseOptionalPositiveIntStrict( + run.maxPollCycles, + `cron.jobs[${index}].run.maxPollCycles`, + ); + const exitWhenIdle = + run.exitWhenIdle === undefined + ? undefined + : run.exitWhenIdle === true + ? true + : run.exitWhenIdle === false + ? false + : invalidCronRunBoolean( + `cron.jobs[${index}].run.exitWhenIdle must be a boolean`, + ); + const allProjects = + run.allProjects === undefined + ? undefined + : run.allProjects === true + ? true + : run.allProjects === false + ? false + : invalidCronRunBoolean( + `cron.jobs[${index}].run.allProjects must be a boolean`, + ); + const poll = + run.poll === undefined + ? undefined + : run.poll === true + ? true + : run.poll === false + ? false + : invalidCronRunBoolean( + `cron.jobs[${index}].run.poll must be a boolean`, + ); + + return { + issueArg, + projectId, + allProjects, + poll, + pollIntervalMs, + maxPollCycles, + exitWhenIdle, + }; +} + +function invalidCronRunBoolean(_message: string): never { + throw new Error(_message); +} + +function parseOptionalPositiveIntStrict( + input: unknown, + field: string, + allowZero = false, +): number | undefined { + if (input === undefined) { + return undefined; + } + if (typeof input !== "number" || !Number.isInteger(input)) { + throw new Error(`${field} must be an integer`); + } + if (allowZero) { + if (input < 0) { + throw new Error(`${field} must be zero or a positive integer`); + } + return input; + } + if (input <= 0) { + throw new Error(`${field} must be a positive integer`); + } + return input; +} + +function normalizeOptionalString( + input: string | undefined, +): string | undefined { + if (!input) { + return undefined; + } + const value = input.trim(); + return value ? value : undefined; +} diff --git a/src/core/config/validation.ts b/src/core/config/validation.ts new file mode 100644 index 00000000..4e0c7df5 --- /dev/null +++ b/src/core/config/validation.ts @@ -0,0 +1,230 @@ +/** + * Configuration validation utilities for ADHD.ai. + * Validates all resolved config values and throws descriptive errors for invalid inputs. + */ + +import type { + CronConfig, + CronJobConfig, + CronJobSchedule, + CronScheduleDayOfWeek, + ResolvedNotificationConfig, + ResolvedProjectConfig, + RunOptions, +} from "../types"; + +export function validateProjects(projects: ResolvedProjectConfig[]): void { + if (projects.length === 0) { + throw new Error("At least one project configuration is required"); + } + + const seen = new Set(); + for (const project of projects) { + if (!project.id) { + throw new Error("Project id cannot be empty"); + } + if (seen.has(project.id)) { + throw new Error(`Duplicate project id: ${project.id}`); + } + seen.add(project.id); + validateProject(project); + } +} + +export function validateProject(project: ResolvedProjectConfig): void { + if (!project.linear.apiKey) { + throw new Error(`LINEAR_API_KEY is required for project '${project.id}'`); + } + if (!project.executionPath) { + throw new Error(`Execution path is required for project '${project.id}'`); + } + + const requiredStateIds = Object.entries(project.linear.statusMap).filter( + ([, value]) => !value, + ); + if (requiredStateIds.length > 0) { + throw new Error( + `Missing Linear status ids for project '${project.id}': ${requiredStateIds + .map(([key]) => key) + .join(", ")}`, + ); + } +} + +export function validatePolling(polling: { + intervalMs: number; + maxCycles?: number; + staleRunTimeoutMs: number; +}): void { + if (!Number.isInteger(polling.intervalMs) || polling.intervalMs <= 0) { + throw new Error("Polling interval must be a positive integer"); + } + if ( + polling.maxCycles !== undefined && + (!Number.isInteger(polling.maxCycles) || polling.maxCycles <= 0) + ) { + throw new Error("Polling max cycles must be a positive integer"); + } + if ( + !Number.isInteger(polling.staleRunTimeoutMs) || + polling.staleRunTimeoutMs <= 0 + ) { + throw new Error("Polling stale run timeout must be a positive integer"); + } +} + +export function validateCron(cron: CronConfig): void { + const seen = new Set(); + for (const job of cron.jobs) { + if (seen.has(job.id)) { + throw new Error(`Duplicate cron job id: ${job.id}`); + } + seen.add(job.id); + validateCronSchedule(job.id, job.schedule); + validateCronRun(job.id, job.run); + } +} + +export function validateCronSchedule( + jobId: string, + schedule: CronJobSchedule, +): void { + if (schedule.frequency === "minute") { + const every = schedule.every ?? 1; + if (!Number.isInteger(every) || every <= 0 || every > 59) { + throw new Error( + `Cron job '${jobId}' minute schedule.every must be between 1 and 59`, + ); + } + return; + } + if (schedule.frequency === "hourly") { + const every = schedule.every ?? 1; + const minute = schedule.minute ?? 0; + if (!Number.isInteger(every) || every <= 0 || every > 24) { + throw new Error( + `Cron job '${jobId}' hourly schedule.every must be between 1 and 24`, + ); + } + if (!Number.isInteger(minute) || minute < 0 || minute > 59) { + throw new Error( + `Cron job '${jobId}' hourly schedule.minute must be between 0 and 59`, + ); + } + return; + } + if (schedule.frequency === "daily") { + assertValidTime(jobId, schedule.time); + return; + } + assertValidDayOfWeek(jobId, schedule.dayOfWeek); + assertValidTime(jobId, schedule.time); +} + +export function validateCronRun(jobId: string, run: RunOptions): void { + if (run.projectId && run.allProjects) { + throw new Error( + `Cron job '${jobId}' run cannot use projectId with allProjects`, + ); + } +} + +export function validateNotifications( + notifications: ResolvedNotificationConfig, +): void { + const { email } = notifications; + if (!email.enabled) { + return; + } + if (!email.resendApiKey) { + throw new Error( + "notifications.email.resendApiKey (or RESEND_API_KEY) is required when email notifications are enabled", + ); + } + if (!email.from) { + throw new Error( + "notifications.email.from (or RESEND_FROM) is required when email notifications are enabled", + ); + } + if (email.to.length === 0) { + throw new Error( + "notifications.email.to (or RESEND_TO) must include at least one recipient when email notifications are enabled", + ); + } +} + +export function assertNoProjectPolling(projects: Array<{ id?: string }>): void { + for (const project of projects) { + if ("polling" in (project as unknown as Record)) { + throw new Error( + `Project-level polling config is not supported for project '${project.id}'. Configure polling once at root level.`, + ); + } + } +} + +export function assertNoProjectNotifications( + projects: Array<{ id?: string }>, +): void { + for (const project of projects) { + if ("notifications" in (project as unknown as Record)) { + throw new Error( + `Project-level notifications config is not supported for project '${project.id}'. Configure notifications once at root level.`, + ); + } + } +} + +export function parseOptionalPositiveIntStrict( + input: unknown, + field: string, + allowZero = false, +): number | undefined { + if (input === undefined) { + return undefined; + } + if (typeof input !== "number" || !Number.isInteger(input)) { + throw new Error(`${field} must be an integer`); + } + if (allowZero) { + if (input < 0) { + throw new Error(`${field} must be zero or a positive integer`); + } + return input; + } + if (input <= 0) { + throw new Error(`${field} must be a positive integer`); + } + return input; +} + +function assertValidTime(jobId: string, time: string): void { + if (!/^\d{2}:\d{2}$/.test(time)) { + throw new Error(`Cron job '${jobId}' time must be in HH:mm 24-hour format`); + } + const [hourRaw, minuteRaw] = time.split(":"); + const hour = Number(hourRaw); + const minute = Number(minuteRaw); + if ( + !Number.isInteger(hour) || + !Number.isInteger(minute) || + hour < 0 || + hour > 23 || + minute < 0 || + minute > 59 + ) { + throw new Error(`Cron job '${jobId}' time must be in HH:mm 24-hour format`); + } +} + +function assertValidDayOfWeek( + jobId: string, + dayOfWeek: CronScheduleDayOfWeek, +): void { + const allowed = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; + if (!allowed.includes(dayOfWeek)) { + throw new Error( + `Cron job '${jobId}' weekly dayOfWeek must be one of: ${allowed.join(", ")}`, + ); + } +} diff --git a/src/core/errors.ts b/src/core/errors.ts new file mode 100644 index 00000000..a193289e --- /dev/null +++ b/src/core/errors.ts @@ -0,0 +1,204 @@ +/** + * Custom error classes for ADHD.ai with context preservation. + * Replaces silent error handling with typed, context-aware errors. + */ + +/** + * Base error class for all ADHD.ai errors. + * Preserves context about where and why the error occurred. + */ +export class ADHDAiError extends Error { + public readonly code: string; + public readonly context?: Record; + public readonly cause?: Error; + + constructor( + code: string, + message: string, + options?: { context?: Record; cause?: Error }, + ) { + super(message); + this.name = "ADHDAiError"; + this.code = code; + this.context = options?.context; + this.cause = options?.cause; + + // Maintains proper stack trace + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } + + /** + * Get a string representation of the error with context. + */ + public toString(): string { + let result = `[${this.code}] ${this.message}`; + if (this.context && Object.keys(this.context).length > 0) { + const contextStr = JSON.stringify(this.context); + result += ` (context: ${contextStr})`; + } + if (this.cause) { + result += `\nCaused by: ${this.cause.message}`; + } + return result; + } +} + +/** + * Error for configuration-related issues. + * Includes details about which config key or environment variable failed. + */ +export class ConfigError extends ADHDAiError { + constructor( + message: string, + options?: { + configKey?: string; + envVar?: string; + projectId?: string; + cause?: Error; + }, + ) { + const context: Record = {}; + if (options?.configKey) context.configKey = options.configKey; + if (options?.envVar) context.envVar = options.envVar; + if (options?.projectId) context.projectId = options.projectId; + + super("CONFIG_ERROR", message, { context, cause: options?.cause }); + this.name = "ConfigError"; + } +} + +/** + * Error for state persistence and retrieval issues. + * Covers run-state file I/O and legacy fallback scenarios. + */ +export class StateError extends ADHDAiError { + constructor( + message: string, + options?: { + projectId?: string; + issueKey?: string; + filePath?: string; + cause?: Error; + }, + ) { + const context: Record = {}; + if (options?.projectId) context.projectId = options.projectId; + if (options?.issueKey) context.issueKey = options.issueKey; + if (options?.filePath) context.filePath = options.filePath; + + super("STATE_ERROR", message, { context, cause: options?.cause }); + this.name = "StateError"; + } +} + +/** + * Error for external integration failures. + * Covers Linear, GitHub, Codex, and notification service errors. + */ +export class IntegrationError extends ADHDAiError { + constructor( + message: string, + options?: { + service?: "linear" | "github" | "codex" | "claude-code" | "notifications"; + issueId?: string; + projectId?: string; + retryable?: boolean; + cause?: Error; + }, + ) { + const context: Record = {}; + if (options?.service) context.service = options.service; + if (options?.issueId) context.issueId = options.issueId; + if (options?.projectId) context.projectId = options.projectId; + if (options?.retryable !== undefined) context.retryable = options.retryable; + + super("INTEGRATION_ERROR", message, { context, cause: options?.cause }); + this.name = "IntegrationError"; + this.retryable = options?.retryable ?? false; + } + + public readonly retryable: boolean; +} + +/** + * Error for workflow execution issues. + * Covers stage transitions, agent execution, and retry logic. + */ +export class WorkflowError extends ADHDAiError { + constructor( + message: string, + options?: { + projectId?: string; + issueKey?: string; + stage?: string; + retryable?: boolean; + cause?: Error; + }, + ) { + const context: Record = {}; + if (options?.projectId) context.projectId = options.projectId; + if (options?.issueKey) context.issueKey = options.issueKey; + if (options?.stage) context.stage = options.stage; + if (options?.retryable !== undefined) context.retryable = options.retryable; + + super("WORKFLOW_ERROR", message, { context, cause: options?.cause }); + this.name = "WorkflowError"; + this.retryable = options?.retryable ?? false; + } + + public readonly retryable: boolean; +} + +/** + * Error for validation failures. + * Covers input validation and schema violations. + */ +export class ValidationError extends ADHDAiError { + constructor( + message: string, + options?: { + field?: string; + value?: unknown; + constraint?: string; + cause?: Error; + }, + ) { + const context: Record = {}; + if (options?.field) context.field = options.field; + if (options?.value !== undefined) context.value = options.value; + if (options?.constraint) context.constraint = options.constraint; + + super("VALIDATION_ERROR", message, { context, cause: options?.cause }); + this.name = "ValidationError"; + } +} + +/** + * Helper to wrap errors with ADHDAiError type. + * Preserves the original error as cause. + */ +export function wrapError( + original: unknown, + code: string, + message: string, + options?: { context?: Record }, +): ADHDAiError { + if (original instanceof ADHDAiError) { + return original; + } + if (original instanceof Error) { + return new ADHDAiError(code, message, { + ...options, + cause: original, + }); + } + return new ADHDAiError(code, message, { + ...options, + context: { + ...options?.context, + originalError: String(original), + }, + }); +} diff --git a/src/services/claude-code-adapter.ts b/src/services/claude-code-adapter.ts index 0e8ff9dd..07111fe0 100644 --- a/src/services/claude-code-adapter.ts +++ b/src/services/claude-code-adapter.ts @@ -1,7 +1,10 @@ -import { mkdir, readFile } from "node:fs/promises"; -import path from "node:path"; import type { AgentAdapter, AgentResult } from "../core/agent-adapter"; import type { ResolvedProjectConfig } from "../core/types"; +import { + extractFinalMessage, + extractSessionId, + extractUsage, +} from "../utils/parsing"; import { assertCommandOk, runCommand } from "../utils/shell"; export class ClaudeCodeAdapter implements AgentAdapter { @@ -20,15 +23,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { } private async runClaude(prompt: string): Promise { - const outputFile = await this.nextOutputFile(); - const args = [ - "-p", - prompt, - "--output-format", - "json", - "--output-file", - outputFile, - ]; + const args = ["-p", prompt, "--output-format", "json"]; const result = await runCommand("claude", args, { cwd: this.config.executionPath, @@ -38,7 +33,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { }); assertCommandOk("claude", args, result); - const finalMessage = await readOutputFile(outputFile); + const finalMessage = extractFinalMessage(result.stdout); const sessionId = extractSessionId(result.stdout); const usage = extractUsage(result.stdout); @@ -51,15 +46,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { } private async runClaudeContinue(prompt: string): Promise { - const outputFile = await this.nextOutputFile(); - const args = [ - "--continue", - prompt, - "--output-format", - "json", - "--output-file", - outputFile, - ]; + const args = ["--continue", prompt, "--output-format", "json"]; const result = await runCommand("claude", args, { cwd: this.config.executionPath, @@ -69,7 +56,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { }); assertCommandOk("claude", args, result); - const finalMessage = await readOutputFile(outputFile); + const finalMessage = extractFinalMessage(result.stdout); const sessionId = extractSessionId(result.stdout); const usage = extractUsage(result.stdout); @@ -80,57 +67,4 @@ export class ClaudeCodeAdapter implements AgentAdapter { usage, }; } - - private async nextOutputFile(): Promise { - const dir = path.join(this.config.workspacePath, ".piv-loop", "tmp"); - await mkdir(dir, { recursive: true }); - return path.join( - dir, - `claude-output-${Date.now()}-${Math.floor(Math.random() * 10000)}.txt`, - ); - } -} - -async function readOutputFile(file: string): Promise { - try { - return (await readFile(file, "utf8")).trim(); - } catch { - return ""; - } -} - -function extractSessionId(jsonOutput: string): string | undefined { - try { - const parsed = JSON.parse(jsonOutput) as Record; - const id = - parsed.session_id ?? - parsed.sessionId ?? - parsed.conversation_id ?? - parsed.conversationId; - if (typeof id === "string") { - return id; - } - } catch {} - return undefined; -} - -function extractUsage(jsonOutput: string): AgentResult["usage"] | undefined { - try { - const parsed = JSON.parse(jsonOutput) as Record; - const usage = parsed.usage as Record | undefined; - if (!usage) { - return undefined; - } - return { - inputTokens: - typeof usage.input_tokens === "number" ? usage.input_tokens : undefined, - outputTokens: - typeof usage.output_tokens === "number" - ? usage.output_tokens - : undefined, - totalTokens: - typeof usage.total_tokens === "number" ? usage.total_tokens : undefined, - }; - } catch {} - return undefined; } diff --git a/src/services/codex-adapter.ts b/src/services/codex-adapter.ts index 324fc0e9..416555db 100644 --- a/src/services/codex-adapter.ts +++ b/src/services/codex-adapter.ts @@ -2,6 +2,7 @@ import { mkdir, readFile } from "node:fs/promises"; import path from "node:path"; import type { AgentAdapter, AgentResult } from "../core/agent-adapter"; import type { ResolvedProjectConfig } from "../core/types"; +import { extractSessionId, extractUsage } from "../utils/parsing"; import { assertCommandOk, runCommand } from "../utils/shell"; export class CodexAdapter implements AgentAdapter { @@ -161,131 +162,6 @@ async function readOutputFile(file: string): Promise { } } -export function extractSessionId(jsonlOutput: string): string | undefined { - const lines = jsonlOutput.split("\n").filter(Boolean); - for (const line of lines) { - try { - const parsed = JSON.parse(line) as unknown; - const id = findStringByKey(parsed, [ - "session_id", - "sessionId", - "thread_id", - "threadId", - "conversation_id", - "conversationId", - ]); - if (id) { - return id; - } - } catch {} - } - return undefined; -} - -export function extractUsage( - jsonlOutput: string, -): AgentResult["usage"] | undefined { - const lines = jsonlOutput.split("\n").filter(Boolean); - let latestUsage: AgentResult["usage"] | undefined; - for (const line of lines) { - try { - const parsed = JSON.parse(line) as unknown; - const usage = findUsageObject(parsed); - if (usage) { - latestUsage = usage; - } - } catch {} - } - return latestUsage; -} - -function findUsageObject(value: unknown): AgentResult["usage"] | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - const asRecord = value as Record; - const usage = buildUsageFromRecord(asRecord); - if (usage) { - return usage; - } - for (const nested of Object.values(asRecord)) { - const found = findUsageObject(nested); - if (found) { - return found; - } - } - return undefined; -} - -function buildUsageFromRecord( - record: Record, -): AgentResult["usage"] | undefined { - const inputTokens = findNumberByKey(record, [ - "input_tokens", - "inputTokens", - "prompt_tokens", - "promptTokens", - ]); - const outputTokens = findNumberByKey(record, [ - "output_tokens", - "outputTokens", - "completion_tokens", - "completionTokens", - ]); - const totalTokens = findNumberByKey(record, ["total_tokens", "totalTokens"]); - - if ( - inputTokens === undefined && - outputTokens === undefined && - totalTokens === undefined - ) { - return undefined; - } - - return { - inputTokens, - outputTokens, - totalTokens: - totalTokens ?? - (inputTokens !== undefined || outputTokens !== undefined - ? (inputTokens ?? 0) + (outputTokens ?? 0) - : undefined), - }; -} - -function findNumberByKey( - record: Record, - keys: string[], -): number | undefined { - for (const key of keys) { - const candidate = record[key]; - if (typeof candidate === "number" && Number.isFinite(candidate)) { - return candidate; - } - } - return undefined; -} - -function findStringByKey(value: unknown, keys: string[]): string | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - const asRecord = value as Record; - for (const key of keys) { - const candidate = asRecord[key]; - if (typeof candidate === "string" && candidate.length > 0) { - return candidate; - } - } - for (const nested of Object.values(asRecord)) { - const id = findStringByKey(nested, keys); - if (id) { - return id; - } - } - return undefined; -} - function normalizeList(values: string[] | undefined): string[] { if (!values) { return []; diff --git a/src/utils/parsing.ts b/src/utils/parsing.ts new file mode 100644 index 00000000..b75f4990 --- /dev/null +++ b/src/utils/parsing.ts @@ -0,0 +1,217 @@ +/** + * Shared JSON/JSONL parsing utilities for agent adapters and other modules. + * Consolidates duplicate extraction logic from Codex and Claude Code adapters. + */ + +import type { AgentAdapter, AgentResult } from "../core/agent-adapter"; + +const SESSION_ID_KEYS = [ + "session_id", + "sessionId", + "thread_id", + "threadId", + "conversation_id", + "conversationId", +] as const; + +const INPUT_TOKEN_KEYS = [ + "input_tokens", + "inputTokens", + "prompt_tokens", + "promptTokens", +] as const; + +const OUTPUT_TOKEN_KEYS = [ + "output_tokens", + "outputTokens", + "completion_tokens", + "completionTokens", +] as const; + +const TOTAL_TOKEN_KEYS = ["total_tokens", "totalTokens"] as const; + +const FINAL_MESSAGE_KEYS = ["result", "content", "message"] as const; + +/** + * Extract session ID from JSON or JSONL output. + * Searches common key names and nested objects. + */ +export function extractSessionId(output: string): string | undefined { + if (!output?.trim()) { + return undefined; + } + + // Handle JSONL (newline-delimited JSON) + const lines = output.split("\n").filter(Boolean); + for (const line of lines) { + const id = findStringKeyInJson(line, SESSION_ID_KEYS); + if (id) { + return id; + } + } + return undefined; +} + +/** + * Extract token usage information from JSON or JSONL output. + * Returns the latest usage record found. + */ +export function extractUsage(output: string): AgentResult["usage"] | undefined { + if (!output?.trim()) { + return undefined; + } + + // Handle JSONL (newline-delimited JSON) - take latest + const lines = output.split("\n").filter(Boolean); + let latestUsage: AgentResult["usage"] | undefined; + + for (const line of lines) { + try { + const parsed = JSON.parse(line) as unknown; + const usage = findUsageInObject(parsed); + if (usage) { + latestUsage = usage; + } + } catch { + // Skip malformed JSON lines + } + } + return latestUsage; +} + +/** + * Extract final message content from JSON output. + * Handles various response formats from different agent backends. + */ +export function extractFinalMessage(jsonOutput: string): string { + if (!jsonOutput?.trim()) { + return jsonOutput; + } + + try { + const parsed = JSON.parse(jsonOutput) as Record; + + // Direct final message keys + for (const key of FINAL_MESSAGE_KEYS) { + if (typeof parsed[key] === "string") { + return parsed[key] as string; + } + } + + // Messages array format (last message) + if (Array.isArray(parsed.messages) && parsed.messages.length > 0) { + const last = parsed.messages[parsed.messages.length - 1]; + if ( + typeof last === "object" && + last !== null && + typeof last.content === "string" + ) { + return last.content; + } + } + } catch { + // Return original if JSON parsing fails + } + + return jsonOutput; +} + +function findUsageInObject(value: unknown): AgentResult["usage"] | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + + const record = value as Record; + const usage = buildUsageFromRecord(record); + if (usage) { + return usage; + } + + // Recursively search nested objects + for (const nested of Object.values(record)) { + const found = findUsageInObject(nested); + if (found) { + return found; + } + } + return undefined; +} + +function buildUsageFromRecord( + record: Record, +): AgentResult["usage"] | undefined { + const inputTokens = findNumberKeyInRecord(record, INPUT_TOKEN_KEYS); + const outputTokens = findNumberKeyInRecord(record, OUTPUT_TOKEN_KEYS); + const totalTokens = findNumberKeyInRecord(record, TOTAL_TOKEN_KEYS); + + if ( + inputTokens === undefined && + outputTokens === undefined && + totalTokens === undefined + ) { + return undefined; + } + + return { + inputTokens, + outputTokens, + totalTokens: + totalTokens ?? + (inputTokens !== undefined || outputTokens !== undefined + ? (inputTokens ?? 0) + (outputTokens ?? 0) + : undefined), + }; +} + +function findStringKeyInJson( + jsonString: string, + keys: readonly string[], +): string | undefined { + try { + const parsed = JSON.parse(jsonString) as unknown; + return findStringKeyInObject(parsed, keys); + } catch { + return undefined; + } +} + +function findStringKeyInObject( + value: unknown, + keys: readonly string[], +): string | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + + const record = value as Record; + + // Check top-level keys + for (const key of keys) { + const candidate = record[key]; + if (typeof candidate === "string" && candidate.length > 0) { + return candidate; + } + } + + // Recursively search nested objects + for (const nested of Object.values(record)) { + const found = findStringKeyInObject(nested, keys); + if (found) { + return found; + } + } + return undefined; +} + +function findNumberKeyInRecord( + record: Record, + keys: readonly string[], +): number | undefined { + for (const key of keys) { + const candidate = record[key]; + if (typeof candidate === "number" && Number.isFinite(candidate)) { + return candidate; + } + } + return undefined; +} diff --git a/tests/claude-code.test.ts b/tests/claude-code.test.ts new file mode 100644 index 00000000..d66b2ef0 --- /dev/null +++ b/tests/claude-code.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "bun:test"; +import type { ResolvedProjectConfig } from "../src/core/types"; +import { ClaudeCodeAdapter } from "../src/services/claude-code-adapter"; +import { + extractFinalMessage, + extractSessionId, + extractUsage, +} from "../src/utils/parsing"; + +const config: ResolvedProjectConfig = { + id: "default", + name: "Default", + workspacePath: "/tmp/work", + executionPath: "/tmp/work/repo", + repo: { owner: "o", name: "n", baseBranch: "main" }, + linear: { + apiKey: "x", + apiUrl: "https://api.linear.app/graphql", + pollLimit: 10, + statusMap: { + assigned: "a", + planning: "b", + implementing: "c", + pr_created: "d", + reviewing: "e", + testing: "f", + blocked: "g", + done: "h", + }, + labelMap: { + pr_created: "PR Created", + reviewing: "Reviewing", + testing: "Testing", + }, + autoCreateLabels: true, + }, + github: { useGhCli: true, defaultBugLabel: "bug" }, + codex: { + binary: "codex", + streamLogs: false, + }, + skills: { plan: "p", implement: "i", reviewTest: "r" }, + dryRun: false, +}; + +describe("claude code adapter", () => { + it("extracts session id from json", () => { + const json = `{"session_id":"abc-123","result":"ok"}`; + expect(extractSessionId(json)).toBe("abc-123"); + }); + + it("extracts session id from camelCase keys", () => { + const json = `{"sessionId":"def-456","result":"ok"}`; + expect(extractSessionId(json)).toBe("def-456"); + }); + + it("extracts session id from conversation_id", () => { + const json = `{"conversation_id":"ghi-789","result":"ok"}`; + expect(extractSessionId(json)).toBe("ghi-789"); + }); + + it("returns undefined when session id is missing", () => { + const json = `{"result":"ok"}`; + expect(extractSessionId(json)).toBeUndefined(); + }); + + it("extracts usage from nested usage object", () => { + const json = `{"result":"ok","usage":{"input_tokens":120,"output_tokens":30,"total_tokens":150}}`; + expect(extractUsage(json)).toEqual({ + inputTokens: 120, + outputTokens: 30, + totalTokens: 150, + }); + }); + + it("returns undefined when usage is missing", () => { + const json = `{"result":"ok"}`; + expect(extractUsage(json)).toBeUndefined(); + }); + + it("creates adapter instance", () => { + const adapter = new ClaudeCodeAdapter(config); + expect(adapter).toBeDefined(); + expect(typeof adapter.runPlan).toBe("function"); + expect(typeof adapter.resume).toBe("function"); + expect(typeof adapter.runReview).toBe("function"); + }); +}); diff --git a/tests/codex.test.ts b/tests/codex.test.ts index 438ac9c0..d1ba91c8 100644 --- a/tests/codex.test.ts +++ b/tests/codex.test.ts @@ -1,10 +1,7 @@ import { describe, expect, it } from "bun:test"; import type { ResolvedProjectConfig } from "../src/core/types"; -import { - CodexAdapter, - extractSessionId, - extractUsage, -} from "../src/services/codex-adapter"; +import { CodexAdapter } from "../src/services/codex-adapter"; +import { extractSessionId, extractUsage } from "../src/utils/parsing"; const config: ResolvedProjectConfig = { id: "default", diff --git a/tests/errors.test.ts b/tests/errors.test.ts new file mode 100644 index 00000000..01581b13 --- /dev/null +++ b/tests/errors.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "bun:test"; +import { + ADHDAiError, + ConfigError, + IntegrationError, + StateError, + ValidationError, + WorkflowError, + wrapError, +} from "../src/core/errors"; + +describe("ADHDAiError", () => { + it("creates error with code and message", () => { + const error = new ADHDAiError("TEST_CODE", "Test message"); + expect(error.code).toBe("TEST_CODE"); + expect(error.message).toBe("Test message"); + expect(error.name).toBe("ADHDAiError"); + }); + + it("includes context when provided", () => { + const error = new ADHDAiError("TEST_CODE", "Test", { + context: { key: "value" }, + }); + expect(error.context).toEqual({ key: "value" }); + }); + + it("preserves cause error", () => { + const cause = new Error("Original error"); + const error = new ADHDAiError("TEST_CODE", "Wrapper", { cause }); + expect(error.cause).toBe(cause); + }); + + it("formats toString with context", () => { + const error = new ADHDAiError("CODE", "Message", { + context: { foo: "bar" }, + }); + expect(error.toString()).toContain("[CODE] Message"); + expect(error.toString()).toContain('{"foo":"bar"}'); + }); + + it("formats toString with cause", () => { + const cause = new Error("Cause message"); + const error = new ADHDAiError("CODE", "Message", { cause }); + expect(error.toString()).toContain("Caused by: Cause message"); + }); +}); + +describe("ConfigError", () => { + it("creates config error with code", () => { + const error = new ConfigError("Invalid config"); + expect(error.code).toBe("CONFIG_ERROR"); + expect(error.name).toBe("ConfigError"); + }); + + it("includes configKey in context", () => { + const error = new ConfigError("Test", { configKey: "apiUrl" }); + expect(error.context?.configKey).toBe("apiUrl"); + }); + + it("includes envVar in context", () => { + const error = new ConfigError("Missing", { envVar: "API_KEY" }); + expect(error.context?.envVar).toBe("API_KEY"); + }); + + it("includes projectId in context", () => { + const error = new ConfigError("Invalid project", { + projectId: "my-project", + }); + expect(error.context?.projectId).toBe("my-project"); + }); +}); + +describe("StateError", () => { + it("creates state error with code", () => { + const error = new StateError("State load failed"); + expect(error.code).toBe("STATE_ERROR"); + expect(error.name).toBe("StateError"); + }); + + it("includes projectId in context", () => { + const error = new StateError("Test", { projectId: "proj-1" }); + expect(error.context?.projectId).toBe("proj-1"); + }); + + it("includes issueKey in context", () => { + const error = new StateError("Test", { issueKey: "ENG-123" }); + expect(error.context?.issueKey).toBe("ENG-123"); + }); + + it("includes filePath in context", () => { + const error = new StateError("Test", { filePath: "/tmp/state.json" }); + expect(error.context?.filePath).toBe("/tmp/state.json"); + }); +}); + +describe("IntegrationError", () => { + it("creates integration error with code", () => { + const error = new IntegrationError("API call failed"); + expect(error.code).toBe("INTEGRATION_ERROR"); + expect(error.name).toBe("IntegrationError"); + }); + + it("includes service in context", () => { + const error = new IntegrationError("Test", { service: "linear" }); + expect(error.context?.service).toBe("linear"); + }); + + it("includes issueId in context", () => { + const error = new IntegrationError("Test", { issueId: "lin_123" }); + expect(error.context?.issueId).toBe("lin_123"); + }); + + it("sets retryable property", () => { + const error1 = new IntegrationError("Test", { retryable: true }); + expect(error1.retryable).toBe(true); + + const error2 = new IntegrationError("Test", { retryable: false }); + expect(error2.retryable).toBe(false); + + const error3 = new IntegrationError("Test"); + expect(error3.retryable).toBe(false); + }); +}); + +describe("WorkflowError", () => { + it("creates workflow error with code", () => { + const error = new WorkflowError("Stage failed"); + expect(error.code).toBe("WORKFLOW_ERROR"); + expect(error.name).toBe("WorkflowError"); + }); + + it("includes stage in context", () => { + const error = new WorkflowError("Test", { stage: "implementing" }); + expect(error.context?.stage).toBe("implementing"); + }); + + it("includes retryable property", () => { + const error = new WorkflowError("Test", { retryable: true }); + expect(error.retryable).toBe(true); + }); +}); + +describe("ValidationError", () => { + it("creates validation error with code", () => { + const error = new ValidationError("Invalid input"); + expect(error.code).toBe("VALIDATION_ERROR"); + expect(error.name).toBe("ValidationError"); + }); + + it("includes field in context", () => { + const error = new ValidationError("Test", { field: "email" }); + expect(error.context?.field).toBe("email"); + }); + + it("includes value in context", () => { + const error = new ValidationError("Test", { value: 123 }); + expect(error.context?.value).toBe(123); + }); + + it("includes constraint in context", () => { + const error = new ValidationError("Test", { + constraint: "must be positive", + }); + expect(error.context?.constraint).toBe("must be positive"); + }); +}); + +describe("wrapError", () => { + it("returns original if already ADHDAiError", () => { + const original = new ConfigError("Original"); + const wrapped = wrapError(original, "OTHER", "Wrapped"); + expect(wrapped).toBe(original); + }); + + it("wraps Error with cause", () => { + const original = new Error("Original error"); + const wrapped = wrapError(original, "CODE", "Wrapped"); + expect(wrapped).toBeInstanceOf(ADHDAiError); + expect(wrapped.cause).toBe(original); + }); + + it("wraps non-Error as string", () => { + const wrapped = wrapError("string error", "CODE", "Wrapped"); + expect(wrapped.context?.originalError).toBe("string error"); + }); + + it("preserves context", () => { + const original = new Error("Original"); + const wrapped = wrapError(original, "CODE", "Wrapped", { + context: { extra: "data" }, + }); + expect(wrapped.context?.extra).toBe("data"); + }); +}); diff --git a/tests/parsing.test.ts b/tests/parsing.test.ts new file mode 100644 index 00000000..22782cc0 --- /dev/null +++ b/tests/parsing.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "bun:test"; +import { + extractFinalMessage, + extractSessionId, + extractUsage, +} from "../src/utils/parsing"; + +describe("extractSessionId", () => { + it("extracts session_id from JSON", () => { + const output = JSON.stringify({ session_id: "abc-123" }); + expect(extractSessionId(output)).toBe("abc-123"); + }); + + it("extracts sessionId from JSON (camelCase)", () => { + const output = JSON.stringify({ sessionId: "def-456" }); + expect(extractSessionId(output)).toBe("def-456"); + }); + + it("extracts conversation_id from nested JSON", () => { + const output = JSON.stringify({ + data: { nested: { conversation_id: "xyz-789" } }, + }); + expect(extractSessionId(output)).toBe("xyz-789"); + }); + + it("handles JSONL with multiple lines", () => { + const output = + '{"event":"log"}\n{"event":"log"}\n{"session_id":"session-1"}'; + expect(extractSessionId(output)).toBe("session-1"); + }); + + it("returns undefined for empty input", () => { + expect(extractSessionId("")).toBeUndefined(); + expect(extractSessionId(" ")).toBeUndefined(); + }); + + it("returns undefined for malformed JSON", () => { + expect(extractSessionId("{invalid}")).toBeUndefined(); + }); + + it("returns undefined when no session key exists", () => { + const output = JSON.stringify({ foo: "bar" }); + expect(extractSessionId(output)).toBeUndefined(); + }); +}); + +describe("extractUsage", () => { + it("extracts full usage from JSON", () => { + const output = JSON.stringify({ + usage: { + input_tokens: 100, + output_tokens: 200, + total_tokens: 300, + }, + }); + expect(extractUsage(output)).toEqual({ + inputTokens: 100, + outputTokens: 200, + totalTokens: 300, + }); + }); + + it("handles camelCase token keys", () => { + const output = JSON.stringify({ + usage: { inputTokens: 50, outputTokens: 75 }, + }); + expect(extractUsage(output)).toEqual({ + inputTokens: 50, + outputTokens: 75, + totalTokens: 125, + }); + }); + + it("handles prompt/completion token keys", () => { + const output = JSON.stringify({ + usage: { prompt_tokens: 30, completion_tokens: 40 }, + }); + expect(extractUsage(output)).toEqual({ + inputTokens: 30, + outputTokens: 40, + totalTokens: 70, + }); + }); + + it("returns latest usage from JSONL", () => { + const output = + '{"usage":{"input_tokens":10,"output_tokens":20}}\n{"usage":{"input_tokens":50,"output_tokens":60}}'; + expect(extractUsage(output)).toEqual({ + inputTokens: 50, + outputTokens: 60, + totalTokens: 110, + }); + }); + + it("calculates total when missing", () => { + const output = JSON.stringify({ + usage: { inputTokens: 100, outputTokens: 200 }, + }); + expect(extractUsage(output)).toEqual({ + inputTokens: 100, + outputTokens: 200, + totalTokens: 300, + }); + }); + + it("returns undefined for empty usage", () => { + const output = JSON.stringify({ foo: "bar" }); + expect(extractUsage(output)).toBeUndefined(); + }); + + it("returns undefined for malformed JSON", () => { + expect(extractUsage("{invalid}")).toBeUndefined(); + }); + + it("handles partial usage data", () => { + const output = JSON.stringify({ usage: { inputTokens: 100 } }); + expect(extractUsage(output)).toEqual({ + inputTokens: 100, + outputTokens: undefined, + totalTokens: 100, + }); + }); + + it("skips malformed JSONL lines", () => { + const output = + '{"usage":{"input_tokens":10}}\n{invalid}\n{"usage":{"output_tokens":20}}'; + expect(extractUsage(output)).toEqual({ + outputTokens: 20, + totalTokens: 20, + }); + }); +}); + +describe("extractFinalMessage", () => { + it("extracts result field", () => { + const output = JSON.stringify({ result: "Plan generated successfully" }); + expect(extractFinalMessage(output)).toBe("Plan generated successfully"); + }); + + it("extracts content field", () => { + const output = JSON.stringify({ content: "Implementation done" }); + expect(extractFinalMessage(output)).toBe("Implementation done"); + }); + + it("extracts message field", () => { + const output = JSON.stringify({ message: "Review completed" }); + expect(extractFinalMessage(output)).toBe("Review completed"); + }); + + it("extracts last message from messages array", () => { + const output = JSON.stringify({ + messages: [ + { content: "First" }, + { content: "Second" }, + { content: "Third" }, + ], + }); + expect(extractFinalMessage(output)).toBe("Third"); + }); + + it("returns original string for malformed JSON", () => { + const input = "plain text output"; + expect(extractFinalMessage(input)).toBe("plain text output"); + }); + + it("returns empty string for empty input", () => { + expect(extractFinalMessage("")).toBe(""); + }); + + it("returns empty string for whitespace input", () => { + expect(extractFinalMessage(" ")).toBe(" "); + }); +});