diff --git a/src/backends/progressMonitor.ts b/src/backends/progressMonitor.ts index dfe81755..32928df8 100644 --- a/src/backends/progressMonitor.ts +++ b/src/backends/progressMonitor.ts @@ -8,24 +8,25 @@ * * Falls back to the existing template-based formatStatusMessage() if * the progress model call fails. + * + * This class is a thin orchestrator that delegates to: + * - ProgressAccumulator — ring buffers for tool calls, text, tasks + * - ProgressScheduler — progressive timer scheduling + * - PMProgressPoster — PM comment create/update/fallback lifecycle + * - GitHubProgressPoster — GitHub PR comment updates */ import type { ModelSpec } from 'llmist'; import { syncCompletedTodosToChecklist } from '../agents/utils/checklistSync.js'; -import { INITIAL_MESSAGES } from '../config/agentMessages.js'; -import { formatGitHubProgressComment, formatStatusMessage } from '../config/statusUpdateConfig.js'; -import { getSessionState } from '../gadgets/sessionState.js'; -import { loadTodos } from '../gadgets/todo/storage.js'; -import { githubClient } from '../github/client.js'; -import { getPMProviderOrNull } from '../pm/index.js'; +import { formatStatusMessage } from '../config/statusUpdateConfig.js'; import { captureException } from '../sentry.js'; -import { type ProgressContext, callProgressModel } from './progressModel.js'; -import { - clearProgressCommentId, - readProgressCommentId, - writeProgressCommentId, -} from './progressState.js'; +import { callProgressModel } from './progressModel.js'; +import { clearProgressCommentId, writeProgressCommentId } from './progressState.js'; +import { ProgressAccumulator } from './progressState/accumulator.js'; +import { GitHubProgressPoster } from './progressState/githubPoster.js'; +import { PMProgressPoster } from './progressState/pmPoster.js'; +import { DEFAULT_SCHEDULE_MINUTES, ProgressScheduler } from './progressState/scheduler.js'; import type { LogWriter, ProgressReporter } from './types.js'; export interface ProgressMonitorConfig { @@ -50,100 +51,65 @@ export interface ProgressMonitorConfig { scheduleMinutes?: number[]; } -/** Default progressive schedule: 1min, 3min, 5min, then every intervalMinutes */ -const DEFAULT_SCHEDULE_MINUTES = [1, 3, 5]; - const PROGRESS_MODEL_TIMEOUT_MS = 20_000; -const RING_BUFFER_MAX = 20; -const TEXT_SNIPPETS_MAX = 10; -const COMPLETED_TASKS_MAX = 5; - -/** - * Extract a meaningful detail string from tool call params. - * Returns file paths, commands, or search patterns — the most useful - * context for progress reporting. - */ -function summarizeToolParams(_toolName: string, params?: Record): string { - if (!params) return ''; - if (params.file_path) return String(params.file_path); - if (params.filePath) return String(params.filePath); - if (params.command) return String(params.command).slice(0, 100); - if (params.pattern) { - const detail = String(params.pattern); - return params.path ? `${detail} in ${params.path}` : detail; - } - return ''; -} export class ProgressMonitor implements ProgressReporter { - private recentToolCalls: { name: string; detail?: string; timestamp: number }[] = []; - private recentTextSnippets: { text: string; timestamp: number }[] = []; - private completedTasks: { subject: string; summary: string; timestamp: number }[] = []; - private currentIteration = 0; - private maxIterations = 0; - private startTime = Date.now(); - private timer: ReturnType | null = null; + private readonly accumulator: ProgressAccumulator; + private readonly scheduler: ProgressScheduler; + private readonly pmPoster: PMProgressPoster | null; + private readonly githubPoster: GitHubProgressPoster | null; + private isGenerating = false; - private progressCommentId: string | null = null; private initialCommentPromise: Promise | null = null; - private tickIndex = 0; - private stopped = false; private started = false; - private readonly schedule: number[]; constructor(private readonly config: ProgressMonitorConfig) { - this.schedule = config.scheduleMinutes ?? DEFAULT_SCHEDULE_MINUTES; + const schedule = config.scheduleMinutes ?? DEFAULT_SCHEDULE_MINUTES; + + this.accumulator = new ProgressAccumulator(config.logWriter); + this.scheduler = new ProgressScheduler(schedule, config.intervalMinutes); + + this.pmPoster = config.trello + ? new PMProgressPoster({ + agentType: config.agentType, + cardId: config.trello.cardId, + repoDir: config.repoDir, + logWriter: config.logWriter, + }) + : null; + + this.githubPoster = config.github + ? new GitHubProgressPoster({ + owner: config.github.owner, + repo: config.github.repo, + headerMessage: config.github.headerMessage, + logWriter: config.logWriter, + }) + : null; } // ── Public accessors ── getProgressCommentId(): string | null { - return this.progressCommentId; + return this.pmPoster?.getCommentId() ?? null; } // ── ProgressReporter interface (accumulate only, no posting) ── async onIteration(iteration: number, maxIterations: number): Promise { - this.currentIteration = iteration; - this.maxIterations = maxIterations; + this.accumulator.onIteration(iteration, maxIterations); } onToolCall(toolName: string, params?: Record): void { - const detail = summarizeToolParams(toolName, params); - this.recentToolCalls.push({ - name: toolName, - detail: detail || undefined, - timestamp: Date.now(), - }); - if (this.recentToolCalls.length > RING_BUFFER_MAX) { - this.recentToolCalls.shift(); - } - this.config.logWriter('INFO', 'Tool call', { toolName, params }); + this.accumulator.onToolCall(toolName, params); } onText(content: string): void { - if (content.trim()) { - this.recentTextSnippets.push({ - text: content.slice(0, 200), - timestamp: Date.now(), - }); - if (this.recentTextSnippets.length > TEXT_SNIPPETS_MAX) { - this.recentTextSnippets.shift(); - } - } - this.config.logWriter('INFO', 'Agent text output', { length: content.length }); + this.accumulator.onText(content); } onTaskCompleted(taskId: string, subject: string, summary: string): void { - this.completedTasks.push({ - subject, - summary: summary.slice(0, 300), - timestamp: Date.now(), - }); - if (this.completedTasks.length > COMPLETED_TASKS_MAX) { - this.completedTasks.shift(); - } - this.config.logWriter('INFO', 'Task completed', { taskId, subject }); + this.accumulator.onTaskCompleted(taskId, subject, summary); } // ── Lifecycle ── @@ -151,13 +117,12 @@ export class ProgressMonitor implements ProgressReporter { start(): void { if (this.started) return; this.started = true; - this.startTime = Date.now(); if (this.config.preSeededCommentId) { // Router already posted the ack comment — reuse its ID - this.progressCommentId = this.config.preSeededCommentId; + this.pmPoster?.setCommentId(this.config.preSeededCommentId); this.config.logWriter('INFO', 'Using pre-seeded ack comment ID from router', { - commentId: this.progressCommentId, + commentId: this.config.preSeededCommentId, }); // Write state file so PostComment gadget can find it @@ -165,12 +130,12 @@ export class ProgressMonitor implements ProgressReporter { writeProgressCommentId( this.config.repoDir, this.config.trello.cardId, - this.progressCommentId, + this.config.preSeededCommentId, ); } - } else { + } else if (this.pmPoster) { // Post initial comment immediately (fire-and-forget) - this.initialCommentPromise = this.postInitialComment().catch((err) => { + this.initialCommentPromise = this.pmPoster.postInitial().catch((err) => { this.config.logWriter('WARN', 'Failed to post initial progress comment', { error: String(err), }); @@ -178,15 +143,11 @@ export class ProgressMonitor implements ProgressReporter { } // Start the progressive tick chain - this.scheduleNextTick(); + this.scheduler.start(() => this.tick()); } stop(): void { - this.stopped = true; - if (this.timer) { - clearTimeout(this.timer); - this.timer = null; - } + this.scheduler.stop(); // Clean up state file on stop (best-effort — stop() is called from finally // blocks, so an rmSync failure must not mask the actual agent result) try { @@ -198,61 +159,6 @@ export class ProgressMonitor implements ProgressReporter { // ── Internal ── - /** - * Schedules the next tick using the progressive schedule. - * Uses schedule[tickIndex] if available, otherwise falls back to intervalMinutes. - */ - private scheduleNextTick(): void { - const delayMinutes = - this.tickIndex < this.schedule.length - ? this.schedule[this.tickIndex] - : this.config.intervalMinutes; - const delayMs = delayMinutes * 60 * 1000; - this.timer = setTimeout(() => { - void this.tickAndScheduleNext(); - }, delayMs); - } - - /** Fires a tick, increments the counter, then schedules the next one. */ - private async tickAndScheduleNext(): Promise { - await this.tick(); - this.tickIndex++; - // Only schedule next tick if stop() hasn't been called - if (!this.stopped) { - this.scheduleNextTick(); - } - } - - private formatInitialMessage(): string { - return ( - INITIAL_MESSAGES[this.config.agentType] ?? - `**🚀 Starting** (${this.config.agentType})\n\nWorking on this now. Progress updates will follow...` - ); - } - - private async postInitialComment(): Promise { - if (!this.config.trello) return; - - const provider = getPMProviderOrNull(); - if (!provider) return; - - const message = this.formatInitialMessage(); - this.progressCommentId = await provider.addComment(this.config.trello.cardId, message); - this.config.logWriter('INFO', 'Posted initial progress comment to work item', { - cardId: this.config.trello.cardId, - commentId: this.progressCommentId, - }); - - // Write state file so PostComment gadget can update this comment - if (this.config.repoDir && this.progressCommentId) { - writeProgressCommentId( - this.config.repoDir, - this.config.trello.cardId, - this.progressCommentId, - ); - } - } - private async tick(): Promise { // Wait for initial comment to complete before proceeding so the first // tick updates the same comment instead of creating a duplicate @@ -263,20 +169,10 @@ export class ProgressMonitor implements ProgressReporter { this.isGenerating = true; try { - const todos = loadTodos(); - const elapsedMinutes = (Date.now() - this.startTime) / 60_000; - - const progressContext: ProgressContext = { - agentType: this.config.agentType, - taskDescription: this.config.taskDescription, - elapsedMinutes, - iteration: this.currentIteration, - maxIterations: this.maxIterations, - todos, - recentToolCalls: [...this.recentToolCalls], - recentTextSnippets: [...this.recentTextSnippets], - completedTasks: [...this.completedTasks], - }; + const progressContext = this.accumulator.getSnapshot( + this.config.agentType, + this.config.taskDescription, + ); let summary: string; try { @@ -290,7 +186,7 @@ export class ProgressMonitor implements ProgressReporter { ), ]); this.config.logWriter('INFO', 'Progress model generated summary', { - elapsedMinutes: Math.round(elapsedMinutes), + elapsedMinutes: Math.round(progressContext.elapsedMinutes), summaryLength: summary.length, }); } catch (err) { @@ -301,123 +197,47 @@ export class ProgressMonitor implements ProgressReporter { tags: { source: 'progress_model', agentType: this.config.agentType }, }); summary = formatStatusMessage( - this.currentIteration, - this.maxIterations, + progressContext.iteration, + progressContext.maxIterations, this.config.agentType, ); } - await this.postProgress(summary); - - // Sync checklist items for implementation agents - if (this.config.agentType === 'implementation' && this.config.trello) { - await syncCompletedTodosToChecklist(this.config.trello.cardId); - } - } catch (err) { - this.config.logWriter('WARN', 'Progress tick failed', { error: String(err) }); - } finally { - this.isGenerating = false; - } - } - - private maybeWriteStateFile(cardId: string, commentId: string | null): void { - if (this.config.repoDir && commentId) { - writeProgressCommentId(this.config.repoDir, cardId, commentId); - } - } - - private async postProgressToPM(summary: string, cardId: string): Promise { - const provider = getPMProviderOrNull(); - if (!provider) return; - - if (this.progressCommentId) { - // If the PostComment gadget (subprocess) cleared the state file, - // the agent has posted its final comment to this ID — do not overwrite. - const stateFile = readProgressCommentId(this.config.repoDir); - if (!stateFile) { - this.config.logWriter('DEBUG', 'State file cleared by agent — skipping progress update', { - commentId: this.progressCommentId, - }); - this.progressCommentId = null; - return; - } - - // Subsequent ticks: update the existing comment. - // On success, the state file written by postInitialComment() remains - // valid (same comment ID), so no need to rewrite it here. - try { - await provider.updateComment(cardId, this.progressCommentId, summary); - this.config.logWriter('INFO', 'Updated progress comment on work item', { - cardId, - commentId: this.progressCommentId, - }); - } catch (updateErr) { - // Comment may have been deleted — fall back to creating a new one - this.config.logWriter('WARN', 'Failed to update progress comment, creating new one', { - error: String(updateErr), - }); - this.progressCommentId = await provider.addComment(cardId, summary); - this.config.logWriter('INFO', 'Posted new progress comment to work item', { - cardId, - commentId: this.progressCommentId, - }); - // Update state file with new comment ID - this.maybeWriteStateFile(cardId, this.progressCommentId); - } - } else { - // First tick: create the comment and store its ID. - // This branch is reached when postInitialComment() failed (transient API error) - // and the first tick creates the comment instead. - this.progressCommentId = await provider.addComment(cardId, summary); - this.config.logWriter('INFO', 'Posted progress update to work item', { - cardId, - commentId: this.progressCommentId, - }); - // Write state file so PostComment gadget can find this comment - this.maybeWriteStateFile(cardId, this.progressCommentId); - } - } - - private async postProgress(summary: string): Promise { - // Post to PM provider (Trello/JIRA) — create once, update in place - if (this.config.trello) { - try { - await this.postProgressToPM(summary, this.config.trello.cardId); - } catch (err) { - this.config.logWriter('WARN', 'Failed to post progress to work item', { - error: String(err), - }); + // Post to PM provider (Trello/JIRA) + if (this.pmPoster) { + try { + await this.pmPoster.update(summary); + } catch (err) { + this.config.logWriter('WARN', 'Failed to post progress to work item', { + error: String(err), + }); + } } - } - // Post to GitHub (update the initial PR comment) - if (this.config.github) { - const { initialCommentId } = getSessionState(); - if (initialCommentId) { + // Post to GitHub + if (this.githubPoster) { try { - const body = formatGitHubProgressComment( - this.config.github.headerMessage, - this.currentIteration, - this.maxIterations, + await this.githubPoster.update( + summary, + progressContext.iteration, + progressContext.maxIterations, this.config.agentType, ); - // Replace the todo section with the AI-generated summary - const bodyWithSummary = body.replace(/\n\n📋[\s\S]*?\n\n/, `\n\n${summary}\n\n`); - await githubClient.updatePRComment( - this.config.github.owner, - this.config.github.repo, - initialCommentId, - bodyWithSummary, - ); - this.config.logWriter('INFO', 'Updated GitHub PR comment with progress', { - commentId: initialCommentId, - }); } catch (err) { this.config.logWriter('WARN', 'Failed to update GitHub PR comment', { error: String(err), }); } } + + // Sync checklist items for implementation agents + if (this.config.agentType === 'implementation' && this.config.trello) { + await syncCompletedTodosToChecklist(this.config.trello.cardId); + } + } catch (err) { + this.config.logWriter('WARN', 'Progress tick failed', { error: String(err) }); + } finally { + this.isGenerating = false; } } } diff --git a/src/backends/progressState/accumulator.ts b/src/backends/progressState/accumulator.ts new file mode 100644 index 00000000..dc63cc30 --- /dev/null +++ b/src/backends/progressState/accumulator.ts @@ -0,0 +1,103 @@ +/** + * Progress state accumulator for CASCADE agents. + * + * Accumulates tool calls, text snippets, completed tasks, and iteration + * counts using ring buffers. Provides a snapshot of current progress + * context for use by the progress model and posting layers. + */ + +import { loadTodos } from '../../gadgets/todo/storage.js'; +import type { ProgressContext } from '../progressModel.js'; +import type { LogWriter } from '../types.js'; + +export const RING_BUFFER_MAX = 20; +export const TEXT_SNIPPETS_MAX = 10; +export const COMPLETED_TASKS_MAX = 5; + +/** + * Extract a meaningful detail string from tool call params. + * Returns file paths, commands, or search patterns — the most useful + * context for progress reporting. + */ +export function summarizeToolParams(_toolName: string, params?: Record): string { + if (!params) return ''; + if (params.file_path) return String(params.file_path); + if (params.filePath) return String(params.filePath); + if (params.command) return String(params.command).slice(0, 100); + if (params.pattern) { + const detail = String(params.pattern); + return params.path ? `${detail} in ${params.path}` : detail; + } + return ''; +} + +export class ProgressAccumulator { + private recentToolCalls: { name: string; detail?: string; timestamp: number }[] = []; + private recentTextSnippets: { text: string; timestamp: number }[] = []; + private completedTasks: { subject: string; summary: string; timestamp: number }[] = []; + private currentIteration = 0; + private maxIterations = 0; + private readonly startTime = Date.now(); + + constructor(private readonly logWriter: LogWriter) {} + + onIteration(iteration: number, maxIterations: number): void { + this.currentIteration = iteration; + this.maxIterations = maxIterations; + } + + onToolCall(toolName: string, params?: Record): void { + const detail = summarizeToolParams(toolName, params); + this.recentToolCalls.push({ + name: toolName, + detail: detail || undefined, + timestamp: Date.now(), + }); + if (this.recentToolCalls.length > RING_BUFFER_MAX) { + this.recentToolCalls.shift(); + } + this.logWriter('INFO', 'Tool call', { toolName, params }); + } + + onText(content: string): void { + if (content.trim()) { + this.recentTextSnippets.push({ + text: content.slice(0, 200), + timestamp: Date.now(), + }); + if (this.recentTextSnippets.length > TEXT_SNIPPETS_MAX) { + this.recentTextSnippets.shift(); + } + } + this.logWriter('INFO', 'Agent text output', { length: content.length }); + } + + onTaskCompleted(taskId: string, subject: string, summary: string): void { + this.completedTasks.push({ + subject, + summary: summary.slice(0, 300), + timestamp: Date.now(), + }); + if (this.completedTasks.length > COMPLETED_TASKS_MAX) { + this.completedTasks.shift(); + } + this.logWriter('INFO', 'Task completed', { taskId, subject }); + } + + getSnapshot(agentType: string, taskDescription: string): ProgressContext { + const todos = loadTodos(); + const elapsedMinutes = (Date.now() - this.startTime) / 60_000; + + return { + agentType, + taskDescription, + elapsedMinutes, + iteration: this.currentIteration, + maxIterations: this.maxIterations, + todos, + recentToolCalls: [...this.recentToolCalls], + recentTextSnippets: [...this.recentTextSnippets], + completedTasks: [...this.completedTasks], + }; + } +} diff --git a/src/backends/progressState/githubPoster.ts b/src/backends/progressState/githubPoster.ts new file mode 100644 index 00000000..52a26be9 --- /dev/null +++ b/src/backends/progressState/githubPoster.ts @@ -0,0 +1,51 @@ +/** + * GitHub PR progress comment poster. + * + * Updates the initial PR comment with AI-generated progress summaries. + * Reads the session state to find the initial comment ID, formats the + * GitHub progress comment, and updates it via the GitHub client. + */ + +import { formatGitHubProgressComment } from '../../config/statusUpdateConfig.js'; +import { getSessionState } from '../../gadgets/sessionState.js'; +import { githubClient } from '../../github/client.js'; +import type { LogWriter } from '../types.js'; + +export interface GitHubProgressPosterConfig { + owner: string; + repo: string; + headerMessage: string; + logWriter: LogWriter; +} + +export class GitHubProgressPoster { + constructor(private readonly config: GitHubProgressPosterConfig) {} + + async update( + summary: string, + iteration: number, + maxIterations: number, + agentType: string, + ): Promise { + const { initialCommentId } = getSessionState(); + if (!initialCommentId) return; + + const body = formatGitHubProgressComment( + this.config.headerMessage, + iteration, + maxIterations, + agentType, + ); + // Replace the todo section with the AI-generated summary + const bodyWithSummary = body.replace(/\n\n📋[\s\S]*?\n\n/, `\n\n${summary}\n\n`); + await githubClient.updatePRComment( + this.config.owner, + this.config.repo, + initialCommentId, + bodyWithSummary, + ); + this.config.logWriter('INFO', 'Updated GitHub PR comment with progress', { + commentId: initialCommentId, + }); + } +} diff --git a/src/backends/progressState/pmPoster.ts b/src/backends/progressState/pmPoster.ts new file mode 100644 index 00000000..07b6a48a --- /dev/null +++ b/src/backends/progressState/pmPoster.ts @@ -0,0 +1,113 @@ +/** + * PM (Project Management) progress comment poster. + * + * Manages the create-once/update-in-place/fallback-to-new lifecycle + * for progress comments on Trello/JIRA work items. Handles state file + * coordination with the PostComment gadget subprocess. + */ + +import { INITIAL_MESSAGES } from '../../config/agentMessages.js'; +import { getPMProviderOrNull } from '../../pm/index.js'; +import { readProgressCommentId, writeProgressCommentId } from '../progressState.js'; +import type { LogWriter } from '../types.js'; + +export interface PMProgressPosterConfig { + agentType: string; + cardId: string; + repoDir?: string; + logWriter: LogWriter; +} + +export class PMProgressPoster { + private progressCommentId: string | null = null; + + constructor(private readonly config: PMProgressPosterConfig) {} + + getCommentId(): string | null { + return this.progressCommentId; + } + + setCommentId(commentId: string): void { + this.progressCommentId = commentId; + } + + private formatInitialMessage(): string { + return ( + INITIAL_MESSAGES[this.config.agentType] ?? + `**🚀 Starting** (${this.config.agentType})\n\nWorking on this now. Progress updates will follow...` + ); + } + + private maybeWriteStateFile(commentId: string | null): void { + if (this.config.repoDir && commentId) { + writeProgressCommentId(this.config.repoDir, this.config.cardId, commentId); + } + } + + async postInitial(): Promise { + const provider = getPMProviderOrNull(); + if (!provider) return; + + const message = this.formatInitialMessage(); + this.progressCommentId = await provider.addComment(this.config.cardId, message); + this.config.logWriter('INFO', 'Posted initial progress comment to work item', { + cardId: this.config.cardId, + commentId: this.progressCommentId, + }); + + // Write state file so PostComment gadget can update this comment + this.maybeWriteStateFile(this.progressCommentId); + } + + async update(summary: string): Promise { + const provider = getPMProviderOrNull(); + if (!provider) return; + + const { cardId } = this.config; + + if (this.progressCommentId) { + // If the PostComment gadget (subprocess) cleared the state file, + // the agent has posted its final comment to this ID — do not overwrite. + const stateFile = readProgressCommentId(this.config.repoDir); + if (!stateFile) { + this.config.logWriter('DEBUG', 'State file cleared by agent — skipping progress update', { + commentId: this.progressCommentId, + }); + this.progressCommentId = null; + return; + } + + // Subsequent ticks: update the existing comment. + try { + await provider.updateComment(cardId, this.progressCommentId, summary); + this.config.logWriter('INFO', 'Updated progress comment on work item', { + cardId, + commentId: this.progressCommentId, + }); + } catch (updateErr) { + // Comment may have been deleted — fall back to creating a new one + this.config.logWriter('WARN', 'Failed to update progress comment, creating new one', { + error: String(updateErr), + }); + this.progressCommentId = await provider.addComment(cardId, summary); + this.config.logWriter('INFO', 'Posted new progress comment to work item', { + cardId, + commentId: this.progressCommentId, + }); + // Update state file with new comment ID + this.maybeWriteStateFile(this.progressCommentId); + } + } else { + // First tick: create the comment and store its ID. + // This branch is reached when postInitial() failed (transient API error) + // and the first tick creates the comment instead. + this.progressCommentId = await provider.addComment(cardId, summary); + this.config.logWriter('INFO', 'Posted progress update to work item', { + cardId, + commentId: this.progressCommentId, + }); + // Write state file so PostComment gadget can find this comment + this.maybeWriteStateFile(this.progressCommentId); + } + } +} diff --git a/src/backends/progressState/scheduler.ts b/src/backends/progressState/scheduler.ts new file mode 100644 index 00000000..aa038efd --- /dev/null +++ b/src/backends/progressState/scheduler.ts @@ -0,0 +1,55 @@ +/** + * Progressive timer scheduler for CASCADE progress monitor. + * + * Fires a tick callback according to a progressive schedule (e.g., 1min, + * 3min, 5min) before falling back to a steady-state interval. Pure + * scheduling — no business logic. + */ + +/** Default progressive schedule: 1min, 3min, 5min, then every intervalMinutes */ +export const DEFAULT_SCHEDULE_MINUTES = [1, 3, 5]; + +export class ProgressScheduler { + private timer: ReturnType | null = null; + private tickIndex = 0; + private stopped = false; + + constructor( + private readonly schedule: number[], + private readonly intervalMinutes: number, + ) {} + + /** + * Start the scheduler, calling `tickFn` on each tick. + * `tickFn` is awaited before the next tick is scheduled. + */ + start(tickFn: () => Promise): void { + this.scheduleNextTick(tickFn); + } + + /** Stop the scheduler. No more ticks will fire after this call. */ + stop(): void { + this.stopped = true; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + } + + private scheduleNextTick(tickFn: () => Promise): void { + const delayMinutes = + this.tickIndex < this.schedule.length ? this.schedule[this.tickIndex] : this.intervalMinutes; + const delayMs = delayMinutes * 60 * 1000; + this.timer = setTimeout(() => { + void this.tickAndScheduleNext(tickFn); + }, delayMs); + } + + private async tickAndScheduleNext(tickFn: () => Promise): Promise { + await tickFn(); + this.tickIndex++; + if (!this.stopped) { + this.scheduleNextTick(tickFn); + } + } +} diff --git a/tests/unit/backends/accumulator.test.ts b/tests/unit/backends/accumulator.test.ts new file mode 100644 index 00000000..6786d01b --- /dev/null +++ b/tests/unit/backends/accumulator.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/gadgets/todo/storage.js', () => ({ + loadTodos: vi.fn(), +})); + +import { + COMPLETED_TASKS_MAX, + ProgressAccumulator, + RING_BUFFER_MAX, + TEXT_SNIPPETS_MAX, + summarizeToolParams, +} from '../../../src/backends/progressState/accumulator.js'; +import { loadTodos } from '../../../src/gadgets/todo/storage.js'; + +const mockLoadTodos = vi.mocked(loadTodos); + +beforeEach(() => { + vi.clearAllMocks(); + mockLoadTodos.mockReturnValue([]); +}); + +describe('summarizeToolParams', () => { + it('returns empty string when no params provided', () => { + expect(summarizeToolParams('Bash')).toBe(''); + }); + + it('returns file_path when present', () => { + expect(summarizeToolParams('Read', { file_path: '/src/foo.ts' })).toBe('/src/foo.ts'); + }); + + it('returns filePath (camelCase) when present', () => { + expect(summarizeToolParams('ReadFile', { filePath: '/src/bar.ts' })).toBe('/src/bar.ts'); + }); + + it('returns truncated command (max 100 chars) when present', () => { + const longCmd = 'npm run test:coverage -- --reporter verbose'.padEnd(120, ' extra'); + const result = summarizeToolParams('Bash', { command: longCmd }); + expect(result.length).toBeLessThanOrEqual(100); + }); + + it('returns pattern when present without path', () => { + expect(summarizeToolParams('Grep', { pattern: 'class.*Foo' })).toBe('class.*Foo'); + }); + + it('returns pattern with path when both present', () => { + expect(summarizeToolParams('Grep', { pattern: 'class.*Foo', path: 'src/' })).toBe( + 'class.*Foo in src/', + ); + }); + + it('returns empty string when params exist but have no recognized keys', () => { + expect(summarizeToolParams('Unknown', { randomKey: 'value' })).toBe(''); + }); +}); + +describe('ProgressAccumulator', () => { + function makeAccumulator() { + return new ProgressAccumulator(vi.fn()); + } + + describe('onToolCall', () => { + it('logs each tool call via logWriter', () => { + const logWriter = vi.fn(); + const acc = new ProgressAccumulator(logWriter); + acc.onToolCall('Bash', { command: 'npm test' }); + expect(logWriter).toHaveBeenCalledWith('INFO', 'Tool call', { + toolName: 'Bash', + params: { command: 'npm test' }, + }); + }); + + it('enforces ring buffer max (RING_BUFFER_MAX)', () => { + const logWriter = vi.fn(); + const acc = new ProgressAccumulator(logWriter); + for (let i = 0; i < RING_BUFFER_MAX + 5; i++) { + acc.onToolCall(`Tool${i}`); + } + // Logged all calls + expect(logWriter).toHaveBeenCalledTimes(RING_BUFFER_MAX + 5); + // Snapshot should only have RING_BUFFER_MAX entries + const snap = acc.getSnapshot('impl', 'task'); + expect(snap.recentToolCalls).toHaveLength(RING_BUFFER_MAX); + // First entries should be the most recent ones + expect(snap.recentToolCalls[0].name).toBe('Tool5'); + expect(snap.recentToolCalls[RING_BUFFER_MAX - 1].name).toBe(`Tool${RING_BUFFER_MAX + 4}`); + }); + }); + + describe('onText', () => { + it('logs text output via logWriter', () => { + const logWriter = vi.fn(); + const acc = new ProgressAccumulator(logWriter); + acc.onText('Hello world'); + expect(logWriter).toHaveBeenCalledWith('INFO', 'Agent text output', { length: 11 }); + }); + + it('ignores whitespace-only text', () => { + const logWriter = vi.fn(); + const acc = new ProgressAccumulator(logWriter); + acc.onText(' '); + // Still logged but nothing added to snippets + const snap = acc.getSnapshot('impl', 'task'); + expect(snap.recentTextSnippets).toHaveLength(0); + }); + + it('truncates text to 200 chars in snippet', () => { + const acc = makeAccumulator(); + const longText = 'x'.repeat(300); + acc.onText(longText); + const snap = acc.getSnapshot('impl', 'task'); + expect(snap.recentTextSnippets[0].text).toHaveLength(200); + }); + + it('enforces ring buffer max (TEXT_SNIPPETS_MAX)', () => { + const acc = makeAccumulator(); + for (let i = 0; i < TEXT_SNIPPETS_MAX + 3; i++) { + acc.onText(`Snippet ${i}`); + } + const snap = acc.getSnapshot('impl', 'task'); + expect(snap.recentTextSnippets).toHaveLength(TEXT_SNIPPETS_MAX); + }); + }); + + describe('onTaskCompleted', () => { + it('logs completed task via logWriter', () => { + const logWriter = vi.fn(); + const acc = new ProgressAccumulator(logWriter); + acc.onTaskCompleted('t1', 'My Task', 'Did the thing'); + expect(logWriter).toHaveBeenCalledWith('INFO', 'Task completed', { + taskId: 't1', + subject: 'My Task', + }); + }); + + it('truncates summary to 300 chars', () => { + const acc = makeAccumulator(); + const longSummary = 'y'.repeat(400); + acc.onTaskCompleted('t1', 'Task', longSummary); + const snap = acc.getSnapshot('impl', 'task'); + expect(snap.completedTasks[0].summary).toHaveLength(300); + }); + + it('enforces ring buffer max (COMPLETED_TASKS_MAX)', () => { + const acc = makeAccumulator(); + for (let i = 0; i < COMPLETED_TASKS_MAX + 2; i++) { + acc.onTaskCompleted(`t${i}`, `Task ${i}`, 'summary'); + } + const snap = acc.getSnapshot('impl', 'task'); + expect(snap.completedTasks).toHaveLength(COMPLETED_TASKS_MAX); + }); + }); + + describe('onIteration', () => { + it('records current and max iterations in snapshot', () => { + const acc = makeAccumulator(); + acc.onIteration(7, 20); + const snap = acc.getSnapshot('impl', 'task'); + expect(snap.iteration).toBe(7); + expect(snap.maxIterations).toBe(20); + }); + }); + + describe('getSnapshot', () => { + it('returns snapshot with correct agentType and taskDescription', () => { + const acc = makeAccumulator(); + const snap = acc.getSnapshot('review', 'Review the PR'); + expect(snap.agentType).toBe('review'); + expect(snap.taskDescription).toBe('Review the PR'); + }); + + it('returns todos from loadTodos()', () => { + mockLoadTodos.mockReturnValue([ + { id: '1', content: 'Do thing', status: 'todo' }, + { id: '2', content: 'Other thing', status: 'done' }, + ]); + const acc = makeAccumulator(); + const snap = acc.getSnapshot('impl', 'task'); + expect(snap.todos).toHaveLength(2); + expect(snap.todos[0].content).toBe('Do thing'); + }); + + it('returns elapsed time > 0', () => { + const acc = makeAccumulator(); + const snap = acc.getSnapshot('impl', 'task'); + expect(snap.elapsedMinutes).toBeGreaterThanOrEqual(0); + }); + + it('returns copies of arrays (not references)', () => { + const acc = makeAccumulator(); + acc.onToolCall('Bash'); + const snap1 = acc.getSnapshot('impl', 'task'); + acc.onToolCall('Read'); + const snap2 = acc.getSnapshot('impl', 'task'); + // snap1 should still have only 1 entry + expect(snap1.recentToolCalls).toHaveLength(1); + expect(snap2.recentToolCalls).toHaveLength(2); + }); + }); +}); diff --git a/tests/unit/backends/githubPoster.test.ts b/tests/unit/backends/githubPoster.test.ts new file mode 100644 index 00000000..90ed6dcb --- /dev/null +++ b/tests/unit/backends/githubPoster.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/github/client.js', () => ({ + githubClient: { + updatePRComment: vi.fn(), + }, +})); + +vi.mock('../../../src/gadgets/sessionState.js', () => ({ + getSessionState: vi.fn(), +})); + +vi.mock('../../../src/config/statusUpdateConfig.js', () => ({ + formatGitHubProgressComment: vi.fn(), +})); + +import { GitHubProgressPoster } from '../../../src/backends/progressState/githubPoster.js'; +import { formatGitHubProgressComment } from '../../../src/config/statusUpdateConfig.js'; +import { getSessionState } from '../../../src/gadgets/sessionState.js'; +import { githubClient } from '../../../src/github/client.js'; + +const mockGithubClient = vi.mocked(githubClient); +const mockGetSessionState = vi.mocked(getSessionState); +const mockFormatGitHubProgressComment = vi.mocked(formatGitHubProgressComment); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function makePoster() { + return new GitHubProgressPoster({ + owner: 'myorg', + repo: 'myrepo', + headerMessage: '**🧑‍💻 Implementation Update**', + logWriter: vi.fn(), + }); +} + +describe('GitHubProgressPoster — update()', () => { + it('does nothing when there is no initialCommentId in session state', async () => { + mockGetSessionState.mockReturnValue({ + agentType: 'implementation', + prCreated: false, + prUrl: null, + reviewSubmitted: false, + reviewUrl: null, + initialCommentId: null, + }); + + const poster = makePoster(); + await poster.update('summary', 3, 20, 'implementation'); + + expect(mockGithubClient.updatePRComment).not.toHaveBeenCalled(); + }); + + it('formats and updates PR comment when initialCommentId exists', async () => { + mockGetSessionState.mockReturnValue({ + agentType: 'implementation', + prCreated: false, + prUrl: null, + reviewSubmitted: false, + reviewUrl: null, + initialCommentId: 99, + }); + mockFormatGitHubProgressComment.mockReturnValue('Header\n\n📋 Old todo section\n\nFooter'); + mockGithubClient.updatePRComment.mockResolvedValue(undefined as never); + + const poster = makePoster(); + await poster.update('AI-generated summary', 5, 20, 'implementation'); + + expect(mockFormatGitHubProgressComment).toHaveBeenCalledWith( + '**🧑‍💻 Implementation Update**', + 5, + 20, + 'implementation', + ); + expect(mockGithubClient.updatePRComment).toHaveBeenCalledWith( + 'myorg', + 'myrepo', + 99, + expect.stringContaining('AI-generated summary'), + ); + }); + + it('replaces the todo section with the AI summary', async () => { + mockGetSessionState.mockReturnValue({ + agentType: 'implementation', + prCreated: false, + prUrl: null, + reviewSubmitted: false, + reviewUrl: null, + initialCommentId: 42, + }); + // The format includes a todo section matching \n\n📋[\s\S]*?\n\n + mockFormatGitHubProgressComment.mockReturnValue( + 'Header text\n\n📋 Todo item 1\nTodo item 2\n\nFooter text', + ); + mockGithubClient.updatePRComment.mockResolvedValue(undefined as never); + + const poster = makePoster(); + await poster.update('My AI summary', 2, 10, 'review'); + + const callArg = mockGithubClient.updatePRComment.mock.calls[0][3]; + expect(callArg).toContain('My AI summary'); + expect(callArg).not.toContain('📋 Todo item'); + }); + + it('logs success after updating comment', async () => { + const logWriter = vi.fn(); + mockGetSessionState.mockReturnValue({ + agentType: 'review', + prCreated: false, + prUrl: null, + reviewSubmitted: false, + reviewUrl: null, + initialCommentId: 7, + }); + mockFormatGitHubProgressComment.mockReturnValue('body'); + mockGithubClient.updatePRComment.mockResolvedValue(undefined as never); + + const poster = new GitHubProgressPoster({ + owner: 'o', + repo: 'r', + headerMessage: 'Header', + logWriter, + }); + await poster.update('summary', 1, 5, 'review'); + + expect(logWriter).toHaveBeenCalledWith( + 'INFO', + 'Updated GitHub PR comment with progress', + expect.objectContaining({ commentId: 7 }), + ); + }); +}); diff --git a/tests/unit/backends/pmPoster.test.ts b/tests/unit/backends/pmPoster.test.ts new file mode 100644 index 00000000..86f880f8 --- /dev/null +++ b/tests/unit/backends/pmPoster.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/pm/index.js', () => ({ + getPMProviderOrNull: vi.fn(), +})); + +vi.mock('../../../src/backends/progressState.js', () => ({ + writeProgressCommentId: vi.fn(), + readProgressCommentId: vi.fn(), + clearProgressCommentId: vi.fn(), +})); + +import { + readProgressCommentId, + writeProgressCommentId, +} from '../../../src/backends/progressState.js'; +import { PMProgressPoster } from '../../../src/backends/progressState/pmPoster.js'; +import type { PMProvider } from '../../../src/pm/index.js'; +import { getPMProviderOrNull } from '../../../src/pm/index.js'; + +const mockGetPMProvider = vi.mocked(getPMProviderOrNull); +const mockWriteProgressCommentId = vi.mocked(writeProgressCommentId); +const mockReadProgressCommentId = vi.mocked(readProgressCommentId); +const mockPMProvider = { + addComment: vi.fn<[string, string], Promise>(), + updateComment: vi.fn<[string, string, string], Promise>(), +}; + +beforeEach(() => { + vi.clearAllMocks(); + // Default: state file exists + mockReadProgressCommentId.mockReturnValue({ workItemId: 'card1', commentId: 'comment1' }); +}); + +function makePoster(overrides?: Partial[0]>) { + return new PMProgressPoster({ + agentType: 'implementation', + cardId: 'card1', + logWriter: vi.fn(), + ...overrides, + }); +} + +describe('PMProgressPoster — getCommentId / setCommentId', () => { + it('returns null initially', () => { + const poster = makePoster(); + expect(poster.getCommentId()).toBeNull(); + }); + + it('returns the ID set via setCommentId', () => { + const poster = makePoster(); + poster.setCommentId('preset-id'); + expect(poster.getCommentId()).toBe('preset-id'); + }); +}); + +describe('PMProgressPoster — postInitial()', () => { + it('does nothing when PM provider is null', async () => { + mockGetPMProvider.mockReturnValue(null); + const poster = makePoster(); + await poster.postInitial(); + expect(mockPMProvider.addComment).not.toHaveBeenCalled(); + expect(poster.getCommentId()).toBeNull(); + }); + + it('posts the initial message and stores the comment ID', async () => { + mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider); + mockPMProvider.addComment.mockResolvedValue('initial-id'); + const poster = makePoster({ agentType: 'implementation' }); + + await poster.postInitial(); + + expect(mockPMProvider.addComment).toHaveBeenCalledWith( + 'card1', + '**🚀 Implementing changes** — Writing code, running tests, and preparing a PR...', + ); + expect(poster.getCommentId()).toBe('initial-id'); + }); + + it('uses fallback message for unknown agent types', async () => { + mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider); + mockPMProvider.addComment.mockResolvedValue('new-id'); + const poster = makePoster({ agentType: 'future-agent' }); + + await poster.postInitial(); + + expect(mockPMProvider.addComment).toHaveBeenCalledWith( + 'card1', + '**🚀 Starting** (future-agent)\n\nWorking on this now. Progress updates will follow...', + ); + }); + + it('writes state file when repoDir is provided', async () => { + mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider); + mockPMProvider.addComment.mockResolvedValue('initial-id'); + const poster = makePoster({ repoDir: '/tmp/repo' }); + + await poster.postInitial(); + + expect(mockWriteProgressCommentId).toHaveBeenCalledWith('/tmp/repo', 'card1', 'initial-id'); + }); + + it('does not write state file when repoDir is absent', async () => { + mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider); + mockPMProvider.addComment.mockResolvedValue('initial-id'); + const poster = makePoster(); // no repoDir + + await poster.postInitial(); + + expect(mockWriteProgressCommentId).not.toHaveBeenCalled(); + }); +}); + +describe('PMProgressPoster — update()', () => { + it('does nothing when PM provider is null', async () => { + mockGetPMProvider.mockReturnValue(null); + const poster = makePoster(); + await poster.update('summary'); + expect(mockPMProvider.addComment).not.toHaveBeenCalled(); + }); + + it('creates new comment when no existing comment ID (fallback branch)', async () => { + mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider); + mockPMProvider.addComment.mockResolvedValue('tick-id'); + const poster = makePoster({ repoDir: '/tmp/repo' }); + // No initial comment was posted + + await poster.update('First progress update'); + + expect(mockPMProvider.addComment).toHaveBeenCalledWith('card1', 'First progress update'); + expect(poster.getCommentId()).toBe('tick-id'); + expect(mockWriteProgressCommentId).toHaveBeenCalledWith('/tmp/repo', 'card1', 'tick-id'); + }); + + it('updates existing comment when comment ID is set', async () => { + mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider); + mockPMProvider.updateComment.mockResolvedValue(undefined); + const poster = makePoster(); + poster.setCommentId('existing-id'); + + await poster.update('Updated progress'); + + expect(mockPMProvider.updateComment).toHaveBeenCalledWith( + 'card1', + 'existing-id', + 'Updated progress', + ); + expect(mockPMProvider.addComment).not.toHaveBeenCalled(); + }); + + it('skips update when state file has been cleared by agent subprocess', async () => { + mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider); + mockReadProgressCommentId.mockReturnValue(null); // state file cleared + const poster = makePoster(); + poster.setCommentId('existing-id'); + + await poster.update('Should be skipped'); + + expect(mockPMProvider.updateComment).not.toHaveBeenCalled(); + expect(poster.getCommentId()).toBeNull(); + }); + + it('falls back to new comment when updateComment throws', async () => { + mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider); + mockPMProvider.updateComment.mockRejectedValue(new Error('Comment not found')); + mockPMProvider.addComment.mockResolvedValue('fallback-id'); + const poster = makePoster({ repoDir: '/tmp/repo' }); + poster.setCommentId('deleted-id'); + + await poster.update('Fallback summary'); + + expect(mockPMProvider.updateComment).toHaveBeenCalledWith( + 'card1', + 'deleted-id', + 'Fallback summary', + ); + expect(mockPMProvider.addComment).toHaveBeenCalledWith('card1', 'Fallback summary'); + expect(poster.getCommentId()).toBe('fallback-id'); + expect(mockWriteProgressCommentId).toHaveBeenCalledWith('/tmp/repo', 'card1', 'fallback-id'); + }); +}); diff --git a/tests/unit/backends/scheduler.test.ts b/tests/unit/backends/scheduler.test.ts new file mode 100644 index 00000000..a7df7f64 --- /dev/null +++ b/tests/unit/backends/scheduler.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + DEFAULT_SCHEDULE_MINUTES, + ProgressScheduler, +} from '../../../src/backends/progressState/scheduler.js'; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('DEFAULT_SCHEDULE_MINUTES', () => { + it('is [1, 3, 5]', () => { + expect(DEFAULT_SCHEDULE_MINUTES).toEqual([1, 3, 5]); + }); +}); + +describe('ProgressScheduler', () => { + describe('start / progressive schedule', () => { + it('fires first tick at schedule[0] minutes', async () => { + const tickFn = vi.fn().mockResolvedValue(undefined); + const scheduler = new ProgressScheduler([1, 3, 5], 10); + scheduler.start(tickFn); + + await vi.advanceTimersByTimeAsync(59_999); + expect(tickFn).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(tickFn).toHaveBeenCalledTimes(1); + + scheduler.stop(); + }); + + it('fires second tick at schedule[1] minutes after first tick', async () => { + const tickFn = vi.fn().mockResolvedValue(undefined); + const scheduler = new ProgressScheduler([1, 3], 10); + scheduler.start(tickFn); + + // First tick at 1min + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(1); + + // Second tick at 3 more minutes + await vi.advanceTimersByTimeAsync(3 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(2); + + scheduler.stop(); + }); + + it('falls back to intervalMinutes after schedule exhausted', async () => { + const tickFn = vi.fn().mockResolvedValue(undefined); + const scheduler = new ProgressScheduler([1], 5); + scheduler.start(tickFn); + + // First tick (from schedule) + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(1); + + // Second tick (steady-state: 5min) + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(2); + + // Third tick (steady-state: another 5min) + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(3); + + scheduler.stop(); + }); + + it('fires ticks at full progressive schedule then steady state', async () => { + const tickFn = vi.fn().mockResolvedValue(undefined); + const scheduler = new ProgressScheduler([1, 3, 5], 5); + scheduler.start(tickFn); + + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(3 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(3); + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(4); + + scheduler.stop(); + }); + }); + + describe('stop()', () => { + it('prevents further ticks from firing', async () => { + const tickFn = vi.fn().mockResolvedValue(undefined); + const scheduler = new ProgressScheduler([1, 3], 10); + scheduler.start(tickFn); + + // Fire first tick + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(1); + + // Stop the scheduler + scheduler.stop(); + + // Advance well past the next scheduled tick + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(1); + }); + + it('is safe to call multiple times', () => { + const tickFn = vi.fn().mockResolvedValue(undefined); + const scheduler = new ProgressScheduler([1], 5); + scheduler.start(tickFn); + scheduler.stop(); + expect(() => scheduler.stop()).not.toThrow(); + }); + + it('prevents next tick from scheduling even if stop called during tick', async () => { + let resolveTickFn!: () => void; + const tickPromise = new Promise((resolve) => { + resolveTickFn = resolve; + }); + const tickFn = vi.fn().mockReturnValue(tickPromise); + const scheduler = new ProgressScheduler([1], 5); + scheduler.start(tickFn); + + // Trigger first tick — it will not resolve yet + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(1); + + // Stop while tick is "running" + scheduler.stop(); + + // Resolve the tick + resolveTickFn(); + await vi.advanceTimersByTimeAsync(0); + + // No further tick should be scheduled + await vi.advanceTimersByTimeAsync(10 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(1); + }); + }); + + describe('edge cases', () => { + it('handles empty schedule by immediately using intervalMinutes', async () => { + const tickFn = vi.fn().mockResolvedValue(undefined); + const scheduler = new ProgressScheduler([], 3); + scheduler.start(tickFn); + + await vi.advanceTimersByTimeAsync(3 * 60 * 1000); + expect(tickFn).toHaveBeenCalledTimes(1); + + scheduler.stop(); + }); + }); +});