diff --git a/CLAUDE.md b/CLAUDE.md index 0de0090d..5b1bd5dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,7 +98,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | Domain | Key files | Notes | |--------|-----------|-------| | **Entry** | `src/index.ts`, `src/cli.ts` | | -| **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts` | | +| **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts`, `src/session-lifecycle-log.ts`, `src/session-task-cache.ts` | | | **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` | | | **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first | | **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first | @@ -109,7 +109,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | | | **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | | | **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (12 route modules + barrel), `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | | -| **Frontend** | `src/web/public/app.js` ★ (~11.8K lines) + 10 JS modules (incl. `sw.js` service worker) | | +| **Frontend** | `src/web/public/app.js` ★ (~12.1K lines) + 9 JS modules (incl. `sw.js` service worker) | | | **Types** | `src/types/index.ts` → 13 domain files | See `@fileoverview` in index.ts | ★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in. diff --git a/src/ai-checker-base.ts b/src/ai-checker-base.ts index f5d1ef4e..23a6bbfc 100644 --- a/src/ai-checker-base.ts +++ b/src/ai-checker-base.ts @@ -30,6 +30,7 @@ import { join } from 'node:path'; import { EventEmitter } from 'node:events'; import { getAugmentedPath } from './utils/claude-cli-resolver.js'; import { ANSI_ESCAPE_PATTERN_SIMPLE } from './utils/index.js'; +import { AI_CHECK_MAX_BACKOFF_MS } from './config/ai-defaults.js'; // ========== Security Validation ========== @@ -534,10 +535,7 @@ export abstract class AiCheckerBase< // P1-005: Exponential backoff for errors // Base cooldown * 2^(consecutiveErrors-1), capped at 5 minutes const backoffMultiplier = Math.pow(2, this.consecutiveErrors - 1); - const backoffCooldownMs = Math.min( - this.config.errorCooldownMs * backoffMultiplier, - 5 * 60 * 1000 // Max 5 minutes - ); + const backoffCooldownMs = Math.min(this.config.errorCooldownMs * backoffMultiplier, AI_CHECK_MAX_BACKOFF_MS); this.log(`Exponential backoff: ${Math.round(backoffCooldownMs / 1000)}s (error #${this.consecutiveErrors})`); this.startCooldown(backoffCooldownMs); } diff --git a/src/ai-idle-checker.ts b/src/ai-idle-checker.ts index 5cd68b03..a4940c0e 100644 --- a/src/ai-idle-checker.ts +++ b/src/ai-idle-checker.ts @@ -30,7 +30,14 @@ import { type AiCheckerResultBase, type AiCheckerStateBase, } from './ai-checker-base.js'; -import { AI_CHECK_MODEL, AI_IDLE_CHECK_MAX_CONTEXT } from './config/ai-defaults.js'; +import { + AI_CHECK_MODEL, + AI_IDLE_CHECK_MAX_CONTEXT, + AI_IDLE_CHECK_TIMEOUT_MS, + AI_IDLE_CHECK_COOLDOWN_MS, + AI_IDLE_CHECK_ERROR_COOLDOWN_MS, + AI_CHECK_MAX_CONSECUTIVE_ERRORS, +} from './config/ai-defaults.js'; // ========== Types ========== @@ -48,10 +55,10 @@ const DEFAULT_AI_CHECK_CONFIG: AiIdleCheckConfig = { enabled: true, model: AI_CHECK_MODEL, maxContextChars: AI_IDLE_CHECK_MAX_CONTEXT, - checkTimeoutMs: 90000, - cooldownMs: 180000, - errorCooldownMs: 60000, - maxConsecutiveErrors: 3, + checkTimeoutMs: AI_IDLE_CHECK_TIMEOUT_MS, + cooldownMs: AI_IDLE_CHECK_COOLDOWN_MS, + errorCooldownMs: AI_IDLE_CHECK_ERROR_COOLDOWN_MS, + maxConsecutiveErrors: AI_CHECK_MAX_CONSECUTIVE_ERRORS, }; /** Pattern to match IDLE or WORKING as the first word of output */ diff --git a/src/ai-plan-checker.ts b/src/ai-plan-checker.ts index 9fbb5421..c03b0ce2 100644 --- a/src/ai-plan-checker.ts +++ b/src/ai-plan-checker.ts @@ -29,7 +29,14 @@ import { type AiCheckerResultBase, type AiCheckerStateBase, } from './ai-checker-base.js'; -import { AI_CHECK_MODEL, AI_PLAN_CHECK_MAX_CONTEXT } from './config/ai-defaults.js'; +import { + AI_CHECK_MODEL, + AI_PLAN_CHECK_MAX_CONTEXT, + AI_PLAN_CHECK_TIMEOUT_MS, + AI_PLAN_CHECK_COOLDOWN_MS, + AI_PLAN_CHECK_ERROR_COOLDOWN_MS, + AI_CHECK_MAX_CONSECUTIVE_ERRORS, +} from './config/ai-defaults.js'; // ========== Types ========== @@ -47,10 +54,10 @@ const DEFAULT_PLAN_CHECK_CONFIG: AiPlanCheckConfig = { enabled: true, model: AI_CHECK_MODEL, maxContextChars: AI_PLAN_CHECK_MAX_CONTEXT, - checkTimeoutMs: 60000, - cooldownMs: 30000, - errorCooldownMs: 30000, - maxConsecutiveErrors: 3, + checkTimeoutMs: AI_PLAN_CHECK_TIMEOUT_MS, + cooldownMs: AI_PLAN_CHECK_COOLDOWN_MS, + errorCooldownMs: AI_PLAN_CHECK_ERROR_COOLDOWN_MS, + maxConsecutiveErrors: AI_CHECK_MAX_CONSECUTIVE_ERRORS, }; /** Pattern to match PLAN_MODE or NOT_PLAN_MODE as the first word(s) of output */ diff --git a/src/config/ai-defaults.ts b/src/config/ai-defaults.ts index 3c230f0b..8393f719 100644 --- a/src/config/ai-defaults.ts +++ b/src/config/ai-defaults.ts @@ -1,13 +1,17 @@ /** - * @fileoverview Default model and context limits for AI-powered checkers. + * @fileoverview Default model, context limits, and timing for AI-powered checkers. * - * Centralizes the AI model identifier and context window sizes used by - * the idle checker, plan checker, respawn controller defaults, and - * respawn route fallbacks. Change the model here when upgrading. + * Centralizes the AI model identifier, context window sizes, and timeout/cooldown + * defaults used by the idle checker, plan checker, respawn controller defaults, + * and respawn route fallbacks. Change values here when tuning AI check behavior. * * @module config/ai-defaults */ +// ============================================================================ +// Model & Context +// ============================================================================ + /** Default model for AI idle and plan checkers */ export const AI_CHECK_MODEL = 'claude-opus-4-5-20251101'; @@ -16,3 +20,39 @@ export const AI_IDLE_CHECK_MAX_CONTEXT = 16000; /** Max context chars for plan checker (~2k tokens, plan mode UI is compact) */ export const AI_PLAN_CHECK_MAX_CONTEXT = 8000; + +// ============================================================================ +// AI Idle Checker Timing +// ============================================================================ + +/** Timeout for AI idle check (90 seconds — thinking can be slow) */ +export const AI_IDLE_CHECK_TIMEOUT_MS = 90_000; + +/** Cooldown after WORKING verdict (3 minutes) */ +export const AI_IDLE_CHECK_COOLDOWN_MS = 180_000; + +/** Cooldown after AI idle check error (1 minute) */ +export const AI_IDLE_CHECK_ERROR_COOLDOWN_MS = 60_000; + +// ============================================================================ +// AI Plan Checker Timing +// ============================================================================ + +/** Timeout for AI plan check (60 seconds — allows time for thinking) */ +export const AI_PLAN_CHECK_TIMEOUT_MS = 60_000; + +/** Cooldown after NOT_PLAN_MODE verdict (30 seconds) */ +export const AI_PLAN_CHECK_COOLDOWN_MS = 30_000; + +/** Cooldown after AI plan check error (30 seconds) */ +export const AI_PLAN_CHECK_ERROR_COOLDOWN_MS = 30_000; + +// ============================================================================ +// Shared AI Checker Limits +// ============================================================================ + +/** Max consecutive errors before disabling an AI checker */ +export const AI_CHECK_MAX_CONSECUTIVE_ERRORS = 3; + +/** Maximum exponential backoff cap for AI checker errors (5 minutes) */ +export const AI_CHECK_MAX_BACKOFF_MS = 5 * 60 * 1000; diff --git a/src/config/server-timing.ts b/src/config/server-timing.ts index 2feeba5a..adbc6efe 100644 --- a/src/config/server-timing.ts +++ b/src/config/server-timing.ts @@ -73,3 +73,16 @@ export const MAX_CONSECUTIVE_ERRORS = 5; /** Error counter reset interval — forgives errors after quiet period (ms) */ export const ERROR_RESET_MS = 60_000; + +// ============================================================================ +// Common Cleanup Intervals +// ============================================================================ + +/** Standard 1-minute cleanup/check interval used by multiple subsystems (ms) */ +export const CLEANUP_CHECK_INTERVAL_MS = 60_000; + +/** Standard 1-hour max age for stale/completed data (ms) */ +export const STALE_DATA_MAX_AGE_MS = 60 * 60 * 1000; + +/** Standard 5-minute inactivity timeout for streams and caches (ms) */ +export const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; diff --git a/src/file-stream-manager.ts b/src/file-stream-manager.ts index 4d92563d..2483ae9f 100644 --- a/src/file-stream-manager.ts +++ b/src/file-stream-manager.ts @@ -16,6 +16,7 @@ import { existsSync, statSync, realpathSync } from 'node:fs'; import { resolve, relative, isAbsolute } from 'node:path'; import { homedir } from 'node:os'; import { EventEmitter } from 'node:events'; +import { CLEANUP_CHECK_INTERVAL_MS, INACTIVITY_TIMEOUT_MS } from './config/server-timing.js'; // ========== Configuration Constants ========== @@ -39,7 +40,7 @@ const MAX_STREAMS_PER_SESSION = 5; * Inactivity timeout for streams (5 minutes). * Streams with no data for this long will be auto-closed. */ -const STREAM_INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; +const STREAM_INACTIVITY_TIMEOUT_MS = INACTIVITY_TIMEOUT_MS; // ========== Types ========== @@ -129,7 +130,7 @@ export class FileStreamManager extends EventEmitter { constructor() { super(); // Start cleanup timer for inactive streams - this.cleanupTimer = setInterval(() => this.cleanupInactiveStreams(), 60 * 1000); + this.cleanupTimer = setInterval(() => this.cleanupInactiveStreams(), CLEANUP_CHECK_INTERVAL_MS); } // ========== Public Methods ========== diff --git a/src/ralph-config.ts b/src/ralph-config.ts index ad42ac26..bf7c0704 100644 --- a/src/ralph-config.ts +++ b/src/ralph-config.ts @@ -9,6 +9,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { execPattern } from './utils/index.js'; // Pattern to extract completion phrase from CLAUDE.md // Matches PHRASE with optional whitespace @@ -83,9 +84,7 @@ export function parseRalphLoopConfigFromContent(content: string): RalphLoopConfi }; // Parse each YAML line - let match; - YAML_LINE_PATTERN.lastIndex = 0; - while ((match = YAML_LINE_PATTERN.exec(yaml)) !== null) { + execPattern(YAML_LINE_PATTERN, yaml, (match) => { const key = match[1].toLowerCase(); const value = match[2].trim(); @@ -103,7 +102,7 @@ export function parseRalphLoopConfigFromContent(content: string): RalphLoopConfi config.completionPromise = value.toUpperCase(); break; } - } + }); return config; } diff --git a/src/ralph-stall-detector.ts b/src/ralph-stall-detector.ts index cc1f0963..290fae5f 100644 --- a/src/ralph-stall-detector.ts +++ b/src/ralph-stall-detector.ts @@ -10,6 +10,7 @@ */ import { EventEmitter } from 'node:events'; +import { CLEANUP_CHECK_INTERVAL_MS } from './config/server-timing.js'; /** * RalphStallDetector - Detects iteration stalls in the Ralph loop. @@ -57,7 +58,7 @@ export class RalphStallDetector extends EventEmitter { // Check every minute this._iterationStallTimer = setInterval(() => { this.checkIterationStall(); - }, 60 * 1000); + }, CLEANUP_CHECK_INTERVAL_MS); } /** diff --git a/src/ralph-tracker.ts b/src/ralph-tracker.ts index 08e49b3b..ff408d87 100644 --- a/src/ralph-tracker.ts +++ b/src/ralph-tracker.ts @@ -55,6 +55,7 @@ import { stringSimilarity, Debouncer, CleanupManager, + execPattern, } from './utils/index.js'; import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js'; import { MAX_TODOS_PER_SESSION } from './config/map-limits.js'; @@ -63,6 +64,7 @@ import type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js import { RalphFixPlanWatcher, generateFixPlanMarkdown, importFixPlanMarkdown } from './ralph-fix-plan-watcher.js'; import { RalphStallDetector } from './ralph-stall-detector.js'; import { RalphStatusParser } from './ralph-status-parser.js'; +import { STALE_DATA_MAX_AGE_MS, INACTIVITY_TIMEOUT_MS } from './config/server-timing.js'; // Re-export sub-module types for backward compatibility export type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js'; @@ -72,9 +74,9 @@ export type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js /** * Todo items older than this duration (in milliseconds) will be auto-expired. - * Default: 1 hour (60 * 60 * 1000) + * Default: 1 hour */ -const TODO_EXPIRY_MS = 60 * 60 * 1000; +const TODO_EXPIRY_MS = STALE_DATA_MAX_AGE_MS; /** * Minimum interval between on-demand cleanup checks (in milliseconds). @@ -88,7 +90,7 @@ const CLEANUP_THROTTLE_MS = 30 * 1000; * Actively purges expired todos even when no terminal data is flowing. * Default: 5 minutes */ -const TODO_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; +const TODO_CLEANUP_INTERVAL_MS = INACTIVITY_TIMEOUT_MS; /** * Similarity threshold for todo deduplication. @@ -1666,35 +1668,32 @@ export class RalphTracker extends EventEmitter { let match: RegExpExecArray | null; if (hasCheckbox) { - TODO_CHECKBOX_PATTERN.lastIndex = 0; - while ((match = TODO_CHECKBOX_PATTERN.exec(line)) !== null) { + execPattern(TODO_CHECKBOX_PATTERN, line, (match) => { const checked = match[1].toLowerCase() === 'x'; const content = match[2].trim(); const status: RalphTodoStatus = checked ? 'completed' : 'pending'; this.upsertTodo(content, status); updated = true; - } + }); } if (hasTodoIndicator) { - TODO_INDICATOR_PATTERN.lastIndex = 0; - while ((match = TODO_INDICATOR_PATTERN.exec(line)) !== null) { + execPattern(TODO_INDICATOR_PATTERN, line, (match) => { const icon = match[1]; const content = match[2].trim(); const status = this.iconToStatus(icon); this.upsertTodo(content, status); updated = true; - } + }); } if (hasStatus) { - TODO_STATUS_PATTERN.lastIndex = 0; - while ((match = TODO_STATUS_PATTERN.exec(line)) !== null) { + execPattern(TODO_STATUS_PATTERN, line, (match) => { const content = match[1].trim(); const status = match[2] as RalphTodoStatus; this.upsertTodo(content, status); updated = true; - } + }); } if (hasNativeCheckbox) { @@ -1715,8 +1714,7 @@ export class RalphTracker extends EventEmitter { } if (hasCheckmark) { - TODO_TASK_CREATED_PATTERN.lastIndex = 0; - while ((match = TODO_TASK_CREATED_PATTERN.exec(line)) !== null) { + execPattern(TODO_TASK_CREATED_PATTERN, line, (match) => { const taskNum = parseInt(match[1], 10); const content = match[2].trim(); if (content.length >= 5) { @@ -1725,10 +1723,9 @@ export class RalphTracker extends EventEmitter { this.upsertTodo(content, 'pending'); updated = true; } - } + }); - TODO_TASK_SUMMARY_PATTERN.lastIndex = 0; - while ((match = TODO_TASK_SUMMARY_PATTERN.exec(line)) !== null) { + execPattern(TODO_TASK_SUMMARY_PATTERN, line, (match) => { const taskNum = parseInt(match[1], 10); const content = match[2].trim(); if (content.length >= 5) { @@ -1739,10 +1736,9 @@ export class RalphTracker extends EventEmitter { this.upsertTodo(this._taskNumberToContent.get(taskNum) || content, 'pending'); updated = true; } - } + }); - TODO_TASK_STATUS_PATTERN.lastIndex = 0; - while ((match = TODO_TASK_STATUS_PATTERN.exec(line)) !== null) { + execPattern(TODO_TASK_STATUS_PATTERN, line, (match) => { const taskNum = parseInt(match[1], 10); const statusStr = match[2].trim(); const status: RalphTodoStatus = @@ -1752,7 +1748,7 @@ export class RalphTracker extends EventEmitter { this.upsertTodo(content, status); updated = true; } - } + }); if (!updated) { TODO_PLAIN_CHECKMARK_PATTERN.lastIndex = 0; diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index 7020971d..55ecbd7b 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -62,7 +62,15 @@ import { import { RespawnAdaptiveTiming } from './respawn-adaptive-timing.js'; import { RespawnCycleMetricsTracker } from './respawn-metrics.js'; import { calculateHealthScore, shouldSkipClear, type HealthInputs } from './respawn-health.js'; -import { AI_CHECK_MODEL, AI_IDLE_CHECK_MAX_CONTEXT, AI_PLAN_CHECK_MAX_CONTEXT } from './config/ai-defaults.js'; +import { + AI_CHECK_MODEL, + AI_IDLE_CHECK_MAX_CONTEXT, + AI_PLAN_CHECK_MAX_CONTEXT, + AI_IDLE_CHECK_TIMEOUT_MS, + AI_IDLE_CHECK_COOLDOWN_MS, + AI_PLAN_CHECK_TIMEOUT_MS, + AI_PLAN_CHECK_COOLDOWN_MS, +} from './config/ai-defaults.js'; import type { RespawnCycleMetrics, RespawnAggregateMetrics, @@ -559,13 +567,13 @@ const DEFAULT_CONFIG: RespawnConfig = { aiIdleCheckEnabled: true, // use AI to confirm idle state aiIdleCheckModel: AI_CHECK_MODEL, aiIdleCheckMaxContext: AI_IDLE_CHECK_MAX_CONTEXT, - aiIdleCheckTimeoutMs: 90000, // 90 seconds (thinking can be slow) - aiIdleCheckCooldownMs: 180000, // 3 minutes after WORKING verdict + aiIdleCheckTimeoutMs: AI_IDLE_CHECK_TIMEOUT_MS, + aiIdleCheckCooldownMs: AI_IDLE_CHECK_COOLDOWN_MS, aiPlanCheckEnabled: true, // use AI to confirm plan mode before auto-accept aiPlanCheckModel: AI_CHECK_MODEL, aiPlanCheckMaxContext: AI_PLAN_CHECK_MAX_CONTEXT, - aiPlanCheckTimeoutMs: 60000, // 60 seconds (thinking can be slow) - aiPlanCheckCooldownMs: 30000, // 30 seconds after NOT_PLAN_MODE + aiPlanCheckTimeoutMs: AI_PLAN_CHECK_TIMEOUT_MS, + aiPlanCheckCooldownMs: AI_PLAN_CHECK_COOLDOWN_MS, stuckStateDetectionEnabled: true, // detect stuck states stuckStateWarningMs: 300000, // 5 minutes warning threshold stuckStateRecoveryMs: 600000, // 10 minutes recovery threshold diff --git a/src/run-summary.ts b/src/run-summary.ts index bf27842a..b6eaa903 100644 --- a/src/run-summary.ts +++ b/src/run-summary.ts @@ -22,6 +22,7 @@ import { RunSummaryStats, createInitialRunSummaryStats, } from './types.js'; +import { CLEANUP_CHECK_INTERVAL_MS } from './config/server-timing.js'; /** Maximum events to keep per session (FIFO trimming) */ const MAX_EVENTS = 1000; @@ -36,7 +37,7 @@ const TOKEN_MILESTONE_INTERVAL = 50000; const STATE_STUCK_WARNING_MS = 10 * 60 * 1000; // 10 minutes /** State stuck check interval (ms) */ -const STATE_STUCK_CHECK_INTERVAL = 60 * 1000; // 1 minute +const STATE_STUCK_CHECK_INTERVAL = CLEANUP_CHECK_INTERVAL_MS; /** * Tracks events and statistics for a session's run summary. diff --git a/src/session.ts b/src/session.ts index e83a0018..210f3eb4 100644 --- a/src/session.ts +++ b/src/session.ts @@ -49,7 +49,13 @@ import { TaskTracker, type BackgroundTask } from './task-tracker.js'; import { RalphTracker } from './ralph-tracker.js'; import { BashToolParser } from './bash-tool-parser.js'; import { BufferAccumulator } from './utils/buffer-accumulator.js'; -import { ANSI_ESCAPE_PATTERN_FULL, TOKEN_PATTERN, SPINNER_PATTERN, MAX_SESSION_TOKENS } from './utils/index.js'; +import { + ANSI_ESCAPE_PATTERN_FULL, + TOKEN_PATTERN, + SPINNER_PATTERN, + MAX_SESSION_TOKENS, + execPattern, +} from './utils/index.js'; import { MAX_TERMINAL_BUFFER_SIZE, TRIM_TERMINAL_TO as TERMINAL_BUFFER_TRIM_SIZE, @@ -1692,16 +1698,12 @@ export class Session extends EventEmitter { // Quick pre-check: skip expensive regex if no common tool patterns present if (!cleanLine.includes('(') || !cleanLine.includes(')')) return; - // Reset regex lastIndex for global pattern - TASK_TOOL_PATTERN.lastIndex = 0; - - let match; - while ((match = TASK_TOOL_PATTERN.exec(cleanLine)) !== null) { + execPattern(TASK_TOOL_PATTERN, cleanLine, (match) => { const description = match[2].trim(); if (description && description.length > 0) { this._taskCache.add(Date.now(), description); } - } + }); } /** diff --git a/src/subagent-watcher.ts b/src/subagent-watcher.ts index d30d8305..2a55e97d 100644 --- a/src/subagent-watcher.ts +++ b/src/subagent-watcher.ts @@ -34,6 +34,7 @@ import { join, basename } from 'node:path'; import { execFile } from 'node:child_process'; import { readFile, readdir, stat as statAsync } from 'node:fs/promises'; import { PENDING_TOOL_CALL_TTL_MS, MAX_PENDING_TOOL_CALLS, MAX_TRACKED_AGENTS } from './config/map-limits.js'; +import { STALE_DATA_MAX_AGE_MS } from './config/server-timing.js'; import { CleanupManager, KeyedDebouncer } from './utils/index.js'; // ========== Types ========== @@ -151,7 +152,7 @@ const POLL_INTERVAL_MS = 1000; // Base poll interval (lightweight checks) const FULL_SCAN_EVERY_N_POLLS = 5; // Full directory traversal every 5th poll (5s) const LIVENESS_CHECK_MS = 10000; // Check if subagent processes are still alive every 10s const FILE_ALIVE_THRESHOLD_MS = 30000; // File mtime within 30s = agent alive (primary check) -const STALE_COMPLETED_MAX_AGE_MS = 60 * 60 * 1000; // Remove completed agents older than 1 hour +const STALE_COMPLETED_MAX_AGE_MS = STALE_DATA_MAX_AGE_MS; // Remove completed agents older than 1 hour const STALE_IDLE_MAX_AGE_MS = 4 * 60 * 60 * 1000; // Remove idle agents older than 4 hours const STARTUP_MAX_FILE_AGE_MS = 4 * 60 * 60 * 1000; // Only load files modified in last 4 hours on startup diff --git a/src/task-tracker.ts b/src/task-tracker.ts index 5440e609..eabb2ce1 100644 --- a/src/task-tracker.ts +++ b/src/task-tracker.ts @@ -22,6 +22,7 @@ import { EventEmitter } from 'node:events'; import { assertNever } from './utils/index.js'; +import { STALE_DATA_MAX_AGE_MS } from './config/server-timing.js'; // ========== Configuration Constants ========== @@ -36,7 +37,7 @@ const MAX_COMPLETED_TASKS = 100; * Entries older than this are cleaned up to prevent unbounded growth. * Default: 1 hour */ -const PENDING_TOOL_USE_MAX_AGE_MS = 60 * 60 * 1000; +const PENDING_TOOL_USE_MAX_AGE_MS = STALE_DATA_MAX_AGE_MS; /** * Maximum number of pending tool uses to allow. diff --git a/src/types/plan.ts b/src/types/plan.ts index 57f66143..41c514dd 100644 --- a/src/types/plan.ts +++ b/src/types/plan.ts @@ -6,7 +6,7 @@ * Key exports: * - PlanItem — a single task with priority (P0/P1/P2), TDD phase, dependencies, verification criteria * - PlanTaskStatus — 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked' - * - TddPhase / PlanPhase — 'setup' | 'test' | 'impl' | 'verify' | 'review' + * - TddPhase — 'setup' | 'test' | 'impl' | 'verify' | 'review' * * Used by PlanOrchestrator (`src/plan-orchestrator.ts`) and the plan API routes * (`src/web/routes/plan-routes.ts`). Served at `GET /api/sessions/:id/plan/tasks`. @@ -21,9 +21,6 @@ export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed' /** TDD phase categories */ export type TddPhase = 'setup' | 'test' | 'impl' | 'verify' | 'review'; -/** Development phase in TDD cycle (alias for TddPhase) */ -export type PlanPhase = TddPhase; - /** * A single plan item for plan orchestration. * Moved here from plan-orchestrator.ts to break circular dependency. @@ -42,7 +39,7 @@ export interface PlanItem { lastError?: string; completedAt?: number; complexity?: 'low' | 'medium' | 'high'; - tddPhase?: PlanPhase; + tddPhase?: TddPhase; pairedWith?: string; reviewChecklist?: string[]; } diff --git a/src/utils/index.ts b/src/utils/index.ts index ebe137b8..9ffa0fc7 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -20,8 +20,9 @@ export { createAnsiPatternSimple, stripAnsi, SAFE_PATH_PATTERN, + execPattern, } from './regex-patterns.js'; -export { MAX_SESSION_TOKENS, validateTokenCounts, validateTokensAndCost } from './token-validation.js'; +export { MAX_SESSION_TOKENS } from './token-validation.js'; export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js'; export { assertNever } from './type-safety.js'; export { wrapWithNice } from './nice-wrapper.js'; diff --git a/src/utils/regex-patterns.ts b/src/utils/regex-patterns.ts index 590043b1..d8cc81ae 100644 --- a/src/utils/regex-patterns.ts +++ b/src/utils/regex-patterns.ts @@ -79,3 +79,15 @@ export function stripAnsi(text: string): string { export const SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧]/; export const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_/\-. ~]+$/; + +/** + * Execute a global regex pattern against data, calling the callback for each match. + * Automatically resets lastIndex before execution. + */ +export function execPattern(pattern: RegExp, data: string, callback: (match: RegExpExecArray) => void): void { + pattern.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = pattern.exec(data)) !== null) { + callback(match); + } +} diff --git a/src/utils/token-validation.ts b/src/utils/token-validation.ts index bb554318..339a419a 100644 --- a/src/utils/token-validation.ts +++ b/src/utils/token-validation.ts @@ -1,7 +1,6 @@ /** - * @fileoverview Token validation utilities. + * @fileoverview Token validation constants. * - * Centralizes token count validation logic used across the codebase. * Claude's context window is ~200k tokens, so 500k is a generous upper bound. * * @module utils/token-validation @@ -12,58 +11,3 @@ * Claude's context is ~200k, so 500k is a safe upper bound for validation. */ export const MAX_SESSION_TOKENS = 500_000; - -/** - * Validates token counts are within acceptable bounds. - * Rejects negative values and values exceeding MAX_SESSION_TOKENS. - * - * @param inputTokens - Input token count to validate - * @param outputTokens - Output token count to validate - * @returns Object with isValid flag and optional error reason - */ -export function validateTokenCounts(inputTokens: number, outputTokens: number): { isValid: boolean; reason?: string } { - if (inputTokens < 0 || outputTokens < 0) { - return { - isValid: false, - reason: `Negative token values: input=${inputTokens}, output=${outputTokens}`, - }; - } - - if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) { - return { - isValid: false, - reason: `Token values exceed maximum (${MAX_SESSION_TOKENS}): input=${inputTokens}, output=${outputTokens}`, - }; - } - - return { isValid: true }; -} - -/** - * Validates token counts and cost for restoration/persistence. - * Returns true if all values are valid. - * - * @param inputTokens - Input token count - * @param outputTokens - Output token count - * @param cost - Cost value (must be non-negative) - * @returns Object with isValid flag and optional error reason - */ -export function validateTokensAndCost( - inputTokens: number, - outputTokens: number, - cost: number -): { isValid: boolean; reason?: string } { - const tokenValidation = validateTokenCounts(inputTokens, outputTokens); - if (!tokenValidation.isValid) { - return tokenValidation; - } - - if (cost < 0) { - return { - isValid: false, - reason: `Negative cost value: ${cost}`, - }; - } - - return { isValid: true }; -} diff --git a/src/web/routes/respawn-routes.ts b/src/web/routes/respawn-routes.ts index 09dc8324..e203b976 100644 --- a/src/web/routes/respawn-routes.ts +++ b/src/web/routes/respawn-routes.ts @@ -11,7 +11,15 @@ import { SseEvent } from '../sse-events.js'; import { findSessionOrFail, autoConfigureRalph } from '../route-helpers.js'; import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js'; -import { AI_CHECK_MODEL, AI_IDLE_CHECK_MAX_CONTEXT, AI_PLAN_CHECK_MAX_CONTEXT } from '../../config/ai-defaults.js'; +import { + AI_CHECK_MODEL, + AI_IDLE_CHECK_MAX_CONTEXT, + AI_PLAN_CHECK_MAX_CONTEXT, + AI_IDLE_CHECK_TIMEOUT_MS, + AI_IDLE_CHECK_COOLDOWN_MS, + AI_PLAN_CHECK_TIMEOUT_MS, + AI_PLAN_CHECK_COOLDOWN_MS, +} from '../../config/ai-defaults.js'; /** No-op EventPort used to suppress broadcasts during pre-start ralph configuration. */ const noopEventPort: EventPort = { @@ -188,14 +196,18 @@ export function registerRespawnRoutes( aiIdleCheckModel: config.aiIdleCheckModel ?? currentConfig?.aiIdleCheckModel ?? AI_CHECK_MODEL, aiIdleCheckMaxContext: config.aiIdleCheckMaxContext ?? currentConfig?.aiIdleCheckMaxContext ?? AI_IDLE_CHECK_MAX_CONTEXT, - aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs ?? 90000, - aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs ?? 180000, + aiIdleCheckTimeoutMs: + config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs ?? AI_IDLE_CHECK_TIMEOUT_MS, + aiIdleCheckCooldownMs: + config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs ?? AI_IDLE_CHECK_COOLDOWN_MS, aiPlanCheckEnabled: config.aiPlanCheckEnabled ?? currentConfig?.aiPlanCheckEnabled ?? true, aiPlanCheckModel: config.aiPlanCheckModel ?? currentConfig?.aiPlanCheckModel ?? AI_CHECK_MODEL, aiPlanCheckMaxContext: config.aiPlanCheckMaxContext ?? currentConfig?.aiPlanCheckMaxContext ?? AI_PLAN_CHECK_MAX_CONTEXT, - aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs ?? 60000, - aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs ?? 30000, + aiPlanCheckTimeoutMs: + config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs ?? AI_PLAN_CHECK_TIMEOUT_MS, + aiPlanCheckCooldownMs: + config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs ?? AI_PLAN_CHECK_COOLDOWN_MS, durationMinutes: currentConfig?.durationMinutes, }; ctx.mux.updateRespawnConfig(id, merged); diff --git a/src/web/server.ts b/src/web/server.ts index 16da8825..53c51ac9 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -120,6 +120,7 @@ import { ITERATION_PAUSE_MS, BATCH_FLUSH_THRESHOLD, STATS_COLLECTION_INTERVAL_MS, + INACTIVITY_TIMEOUT_MS, } from '../config/server-timing.js'; // DEC mode 2026 - Synchronized Output @@ -230,7 +231,7 @@ export class WebServer extends EventEmitter { // Adaptive batching: track rapid events to extend batch window (per-session) // StaleExpirationMap auto-cleans entries for sessions that stop generating output private lastTerminalEventTime: StaleExpirationMap = new StaleExpirationMap({ - ttlMs: 5 * 60 * 1000, // 5 minutes - auto-expire stale session timing data + ttlMs: INACTIVITY_TIMEOUT_MS, // 5 minutes - auto-expire stale session timing data refreshOnGet: false, // Don't refresh on reads, only on explicit sets }); // Centralized cleanup for standalone timers (intervals + resettable timeouts) @@ -2397,7 +2398,7 @@ export class WebServer extends EventEmitter { () => { this.recordPeriodicTokenUsage(); }, - 5 * 60 * 1000, + INACTIVITY_TIMEOUT_MS, { description: 'periodic token recording' } ); diff --git a/test/buffer-accumulator.test.ts b/test/buffer-accumulator.test.ts new file mode 100644 index 00000000..a702f4b0 --- /dev/null +++ b/test/buffer-accumulator.test.ts @@ -0,0 +1,279 @@ +import { describe, it, expect, vi } from 'vitest'; +import { BufferAccumulator } from '../src/utils/buffer-accumulator.js'; + +describe('BufferAccumulator', () => { + describe('construction', () => { + it('creates with simple size parameters', () => { + const buf = new BufferAccumulator(1000, 800); + expect(buf.isEmpty).toBe(true); + expect(buf.length).toBe(0); + expect(buf.value).toBe(''); + }); + + it('creates with BufferConfig object', () => { + const buf = new BufferAccumulator({ maxSize: 1000, trimSize: 800 }); + expect(buf.isEmpty).toBe(true); + }); + + it('creates with BufferConfig including onTrim', () => { + const onTrim = vi.fn(); + const buf = new BufferAccumulator({ maxSize: 100, trimSize: 50, onTrim }); + buf.append('x'.repeat(101)); + expect(onTrim).toHaveBeenCalled(); + }); + }); + + describe('append', () => { + it('appends data and updates length', () => { + const buf = new BufferAccumulator(1000, 800); + buf.append('hello'); + expect(buf.length).toBe(5); + expect(buf.isEmpty).toBe(false); + buf.append(' world'); + expect(buf.length).toBe(11); + expect(buf.value).toBe('hello world'); + }); + + it('ignores empty strings', () => { + const buf = new BufferAccumulator(1000, 800); + buf.append(''); + expect(buf.length).toBe(0); + expect(buf.isEmpty).toBe(true); + }); + + it('handles multiple chunks efficiently', () => { + const buf = new BufferAccumulator(10000, 8000); + for (let i = 0; i < 100; i++) { + buf.append(`chunk${i}`); + } + expect(buf.length).toBeGreaterThan(0); + const value = buf.value; + expect(value).toContain('chunk0'); + expect(value).toContain('chunk99'); + }); + }); + + describe('value', () => { + it('returns empty string when empty', () => { + const buf = new BufferAccumulator(1000, 800); + expect(buf.value).toBe(''); + }); + + it('returns single chunk without joining', () => { + const buf = new BufferAccumulator(1000, 800); + buf.append('single'); + expect(buf.value).toBe('single'); + }); + + it('consolidates chunks on access', () => { + const buf = new BufferAccumulator(1000, 800); + buf.append('a'); + buf.append('b'); + buf.append('c'); + expect(buf.value).toBe('abc'); + // Access again should be same (consolidated) + expect(buf.value).toBe('abc'); + }); + }); + + describe('clear', () => { + it('resets buffer to empty', () => { + const buf = new BufferAccumulator(1000, 800); + buf.append('some data'); + buf.clear(); + expect(buf.isEmpty).toBe(true); + expect(buf.length).toBe(0); + expect(buf.value).toBe(''); + }); + }); + + describe('set', () => { + it('replaces buffer content', () => { + const buf = new BufferAccumulator(1000, 800); + buf.append('old data'); + buf.set('new data'); + expect(buf.value).toBe('new data'); + expect(buf.length).toBe(8); + }); + + it('handles empty string', () => { + const buf = new BufferAccumulator(1000, 800); + buf.append('some data'); + buf.set(''); + expect(buf.isEmpty).toBe(true); + expect(buf.length).toBe(0); + }); + }); + + describe('tail', () => { + it('returns last N characters', () => { + const buf = new BufferAccumulator(1000, 800); + buf.append('hello world'); + expect(buf.tail(5)).toBe('world'); + }); + + it('returns entire buffer when N >= length', () => { + const buf = new BufferAccumulator(1000, 800); + buf.append('short'); + expect(buf.tail(100)).toBe('short'); + }); + + it('returns empty string on empty buffer', () => { + const buf = new BufferAccumulator(1000, 800); + expect(buf.tail(5)).toBe(''); + }); + }); + + describe('endsWith', () => { + it('returns true when buffer ends with suffix', () => { + const buf = new BufferAccumulator(1000, 800); + buf.append('hello world'); + expect(buf.endsWith('world')).toBe(true); + }); + + it('returns false when buffer does not end with suffix', () => { + const buf = new BufferAccumulator(1000, 800); + buf.append('hello world'); + expect(buf.endsWith('hello')).toBe(false); + }); + + it('returns true for empty suffix', () => { + const buf = new BufferAccumulator(1000, 800); + buf.append('hello'); + expect(buf.endsWith('')).toBe(true); + }); + + it('returns false when suffix is longer than buffer', () => { + const buf = new BufferAccumulator(1000, 800); + buf.append('hi'); + expect(buf.endsWith('hello world')).toBe(false); + }); + }); + + describe('contains', () => { + it('finds string pattern in buffer', () => { + const buf = new BufferAccumulator(1000, 800); + buf.append('hello world foo bar'); + expect(buf.contains('world')).toBe(true); + expect(buf.contains('baz')).toBe(false); + }); + + it('finds regex pattern in buffer', () => { + const buf = new BufferAccumulator(1000, 800); + buf.append('hello world 123'); + expect(buf.contains(/\d+/)).toBe(true); + expect(buf.contains(/[A-Z]{3}/)).toBe(false); + }); + + it('searches within last N characters when fromEnd provided', () => { + const buf = new BufferAccumulator(1000, 800); + buf.append('hello world'); + expect(buf.contains('hello', 5)).toBe(false); // last 5 = 'world' + expect(buf.contains('world', 5)).toBe(true); + }); + + it('regex search with fromEnd', () => { + const buf = new BufferAccumulator(1000, 800); + buf.append('abc 123 xyz'); + expect(buf.contains(/\d+/, 4)).toBe(false); // last 4 = ' xyz' + expect(buf.contains(/\d+/, 8)).toBe(true); // last 8 = '123 xyz' + }); + }); + + describe('trimming', () => { + it('trims when maxSize is exceeded', () => { + const buf = new BufferAccumulator(100, 50); + buf.append('x'.repeat(60)); + buf.append('y'.repeat(50)); // total 110, triggers trim + expect(buf.length).toBeLessThanOrEqual(100); + // After trim, should keep most recent data + expect(buf.value).toContain('y'); + }); + + it('calls onTrim callback with trimmed byte count', () => { + const onTrim = vi.fn(); + const buf = new BufferAccumulator({ maxSize: 100, trimSize: 50, onTrim }); + buf.append('x'.repeat(101)); // triggers trim + expect(onTrim).toHaveBeenCalledTimes(1); + expect(onTrim).toHaveBeenCalledWith(expect.any(Number)); + const trimmedBytes = onTrim.mock.calls[0][0] as number; + expect(trimmedBytes).toBeGreaterThan(0); + }); + + it('keeps most recent data after trim', () => { + const buf = new BufferAccumulator(100, 50); + buf.append('A'.repeat(60)); + buf.append('B'.repeat(50)); // total 110, triggers trim to ~50 chars + const value = buf.value; + // The end should be B's + expect(value.endsWith('B')).toBe(true); + }); + + it('advances past first newline within 4KB to avoid mid-ANSI-escape', () => { + const buf = new BufferAccumulator(100, 80); + // Create content that when trimmed, has a newline near the start of the kept portion + const prefix = 'A'.repeat(30); + const newlineSection = 'X'.repeat(10) + '\n' + 'Y'.repeat(10); + const suffix = 'B'.repeat(60); + buf.append(prefix + newlineSection + suffix); + // After trimming, the buffer should start after the first newline in the kept portion + const value = buf.value; + // Should not start with partial X's before the newline + if (value.includes('\n')) { + // If the newline fell within the kept window, it should be skipped + expect(value.startsWith('X')).toBe(false); + } + }); + + it('handles trim with no newline in first 4KB', () => { + const buf = new BufferAccumulator(100, 50); + // No newlines at all + buf.append('A'.repeat(110)); + // Should still trim without error + expect(buf.length).toBeLessThanOrEqual(100); + }); + + it('does not call onTrim when trimmedBytes is 0 or negative', () => { + const onTrim = vi.fn(); + // trimSize equal to maxSize means nothing actually gets trimmed in bytes + // But this scenario is unusual; just test that onTrim is only called with positive values + const buf = new BufferAccumulator({ maxSize: 50, trimSize: 50, onTrim }); + buf.append('x'.repeat(51)); // triggers trim, but trimSize == 50 so 1 byte trimmed + // The trim logic: full.length(51) - trimSize(50) = 1 > 0, so onTrim IS called + expect(onTrim).toHaveBeenCalledWith(expect.any(Number)); + }); + }); + + describe('edge cases', () => { + it('works with very small maxSize', () => { + const buf = new BufferAccumulator(5, 3); + buf.append('abcdef'); // 6 chars, triggers trim to 3 + expect(buf.length).toBeLessThanOrEqual(5); + }); + + it('handles rapid appends', () => { + const buf = new BufferAccumulator(10000, 8000); + for (let i = 0; i < 1000; i++) { + buf.append('data'); + } + expect(buf.length).toBeLessThanOrEqual(10000); + expect(buf.length).toBeGreaterThan(0); + }); + + it('set after trim works correctly', () => { + const buf = new BufferAccumulator(100, 50); + buf.append('x'.repeat(110)); // triggers trim + buf.set('fresh start'); + expect(buf.value).toBe('fresh start'); + expect(buf.length).toBe(11); + }); + + it('clear after trim works correctly', () => { + const buf = new BufferAccumulator(100, 50); + buf.append('x'.repeat(110)); // triggers trim + buf.clear(); + expect(buf.isEmpty).toBe(true); + expect(buf.value).toBe(''); + }); + }); +}); diff --git a/test/lru-map.test.ts b/test/lru-map.test.ts new file mode 100644 index 00000000..618ba61a --- /dev/null +++ b/test/lru-map.test.ts @@ -0,0 +1,356 @@ +import { describe, it, expect, vi } from 'vitest'; +import { LRUMap } from '../src/utils/lru-map.js'; + +describe('LRUMap', () => { + describe('construction', () => { + it('creates an empty map with maxSize', () => { + const map = new LRUMap({ maxSize: 5 }); + expect(map.size).toBe(0); + expect(map.maxEntries).toBe(5); + expect(map.freeSlots).toBe(5); + }); + + it('accepts an onEvict callback', () => { + const onEvict = vi.fn(); + const map = new LRUMap({ maxSize: 2, onEvict }); + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); // evicts 'a' + expect(onEvict).toHaveBeenCalledWith('a', 1); + }); + }); + + describe('set/get', () => { + it('stores and retrieves values', () => { + const map = new LRUMap({ maxSize: 3 }); + map.set('a', 1); + map.set('b', 2); + expect(map.get('a')).toBe(1); + expect(map.get('b')).toBe(2); + }); + + it('returns undefined for missing keys', () => { + const map = new LRUMap({ maxSize: 3 }); + expect(map.get('nonexistent')).toBeUndefined(); + }); + + it('overwrites existing key and refreshes position', () => { + const map = new LRUMap({ maxSize: 3 }); + map.set('a', 1); + map.set('b', 2); + map.set('a', 10); // refresh 'a' position + expect(map.get('a')).toBe(10); + expect(map.keysInOrder()).toEqual(['b', 'a']); + }); + + it('supports chaining on set', () => { + const map = new LRUMap({ maxSize: 3 }); + const result = map.set('a', 1).set('b', 2); + expect(result).toBe(map); + expect(map.size).toBe(2); + }); + }); + + describe('has', () => { + it('returns true for existing keys', () => { + const map = new LRUMap({ maxSize: 3 }); + map.set('a', 1); + expect(map.has('a')).toBe(true); + }); + + it('returns false for missing keys', () => { + const map = new LRUMap({ maxSize: 3 }); + expect(map.has('a')).toBe(false); + }); + + it('does not refresh position', () => { + const map = new LRUMap({ maxSize: 3 }); + map.set('a', 1); + map.set('b', 2); + map.has('a'); // should NOT refresh 'a' + expect(map.keysInOrder()).toEqual(['a', 'b']); + }); + }); + + describe('delete', () => { + it('removes an existing key', () => { + const map = new LRUMap({ maxSize: 3 }); + map.set('a', 1); + expect(map.delete('a')).toBe(true); + expect(map.has('a')).toBe(false); + expect(map.size).toBe(0); + }); + + it('returns false for non-existent key', () => { + const map = new LRUMap({ maxSize: 3 }); + expect(map.delete('nonexistent')).toBe(false); + }); + + it('updates newestKey when deleting the newest entry', () => { + const map = new LRUMap({ maxSize: 3 }); + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); + map.delete('c'); // delete newest + expect(map.newest()).toEqual(['b', 2]); + }); + + it('handles deleting the only entry', () => { + const map = new LRUMap({ maxSize: 3 }); + map.set('a', 1); + map.delete('a'); + expect(map.newest()).toBeUndefined(); + expect(map.oldest()).toBeUndefined(); + }); + }); + + describe('clear', () => { + it('removes all entries', () => { + const map = new LRUMap({ maxSize: 3 }); + map.set('a', 1); + map.set('b', 2); + map.clear(); + expect(map.size).toBe(0); + expect(map.newest()).toBeUndefined(); + expect(map.oldest()).toBeUndefined(); + }); + }); + + describe('eviction', () => { + it('evicts oldest entry when exceeding maxSize', () => { + const map = new LRUMap({ maxSize: 2 }); + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); // evicts 'a' + expect(map.has('a')).toBe(false); + expect(map.has('b')).toBe(true); + expect(map.has('c')).toBe(true); + expect(map.size).toBe(2); + }); + + it('evicts multiple entries to stay within maxSize', () => { + const map = new LRUMap({ maxSize: 1 }); + map.set('a', 1); + map.set('b', 2); // evicts 'a' + expect(map.size).toBe(1); + expect(map.has('a')).toBe(false); + expect(map.get('b')).toBe(2); + }); + + it('calls onEvict for each evicted entry', () => { + const onEvict = vi.fn(); + const map = new LRUMap({ maxSize: 2, onEvict }); + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); // evicts 'a' + map.set('d', 4); // evicts 'b' + expect(onEvict).toHaveBeenCalledTimes(2); + expect(onEvict).toHaveBeenCalledWith('a', 1); + expect(onEvict).toHaveBeenCalledWith('b', 2); + }); + + it('get() refreshes position and changes eviction order', () => { + const map = new LRUMap({ maxSize: 2 }); + map.set('a', 1); + map.set('b', 2); + map.get('a'); // refresh 'a' — now 'b' is oldest + map.set('c', 3); // evicts 'b', not 'a' + expect(map.has('a')).toBe(true); + expect(map.has('b')).toBe(false); + expect(map.has('c')).toBe(true); + }); + }); + + describe('peek', () => { + it('returns value without refreshing position', () => { + const map = new LRUMap({ maxSize: 2 }); + map.set('a', 1); + map.set('b', 2); + expect(map.peek('a')).toBe(1); + // 'a' should still be oldest since peek doesn't refresh + map.set('c', 3); // should evict 'a' + expect(map.has('a')).toBe(false); + }); + + it('returns undefined for missing keys', () => { + const map = new LRUMap({ maxSize: 3 }); + expect(map.peek('nonexistent')).toBeUndefined(); + }); + }); + + describe('oldest/newest', () => { + it('returns undefined on empty map', () => { + const map = new LRUMap({ maxSize: 3 }); + expect(map.oldest()).toBeUndefined(); + expect(map.newest()).toBeUndefined(); + }); + + it('returns correct oldest and newest', () => { + const map = new LRUMap({ maxSize: 3 }); + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); + expect(map.oldest()).toEqual(['a', 1]); + expect(map.newest()).toEqual(['c', 3]); + }); + + it('newest updates after get()', () => { + const map = new LRUMap({ maxSize: 3 }); + map.set('a', 1); + map.set('b', 2); + map.get('a'); // 'a' becomes newest + expect(map.newest()).toEqual(['a', 1]); + expect(map.oldest()).toEqual(['b', 2]); + }); + + it('newest updates after set() overwrites', () => { + const map = new LRUMap({ maxSize: 3 }); + map.set('a', 1); + map.set('b', 2); + map.set('a', 10); // 'a' refreshed to newest + expect(map.newest()).toEqual(['a', 10]); + }); + }); + + describe('expireOlderThan', () => { + it('removes entries older than cutoff', () => { + const map = new LRUMap({ maxSize: 5 }); + const now = Date.now(); + map.set('old1', { ts: now - 10000 }); + map.set('old2', { ts: now - 8000 }); + map.set('new1', { ts: now - 1000 }); + + const evicted = map.expireOlderThan(5000, (v) => v.ts); + expect(evicted).toBe(2); + expect(map.size).toBe(1); + expect(map.has('new1')).toBe(true); + }); + + it('calls onEvict for expired entries', () => { + const onEvict = vi.fn(); + const map = new LRUMap({ maxSize: 5, onEvict }); + const now = Date.now(); + map.set('old', { ts: now - 10000 }); + map.set('new', { ts: now - 100 }); + + map.expireOlderThan(5000, (v) => v.ts); + expect(onEvict).toHaveBeenCalledTimes(1); + expect(onEvict).toHaveBeenCalledWith('old', { ts: now - 10000 }); + }); + + it('returns 0 when nothing to expire', () => { + const map = new LRUMap({ maxSize: 5 }); + const now = Date.now(); + map.set('a', { ts: now }); + expect(map.expireOlderThan(5000, (v) => v.ts)).toBe(0); + }); + + it('handles empty map', () => { + const map = new LRUMap({ maxSize: 5 }); + expect(map.expireOlderThan(5000, (v) => v.ts)).toBe(0); + }); + + it('updates newestKey when all entries expired', () => { + const map = new LRUMap({ maxSize: 5 }); + const now = Date.now(); + map.set('a', { ts: now - 20000 }); + map.set('b', { ts: now - 10000 }); + map.expireOlderThan(5000, (v) => v.ts); + expect(map.newest()).toBeUndefined(); + expect(map.size).toBe(0); + }); + }); + + describe('keysInOrder/valuesInOrder', () => { + it('returns keys from oldest to newest', () => { + const map = new LRUMap({ maxSize: 5 }); + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); + expect(map.keysInOrder()).toEqual(['a', 'b', 'c']); + }); + + it('returns values from oldest to newest', () => { + const map = new LRUMap({ maxSize: 5 }); + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); + expect(map.valuesInOrder()).toEqual([1, 2, 3]); + }); + + it('reflects refreshed order', () => { + const map = new LRUMap({ maxSize: 5 }); + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); + map.get('a'); // 'a' moves to end + expect(map.keysInOrder()).toEqual(['b', 'c', 'a']); + expect(map.valuesInOrder()).toEqual([2, 3, 1]); + }); + + it('returns empty arrays for empty map', () => { + const map = new LRUMap({ maxSize: 5 }); + expect(map.keysInOrder()).toEqual([]); + expect(map.valuesInOrder()).toEqual([]); + }); + }); + + describe('maxEntries/freeSlots', () => { + it('freeSlots decreases as entries are added', () => { + const map = new LRUMap({ maxSize: 3 }); + expect(map.freeSlots).toBe(3); + map.set('a', 1); + expect(map.freeSlots).toBe(2); + map.set('b', 2); + expect(map.freeSlots).toBe(1); + map.set('c', 3); + expect(map.freeSlots).toBe(0); + }); + + it('freeSlots does not go below 0 after eviction', () => { + const map = new LRUMap({ maxSize: 2 }); + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); // evicts 'a' + expect(map.freeSlots).toBe(0); + expect(map.size).toBe(2); + }); + }); + + describe('iteration', () => { + it('iterates entries via for..of', () => { + const map = new LRUMap({ maxSize: 3 }); + map.set('a', 1); + map.set('b', 2); + const entries: [string, number][] = []; + for (const [k, v] of map) { + entries.push([k, v]); + } + expect(entries).toEqual([ + ['a', 1], + ['b', 2], + ]); + }); + }); + + describe('edge cases', () => { + it('works with maxSize of 1', () => { + const map = new LRUMap({ maxSize: 1 }); + map.set('a', 1); + expect(map.get('a')).toBe(1); + map.set('b', 2); + expect(map.has('a')).toBe(false); + expect(map.get('b')).toBe(2); + expect(map.size).toBe(1); + }); + + it('handles non-string keys', () => { + const map = new LRUMap({ maxSize: 3 }); + map.set(1, 'one'); + map.set(2, 'two'); + expect(map.get(1)).toBe('one'); + // get(1) refreshes key 1, so order is now [2, 1] + expect(map.keysInOrder()).toEqual([2, 1]); + }); + }); +}); diff --git a/test/respawn-adaptive-timing.test.ts b/test/respawn-adaptive-timing.test.ts new file mode 100644 index 00000000..ed3287ab --- /dev/null +++ b/test/respawn-adaptive-timing.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { RespawnAdaptiveTiming, AdaptiveTimingConfig } from '../src/respawn-adaptive-timing.js'; + +describe('RespawnAdaptiveTiming', () => { + let timing: RespawnAdaptiveTiming; + const defaultConfig: AdaptiveTimingConfig = { + adaptiveMinConfirmMs: 5000, + adaptiveMaxConfirmMs: 30000, + }; + + beforeEach(() => { + timing = new RespawnAdaptiveTiming(defaultConfig); + }); + + describe('initial state', () => { + it('should start with default 10000ms completion confirm', () => { + expect(timing.getAdaptiveCompletionConfirmMs()).toBe(10000); + }); + + it('should start with empty timing history', () => { + const history = timing.getTimingHistory(); + expect(history.recentIdleDetectionMs).toEqual([]); + expect(history.recentCycleDurationMs).toEqual([]); + expect(history.sampleCount).toBe(0); + expect(history.maxSamples).toBe(20); + }); + }); + + describe('recordTimingData', () => { + it('should add samples to rolling windows', () => { + timing.recordTimingData(1000, 5000); + + const history = timing.getTimingHistory(); + expect(history.recentIdleDetectionMs).toEqual([1000]); + expect(history.recentCycleDurationMs).toEqual([5000]); + expect(history.sampleCount).toBe(1); + }); + + it('should accumulate multiple samples', () => { + timing.recordTimingData(1000, 5000); + timing.recordTimingData(2000, 6000); + timing.recordTimingData(3000, 7000); + + const history = timing.getTimingHistory(); + expect(history.recentIdleDetectionMs).toEqual([1000, 2000, 3000]); + expect(history.recentCycleDurationMs).toEqual([5000, 6000, 7000]); + expect(history.sampleCount).toBe(3); + }); + + it('should trim to maxSamples when exceeded', () => { + // Add 22 samples (exceeds maxSamples of 20) + for (let i = 1; i <= 22; i++) { + timing.recordTimingData(i * 100, i * 500); + } + + const history = timing.getTimingHistory(); + expect(history.recentIdleDetectionMs.length).toBe(20); + expect(history.recentCycleDurationMs.length).toBe(20); + // First two should have been shifted off + expect(history.recentIdleDetectionMs[0]).toBe(300); // 3rd sample + expect(history.recentCycleDurationMs[0]).toBe(1500); + expect(history.sampleCount).toBe(20); + }); + + it('should update lastUpdatedAt timestamp', () => { + const before = Date.now(); + timing.recordTimingData(1000, 5000); + const after = Date.now(); + + const history = timing.getTimingHistory(); + expect(history.lastUpdatedAt).toBeGreaterThanOrEqual(before); + expect(history.lastUpdatedAt).toBeLessThanOrEqual(after); + }); + }); + + describe('adaptive timing calculation', () => { + it('should not recalculate with fewer than 5 samples', () => { + // Add 4 samples -- below the threshold + for (let i = 0; i < 4; i++) { + timing.recordTimingData(8000, 20000); + } + + // Should still be the default + expect(timing.getAdaptiveCompletionConfirmMs()).toBe(10000); + }); + + it('should recalculate at exactly 5 samples', () => { + // All the same value = easy to predict + for (let i = 0; i < 5; i++) { + timing.recordTimingData(10000, 20000); + } + + // P75 of [10000, 10000, 10000, 10000, 10000] = 10000 + // With 20% buffer = 12000, clamped to [5000, 30000] = 12000 + expect(timing.getAdaptiveCompletionConfirmMs()).toBe(12000); + }); + + it('should use 75th percentile with 20% buffer', () => { + // 5 sorted values: 1000, 2000, 3000, 4000, 5000 + timing.recordTimingData(3000, 10000); + timing.recordTimingData(1000, 10000); + timing.recordTimingData(5000, 10000); + timing.recordTimingData(2000, 10000); + timing.recordTimingData(4000, 10000); + + // Sorted: [1000, 2000, 3000, 4000, 5000] + // P75 index = floor(5 * 0.75) = 3 -> value = 4000 + // With 20% buffer: 4000 * 1.2 = 4800 + // Clamped to min 5000 + expect(timing.getAdaptiveCompletionConfirmMs()).toBe(5000); + }); + + it('should clamp to minimum configured value', () => { + // Very small idle detection times + for (let i = 0; i < 5; i++) { + timing.recordTimingData(1000, 5000); + } + + // P75 = 1000, with buffer = 1200, clamped to min 5000 + expect(timing.getAdaptiveCompletionConfirmMs()).toBe(5000); + }); + + it('should clamp to maximum configured value', () => { + // Very large idle detection times + for (let i = 0; i < 5; i++) { + timing.recordTimingData(50000, 100000); + } + + // P75 = 50000, with buffer = 60000, clamped to max 30000 + expect(timing.getAdaptiveCompletionConfirmMs()).toBe(30000); + }); + + it('should handle varying values correctly', () => { + // 10 values, spread out + const values = [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000]; + for (const v of values) { + timing.recordTimingData(v, v * 2); + } + + // Sorted: [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000] + // P75 index = floor(10 * 0.75) = 7 -> value = 16000 + // With 20% buffer: 16000 * 1.2 = 19200 + // Clamped to [5000, 30000] = 19200 + expect(timing.getAdaptiveCompletionConfirmMs()).toBe(19200); + }); + + it('should adapt as new data arrives', () => { + // Start with small values + for (let i = 0; i < 5; i++) { + timing.recordTimingData(3000, 10000); + } + // P75 = 3000, buffer = 3600, clamped to min 5000 + expect(timing.getAdaptiveCompletionConfirmMs()).toBe(5000); + + // Now add larger values that shift the P75 up + for (let i = 0; i < 10; i++) { + timing.recordTimingData(20000, 40000); + } + // P75 of mostly-20000 values = 20000, buffer = 24000 + expect(timing.getAdaptiveCompletionConfirmMs()).toBe(24000); + }); + }); + + describe('getTimingHistory', () => { + it('should return a new object each call (shallow copy)', () => { + timing.recordTimingData(1000, 5000); + const history1 = timing.getTimingHistory(); + const history2 = timing.getTimingHistory(); + + // Different object references each time + expect(history1).not.toBe(history2); + // But equal content + expect(history1).toEqual(history2); + }); + + it('should allow overwriting top-level fields on the copy without affecting source', () => { + timing.recordTimingData(1000, 5000); + const copy = timing.getTimingHistory(); + copy.sampleCount = 999; + + expect(timing.getTimingHistory().sampleCount).toBe(1); + }); + }); + + describe('reset', () => { + it('should clear all timing history', () => { + // Add some data + for (let i = 0; i < 10; i++) { + timing.recordTimingData(i * 1000, i * 5000); + } + + timing.reset(); + + const history = timing.getTimingHistory(); + expect(history.recentIdleDetectionMs).toEqual([]); + expect(history.recentCycleDurationMs).toEqual([]); + expect(history.adaptiveCompletionConfirmMs).toBe(10000); + expect(history.sampleCount).toBe(0); + expect(history.maxSamples).toBe(20); + }); + + it('should allow re-recording after reset', () => { + for (let i = 0; i < 5; i++) { + timing.recordTimingData(10000, 20000); + } + expect(timing.getAdaptiveCompletionConfirmMs()).toBe(12000); + + timing.reset(); + expect(timing.getAdaptiveCompletionConfirmMs()).toBe(10000); + + // Record new data -- below 5 samples, should stay at default + timing.recordTimingData(5000, 10000); + expect(timing.getAdaptiveCompletionConfirmMs()).toBe(10000); + }); + }); + + describe('edge cases', () => { + it('should handle zero idle detection times', () => { + for (let i = 0; i < 5; i++) { + timing.recordTimingData(0, 5000); + } + // P75 = 0, buffer = 0, clamped to min 5000 + expect(timing.getAdaptiveCompletionConfirmMs()).toBe(5000); + }); + + it('should handle identical values', () => { + for (let i = 0; i < 20; i++) { + timing.recordTimingData(15000, 30000); + } + // P75 = 15000, buffer = 18000 + expect(timing.getAdaptiveCompletionConfirmMs()).toBe(18000); + }); + + it('should handle config with very narrow bounds', () => { + const narrowTiming = new RespawnAdaptiveTiming({ + adaptiveMinConfirmMs: 10000, + adaptiveMaxConfirmMs: 10000, + }); + + for (let i = 0; i < 5; i++) { + narrowTiming.recordTimingData(50000, 100000); + } + + // Always clamped to 10000 regardless of data + expect(narrowTiming.getAdaptiveCompletionConfirmMs()).toBe(10000); + }); + }); +}); diff --git a/test/respawn-health.test.ts b/test/respawn-health.test.ts new file mode 100644 index 00000000..91b01c7e --- /dev/null +++ b/test/respawn-health.test.ts @@ -0,0 +1,517 @@ +import { describe, it, expect } from 'vitest'; +import { calculateHealthScore, shouldSkipClear, HealthInputs } from '../src/respawn-health.js'; +import type { RespawnAggregateMetrics, CircuitBreakerStatus } from '../src/types/index.js'; + +/** + * Helper to create a default healthy HealthInputs object. + * Override specific fields as needed. + */ +function createHealthInputs(overrides: Partial = {}): HealthInputs { + return { + aggregateMetrics: { + totalCycles: 10, + successfulCycles: 10, + stuckRecoveryCycles: 0, + blockedCycles: 0, + errorCycles: 0, + avgCycleDurationMs: 5000, + avgIdleDetectionMs: 1000, + p90CycleDurationMs: 8000, + successRate: 100, + lastUpdatedAt: Date.now(), + }, + circuitBreakerStatus: null, + iterationStallMetrics: null, + aiCheckerState: { status: 'ready', consecutiveErrors: 0 }, + stuckRecoveryCount: 0, + maxStuckRecoveries: 5, + ...overrides, + }; +} + +function createCircuitBreakerStatus(state: 'CLOSED' | 'HALF_OPEN' | 'OPEN'): CircuitBreakerStatus { + return { + state, + consecutiveNoProgress: state === 'OPEN' ? 5 : state === 'HALF_OPEN' ? 2 : 0, + consecutiveSameError: 0, + consecutiveTestsFailure: 0, + lastProgressIteration: 0, + reason: `State: ${state}`, + reasonCode: 'no_progress', + lastTransitionAt: Date.now(), + lastErrorMessage: null, + }; +} + +describe('calculateHealthScore', () => { + describe('overall score calculation', () => { + it('should return 100 for perfectly healthy system', () => { + const result = calculateHealthScore(createHealthInputs()); + + expect(result.score).toBe(100); + expect(result.status).toBe('excellent'); + expect(result.components.cycleSuccess).toBe(100); + expect(result.components.circuitBreaker).toBe(100); + expect(result.components.iterationProgress).toBe(100); + expect(result.components.aiChecker).toBe(100); + expect(result.components.stuckRecovery).toBe(100); + }); + + it('should return excellent for score >= 90', () => { + const result = calculateHealthScore(createHealthInputs()); + expect(result.score).toBeGreaterThanOrEqual(90); + expect(result.status).toBe('excellent'); + }); + + it('should return good for score >= 70 and < 90', () => { + // AI checker disabled = 30 (weight 0.15), everything else 100 + // Score = 100*0.35 + 100*0.2 + 100*0.2 + 30*0.15 + 100*0.1 = 35+20+20+4.5+10 = 89.5 -> 90 + // That's still excellent. Let's use circuitBreaker half-open for 50 (weight 0.2): + // Score = 100*0.35 + 50*0.2 + 100*0.2 + 100*0.15 + 100*0.1 = 35+10+20+15+10 = 90 + // Still excellent. Use a lower cycleSuccess: + const result = calculateHealthScore( + createHealthInputs({ + aggregateMetrics: { + totalCycles: 10, + successfulCycles: 8, + stuckRecoveryCycles: 1, + blockedCycles: 1, + errorCycles: 0, + avgCycleDurationMs: 5000, + avgIdleDetectionMs: 1000, + p90CycleDurationMs: 8000, + successRate: 80, + lastUpdatedAt: Date.now(), + }, + circuitBreakerStatus: createCircuitBreakerStatus('HALF_OPEN'), + }) + ); + // cycleSuccess=80, circuitBreaker=50, iterationProgress=100, aiChecker=100, stuckRecovery=100 + // 80*0.35 + 50*0.2 + 100*0.2 + 100*0.15 + 100*0.1 = 28+10+20+15+10 = 83 + expect(result.score).toBe(83); + expect(result.status).toBe('good'); + }); + + it('should return degraded for score >= 50 and < 70', () => { + const result = calculateHealthScore( + createHealthInputs({ + aggregateMetrics: { + totalCycles: 10, + successfulCycles: 5, + stuckRecoveryCycles: 2, + blockedCycles: 2, + errorCycles: 1, + avgCycleDurationMs: 5000, + avgIdleDetectionMs: 1000, + p90CycleDurationMs: 8000, + successRate: 50, + lastUpdatedAt: Date.now(), + }, + circuitBreakerStatus: createCircuitBreakerStatus('HALF_OPEN'), + aiCheckerState: { status: 'cooldown', consecutiveErrors: 0 }, + }) + ); + // cycleSuccess=50, circuitBreaker=50, iterationProgress=100, aiChecker=70, stuckRecovery=100 + // 50*0.35 + 50*0.2 + 100*0.2 + 70*0.15 + 100*0.1 = 17.5+10+20+10.5+10 = 68 + expect(result.score).toBe(68); + expect(result.status).toBe('degraded'); + }); + + it('should return critical for score < 50', () => { + const result = calculateHealthScore( + createHealthInputs({ + aggregateMetrics: { + totalCycles: 10, + successfulCycles: 0, + stuckRecoveryCycles: 3, + blockedCycles: 4, + errorCycles: 3, + avgCycleDurationMs: 5000, + avgIdleDetectionMs: 1000, + p90CycleDurationMs: 8000, + successRate: 0, + lastUpdatedAt: Date.now(), + }, + circuitBreakerStatus: createCircuitBreakerStatus('OPEN'), + iterationStallMetrics: { + stallDurationMs: 600000, + warningThresholdMs: 300000, + criticalThresholdMs: 600000, + }, + aiCheckerState: { status: 'error', consecutiveErrors: 3 }, + stuckRecoveryCount: 5, + maxStuckRecoveries: 5, + }) + ); + // cycleSuccess=0, circuitBreaker=0, iterationProgress=0, aiChecker=50, stuckRecovery=0 + // 0*0.35 + 0*0.2 + 0*0.2 + 50*0.15 + 0*0.1 = 7.5 -> 8 + expect(result.score).toBeLessThan(50); + expect(result.status).toBe('critical'); + }); + }); + + describe('component: cycleSuccess', () => { + it('should return 100 when no cycles have been tracked', () => { + const result = calculateHealthScore( + createHealthInputs({ + aggregateMetrics: { + totalCycles: 0, + successfulCycles: 0, + stuckRecoveryCycles: 0, + blockedCycles: 0, + errorCycles: 0, + avgCycleDurationMs: 0, + avgIdleDetectionMs: 0, + p90CycleDurationMs: 0, + successRate: 100, + lastUpdatedAt: Date.now(), + }, + }) + ); + expect(result.components.cycleSuccess).toBe(100); + }); + + it('should use successRate from aggregate metrics', () => { + const result = calculateHealthScore( + createHealthInputs({ + aggregateMetrics: { + totalCycles: 20, + successfulCycles: 15, + stuckRecoveryCycles: 3, + blockedCycles: 1, + errorCycles: 1, + avgCycleDurationMs: 5000, + avgIdleDetectionMs: 1000, + p90CycleDurationMs: 8000, + successRate: 75, + lastUpdatedAt: Date.now(), + }, + }) + ); + expect(result.components.cycleSuccess).toBe(75); + }); + }); + + describe('component: circuitBreaker', () => { + it('should return 100 when circuit breaker is null', () => { + const result = calculateHealthScore(createHealthInputs({ circuitBreakerStatus: null })); + expect(result.components.circuitBreaker).toBe(100); + }); + + it('should return 100 for CLOSED state', () => { + const result = calculateHealthScore( + createHealthInputs({ circuitBreakerStatus: createCircuitBreakerStatus('CLOSED') }) + ); + expect(result.components.circuitBreaker).toBe(100); + }); + + it('should return 50 for HALF_OPEN state', () => { + const result = calculateHealthScore( + createHealthInputs({ circuitBreakerStatus: createCircuitBreakerStatus('HALF_OPEN') }) + ); + expect(result.components.circuitBreaker).toBe(50); + }); + + it('should return 0 for OPEN state', () => { + const result = calculateHealthScore( + createHealthInputs({ circuitBreakerStatus: createCircuitBreakerStatus('OPEN') }) + ); + expect(result.components.circuitBreaker).toBe(0); + }); + }); + + describe('component: iterationProgress', () => { + it('should return 100 when stall metrics are null', () => { + const result = calculateHealthScore(createHealthInputs({ iterationStallMetrics: null })); + expect(result.components.iterationProgress).toBe(100); + }); + + it('should return 100 when stall duration is below half the warning threshold', () => { + const result = calculateHealthScore( + createHealthInputs({ + iterationStallMetrics: { + stallDurationMs: 10000, + warningThresholdMs: 300000, + criticalThresholdMs: 600000, + }, + }) + ); + expect(result.components.iterationProgress).toBe(100); + }); + + it('should return 70 when stall duration is at half the warning threshold', () => { + const result = calculateHealthScore( + createHealthInputs({ + iterationStallMetrics: { + stallDurationMs: 150000, + warningThresholdMs: 300000, + criticalThresholdMs: 600000, + }, + }) + ); + expect(result.components.iterationProgress).toBe(70); + }); + + it('should return 30 when stall duration hits warning threshold', () => { + const result = calculateHealthScore( + createHealthInputs({ + iterationStallMetrics: { + stallDurationMs: 300000, + warningThresholdMs: 300000, + criticalThresholdMs: 600000, + }, + }) + ); + expect(result.components.iterationProgress).toBe(30); + }); + + it('should return 0 when stall duration hits critical threshold', () => { + const result = calculateHealthScore( + createHealthInputs({ + iterationStallMetrics: { + stallDurationMs: 600000, + warningThresholdMs: 300000, + criticalThresholdMs: 600000, + }, + }) + ); + expect(result.components.iterationProgress).toBe(0); + }); + + it('should return 0 when stall duration exceeds critical threshold', () => { + const result = calculateHealthScore( + createHealthInputs({ + iterationStallMetrics: { + stallDurationMs: 900000, + warningThresholdMs: 300000, + criticalThresholdMs: 600000, + }, + }) + ); + expect(result.components.iterationProgress).toBe(0); + }); + }); + + describe('component: aiChecker', () => { + it('should return 100 for ready status with no errors', () => { + const result = calculateHealthScore( + createHealthInputs({ aiCheckerState: { status: 'ready', consecutiveErrors: 0 } }) + ); + expect(result.components.aiChecker).toBe(100); + }); + + it('should return 30 for disabled status', () => { + const result = calculateHealthScore( + createHealthInputs({ aiCheckerState: { status: 'disabled', consecutiveErrors: 0 } }) + ); + expect(result.components.aiChecker).toBe(30); + }); + + it('should return 70 for cooldown status', () => { + const result = calculateHealthScore( + createHealthInputs({ aiCheckerState: { status: 'cooldown', consecutiveErrors: 0 } }) + ); + expect(result.components.aiChecker).toBe(70); + }); + + it('should return 50 for ready status with consecutive errors', () => { + const result = calculateHealthScore( + createHealthInputs({ aiCheckerState: { status: 'ready', consecutiveErrors: 2 } }) + ); + expect(result.components.aiChecker).toBe(50); + }); + }); + + describe('component: stuckRecovery', () => { + it('should return 100 when no stuck recoveries', () => { + const result = calculateHealthScore(createHealthInputs({ stuckRecoveryCount: 0, maxStuckRecoveries: 5 })); + expect(result.components.stuckRecovery).toBe(100); + }); + + it('should return 0 when stuck recoveries reach max', () => { + const result = calculateHealthScore(createHealthInputs({ stuckRecoveryCount: 5, maxStuckRecoveries: 5 })); + expect(result.components.stuckRecovery).toBe(0); + }); + + it('should return proportional score for partial recoveries', () => { + const result = calculateHealthScore(createHealthInputs({ stuckRecoveryCount: 2, maxStuckRecoveries: 5 })); + // 100 - (2/5) * 100 = 60 + expect(result.components.stuckRecovery).toBe(60); + }); + + it('should return 0 when stuck recoveries exceed max', () => { + const result = calculateHealthScore(createHealthInputs({ stuckRecoveryCount: 10, maxStuckRecoveries: 5 })); + expect(result.components.stuckRecovery).toBe(0); + }); + }); + + describe('recommendations', () => { + it('should recommend no action for healthy system', () => { + const result = calculateHealthScore(createHealthInputs()); + expect(result.recommendations).toEqual(['System is healthy. No action needed.']); + }); + + it('should recommend checking cycle success when low', () => { + const result = calculateHealthScore( + createHealthInputs({ + aggregateMetrics: { + totalCycles: 10, + successfulCycles: 5, + stuckRecoveryCycles: 3, + blockedCycles: 1, + errorCycles: 1, + avgCycleDurationMs: 5000, + avgIdleDetectionMs: 1000, + p90CycleDurationMs: 8000, + successRate: 50, + lastUpdatedAt: Date.now(), + }, + }) + ); + expect(result.recommendations).toContainEqual(expect.stringContaining('Cycle success rate is low')); + }); + + it('should recommend reviewing circuit breaker when open', () => { + const result = calculateHealthScore( + createHealthInputs({ + circuitBreakerStatus: createCircuitBreakerStatus('OPEN'), + }) + ); + expect(result.recommendations).toContainEqual(expect.stringContaining('Circuit breaker is open')); + }); + + it('should recommend checking iteration when stalled', () => { + const result = calculateHealthScore( + createHealthInputs({ + iterationStallMetrics: { + stallDurationMs: 400000, + warningThresholdMs: 300000, + criticalThresholdMs: 600000, + }, + }) + ); + expect(result.recommendations).toContainEqual(expect.stringContaining('Iteration progress has stalled')); + }); + + it('should recommend checking AI checker when disabled', () => { + const result = calculateHealthScore( + createHealthInputs({ + aiCheckerState: { status: 'disabled', consecutiveErrors: 3 }, + }) + ); + // aiChecker score = 30 (disabled), which is < 50 -> triggers recommendation + expect(result.recommendations).toContainEqual(expect.stringContaining('AI idle checker has errors')); + }); + + it('should recommend adjusting timeouts when stuck recoveries are high', () => { + const result = calculateHealthScore( + createHealthInputs({ + stuckRecoveryCount: 4, + maxStuckRecoveries: 5, + }) + ); + expect(result.recommendations).toContainEqual(expect.stringContaining('Multiple stuck-state recoveries')); + }); + + it('should include multiple recommendations when multiple components are degraded', () => { + const result = calculateHealthScore( + createHealthInputs({ + aggregateMetrics: { + totalCycles: 10, + successfulCycles: 3, + stuckRecoveryCycles: 3, + blockedCycles: 2, + errorCycles: 2, + avgCycleDurationMs: 5000, + avgIdleDetectionMs: 1000, + p90CycleDurationMs: 8000, + successRate: 30, + lastUpdatedAt: Date.now(), + }, + circuitBreakerStatus: createCircuitBreakerStatus('OPEN'), + stuckRecoveryCount: 4, + maxStuckRecoveries: 5, + }) + ); + expect(result.recommendations.length).toBeGreaterThanOrEqual(3); + }); + }); + + describe('summary', () => { + it('should include score in summary', () => { + const result = calculateHealthScore(createHealthInputs()); + expect(result.summary).toContain('100/100'); + }); + + it('should describe excellent status', () => { + const result = calculateHealthScore(createHealthInputs()); + expect(result.summary).toContain('excellently'); + }); + + it('should describe critical status with lowest component', () => { + const result = calculateHealthScore( + createHealthInputs({ + aggregateMetrics: { + totalCycles: 10, + successfulCycles: 0, + stuckRecoveryCycles: 5, + blockedCycles: 3, + errorCycles: 2, + avgCycleDurationMs: 5000, + avgIdleDetectionMs: 1000, + p90CycleDurationMs: 8000, + successRate: 0, + lastUpdatedAt: Date.now(), + }, + circuitBreakerStatus: createCircuitBreakerStatus('OPEN'), + }) + ); + expect(result.summary).toContain('critical'); + }); + + it('should include calculatedAt timestamp', () => { + const before = Date.now(); + const result = calculateHealthScore(createHealthInputs()); + const after = Date.now(); + + expect(result.calculatedAt).toBeGreaterThanOrEqual(before); + expect(result.calculatedAt).toBeLessThanOrEqual(after); + }); + }); +}); + +describe('shouldSkipClear', () => { + it('should not skip when token count is 0 (unknown)', () => { + expect(shouldSkipClear(0, 50, 200000)).toBe(false); + }); + + it('should skip when usage is below threshold', () => { + // 10000 / 200000 = 5% < 50% + expect(shouldSkipClear(10000, 50, 200000)).toBe(true); + }); + + it('should not skip when usage is above threshold', () => { + // 150000 / 200000 = 75% > 50% + expect(shouldSkipClear(150000, 50, 200000)).toBe(false); + }); + + it('should not skip when usage equals threshold', () => { + // 100000 / 200000 = 50% == 50% -> not less than, so false + expect(shouldSkipClear(100000, 50, 200000)).toBe(false); + }); + + it('should handle edge case of very small threshold', () => { + // 1000 / 200000 = 0.5% < 1% + expect(shouldSkipClear(1000, 1, 200000)).toBe(true); + }); + + it('should handle 100% threshold (always skip)', () => { + // 190000 / 200000 = 95% < 100% + expect(shouldSkipClear(190000, 100, 200000)).toBe(true); + }); + + it('should handle 0% threshold (never skip except when unknown)', () => { + // Any positive count / total > 0% so not < 0% + expect(shouldSkipClear(1, 0, 200000)).toBe(false); + }); +}); diff --git a/test/respawn-metrics.test.ts b/test/respawn-metrics.test.ts new file mode 100644 index 00000000..dfe0752a --- /dev/null +++ b/test/respawn-metrics.test.ts @@ -0,0 +1,332 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { RespawnCycleMetricsTracker } from '../src/respawn-metrics.js'; +import type { CycleOutcome } from '../src/types/index.js'; + +describe('RespawnCycleMetricsTracker', () => { + let tracker: RespawnCycleMetricsTracker; + + beforeEach(() => { + tracker = new RespawnCycleMetricsTracker(); + }); + + describe('initial state', () => { + it('should have no current cycle', () => { + expect(tracker.getCurrentCycle()).toBeNull(); + }); + + it('should have empty recent metrics', () => { + expect(tracker.getRecent()).toEqual([]); + }); + + it('should have zeroed aggregate metrics', () => { + const agg = tracker.getAggregate(); + expect(agg.totalCycles).toBe(0); + expect(agg.successfulCycles).toBe(0); + expect(agg.stuckRecoveryCycles).toBe(0); + expect(agg.blockedCycles).toBe(0); + expect(agg.errorCycles).toBe(0); + expect(agg.avgCycleDurationMs).toBe(0); + expect(agg.avgIdleDetectionMs).toBe(0); + expect(agg.p90CycleDurationMs).toBe(0); + expect(agg.successRate).toBe(100); + }); + }); + + describe('startCycle', () => { + it('should create a current cycle with correct fields', () => { + const idleDetectionStartTime = Date.now() - 1500; + tracker.startCycle('session-1', 1, 'completion_message', idleDetectionStartTime, 50000, 10000); + + const cycle = tracker.getCurrentCycle(); + expect(cycle).not.toBeNull(); + expect(cycle!.cycleId).toBe('session-1:1'); + expect(cycle!.sessionId).toBe('session-1'); + expect(cycle!.cycleNumber).toBe(1); + expect(cycle!.idleReason).toBe('completion_message'); + expect(cycle!.stepsCompleted).toEqual([]); + expect(cycle!.clearSkipped).toBe(false); + expect(cycle!.tokenCountAtStart).toBe(50000); + expect(cycle!.completionConfirmMsUsed).toBe(10000); + // idleDetectionMs should be approximately 1500 (within margin) + expect(cycle!.idleDetectionMs).toBeGreaterThanOrEqual(1400); + expect(cycle!.idleDetectionMs).toBeLessThanOrEqual(2000); + }); + + it('should overwrite a previous current cycle', () => { + tracker.startCycle('session-1', 1, 'reason1', Date.now(), 10000, 5000); + tracker.startCycle('session-1', 2, 'reason2', Date.now(), 20000, 6000); + + const cycle = tracker.getCurrentCycle(); + expect(cycle!.cycleNumber).toBe(2); + expect(cycle!.idleReason).toBe('reason2'); + }); + }); + + describe('recordStep', () => { + it('should add steps to current cycle', () => { + tracker.startCycle('session-1', 1, 'test', Date.now(), 0, 10000); + + tracker.recordStep('update'); + tracker.recordStep('clear'); + tracker.recordStep('init'); + + const cycle = tracker.getCurrentCycle(); + expect(cycle!.stepsCompleted).toEqual(['update', 'clear', 'init']); + }); + + it('should be a no-op when no cycle is in progress', () => { + // Should not throw + tracker.recordStep('update'); + expect(tracker.getCurrentCycle()).toBeNull(); + }); + }); + + describe('markClearSkipped', () => { + it('should set clearSkipped flag on current cycle', () => { + tracker.startCycle('session-1', 1, 'test', Date.now(), 0, 10000); + + tracker.markClearSkipped(); + + const cycle = tracker.getCurrentCycle(); + expect(cycle!.clearSkipped).toBe(true); + }); + + it('should be a no-op when no cycle is in progress', () => { + // Should not throw + tracker.markClearSkipped(); + expect(tracker.getCurrentCycle()).toBeNull(); + }); + }); + + describe('completeCycle', () => { + it('should return null when no cycle is in progress', () => { + const result = tracker.completeCycle('success', 60000); + expect(result).toBeNull(); + }); + + it('should return completed metrics with correct fields', () => { + tracker.startCycle('session-1', 1, 'completion_message', Date.now(), 50000, 10000); + tracker.recordStep('update'); + tracker.recordStep('clear'); + + const result = tracker.completeCycle('success', 60000); + + expect(result).not.toBeNull(); + expect(result!.outcome).toBe('success'); + expect(result!.tokenCountAtEnd).toBe(60000); + expect(result!.completedAt).toBeGreaterThan(0); + expect(result!.durationMs).toBeGreaterThanOrEqual(0); + expect(result!.stepsCompleted).toEqual(['update', 'clear']); + }); + + it('should include error message for error outcome', () => { + tracker.startCycle('session-1', 1, 'test', Date.now(), 0, 10000); + + const result = tracker.completeCycle('error', 0, 'Something went wrong'); + + expect(result!.outcome).toBe('error'); + expect(result!.errorMessage).toBe('Something went wrong'); + }); + + it('should clear current cycle after completion', () => { + tracker.startCycle('session-1', 1, 'test', Date.now(), 0, 10000); + tracker.completeCycle('success', 0); + + expect(tracker.getCurrentCycle()).toBeNull(); + }); + + it('should add to recent metrics', () => { + tracker.startCycle('session-1', 1, 'test', Date.now(), 0, 10000); + tracker.completeCycle('success', 0); + + const recent = tracker.getRecent(); + expect(recent.length).toBe(1); + expect(recent[0].outcome).toBe('success'); + }); + }); + + describe('aggregate metrics updates', () => { + function completeOneCycle(outcome: CycleOutcome): void { + tracker.startCycle('session-1', tracker.getAggregate().totalCycles + 1, 'test', Date.now(), 0, 10000); + tracker.completeCycle(outcome, 0); + } + + it('should count successful cycles', () => { + completeOneCycle('success'); + completeOneCycle('success'); + + const agg = tracker.getAggregate(); + expect(agg.totalCycles).toBe(2); + expect(agg.successfulCycles).toBe(2); + expect(agg.successRate).toBe(100); + }); + + it('should count stuck_recovery cycles', () => { + completeOneCycle('stuck_recovery'); + + const agg = tracker.getAggregate(); + expect(agg.totalCycles).toBe(1); + expect(agg.stuckRecoveryCycles).toBe(1); + expect(agg.successRate).toBe(0); + }); + + it('should count blocked cycles', () => { + completeOneCycle('blocked'); + + const agg = tracker.getAggregate(); + expect(agg.totalCycles).toBe(1); + expect(agg.blockedCycles).toBe(1); + }); + + it('should count error cycles', () => { + completeOneCycle('error'); + + const agg = tracker.getAggregate(); + expect(agg.totalCycles).toBe(1); + expect(agg.errorCycles).toBe(1); + }); + + it('should count cancelled cycles in total but no specific category', () => { + completeOneCycle('cancelled'); + + const agg = tracker.getAggregate(); + expect(agg.totalCycles).toBe(1); + expect(agg.successfulCycles).toBe(0); + expect(agg.stuckRecoveryCycles).toBe(0); + expect(agg.blockedCycles).toBe(0); + expect(agg.errorCycles).toBe(0); + }); + + it('should calculate success rate correctly', () => { + completeOneCycle('success'); + completeOneCycle('success'); + completeOneCycle('error'); + completeOneCycle('success'); + + const agg = tracker.getAggregate(); + expect(agg.totalCycles).toBe(4); + expect(agg.successfulCycles).toBe(3); + expect(agg.successRate).toBe(75); + }); + + it('should update lastUpdatedAt timestamp', () => { + const before = Date.now(); + completeOneCycle('success'); + const after = Date.now(); + + const agg = tracker.getAggregate(); + expect(agg.lastUpdatedAt).toBeGreaterThanOrEqual(before); + expect(agg.lastUpdatedAt).toBeLessThanOrEqual(after); + }); + }); + + describe('getRecent', () => { + it('should return newest first', () => { + for (let i = 1; i <= 5; i++) { + tracker.startCycle('session-1', i, `reason-${i}`, Date.now(), 0, 10000); + tracker.completeCycle('success', 0); + } + + const recent = tracker.getRecent(); + expect(recent.length).toBe(5); + expect(recent[0].cycleNumber).toBe(5); + expect(recent[4].cycleNumber).toBe(1); + }); + + it('should respect limit parameter', () => { + for (let i = 1; i <= 10; i++) { + tracker.startCycle('session-1', i, 'test', Date.now(), 0, 10000); + tracker.completeCycle('success', 0); + } + + const recent = tracker.getRecent(3); + expect(recent.length).toBe(3); + expect(recent[0].cycleNumber).toBe(10); + }); + + it('should default to 20 items', () => { + for (let i = 1; i <= 25; i++) { + tracker.startCycle('session-1', i, 'test', Date.now(), 0, 10000); + tracker.completeCycle('success', 0); + } + + const recent = tracker.getRecent(); + expect(recent.length).toBe(20); + }); + }); + + describe('memory limit (MAX_CYCLE_METRICS_IN_MEMORY)', () => { + it('should trim recent metrics beyond 100 entries', () => { + for (let i = 1; i <= 105; i++) { + tracker.startCycle('session-1', i, 'test', Date.now(), 0, 10000); + tracker.completeCycle('success', 0); + } + + // Internal storage should be capped at 100 + // We can verify via getRecent with a high limit + const recent = tracker.getRecent(200); + expect(recent.length).toBe(100); + // The earliest should be cycle 6 (first 5 were shifted off) + expect(recent[recent.length - 1].cycleNumber).toBe(6); + }); + }); + + describe('getAggregate', () => { + it('should return a copy, not a reference', () => { + const agg1 = tracker.getAggregate(); + const agg2 = tracker.getAggregate(); + + expect(agg1).not.toBe(agg2); + expect(agg1).toEqual(agg2); + }); + }); + + describe('reset', () => { + it('should clear all state', () => { + // Add some cycles + for (let i = 1; i <= 5; i++) { + tracker.startCycle('session-1', i, 'test', Date.now(), 0, 10000); + tracker.completeCycle('success', 0); + } + + // Start another cycle + tracker.startCycle('session-1', 6, 'test', Date.now(), 0, 10000); + + tracker.reset(); + + expect(tracker.getCurrentCycle()).toBeNull(); + expect(tracker.getRecent()).toEqual([]); + + const agg = tracker.getAggregate(); + expect(agg.totalCycles).toBe(0); + expect(agg.successfulCycles).toBe(0); + expect(agg.successRate).toBe(100); + }); + }); + + describe('aggregate averages and P90', () => { + it('should calculate average cycle duration across recent cycles', () => { + // We can't directly control durationMs since it's computed from Date.now(), + // but we can verify the aggregate fields are populated after completing cycles + tracker.startCycle('session-1', 1, 'test', Date.now(), 0, 10000); + tracker.completeCycle('success', 0); + + const agg = tracker.getAggregate(); + // durationMs should be very small (within the same tick) + expect(agg.avgCycleDurationMs).toBeGreaterThanOrEqual(0); + expect(agg.p90CycleDurationMs).toBeGreaterThanOrEqual(0); + }); + + it('should calculate P90 from sorted durations', () => { + // Complete multiple cycles to populate P90 + for (let i = 1; i <= 10; i++) { + tracker.startCycle('session-1', i, 'test', Date.now(), 0, 10000); + tracker.completeCycle('success', 0); + } + + const agg = tracker.getAggregate(); + // P90 should be >= avg (for uniform values, P90 ~= avg) + expect(agg.p90CycleDurationMs).toBeGreaterThanOrEqual(0); + }); + }); +}); diff --git a/test/respawn-patterns.test.ts b/test/respawn-patterns.test.ts new file mode 100644 index 00000000..3060d8cb --- /dev/null +++ b/test/respawn-patterns.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect } from 'vitest'; +import { + isCompletionMessage, + hasWorkingPattern, + extractTokenCount, + PROMPT_PATTERNS, + WORKING_PATTERNS, +} from '../src/respawn-patterns.js'; + +describe('isCompletionMessage', () => { + describe('valid completion messages', () => { + it('should match "Worked for 2m 46s"', () => { + expect(isCompletionMessage('Worked for 2m 46s')).toBe(true); + }); + + it('should match "Worked for 46s"', () => { + expect(isCompletionMessage('Worked for 46s')).toBe(true); + }); + + it('should match "Worked for 1h 2m 3s"', () => { + expect(isCompletionMessage('Worked for 1h 2m 3s')).toBe(true); + }); + + it('should match "Worked for 5m"', () => { + expect(isCompletionMessage('Worked for 5m')).toBe(true); + }); + + it('should match "Worked for 2h"', () => { + expect(isCompletionMessage('Worked for 2h')).toBe(true); + }); + + it('should match case-insensitively', () => { + expect(isCompletionMessage('worked for 10s')).toBe(true); + expect(isCompletionMessage('WORKED FOR 10s')).toBe(true); + }); + + it('should match when embedded in larger string', () => { + expect(isCompletionMessage('Some prefix text Worked for 5m 30s and more text')).toBe(true); + }); + + it('should not match when ANSI escape codes break the word boundary', () => { + // The regex uses \b word boundary, so ANSI codes right before "Worked" + // break the match. In practice, data is ANSI-stripped before reaching this function. + expect(isCompletionMessage('\x1b[32mWorked for 3m 15s\x1b[0m')).toBe(false); + }); + + it('should match ANSI-stripped output', () => { + // After stripping ANSI codes, the pattern works fine + expect(isCompletionMessage('Worked for 3m 15s')).toBe(true); + }); + }); + + describe('invalid patterns (should not match)', () => { + it('should not match "wait for 5s"', () => { + expect(isCompletionMessage('wait for 5s')).toBe(false); + }); + + it('should not match "run for 2m"', () => { + expect(isCompletionMessage('run for 2m')).toBe(false); + }); + + it('should not match "for 3s the system..."', () => { + expect(isCompletionMessage('for 3s the system responded')).toBe(false); + }); + + it('should not match bare time durations', () => { + expect(isCompletionMessage('2m 46s')).toBe(false); + }); + + it('should not match empty string', () => { + expect(isCompletionMessage('')).toBe(false); + }); + + it('should not match "Worked" without time pattern', () => { + expect(isCompletionMessage('Worked on the task')).toBe(false); + }); + + it('should not match "Worked for" without time', () => { + expect(isCompletionMessage('Worked for a long time')).toBe(false); + }); + }); +}); + +describe('hasWorkingPattern', () => { + describe('text working indicators', () => { + it('should detect "Thinking" in window', () => { + expect(hasWorkingPattern('Claude is Thinking about your request')).toBe(true); + }); + + it('should detect "Writing" in window', () => { + expect(hasWorkingPattern('Writing to file src/main.ts')).toBe(true); + }); + + it('should detect "Reading" in window', () => { + expect(hasWorkingPattern('Reading file contents')).toBe(true); + }); + + it('should detect "Running" in window', () => { + expect(hasWorkingPattern('Running npm install')).toBe(true); + }); + + it('should detect "Searching" in window', () => { + expect(hasWorkingPattern('Searching for patterns')).toBe(true); + }); + + it('should detect "Editing" in window', () => { + expect(hasWorkingPattern('Editing src/utils.ts')).toBe(true); + }); + + it('should detect all defined text patterns', () => { + const textPatterns = WORKING_PATTERNS.filter((p) => p.length > 2); + for (const pattern of textPatterns) { + expect(hasWorkingPattern(`Some text with ${pattern} in it`)).toBe(true); + } + }); + }); + + describe('spinner characters', () => { + it('should detect braille spinner characters', () => { + expect(hasWorkingPattern('Loading... \u280B')).toBe(true); + expect(hasWorkingPattern('\u2839 processing')).toBe(true); + }); + + it('should detect circle spinner characters', () => { + expect(hasWorkingPattern('\u25D0 working')).toBe(true); + expect(hasWorkingPattern('status: \u25D3')).toBe(true); + }); + + it('should detect braille block spinners', () => { + expect(hasWorkingPattern('\u28FE loading')).toBe(true); // ⣾ + expect(hasWorkingPattern('\u28FD processing')).toBe(true); // ⣽ + }); + }); + + describe('negative cases', () => { + it('should return false for empty string', () => { + expect(hasWorkingPattern('')).toBe(false); + }); + + it('should return false for prompt characters only', () => { + expect(hasWorkingPattern('\u276F ')).toBe(false); + }); + + it('should return false for regular output text', () => { + expect(hasWorkingPattern('Here is the result of the computation')).toBe(false); + }); + + it('should return false for completion messages', () => { + expect(hasWorkingPattern('Worked for 2m 46s')).toBe(false); + }); + }); +}); + +describe('extractTokenCount', () => { + describe('basic token patterns', () => { + it('should extract plain number tokens', () => { + expect(extractTokenCount('500 tokens')).toBe(500); + }); + + it('should extract decimal number tokens', () => { + expect(extractTokenCount('123.4 tokens')).toBe(123); + }); + + it('should extract k suffix (thousands)', () => { + expect(extractTokenCount('123.4k tokens')).toBe(123400); + }); + + it('should extract K suffix (uppercase)', () => { + expect(extractTokenCount('50K tokens')).toBe(50000); + }); + + it('should extract m suffix (millions)', () => { + expect(extractTokenCount('1.5m tokens')).toBe(1500000); + }); + + it('should extract M suffix (uppercase)', () => { + expect(extractTokenCount('2M tokens')).toBe(2000000); + }); + + it('should handle whitespace between number and suffix', () => { + expect(extractTokenCount('123.4 k tokens')).toBe(123400); + }); + }); + + describe('embedded in text', () => { + it('should extract from terminal output', () => { + expect(extractTokenCount('Current usage: 45.2k tokens remaining')).toBe(45200); + }); + + it('should extract from completion message', () => { + expect(extractTokenCount('Worked for 2m 46s | 150.3k tokens used')).toBe(150300); + }); + }); + + describe('negative cases', () => { + it('should return null for empty string', () => { + expect(extractTokenCount('')).toBeNull(); + }); + + it('should return null for string without token pattern', () => { + expect(extractTokenCount('Hello world')).toBeNull(); + }); + + it('should return null for "tokens" without a number', () => { + expect(extractTokenCount('many tokens')).toBeNull(); + }); + }); + + describe('rounding', () => { + it('should round to nearest integer', () => { + // 1.7k = 1700 (exact, no rounding needed) + expect(extractTokenCount('1.7k tokens')).toBe(1700); + }); + + it('should round fractional results', () => { + // 1.23456k = 1234.56 -> rounds to 1235 + expect(extractTokenCount('1.23456k tokens')).toBe(1235); + }); + }); +}); + +describe('PROMPT_PATTERNS', () => { + it('should contain standard prompt characters', () => { + expect(PROMPT_PATTERNS).toContain('\u276F'); // Unicode right-pointing angle + expect(PROMPT_PATTERNS).toContain('\u23F5'); // Play button variant + }); + + it('should be a non-empty array', () => { + expect(PROMPT_PATTERNS.length).toBeGreaterThan(0); + }); +}); + +describe('WORKING_PATTERNS', () => { + it('should be a non-empty array', () => { + expect(WORKING_PATTERNS.length).toBeGreaterThan(0); + }); + + it('should contain common Claude activity indicators', () => { + expect(WORKING_PATTERNS).toContain('Thinking'); + expect(WORKING_PATTERNS).toContain('Writing'); + expect(WORKING_PATTERNS).toContain('Reading'); + expect(WORKING_PATTERNS).toContain('Running'); + }); + + it('should contain spinner characters', () => { + expect(WORKING_PATTERNS).toContain('\u280B'); + expect(WORKING_PATTERNS).toContain('\u2819'); + }); + + it('should not contain completion indicators like stars', () => { + // Per source comment: "Note: \u273B and \u273D removed - they appear in completion messages too." + expect(WORKING_PATTERNS).not.toContain('\u273B'); + expect(WORKING_PATTERNS).not.toContain('\u273D'); + }); +}); diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts index 947ae32b..453cef69 100644 --- a/test/routes/system-routes.test.ts +++ b/test/routes/system-routes.test.ts @@ -4,20 +4,119 @@ * Uses app.inject() — no real HTTP ports needed. * Port: N/A (app.inject doesn't open ports) * - * Note: Some system routes access singleton modules (subagentWatcher, imageWatcher) - * directly. Those routes are tested lightly here since we can't easily mock singletons - * without vi.mock(). Focus is on routes that use the port context. + * Mocks: fs (promises + sync), subagentWatcher, imageWatcher, + * session-lifecycle-log, and opencode-cli-resolver singletons. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; import { registerSystemRoutes } from '../../src/web/routes/system-routes.js'; +// ── Mocks ────────────────────────────────────────────────────────── + +vi.mock('node:fs/promises', () => ({ + default: { + readFile: vi.fn(async () => '{}'), + writeFile: vi.fn(async () => undefined), + }, +})); + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(() => true), + mkdirSync: vi.fn(), + readdirSync: vi.fn(() => []), + }; +}); + +vi.mock('../../src/subagent-watcher.js', () => ({ + subagentWatcher: { + getSubagents: vi.fn(() => []), + getRecentSubagents: vi.fn(() => []), + getSubagentsForSession: vi.fn(() => []), + getSubagent: vi.fn(() => null), + getTranscript: vi.fn(async () => []), + formatTranscript: vi.fn(() => ''), + killSubagent: vi.fn(async () => false), + cleanupNow: vi.fn(() => 0), + clearAll: vi.fn(() => 0), + getStats: vi.fn(() => ({ + totalAgents: 0, + activeAgents: 0, + fileDebouncerCount: 0, + dirWatcherCount: 0, + idleTimerCount: 0, + })), + isRunning: vi.fn(() => true), + start: vi.fn(), + stop: vi.fn(), + }, +})); + +vi.mock('../../src/image-watcher.js', () => ({ + imageWatcher: { + isRunning: vi.fn(() => false), + start: vi.fn(), + stop: vi.fn(), + watchSession: vi.fn(), + }, +})); + +vi.mock('../../src/session-lifecycle-log.js', () => ({ + getLifecycleLog: vi.fn(() => ({ + log: vi.fn(), + query: vi.fn(async () => []), + })), +})); + +vi.mock('../../src/utils/opencode-cli-resolver.js', () => ({ + isOpenCodeAvailable: vi.fn(() => false), + resolveOpenCodeDir: vi.fn(() => null), +})); + +import fs from 'node:fs/promises'; +import { existsSync, readdirSync } from 'node:fs'; +import { subagentWatcher } from '../../src/subagent-watcher.js'; +import { getLifecycleLog } from '../../src/session-lifecycle-log.js'; +import { isOpenCodeAvailable, resolveOpenCodeDir } from '../../src/utils/opencode-cli-resolver.js'; + +const mockedReadFile = vi.mocked(fs.readFile); +const mockedWriteFile = vi.mocked(fs.writeFile); +const mockedExistsSync = vi.mocked(existsSync); +const mockedReaddirSync = vi.mocked(readdirSync); +const mockedSubagentWatcher = vi.mocked(subagentWatcher); +const mockedGetLifecycleLog = vi.mocked(getLifecycleLog); +const mockedIsOpenCodeAvailable = vi.mocked(isOpenCodeAvailable); +const mockedResolveOpenCodeDir = vi.mocked(resolveOpenCodeDir); + describe('system-routes', () => { let harness: RouteTestHarness; beforeEach(async () => { harness = await createRouteTestHarness(registerSystemRoutes); + vi.clearAllMocks(); + + // Re-apply defaults after clearAllMocks + mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + mockedWriteFile.mockResolvedValue(undefined); + mockedExistsSync.mockReturnValue(true); + mockedReaddirSync.mockReturnValue([]); + mockedSubagentWatcher.getSubagents.mockReturnValue([]); + mockedSubagentWatcher.getStats.mockReturnValue({ + totalAgents: 0, + activeAgents: 0, + fileDebouncerCount: 0, + dirWatcherCount: 0, + idleTimerCount: 0, + } as never); + mockedGetLifecycleLog.mockReturnValue({ + log: vi.fn(), + query: vi.fn(async () => []), + } as never); + mockedIsOpenCodeAvailable.mockReturnValue(false); + mockedResolveOpenCodeDir.mockReturnValue(null); }); afterEach(async () => { @@ -147,6 +246,13 @@ describe('system-routes', () => { expect(body.success).toBe(true); expect(body.data).toBeDefined(); }); + + it('filters by minutes when query param provided', async () => { + mockedSubagentWatcher.getRecentSubagents.mockReturnValue([]); + const res = await harness.app.inject({ method: 'GET', url: '/api/subagents?minutes=30' }); + expect(res.statusCode).toBe(200); + expect(mockedSubagentWatcher.getRecentSubagents).toHaveBeenCalledWith(30); + }); }); // ========== POST /api/auth/revoke ========== @@ -162,5 +268,681 @@ describe('system-routes', () => { const body = JSON.parse(res.body); expect(body.success).toBe(true); }); + + it('revokes a specific session token', async () => { + const authSessions = new Map(); + authSessions.set('tok-123', { ip: '1.2.3.4', ua: 'test', createdAt: Date.now(), method: 'basic' }); + harness.ctx.authSessions = authSessions; + + const res = await harness.app.inject({ + method: 'POST', + url: '/api/auth/revoke', + payload: { sessionToken: 'tok-123' }, + }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).success).toBe(true); + expect(authSessions.has('tok-123')).toBe(false); + }); + + it('clears all sessions when no token specified', async () => { + const authSessions = new Map(); + authSessions.set('a', { ip: '1', ua: '', createdAt: 0, method: 'basic' }); + authSessions.set('b', { ip: '2', ua: '', createdAt: 0, method: 'qr' }); + harness.ctx.authSessions = authSessions; + + const res = await harness.app.inject({ + method: 'POST', + url: '/api/auth/revoke', + payload: {}, + }); + expect(res.statusCode).toBe(200); + expect(authSessions.size).toBe(0); + }); + }); + + // ========== GET /api/settings ========== + + describe('GET /api/settings', () => { + it('returns empty object when settings file does not exist', async () => { + mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + const res = await harness.app.inject({ method: 'GET', url: '/api/settings' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toEqual({}); + }); + + it('returns parsed settings when file exists', async () => { + const settings = { subagentTrackingEnabled: true, showSystemStats: false }; + mockedReadFile.mockResolvedValue(JSON.stringify(settings) as never); + + const res = await harness.app.inject({ method: 'GET', url: '/api/settings' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toEqual(settings); + }); + }); + + // ========== PUT /api/settings ========== + + describe('PUT /api/settings', () => { + it('saves valid settings', async () => { + mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/settings', + payload: { showSystemStats: true, subagentTrackingEnabled: false }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(mockedWriteFile).toHaveBeenCalled(); + }); + + it('merges with existing settings', async () => { + mockedReadFile.mockResolvedValue(JSON.stringify({ showCost: true }) as never); + + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/settings', + payload: { showTokenCount: false }, + }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).success).toBe(true); + + // Verify writeFile was called with merged content + const writtenContent = JSON.parse(mockedWriteFile.mock.calls[0][1] as string); + expect(writtenContent.showCost).toBe(true); + expect(writtenContent.showTokenCount).toBe(false); + }); + + it('rejects unknown settings fields (strict schema)', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/settings', + payload: { unknownField: 'bad' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('rejects non-object body', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/settings', + payload: 'not-an-object', + headers: { 'content-type': 'application/json' }, + }); + // Fastify will reject non-object JSON before it reaches the handler + expect(res.statusCode).not.toBe(200); + }); + }); + + // ========== GET /api/subagent-window-states ========== + + describe('GET /api/subagent-window-states', () => { + it('returns default when file does not exist', async () => { + mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + const res = await harness.app.inject({ method: 'GET', url: '/api/subagent-window-states' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toEqual({ minimized: {}, open: [] }); + }); + + it('returns persisted window states', async () => { + const states = { minimized: { 'agent-1': true }, open: ['agent-2'] }; + mockedReadFile.mockResolvedValue(JSON.stringify(states) as never); + + const res = await harness.app.inject({ method: 'GET', url: '/api/subagent-window-states' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toEqual(states); + }); + }); + + // ========== PUT /api/subagent-window-states ========== + + describe('PUT /api/subagent-window-states', () => { + it('saves valid window states', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/subagent-window-states', + payload: { minimized: { 'agent-1': true }, open: ['agent-2'] }, + }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).success).toBe(true); + expect(mockedWriteFile).toHaveBeenCalled(); + }); + + it('accepts empty state', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/subagent-window-states', + payload: { minimized: {}, open: [] }, + }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).success).toBe(true); + }); + + it('rejects invalid minimized values', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/subagent-window-states', + payload: { minimized: { 'agent-1': 'not-a-boolean' } }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== GET /api/subagent-parents ========== + + describe('GET /api/subagent-parents', () => { + it('returns empty object when file does not exist', async () => { + mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + const res = await harness.app.inject({ method: 'GET', url: '/api/subagent-parents' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toEqual({}); + }); + + it('returns persisted parent map', async () => { + const parents = { 'agent-1': 'session-abc', 'agent-2': 'session-xyz' }; + mockedReadFile.mockResolvedValue(JSON.stringify(parents) as never); + + const res = await harness.app.inject({ method: 'GET', url: '/api/subagent-parents' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toEqual(parents); + }); + }); + + // ========== PUT /api/subagent-parents ========== + + describe('PUT /api/subagent-parents', () => { + it('saves valid parent map', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/subagent-parents', + payload: { 'agent-1': 'session-abc' }, + }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).success).toBe(true); + expect(mockedWriteFile).toHaveBeenCalled(); + }); + + it('accepts empty parent map', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/subagent-parents', + payload: {}, + }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).success).toBe(true); + }); + + it('rejects non-string values in parent map', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/subagent-parents', + payload: { 'agent-1': 123 }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== GET /api/screenshots ========== + + describe('GET /api/screenshots', () => { + it('returns empty list when directory does not exist', async () => { + mockedExistsSync.mockReturnValue(false); + + const res = await harness.app.inject({ method: 'GET', url: '/api/screenshots' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toEqual({ files: [] }); + }); + + it('returns image files sorted in reverse order', async () => { + mockedExistsSync.mockReturnValue(true); + mockedReaddirSync.mockReturnValue([ + 'screenshot_2026-01-01_00-00-00.png', + 'screenshot_2026-01-02_00-00-00.png', + 'not-an-image.txt', + 'screenshot_2026-01-03_00-00-00.jpg', + ] as never); + + const res = await harness.app.inject({ method: 'GET', url: '/api/screenshots' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.files).toHaveLength(3); // excludes .txt + // Sorted reverse: 03, 02, 01 + expect(body.files[0].name).toBe('screenshot_2026-01-03_00-00-00.jpg'); + expect(body.files[1].name).toBe('screenshot_2026-01-02_00-00-00.png'); + expect(body.files[2].name).toBe('screenshot_2026-01-01_00-00-00.png'); + }); + }); + + // ========== GET /api/screenshots/:name ========== + + describe('GET /api/screenshots/:name', () => { + it('rejects path traversal attempts', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: '/api/screenshots/..%2F..%2Fetc%2Fpasswd', + }); + expect(res.statusCode).toBe(400); + }); + + it('returns 404 when screenshot does not exist', async () => { + mockedExistsSync.mockReturnValue(false); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/screenshots/nonexistent.png', + }); + expect(res.statusCode).toBe(404); + }); + + it('serves existing screenshot with correct content type', async () => { + mockedExistsSync.mockReturnValue(true); + mockedReadFile.mockResolvedValue(Buffer.from('fake-png-data') as never); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/screenshots/test-image.png', + }); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/png'); + }); + + it('serves jpeg with correct content type', async () => { + mockedExistsSync.mockReturnValue(true); + mockedReadFile.mockResolvedValue(Buffer.from('fake-jpg-data') as never); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/screenshots/photo.jpg', + }); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/jpeg'); + }); + }); + + // ========== POST /api/screenshots ========== + + describe('POST /api/screenshots', () => { + it('rejects non-multipart content type', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/screenshots', + payload: { file: 'data' }, + headers: { 'content-type': 'application/json' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toContain('multipart'); + }); + }); + + // ========== GET /api/session-lifecycle ========== + + describe('GET /api/session-lifecycle', () => { + it('returns lifecycle entries', async () => { + const mockEntries = [{ event: 'session_created', sessionId: 'test-1', timestamp: Date.now() }]; + mockedGetLifecycleLog.mockReturnValue({ + log: vi.fn(), + query: vi.fn(async () => mockEntries), + } as never); + + const res = await harness.app.inject({ method: 'GET', url: '/api/session-lifecycle' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.entries).toEqual(mockEntries); + }); + + it('passes filter query params to lifecycle log', async () => { + const queryFn = vi.fn(async () => []); + mockedGetLifecycleLog.mockReturnValue({ + log: vi.fn(), + query: queryFn, + } as never); + + await harness.app.inject({ + method: 'GET', + url: '/api/session-lifecycle?sessionId=s1&event=session_created&since=1000&limit=50', + }); + + expect(queryFn).toHaveBeenCalledWith({ + sessionId: 's1', + event: 'session_created', + since: 1000, + limit: 50, + }); + }); + + it('caps limit at 1000', async () => { + const queryFn = vi.fn(async () => []); + mockedGetLifecycleLog.mockReturnValue({ + log: vi.fn(), + query: queryFn, + } as never); + + await harness.app.inject({ + method: 'GET', + url: '/api/session-lifecycle?limit=5000', + }); + + expect(queryFn).toHaveBeenCalledWith(expect.objectContaining({ limit: 1000 })); + }); + + it('defaults limit to 200 when not specified', async () => { + const queryFn = vi.fn(async () => []); + mockedGetLifecycleLog.mockReturnValue({ + log: vi.fn(), + query: queryFn, + } as never); + + await harness.app.inject({ method: 'GET', url: '/api/session-lifecycle' }); + + expect(queryFn).toHaveBeenCalledWith(expect.objectContaining({ limit: 200 })); + }); + }); + + // ========== GET /api/opencode/status ========== + + describe('GET /api/opencode/status', () => { + it('returns unavailable when opencode is not installed', async () => { + mockedIsOpenCodeAvailable.mockReturnValue(false); + mockedResolveOpenCodeDir.mockReturnValue(null); + + const res = await harness.app.inject({ method: 'GET', url: '/api/opencode/status' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.available).toBe(false); + expect(body.path).toBeNull(); + }); + + it('returns available with path when opencode is installed', async () => { + mockedIsOpenCodeAvailable.mockReturnValue(true); + mockedResolveOpenCodeDir.mockReturnValue('/usr/local/bin'); + + const res = await harness.app.inject({ method: 'GET', url: '/api/opencode/status' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.available).toBe(true); + expect(body.path).toBe('/usr/local/bin'); + }); + }); + + // ========== GET /api/execution/model-config ========== + + describe('GET /api/execution/model-config', () => { + it('returns empty data when settings file does not exist', async () => { + mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + const res = await harness.app.inject({ method: 'GET', url: '/api/execution/model-config' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data).toEqual({}); + }); + + it('returns model config from settings', async () => { + const settings = { modelConfig: { model: 'claude-3', temperature: 0.7 } }; + mockedReadFile.mockResolvedValue(JSON.stringify(settings) as never); + + const res = await harness.app.inject({ method: 'GET', url: '/api/execution/model-config' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data).toEqual(settings.modelConfig); + }); + + it('returns empty data when settings has no modelConfig', async () => { + mockedReadFile.mockResolvedValue(JSON.stringify({ showCost: true }) as never); + + const res = await harness.app.inject({ method: 'GET', url: '/api/execution/model-config' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data).toEqual({}); + }); + }); + + // ========== PUT /api/execution/model-config ========== + + describe('PUT /api/execution/model-config', () => { + it('saves valid model config', async () => { + mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/execution/model-config', + payload: { model: 'claude-3', temperature: 0.5 }, + }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).success).toBe(true); + expect(mockedWriteFile).toHaveBeenCalled(); + + // Verify the written content contains modelConfig + const writtenContent = JSON.parse(mockedWriteFile.mock.calls[0][1] as string); + expect(writtenContent.modelConfig).toEqual({ model: 'claude-3', temperature: 0.5 }); + }); + + it('preserves existing settings when updating model config', async () => { + mockedReadFile.mockResolvedValue(JSON.stringify({ showCost: true, other: 'value' }) as never); + + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/execution/model-config', + payload: { model: 'new-model' }, + }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).success).toBe(true); + + const writtenContent = JSON.parse(mockedWriteFile.mock.calls[0][1] as string); + expect(writtenContent.showCost).toBe(true); + expect(writtenContent.other).toBe('value'); + expect(writtenContent.modelConfig).toEqual({ model: 'new-model' }); + }); + }); + + // ========== GET /api/subagents/:agentId ========== + + describe('GET /api/subagents/:agentId', () => { + it('returns 200 with error for unknown agent', async () => { + mockedSubagentWatcher.getSubagent.mockReturnValue(null); + + const res = await harness.app.inject({ method: 'GET', url: '/api/subagents/unknown-agent' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toContain('not found'); + }); + + it('returns agent info for known agent', async () => { + const agentInfo = { agentId: 'agent-1', status: 'active', pid: 1234 }; + mockedSubagentWatcher.getSubagent.mockReturnValue(agentInfo as never); + + const res = await harness.app.inject({ method: 'GET', url: '/api/subagents/agent-1' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data).toEqual(agentInfo); + }); + }); + + // ========== GET /api/subagents/:agentId/transcript ========== + + describe('GET /api/subagents/:agentId/transcript', () => { + it('returns raw transcript by default', async () => { + const transcript = [{ role: 'assistant', content: 'hello' }]; + mockedSubagentWatcher.getTranscript.mockResolvedValue(transcript as never); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/subagents/agent-1/transcript', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data).toEqual(transcript); + }); + + it('returns formatted transcript when format=formatted', async () => { + const transcript = [{ role: 'assistant', content: 'hello' }]; + mockedSubagentWatcher.getTranscript.mockResolvedValue(transcript as never); + mockedSubagentWatcher.formatTranscript.mockReturnValue('## Formatted\nhello'); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/subagents/agent-1/transcript?format=formatted', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.formatted).toBe('## Formatted\nhello'); + expect(body.data.entryCount).toBe(1); + }); + + it('passes limit parameter', async () => { + mockedSubagentWatcher.getTranscript.mockResolvedValue([] as never); + + await harness.app.inject({ + method: 'GET', + url: '/api/subagents/agent-1/transcript?limit=10', + }); + expect(mockedSubagentWatcher.getTranscript).toHaveBeenCalledWith('agent-1', 10); + }); + }); + + // ========== DELETE /api/subagents/:agentId ========== + + describe('DELETE /api/subagents/:agentId', () => { + it('returns error for unknown agent', async () => { + mockedSubagentWatcher.getSubagent.mockReturnValue(null); + + const res = await harness.app.inject({ method: 'DELETE', url: '/api/subagents/unknown' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toContain('not found'); + }); + + it('kills a known agent successfully', async () => { + mockedSubagentWatcher.getSubagent.mockReturnValue({ agentId: 'agent-1' } as never); + mockedSubagentWatcher.killSubagent.mockResolvedValue(true); + + const res = await harness.app.inject({ method: 'DELETE', url: '/api/subagents/agent-1' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.status).toBe('killed'); + }); + + it('returns error when kill fails', async () => { + mockedSubagentWatcher.getSubagent.mockReturnValue({ agentId: 'agent-1' } as never); + mockedSubagentWatcher.killSubagent.mockResolvedValue(false); + + const res = await harness.app.inject({ method: 'DELETE', url: '/api/subagents/agent-1' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toContain('already completed'); + }); + }); + + // ========== POST /api/subagents/cleanup ========== + + describe('POST /api/subagents/cleanup', () => { + it('triggers cleanup and returns count', async () => { + mockedSubagentWatcher.cleanupNow.mockReturnValue(3); + mockedSubagentWatcher.getSubagents.mockReturnValue([{ agentId: 'a' }, { agentId: 'b' }] as never); + + const res = await harness.app.inject({ method: 'POST', url: '/api/subagents/cleanup' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.removed).toBe(3); + expect(body.data.remaining).toBe(2); + }); + }); + + // ========== DELETE /api/subagents ========== + + describe('DELETE /api/subagents', () => { + it('clears all subagents', async () => { + mockedSubagentWatcher.clearAll.mockReturnValue(5); + + const res = await harness.app.inject({ method: 'DELETE', url: '/api/subagents' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.cleared).toBe(5); + }); + }); + + // ========== GET /api/sessions/:id/subagents ========== + + describe('GET /api/sessions/:id/subagents', () => { + it('returns 404 for unknown session', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: '/api/sessions/nonexistent/subagents', + }); + expect(res.statusCode).toBe(404); + }); + + it('returns subagents for valid session', async () => { + mockedSubagentWatcher.getSubagentsForSession.mockReturnValue([{ agentId: 'sub-1', status: 'active' }] as never); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/subagents`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data).toHaveLength(1); + }); + }); + + // ========== POST /api/tunnel/qr/regenerate ========== + + describe('POST /api/tunnel/qr/regenerate', () => { + it('calls regenerateQrToken on tunnel manager', async () => { + // Set up a mock tunnel manager + harness.ctx.tunnelManager = { + regenerateQrToken: vi.fn(), + getStatus: vi.fn(() => ({ running: false })), + getUrl: vi.fn(() => null), + getQrSvg: vi.fn(), + consumeToken: vi.fn(), + isRunning: vi.fn(() => false), + start: vi.fn(), + stop: vi.fn(), + } as never; + + // Re-create the harness since tunnelManager was null originally + await harness.app.close(); + harness = await createRouteTestHarness(registerSystemRoutes); + harness.ctx.tunnelManager = { + regenerateQrToken: vi.fn(), + getStatus: vi.fn(() => ({ running: false })), + getUrl: vi.fn(() => null), + getQrSvg: vi.fn(), + consumeToken: vi.fn(), + isRunning: vi.fn(() => false), + start: vi.fn(), + stop: vi.fn(), + } as never; + + const res = await harness.app.inject({ method: 'POST', url: '/api/tunnel/qr/regenerate' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).success).toBe(true); + }); }); }); diff --git a/test/stale-expiration-map.test.ts b/test/stale-expiration-map.test.ts new file mode 100644 index 00000000..7bc99abc --- /dev/null +++ b/test/stale-expiration-map.test.ts @@ -0,0 +1,402 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { StaleExpirationMap } from '../src/utils/stale-expiration-map.js'; + +describe('StaleExpirationMap', () => { + let map: StaleExpirationMap; + + afterEach(() => { + // Always dispose to clear cleanup timers + map?.dispose(); + }); + + describe('construction', () => { + it('creates an empty map', () => { + map = new StaleExpirationMap({ ttlMs: 1000 }); + expect(map.size).toBe(0); + expect(map.isDisposed).toBe(false); + }); + + it('defaults refreshOnGet to true', () => { + map = new StaleExpirationMap({ ttlMs: 1000 }); + // Verify by setting, advancing time partially, getting (refreshes), advancing again + vi.useFakeTimers(); + map.set('a', 1); + vi.advanceTimersByTime(800); // 800ms of 1000ms TTL + map.get('a'); // should refresh + vi.advanceTimersByTime(800); // 1600ms total, but only 800ms since last access + expect(map.has('a')).toBe(true); + vi.useRealTimers(); + }); + }); + + describe('set/get', () => { + beforeEach(() => { + map = new StaleExpirationMap({ ttlMs: 1000 }); + }); + + it('stores and retrieves values', () => { + map.set('a', 1); + map.set('b', 2); + expect(map.get('a')).toBe(1); + expect(map.get('b')).toBe(2); + }); + + it('returns undefined for missing keys', () => { + expect(map.get('nonexistent')).toBeUndefined(); + }); + + it('overwrites existing values', () => { + map.set('a', 1); + map.set('a', 10); + expect(map.get('a')).toBe(10); + }); + + it('supports chaining on set', () => { + const result = map.set('a', 1).set('b', 2); + expect(result).toBe(map); + expect(map.size).toBe(2); + }); + + it('ignores set when disposed', () => { + map.dispose(); + map.set('a', 1); + expect(map.size).toBe(0); + }); + }); + + describe('TTL expiration', () => { + it('expires entries after TTL', () => { + vi.useFakeTimers(); + map = new StaleExpirationMap({ ttlMs: 1000 }); + map.set('a', 1); + vi.advanceTimersByTime(1001); + expect(map.get('a')).toBeUndefined(); + expect(map.size).toBe(0); + vi.useRealTimers(); + }); + + it('entries are accessible before TTL', () => { + vi.useFakeTimers(); + map = new StaleExpirationMap({ ttlMs: 1000 }); + map.set('a', 1); + vi.advanceTimersByTime(500); + expect(map.get('a')).toBe(1); + vi.useRealTimers(); + }); + + it('get refreshes TTL when refreshOnGet is true', () => { + vi.useFakeTimers(); + map = new StaleExpirationMap({ ttlMs: 1000, refreshOnGet: true }); + map.set('a', 1); + vi.advanceTimersByTime(800); + map.get('a'); // refreshes to now + vi.advanceTimersByTime(800); // 800ms after refresh, still within TTL + expect(map.get('a')).toBe(1); + vi.useRealTimers(); + }); + + it('get does not refresh TTL when refreshOnGet is false', () => { + vi.useFakeTimers(); + map = new StaleExpirationMap({ ttlMs: 1000, refreshOnGet: false }); + map.set('a', 1); + vi.advanceTimersByTime(800); + map.get('a'); // does NOT refresh + vi.advanceTimersByTime(300); // 1100ms total, past TTL + expect(map.get('a')).toBeUndefined(); + vi.useRealTimers(); + }); + }); + + describe('has', () => { + it('returns true for non-expired keys', () => { + map = new StaleExpirationMap({ ttlMs: 1000 }); + map.set('a', 1); + expect(map.has('a')).toBe(true); + }); + + it('returns false for missing keys', () => { + map = new StaleExpirationMap({ ttlMs: 1000 }); + expect(map.has('nonexistent')).toBe(false); + }); + + it('returns false and deletes expired keys', () => { + vi.useFakeTimers(); + map = new StaleExpirationMap({ ttlMs: 1000 }); + map.set('a', 1); + vi.advanceTimersByTime(1001); + expect(map.has('a')).toBe(false); + vi.useRealTimers(); + }); + }); + + describe('peek', () => { + it('returns value without refreshing TTL', () => { + vi.useFakeTimers(); + map = new StaleExpirationMap({ ttlMs: 1000 }); + map.set('a', 1); + vi.advanceTimersByTime(800); + expect(map.peek('a')).toBe(1); // does not refresh + vi.advanceTimersByTime(300); // 1100ms total + expect(map.peek('a')).toBeUndefined(); + vi.useRealTimers(); + }); + + it('returns undefined for missing keys', () => { + map = new StaleExpirationMap({ ttlMs: 1000 }); + expect(map.peek('nonexistent')).toBeUndefined(); + }); + + it('deletes expired entries on peek', () => { + vi.useFakeTimers(); + map = new StaleExpirationMap({ ttlMs: 1000 }); + map.set('a', 1); + vi.advanceTimersByTime(1001); + map.peek('a'); + // The internal entry should be deleted + expect(map.size).toBe(0); + vi.useRealTimers(); + }); + }); + + describe('delete', () => { + it('removes an existing key', () => { + map = new StaleExpirationMap({ ttlMs: 1000 }); + map.set('a', 1); + expect(map.delete('a')).toBe(true); + expect(map.has('a')).toBe(false); + expect(map.size).toBe(0); + }); + + it('returns false for non-existent key', () => { + map = new StaleExpirationMap({ ttlMs: 1000 }); + expect(map.delete('nonexistent')).toBe(false); + }); + + it('does not call onExpire on manual delete', () => { + const onExpire = vi.fn(); + map = new StaleExpirationMap({ ttlMs: 1000, onExpire }); + map.set('a', 1); + map.delete('a'); + expect(onExpire).not.toHaveBeenCalled(); + }); + }); + + describe('clear', () => { + it('removes all entries', () => { + map = new StaleExpirationMap({ ttlMs: 1000 }); + map.set('a', 1); + map.set('b', 2); + map.clear(); + expect(map.size).toBe(0); + expect(map.get('a')).toBeUndefined(); + }); + }); + + describe('touch', () => { + it('refreshes TTL without returning value', () => { + vi.useFakeTimers(); + map = new StaleExpirationMap({ ttlMs: 1000 }); + map.set('a', 1); + vi.advanceTimersByTime(800); + expect(map.touch('a')).toBe(true); + vi.advanceTimersByTime(800); // 1600ms total, but 800ms since touch + expect(map.has('a')).toBe(true); + vi.useRealTimers(); + }); + + it('returns false for non-existent keys', () => { + map = new StaleExpirationMap({ ttlMs: 1000 }); + expect(map.touch('nonexistent')).toBe(false); + }); + + it('returns false for expired keys', () => { + vi.useFakeTimers(); + map = new StaleExpirationMap({ ttlMs: 1000 }); + map.set('a', 1); + vi.advanceTimersByTime(1001); + expect(map.touch('a')).toBe(false); + vi.useRealTimers(); + }); + }); + + describe('getAge', () => { + it('returns age since creation', () => { + vi.useFakeTimers(); + map = new StaleExpirationMap({ ttlMs: 10000 }); + map.set('a', 1); + vi.advanceTimersByTime(3000); + const age = map.getAge('a'); + expect(age).toBe(3000); + vi.useRealTimers(); + }); + + it('returns undefined for missing keys', () => { + map = new StaleExpirationMap({ ttlMs: 1000 }); + expect(map.getAge('nonexistent')).toBeUndefined(); + }); + }); + + describe('getRemainingTtl', () => { + it('returns remaining time before expiration', () => { + vi.useFakeTimers(); + map = new StaleExpirationMap({ ttlMs: 1000 }); + map.set('a', 1); + vi.advanceTimersByTime(300); + const remaining = map.getRemainingTtl('a'); + expect(remaining).toBe(700); + vi.useRealTimers(); + }); + + it('returns 0 for expired entries (before cleanup removes them)', () => { + vi.useFakeTimers(); + // Use a long cleanup interval so the entry is not auto-removed + map = new StaleExpirationMap({ ttlMs: 1000, cleanupIntervalMs: 60000 }); + map.set('a', 1); + vi.advanceTimersByTime(2000); + expect(map.getRemainingTtl('a')).toBe(0); + vi.useRealTimers(); + }); + + it('returns undefined after cleanup removes expired entry', () => { + vi.useFakeTimers(); + map = new StaleExpirationMap({ ttlMs: 1000 }); + map.set('a', 1); + vi.advanceTimersByTime(2000); // cleanup fires and removes 'a' + expect(map.getRemainingTtl('a')).toBeUndefined(); + vi.useRealTimers(); + }); + + it('returns undefined for missing keys', () => { + map = new StaleExpirationMap({ ttlMs: 1000 }); + expect(map.getRemainingTtl('nonexistent')).toBeUndefined(); + }); + }); + + describe('cleanup', () => { + it('removes expired entries and returns count', () => { + vi.useFakeTimers(); + map = new StaleExpirationMap({ ttlMs: 1000 }); + map.set('a', 1); + map.set('b', 2); + vi.advanceTimersByTime(500); + map.set('c', 3); + vi.advanceTimersByTime(600); // 'a' and 'b' at 1100ms, 'c' at 600ms + const removed = map.cleanup(); + expect(removed).toBe(2); + expect(map.size).toBe(1); + expect(map.has('c')).toBe(true); + vi.useRealTimers(); + }); + + it('calls onExpire for each expired entry', () => { + vi.useFakeTimers(); + const onExpire = vi.fn(); + map = new StaleExpirationMap({ ttlMs: 1000, onExpire }); + map.set('a', 1); + map.set('b', 2); + vi.advanceTimersByTime(1001); + map.cleanup(); + expect(onExpire).toHaveBeenCalledTimes(2); + expect(onExpire).toHaveBeenCalledWith('a', 1); + expect(onExpire).toHaveBeenCalledWith('b', 2); + vi.useRealTimers(); + }); + + it('returns 0 when nothing is expired', () => { + map = new StaleExpirationMap({ ttlMs: 1000 }); + map.set('a', 1); + expect(map.cleanup()).toBe(0); + }); + + it('periodic cleanup runs automatically', () => { + vi.useFakeTimers(); + const onExpire = vi.fn(); + map = new StaleExpirationMap({ ttlMs: 1000, cleanupIntervalMs: 500, onExpire }); + map.set('a', 1); + vi.advanceTimersByTime(1500); // past TTL, and cleanup interval fires + expect(onExpire).toHaveBeenCalledWith('a', 1); + vi.useRealTimers(); + }); + }); + + describe('iteration', () => { + it('iterates non-expired entries via for..of', () => { + map = new StaleExpirationMap({ ttlMs: 5000 }); + map.set('a', 1); + map.set('b', 2); + const entries: [string, number][] = []; + for (const [k, v] of map) { + entries.push([k, v]); + } + expect(entries).toEqual([ + ['a', 1], + ['b', 2], + ]); + }); + + it('skips expired entries during iteration', () => { + vi.useFakeTimers(); + map = new StaleExpirationMap({ ttlMs: 1000 }); + map.set('a', 1); + vi.advanceTimersByTime(500); + map.set('b', 2); + vi.advanceTimersByTime(600); // 'a' expired at 1100ms, 'b' at 600ms + + const entries: [string, number][] = []; + for (const [k, v] of map) { + entries.push([k, v]); + } + expect(entries).toEqual([['b', 2]]); + vi.useRealTimers(); + }); + + it('keys() yields non-expired keys', () => { + map = new StaleExpirationMap({ ttlMs: 5000 }); + map.set('a', 1); + map.set('b', 2); + expect([...map.keys()]).toEqual(['a', 'b']); + }); + + it('values() yields non-expired values', () => { + map = new StaleExpirationMap({ ttlMs: 5000 }); + map.set('a', 1); + map.set('b', 2); + expect([...map.values()]).toEqual([1, 2]); + }); + }); + + describe('dispose', () => { + it('marks map as disposed', () => { + map = new StaleExpirationMap({ ttlMs: 1000 }); + expect(map.isDisposed).toBe(false); + map.dispose(); + expect(map.isDisposed).toBe(true); + }); + + it('clears all entries on dispose', () => { + map = new StaleExpirationMap({ ttlMs: 1000 }); + map.set('a', 1); + map.dispose(); + expect(map.size).toBe(0); + }); + + it('is idempotent', () => { + map = new StaleExpirationMap({ ttlMs: 1000 }); + map.dispose(); + map.dispose(); // should not throw + expect(map.isDisposed).toBe(true); + }); + + it('stops periodic cleanup after dispose', () => { + vi.useFakeTimers(); + const onExpire = vi.fn(); + map = new StaleExpirationMap({ ttlMs: 1000, cleanupIntervalMs: 500, onExpire }); + map.set('a', 1); + map.dispose(); + vi.advanceTimersByTime(2000); + // onExpire should not be called because timer was stopped + expect(onExpire).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 9321f836..b0764136 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,5 +23,5 @@ "allowUnusedLabels": false }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "src/tui"] + "exclude": ["node_modules", "dist"] }