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"]
}