/** * @fileoverview Deep logic tests for Ralph Tracker * * Tests the core completion detection, occurrence-based tracking, * cross-chunk promise handling, circuit breaker state machine, * and realistic ralph loop lifecycle scenarios. * * Test port: 3160 (not used — unit tests only) */ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { RalphTracker } from '../src/ralph-tracker.js'; describe('Ralph Tracker Deep Logic', () => { let tracker: RalphTracker; beforeEach(() => { tracker = new RalphTracker(); tracker.enable(); }); afterEach(() => { tracker.clear(); }); // ========================================================================== // BUG 1: First occurrence should NOT trigger completion without active loop // ========================================================================== describe('Occurrence-based completion detection', () => { it('BUG: first occurrence should NOT fire completionDetected when loop is not active', () => { const completionHandler = vi.fn(); tracker.on('completionDetected', completionHandler); // Simulate: Claude shows the prompt containing the completion phrase // (first occurrence = from the prompt, NOT actual completion) tracker.processTerminalData( 'When done, output exactly: ALL_TASKS_COMPLETE\n' ); tracker.flushPendingEvents(); // BUG: This SHOULD NOT fire, but currently does because of // canonicalCount >= 1 check in handleCompletionPhrase expect(completionHandler).not.toHaveBeenCalled(); expect(tracker.loopState.completionPhrase).toBe('ALL_TASKS_COMPLETE'); // Phrase should be stored, but loop should NOT be marked inactive }); it('second occurrence SHOULD fire completionDetected', () => { const completionHandler = vi.fn(); tracker.on('completionDetected', completionHandler); // First occurrence (from prompt) — store phrase tracker.processTerminalData('ALL_TASKS_COMPLETE\n'); tracker.flushPendingEvents(); completionHandler.mockClear(); // Second occurrence (actual completion signal) tracker.processTerminalData('ALL_TASKS_COMPLETE\n'); tracker.flushPendingEvents(); expect(completionHandler).toHaveBeenCalledWith('ALL_TASKS_COMPLETE'); }); it('first occurrence WITH active loop SHOULD fire completionDetected', () => { const completionHandler = vi.fn(); tracker.on('completionDetected', completionHandler); // Explicitly start the loop tracker.startLoop('MY_PHRASE'); // First occurrence — should fire because loop is active tracker.processTerminalData('MY_PHRASE\n'); tracker.flushPendingEvents(); expect(completionHandler).toHaveBeenCalledWith('MY_PHRASE'); expect(tracker.loopState.active).toBe(false); }); it('completion phrase stored on first occurrence but loop remains active', () => { tracker.startLoop(); // Before any promise tag, no phrase stored expect(tracker.loopState.completionPhrase).toBeNull(); expect(tracker.loopState.active).toBe(true); // First occurrence — phrase stored tracker.processTerminalData('DONE_SIGNAL\n'); tracker.flushPendingEvents(); expect(tracker.loopState.completionPhrase).toBe('DONE_SIGNAL'); // Since loop was active, completion fires and loop becomes inactive expect(tracker.loopState.active).toBe(false); }); }); // ========================================================================== // BUG 2: Double processing of promise tags // ========================================================================== describe('Double-processing prevention', () => { it('BUG: single complete promise tag should call handleCompletionPhrase only once', () => { const completionHandler = vi.fn(); tracker.on('completionDetected', completionHandler); // Start loop to make completion fire on first occurrence tracker.startLoop('UNIQUE_PHRASE_XYZ'); // Single chunk with complete tag tracker.processTerminalData('UNIQUE_PHRASE_XYZ\n'); tracker.flushPendingEvents(); // Should fire exactly once, not twice (from processLine AND checkMultiLinePatterns) expect(completionHandler).toHaveBeenCalledTimes(1); }); it('cross-chunk promise tag should be detected correctly', () => { const completionHandler = vi.fn(); tracker.on('completionDetected', completionHandler); tracker.startLoop('CROSS_CHUNK'); // Split across two chunks tracker.processTerminalData('text CROSS_'); tracker.processTerminalData('CHUNK more\n'); tracker.flushPendingEvents(); expect(completionHandler).toHaveBeenCalledWith('CROSS_CHUNK'); }); it('partial promise tag at chunk boundary should be buffered', () => { const completionHandler = vi.fn(); tracker.on('completionDetected', completionHandler); tracker.startLoop('BUFFERED'); // Chunk ends with incomplete tag tracker.processTerminalData('output: BUFF'); // No completion yet expect(completionHandler).not.toHaveBeenCalled(); // Complete in next chunk tracker.processTerminalData('ERED\n'); tracker.flushPendingEvents(); expect(completionHandler).toHaveBeenCalledWith('BUFFERED'); }); }); // ========================================================================== // Bare phrase detection // ========================================================================== describe('Bare phrase detection', () => { it('bare phrase should fire when loop is explicitly active', () => { const completionHandler = vi.fn(); tracker.on('completionDetected', completionHandler); tracker.startLoop('TASKS_FINISHED'); // Bare phrase (without tags) in output tracker.processTerminalData('TASKS_FINISHED\n'); tracker.flushPendingEvents(); expect(completionHandler).toHaveBeenCalledWith('TASKS_FINISHED'); }); it('bare phrase should fire when phrase was previously seen in tagged form', () => { const completionHandler = vi.fn(); tracker.on('completionDetected', completionHandler); // First: see phrase in tagged form (from prompt) tracker.processTerminalData('WORK_DONE\n'); tracker.flushPendingEvents(); completionHandler.mockClear(); // Then: bare phrase appears (Claude outputs it without tags) tracker.processTerminalData('WORK_DONE\n'); tracker.flushPendingEvents(); expect(completionHandler).toHaveBeenCalledWith('WORK_DONE'); }); it('bare phrase should NOT fire without prior tagged occurrence or active loop', () => { const completionHandler = vi.fn(); tracker.on('completionDetected', completionHandler); // Manually set a completion phrase but don't start loop tracker.configure({ completionPhrase: 'SECRET_PHRASE' }); // Bare phrase in output — should NOT fire (no tagged occurrence, no active loop) tracker.processTerminalData('SECRET_PHRASE\n'); tracker.flushPendingEvents(); expect(completionHandler).not.toHaveBeenCalled(); }); it('bare phrase should fire only once', () => { const completionHandler = vi.fn(); tracker.on('completionDetected', completionHandler); tracker.startLoop('ONCE_ONLY'); tracker.processTerminalData('ONCE_ONLY\n'); tracker.flushPendingEvents(); tracker.processTerminalData('ONCE_ONLY\n'); tracker.flushPendingEvents(); // First bare occurrence fires, second is silently ignored expect(completionHandler).toHaveBeenCalledTimes(1); }); it('bare phrase should NOT fire on prompt-like context', () => { const completionHandler = vi.fn(); tracker.on('completionDetected', completionHandler); tracker.startLoop('SAFE_PHRASE'); // Phrase appears in explanation context tracker.processTerminalData('The completion phrase is SAFE_PHRASE\n'); tracker.flushPendingEvents(); expect(completionHandler).not.toHaveBeenCalled(); }); }); // ========================================================================== // "All tasks complete" detection // ========================================================================== describe('All tasks complete detection', () => { it('should mark all todos as complete when "all tasks completed" detected', () => { // Add some todos first tracker.processTerminalData('- [ ] Fix the login bug\n'); tracker.processTerminalData('- [ ] Add unit tests for auth\n'); tracker.processTerminalData('- [ ] Update documentation\n'); tracker.flushPendingEvents(); expect(tracker.todos.filter(t => t.status === 'pending')).toHaveLength(3); // Trigger all-complete detection tracker.processTerminalData('All tasks completed successfully\n'); tracker.flushPendingEvents(); expect(tracker.todos.filter(t => t.status === 'completed')).toHaveLength(3); }); it('should NOT trigger on long commentary lines', () => { tracker.processTerminalData('- [ ] Task A is pending\n'); tracker.flushPendingEvents(); // Long line (>100 chars) should not trigger const longLine = 'Once all tasks are complete, we should run the integration tests to make sure everything works properly and nothing is broken in the deployment pipeline' + '\n'; tracker.processTerminalData(longLine); tracker.flushPendingEvents(); expect(tracker.todos[0].status).toBe('pending'); }); it('should NOT trigger without any tracked todos', () => { const completionHandler = vi.fn(); tracker.on('completionDetected', completionHandler); tracker.processTerminalData('All tasks completed\n'); tracker.flushPendingEvents(); // No todos tracked, so all-complete should not fire expect(completionHandler).not.toHaveBeenCalled(); }); it('should NOT trigger when count mismatch is too large', () => { tracker.processTerminalData('- [ ] Only two tasks here\n'); tracker.processTerminalData('- [ ] And this second one\n'); tracker.flushPendingEvents(); // "All 15 files created" — count 15 vs 2 todos → too different tracker.processTerminalData('All 15 files have been created\n'); tracker.flushPendingEvents(); expect(tracker.todos.filter(t => t.status === 'pending')).toHaveLength(2); }); it('should emit completionDetected if completion phrase is set', () => { const completionHandler = vi.fn(); tracker.on('completionDetected', completionHandler); // Set a completion phrase tracker.startLoop('LOOP_DONE'); // Add todos and trigger all-complete tracker.processTerminalData('- [ ] Single task to complete\n'); tracker.flushPendingEvents(); tracker.processTerminalData('All tasks completed\n'); tracker.flushPendingEvents(); expect(completionHandler).toHaveBeenCalledWith('LOOP_DONE'); }); }); // ========================================================================== // Circuit breaker state machine // ========================================================================== describe('Circuit breaker state transitions', () => { it('should start in CLOSED state', () => { expect(tracker.circuitBreakerStatus.state).toBe('CLOSED'); }); it('should transition CLOSED → HALF_OPEN after 2 no-progress iterations', () => { const cbHandler = vi.fn(); tracker.on('circuitBreakerUpdate', cbHandler); tracker.enable(); // 2 status blocks with no progress tracker.processTerminalData('---RALPH_STATUS---\nSTATUS: IN_PROGRESS\nTASKS_COMPLETED_THIS_LOOP: 0\nFILES_MODIFIED: 0\n---END_RALPH_STATUS---\n'); tracker.processTerminalData('---RALPH_STATUS---\nSTATUS: IN_PROGRESS\nTASKS_COMPLETED_THIS_LOOP: 0\nFILES_MODIFIED: 0\n---END_RALPH_STATUS---\n'); tracker.flushPendingEvents(); expect(tracker.circuitBreakerStatus.state).toBe('HALF_OPEN'); }); it('should transition HALF_OPEN → OPEN after 3 no-progress iterations', () => { tracker.enable(); // 3 no-progress iterations for (let i = 0; i < 3; i++) { tracker.processTerminalData('---RALPH_STATUS---\nSTATUS: IN_PROGRESS\nTASKS_COMPLETED_THIS_LOOP: 0\nFILES_MODIFIED: 0\n---END_RALPH_STATUS---\n'); } tracker.flushPendingEvents(); expect(tracker.circuitBreakerStatus.state).toBe('OPEN'); }); it('should reset to CLOSED on progress', () => { tracker.enable(); // Get to HALF_OPEN for (let i = 0; i < 2; i++) { tracker.processTerminalData('---RALPH_STATUS---\nSTATUS: IN_PROGRESS\nTASKS_COMPLETED_THIS_LOOP: 0\nFILES_MODIFIED: 0\n---END_RALPH_STATUS---\n'); } expect(tracker.circuitBreakerStatus.state).toBe('HALF_OPEN'); // Progress detected tracker.processTerminalData('---RALPH_STATUS---\nSTATUS: IN_PROGRESS\nTASKS_COMPLETED_THIS_LOOP: 2\nFILES_MODIFIED: 1\n---END_RALPH_STATUS---\n'); tracker.flushPendingEvents(); expect(tracker.circuitBreakerStatus.state).toBe('CLOSED'); }); it('should open on BLOCKED status', () => { tracker.enable(); tracker.processTerminalData('---RALPH_STATUS---\nSTATUS: BLOCKED\nTASKS_COMPLETED_THIS_LOOP: 0\nFILES_MODIFIED: 0\n---END_RALPH_STATUS---\n'); tracker.flushPendingEvents(); expect(tracker.circuitBreakerStatus.state).toBe('OPEN'); }); it('should open after 5 consecutive test failures', () => { tracker.enable(); for (let i = 0; i < 5; i++) { tracker.processTerminalData('---RALPH_STATUS---\nSTATUS: IN_PROGRESS\nTASKS_COMPLETED_THIS_LOOP: 1\nFILES_MODIFIED: 1\nTESTS_STATUS: FAILING\n---END_RALPH_STATUS---\n'); } tracker.flushPendingEvents(); expect(tracker.circuitBreakerStatus.state).toBe('OPEN'); expect(tracker.circuitBreakerStatus.reasonCode).toBe('tests_failing_too_long'); }); it('should be resettable manually', () => { tracker.enable(); // Get to OPEN state for (let i = 0; i < 3; i++) { tracker.processTerminalData('---RALPH_STATUS---\nSTATUS: IN_PROGRESS\nTASKS_COMPLETED_THIS_LOOP: 0\nFILES_MODIFIED: 0\n---END_RALPH_STATUS---\n'); } expect(tracker.circuitBreakerStatus.state).toBe('OPEN'); tracker.resetCircuitBreaker(); expect(tracker.circuitBreakerStatus.state).toBe('CLOSED'); expect(tracker.circuitBreakerStatus.reasonCode).toBe('manual_reset'); }); }); // ========================================================================== // RALPH_STATUS block parsing // ========================================================================== describe('RALPH_STATUS block parsing', () => { it('should parse a complete status block', () => { const statusHandler = vi.fn(); tracker.on('statusBlockDetected', statusHandler); tracker.enable(); tracker.processTerminalData( '---RALPH_STATUS---\n' + 'STATUS: IN_PROGRESS\n' + 'TASKS_COMPLETED_THIS_LOOP: 3\n' + 'FILES_MODIFIED: 7\n' + 'TESTS_STATUS: PASSING\n' + 'WORK_TYPE: IMPLEMENTATION\n' + 'EXIT_SIGNAL: false\n' + 'RECOMMENDATION: Continue with remaining tasks\n' + '---END_RALPH_STATUS---\n' ); tracker.flushPendingEvents(); expect(statusHandler).toHaveBeenCalled(); const block = statusHandler.mock.calls[0][0]; expect(block.status).toBe('IN_PROGRESS'); expect(block.tasksCompletedThisLoop).toBe(3); expect(block.filesModified).toBe(7); expect(block.testsStatus).toBe('PASSING'); expect(block.workType).toBe('IMPLEMENTATION'); expect(block.exitSignal).toBe(false); expect(block.recommendation).toBe('Continue with remaining tasks'); }); it('should handle block with only required STATUS field', () => { const statusHandler = vi.fn(); tracker.on('statusBlockDetected', statusHandler); tracker.enable(); tracker.processTerminalData( '---RALPH_STATUS---\n' + 'STATUS: COMPLETE\n' + '---END_RALPH_STATUS---\n' ); expect(statusHandler).toHaveBeenCalled(); const block = statusHandler.mock.calls[0][0]; expect(block.status).toBe('COMPLETE'); // Defaults for optional fields expect(block.tasksCompletedThisLoop).toBe(0); expect(block.filesModified).toBe(0); expect(block.testsStatus).toBe('NOT_RUN'); expect(block.exitSignal).toBe(false); }); it('should skip block without STATUS field', () => { const statusHandler = vi.fn(); tracker.on('statusBlockDetected', statusHandler); tracker.enable(); tracker.processTerminalData( '---RALPH_STATUS---\n' + 'TASKS_COMPLETED_THIS_LOOP: 5\n' + '---END_RALPH_STATUS---\n' ); expect(statusHandler).not.toHaveBeenCalled(); }); it('should accumulate cumulative stats across blocks', () => { tracker.enable(); tracker.processTerminalData( '---RALPH_STATUS---\nSTATUS: IN_PROGRESS\nTASKS_COMPLETED_THIS_LOOP: 3\nFILES_MODIFIED: 5\n---END_RALPH_STATUS---\n' ); tracker.processTerminalData( '---RALPH_STATUS---\nSTATUS: IN_PROGRESS\nTASKS_COMPLETED_THIS_LOOP: 2\nFILES_MODIFIED: 3\n---END_RALPH_STATUS---\n' ); tracker.flushPendingEvents(); expect(tracker.cumulativeStats.tasksCompleted).toBe(5); expect(tracker.cumulativeStats.filesModified).toBe(8); }); }); // ========================================================================== // Dual-condition exit gate // ========================================================================== describe('Dual-condition exit gate', () => { it('should fire exitGateMet when completion indicators >= 2 AND EXIT_SIGNAL true', () => { const exitHandler = vi.fn(); tracker.on('exitGateMet', exitHandler); tracker.enable(); // Two COMPLETE status blocks (provides 2 completion indicators) tracker.processTerminalData( '---RALPH_STATUS---\nSTATUS: COMPLETE\nTASKS_COMPLETED_THIS_LOOP: 5\nFILES_MODIFIED: 10\nEXIT_SIGNAL: false\n---END_RALPH_STATUS---\n' ); expect(exitHandler).not.toHaveBeenCalled(); tracker.processTerminalData( '---RALPH_STATUS---\nSTATUS: COMPLETE\nTASKS_COMPLETED_THIS_LOOP: 0\nFILES_MODIFIED: 0\nEXIT_SIGNAL: true\n---END_RALPH_STATUS---\n' ); tracker.flushPendingEvents(); expect(exitHandler).toHaveBeenCalled(); expect(tracker.exitGateMet).toBe(true); }); it('should NOT fire with only 1 completion indicator + EXIT_SIGNAL', () => { const exitHandler = vi.fn(); tracker.on('exitGateMet', exitHandler); tracker.enable(); tracker.processTerminalData( '---RALPH_STATUS---\nSTATUS: COMPLETE\nEXIT_SIGNAL: true\n---END_RALPH_STATUS---\n' ); tracker.flushPendingEvents(); // Only 1 completion indicator, need >= 2 expect(exitHandler).not.toHaveBeenCalled(); }); it('should count natural language completion indicators', () => { tracker.enable(); // Natural language indicators contribute to completion count tracker.processTerminalData('All tasks are completed\n'); tracker.processTerminalData('Nothing remaining to do\n'); tracker.flushPendingEvents(); // Now EXIT_SIGNAL should trigger the gate const exitHandler = vi.fn(); tracker.on('exitGateMet', exitHandler); tracker.processTerminalData( '---RALPH_STATUS---\nSTATUS: COMPLETE\nEXIT_SIGNAL: true\n---END_RALPH_STATUS---\n' ); tracker.flushPendingEvents(); // 2 NL indicators + 1 COMPLETE status = 3 indicators, plus EXIT_SIGNAL expect(exitHandler).toHaveBeenCalled(); }); }); // ========================================================================== // Todo detection: Claude Code checkmark format (Format 5) // ========================================================================== describe('Todo detection - checkmark format', () => { it('should detect "✔ Task #N created: content"', () => { tracker.processTerminalData('✔ Task #1 created: Fix the authentication bug\n'); tracker.flushPendingEvents(); const todos = tracker.todos; expect(todos).toHaveLength(1); expect(todos[0].content).toBe('Fix the authentication bug'); expect(todos[0].status).toBe('pending'); }); it('should track task number for status updates', () => { tracker.processTerminalData('✔ Task #1 created: Implement caching layer\n'); tracker.processTerminalData('✔ Task #2 created: Write tests for caching\n'); tracker.flushPendingEvents(); expect(tracker.todos).toHaveLength(2); // Status update by task number tracker.processTerminalData('✔ Task #1 updated: status → completed\n'); tracker.flushPendingEvents(); const caching = tracker.todos.find(t => t.content.includes('caching layer')); expect(caching?.status).toBe('completed'); const tests = tracker.todos.find(t => t.content.includes('tests')); expect(tests?.status).toBe('pending'); }); it('should detect "✔ #N content" summary format', () => { tracker.processTerminalData('✔ #1 Fix login timeout issue\n'); tracker.processTerminalData('✔ #2 Add retry logic for API calls\n'); tracker.flushPendingEvents(); expect(tracker.todos).toHaveLength(2); }); it('should map summary to existing created task', () => { // Created with full format tracker.processTerminalData('✔ Task #3 created: Optimize database queries\n'); tracker.flushPendingEvents(); // Summary references same task number tracker.processTerminalData('✔ #3 Optimize database queries\n'); tracker.flushPendingEvents(); // Should still be just 1 todo (same content) expect(tracker.todos).toHaveLength(1); }); }); // ========================================================================== // Bug 3: Plain checkmark format (real Claude Code TodoWrite output) // ========================================================================== describe('Todo detection - plain checkmark (no task number)', () => { it('should detect "✔ content" without task number', () => { tracker.processTerminalData('✔ Create hello.txt with "Hello World"\n'); tracker.flushPendingEvents(); const todos = tracker.todos; expect(todos).toHaveLength(1); expect(todos[0].content).toBe('Create hello.txt with "Hello World"'); expect(todos[0].status).toBe('completed'); }); it('should detect multiple plain checkmark todos', () => { tracker.processTerminalData('✔ Create greeting.txt with Hello from Ralph\n'); tracker.processTerminalData('✔ Create numbers.txt with one two three\n'); tracker.processTerminalData('✔ Create done.txt with all done\n'); tracker.flushPendingEvents(); expect(tracker.todos).toHaveLength(3); expect(tracker.todos.every(t => t.status === 'completed')).toBe(true); }); it('should skip short content in plain checkmark', () => { tracker.processTerminalData('✔ ok\n'); tracker.flushPendingEvents(); expect(tracker.todos).toHaveLength(0); }); it('should skip tool invocation patterns in plain checkmark', () => { tracker.processTerminalData('✔ Bash(ls -la /tmp)\n'); tracker.flushPendingEvents(); expect(tracker.todos).toHaveLength(0); }); it('should prefer numbered format over plain when available', () => { tracker.processTerminalData('✔ Task #1 created: Fix the authentication bug\n'); tracker.flushPendingEvents(); // Numbered format takes priority expect(tracker.todos).toHaveLength(1); expect(tracker.todos[0].status).toBe('pending'); // numbered = pending }); }); // ========================================================================== // Priority detection // ========================================================================== describe('Priority detection', () => { it('should detect P0 keywords', () => { tracker.processTerminalData('- [ ] CRITICAL: Fix security vulnerability in auth\n'); tracker.flushPendingEvents(); expect(tracker.todos[0].priority).toBe('P0'); }); it('should detect P1 keywords', () => { tracker.processTerminalData('- [ ] Fix the login error for mobile users\n'); tracker.flushPendingEvents(); expect(tracker.todos[0].priority).toBe('P1'); }); it('should detect P2 keywords', () => { tracker.processTerminalData('- [ ] Refactor the user service module\n'); tracker.flushPendingEvents(); expect(tracker.todos[0].priority).toBe('P2'); }); it('should detect explicit priority labels', () => { tracker.processTerminalData('- [ ] (P0) Server crashes on startup\n'); tracker.flushPendingEvents(); expect(tracker.todos[0].priority).toBe('P0'); }); it('should return null for unprioritized tasks', () => { tracker.processTerminalData('- [ ] Update README with new instructions\n'); tracker.flushPendingEvents(); // "Update" alone doesn't match P2 patterns (needs "update version" or "update readme") // But actually P2 has DOCUMENTATION pattern and README is documentation // Let's use something truly generic }); }); // ========================================================================== // Todo deduplication // ========================================================================== describe('Todo deduplication', () => { it('should deduplicate identical content', () => { tracker.processTerminalData('- [ ] Fix the login bug\n'); tracker.processTerminalData('- [ ] Fix the login bug\n'); tracker.flushPendingEvents(); expect(tracker.todos).toHaveLength(1); }); it('should deduplicate content with different checkbox states', () => { tracker.processTerminalData('- [ ] Fix the login bug\n'); tracker.processTerminalData('- [x] Fix the login bug\n'); tracker.flushPendingEvents(); expect(tracker.todos).toHaveLength(1); expect(tracker.todos[0].status).toBe('completed'); }); it('should keep longer content when deduplicating', () => { tracker.processTerminalData('☐ Fix auth\n'); // Short content (<5 chars) is skipped by upsertTodo, so use slightly longer tracker.processTerminalData('☐ Fix auth module\n'); tracker.processTerminalData('☐ Fix auth module for SSO login flow\n'); tracker.flushPendingEvents(); // The longest version should be kept const matchingTodos = tracker.todos.filter(t => t.content.includes('Fix auth')); // Due to fuzzy dedup, similar items might get merged expect(matchingTodos.length).toBeGreaterThanOrEqual(1); }); it('should NOT merge dissimilar short tasks', () => { tracker.processTerminalData('- [ ] Fix task A quickly\n'); tracker.processTerminalData('- [ ] Fix task B quickly\n'); tracker.flushPendingEvents(); // These are similar but short strings need 95% match — "A" vs "B" differs expect(tracker.todos).toHaveLength(2); }); }); // ========================================================================== // Fix plan import/export // ========================================================================== describe('Fix plan markdown round-trip', () => { it('should export and re-import todos correctly', () => { // Add todos with different statuses and priorities tracker.processTerminalData('- [ ] CRITICAL: Fix the crash on startup\n'); tracker.processTerminalData('- [ ] Add logging to API endpoints\n'); tracker.processTerminalData('- [x] Set up CI pipeline for main branch\n'); tracker.flushPendingEvents(); // Export const markdown = tracker.generateFixPlanMarkdown(); expect(markdown).toContain('# Fix Plan'); expect(markdown).toContain('Fix the crash on startup'); expect(markdown).toContain('Set up CI pipeline'); // Import into a fresh tracker const tracker2 = new RalphTracker(); tracker2.enable(); const count = tracker2.importFixPlanMarkdown(markdown); expect(count).toBe(3); expect(tracker2.todos).toHaveLength(3); // Verify statuses are preserved const completed = tracker2.todos.filter(t => t.status === 'completed'); expect(completed).toHaveLength(1); expect(completed[0].content).toContain('CI pipeline'); tracker2.clear(); }); it('should parse priority sections correctly', () => { const markdown = `# Fix Plan ## High Priority (P0) - [ ] Fix security hole in auth - [ ] Fix crash on file upload ## Standard (P1) - [-] Implement retry logic ## Nice to Have (P2) - [ ] Improve error messages ## Completed - [x] Set up database indexes `; const count = tracker.importFixPlanMarkdown(markdown); expect(count).toBe(5); const p0 = tracker.todos.filter(t => t.priority === 'P0'); expect(p0).toHaveLength(2); const p1 = tracker.todos.filter(t => t.priority === 'P1'); expect(p1).toHaveLength(1); expect(p1[0].status).toBe('in_progress'); const completed = tracker.todos.filter(t => t.status === 'completed'); expect(completed).toHaveLength(1); }); }); // ========================================================================== // Progress estimation // ========================================================================== describe('Progress estimation', () => { it('should calculate correct percentages', () => { tracker.processTerminalData('- [ ] Task pending one\n'); tracker.processTerminalData('- [x] Task done number one\n'); tracker.processTerminalData('- [x] Task done number two\n'); tracker.flushPendingEvents(); const progress = tracker.getTodoProgress(); expect(progress.total).toBe(3); expect(progress.completed).toBe(2); expect(progress.pending).toBe(1); expect(progress.percentComplete).toBe(67); // 2/3 rounded }); it('should return 0% with no todos', () => { const progress = tracker.getTodoProgress(); expect(progress.total).toBe(0); expect(progress.percentComplete).toBe(0); }); }); // ========================================================================== // Iteration stall detection // ========================================================================== describe('Iteration stall detection', () => { it('should reset warning on iteration change', () => { tracker.enable(); // Simulate iteration progress tracker.processTerminalData('Iteration 1/10\n'); tracker.flushPendingEvents(); const metrics = tracker.getIterationStallMetrics(); expect(metrics.currentIteration).toBe(1); expect(metrics.isWarned).toBe(false); }); it('should track iteration changes for stall detection', () => { tracker.enable(); tracker.processTerminalData('Iteration 5/50\n'); tracker.flushPendingEvents(); const m1 = tracker.getIterationStallMetrics(); expect(m1.currentIteration).toBe(5); tracker.processTerminalData('Iteration 6/50\n'); tracker.flushPendingEvents(); const m2 = tracker.getIterationStallMetrics(); expect(m2.currentIteration).toBe(6); // Stall duration should be small since iteration just changed expect(m2.stallDurationMs).toBeLessThan(1000); }); }); // ========================================================================== // Completion confidence scoring // ========================================================================== describe('Completion confidence scoring', () => { it('should give high confidence with promise tag + matching phrase + active loop', () => { tracker.startLoop('TARGET_PHRASE'); const confidence = tracker.calculateCompletionConfidence( 'TARGET_PHRASE', 'TARGET_PHRASE' ); // hasPromiseTag(30) + matchesExpected(25) + contextAppropriate(10) + loopActive(10) = 75+ expect(confidence.score).toBeGreaterThanOrEqual(65); expect(confidence.isConfident).toBe(true); expect(confidence.signals.hasPromiseTag).toBe(true); expect(confidence.signals.matchesExpected).toBe(true); }); it('should give low confidence for phrase in prompt context', () => { tracker.startLoop('MY_PHRASE'); const confidence = tracker.calculateCompletionConfidence( 'MY_PHRASE', 'When done, output exactly: MY_PHRASE' ); // contextAppropriate is false (-20), hasPromiseTag(30), matchesExpected(25), loopActive(10) = 45 expect(confidence.signals.contextAppropriate).toBe(false); // Score is lower due to prompt context deduction }); it('should boost score for all todos complete', () => { tracker.processTerminalData('- [x] Completed task alpha\n'); tracker.processTerminalData('- [x] Completed task beta\n'); tracker.flushPendingEvents(); const confidence = tracker.calculateCompletionConfidence('DONE'); expect(confidence.signals.allTodosComplete).toBe(true); }); }); // ========================================================================== // Phrase validation warnings // ========================================================================== describe('Phrase validation warnings', () => { it('should warn about common phrases', () => { const warningHandler = vi.fn(); tracker.on('phraseValidationWarning', warningHandler); tracker.processTerminalData('DONE\n'); tracker.flushPendingEvents(); expect(warningHandler).toHaveBeenCalled(); expect(warningHandler.mock.calls[0][0].reason).toBe('common'); }); it('should warn about short phrases', () => { const warningHandler = vi.fn(); tracker.on('phraseValidationWarning', warningHandler); tracker.processTerminalData('AB\n'); tracker.flushPendingEvents(); expect(warningHandler).toHaveBeenCalled(); expect(warningHandler.mock.calls[0][0].reason).toBe('short'); }); it('should warn about numeric phrases', () => { const warningHandler = vi.fn(); tracker.on('phraseValidationWarning', warningHandler); tracker.processTerminalData('123456\n'); tracker.flushPendingEvents(); expect(warningHandler).toHaveBeenCalled(); expect(warningHandler.mock.calls[0][0].reason).toBe('numeric'); }); it('should NOT warn about good phrases', () => { const warningHandler = vi.fn(); tracker.on('phraseValidationWarning', warningHandler); tracker.processTerminalData('ALL_TASKS_FINISHED_SUCCESSFULLY\n'); tracker.flushPendingEvents(); expect(warningHandler).not.toHaveBeenCalled(); }); }); // ========================================================================== // Alternate completion phrases // ========================================================================== describe('Alternate completion phrases', () => { it('should match alternate phrases for completion', () => { const completionHandler = vi.fn(); tracker.on('completionDetected', completionHandler); tracker.startLoop('PRIMARY_DONE'); tracker.addAlternateCompletionPhrase('BACKUP_DONE'); // Use alternate phrase tracker.processTerminalData('BACKUP_DONE\n'); tracker.flushPendingEvents(); expect(completionHandler).toHaveBeenCalledWith('BACKUP_DONE'); }); it('should list alternate phrases in loop state', () => { tracker.addAlternateCompletionPhrase('ALT_A'); tracker.addAlternateCompletionPhrase('ALT_B'); expect(tracker.loopState.alternateCompletionPhrases).toEqual(['ALT_A', 'ALT_B']); }); it('should allow removing alternate phrases', () => { tracker.addAlternateCompletionPhrase('REMOVEME'); expect(tracker.loopState.alternateCompletionPhrases).toContain('REMOVEME'); tracker.removeAlternateCompletionPhrase('REMOVEME'); expect(tracker.loopState.alternateCompletionPhrases).not.toContain('REMOVEME'); }); }); // ========================================================================== // Realistic lifecycle: Full Ralph loop scenario // ========================================================================== describe('Realistic lifecycle', () => { it('should handle complete autonomous loop lifecycle', () => { const events: string[] = []; tracker.on('loopUpdate', () => events.push('loopUpdate')); tracker.on('todoUpdate', () => events.push('todoUpdate')); tracker.on('completionDetected', (p) => events.push(`completion:${p}`)); tracker.on('statusBlockDetected', () => events.push('statusBlock')); // Step 1: Loop starts tracker.startLoop('WORK_COMPLETE', 10); expect(tracker.loopState.active).toBe(true); // Step 2: Iteration begins, todos appear tracker.processTerminalData('Iteration 1/10\n'); tracker.processTerminalData('☐ Fix the login timeout\n'); tracker.processTerminalData('☐ Add retry logic to API calls\n'); tracker.processTerminalData('☐ Update error messages for clarity\n'); tracker.flushPendingEvents(); expect(tracker.loopState.cycleCount).toBe(1); expect(tracker.todos).toHaveLength(3); // Step 3: Progress on iteration 2 tracker.processTerminalData('Iteration 2/10\n'); tracker.processTerminalData('☒ Fix the login timeout\n'); tracker.processTerminalData('◐ Add retry logic to API calls\n'); tracker.flushPendingEvents(); expect(tracker.loopState.cycleCount).toBe(2); const stats = tracker.getTodoStats(); expect(stats.completed).toBe(1); expect(stats.inProgress).toBe(1); // Step 4: Status block tracker.processTerminalData( '---RALPH_STATUS---\n' + 'STATUS: IN_PROGRESS\n' + 'TASKS_COMPLETED_THIS_LOOP: 1\n' + 'FILES_MODIFIED: 3\n' + 'TESTS_STATUS: PASSING\n' + 'EXIT_SIGNAL: false\n' + '---END_RALPH_STATUS---\n' ); tracker.flushPendingEvents(); expect(tracker.circuitBreakerStatus.state).toBe('CLOSED'); expect(tracker.cumulativeStats.tasksCompleted).toBe(1); // Step 5: All done, completion phrase tracker.processTerminalData('Iteration 3/10\n'); tracker.processTerminalData('☒ Add retry logic to API calls\n'); tracker.processTerminalData('☒ Update error messages for clarity\n'); tracker.flushPendingEvents(); tracker.processTerminalData('WORK_COMPLETE\n'); tracker.flushPendingEvents(); // Loop should be complete expect(tracker.loopState.active).toBe(false); expect(tracker.loopState.cycleCount).toBe(3); expect(events).toContain('completion:WORK_COMPLETE'); // All todos marked complete const finalStats = tracker.getTodoStats(); expect(finalStats.completed).toBe(3); }); it('should handle reset between loops', () => { // First loop tracker.startLoop('LOOP_1_DONE'); tracker.processTerminalData('☐ Task from first loop\n'); tracker.processTerminalData('LOOP_1_DONE\n'); tracker.flushPendingEvents(); expect(tracker.loopState.active).toBe(false); // Soft reset for new loop tracker.reset(); expect(tracker.loopState.active).toBe(false); expect(tracker.loopState.completionPhrase).toBeNull(); expect(tracker.todos).toHaveLength(0); expect(tracker.enabled).toBe(true); // Still enabled after soft reset // Second loop tracker.startLoop('LOOP_2_DONE'); tracker.processTerminalData('☐ Task from second loop\n'); tracker.flushPendingEvents(); expect(tracker.loopState.active).toBe(true); expect(tracker.todos).toHaveLength(1); }); }); // ========================================================================== // Edge cases: file-authoritative mode // ========================================================================== describe('File-authoritative mode (@fix_plan.md)', () => { it('should prevent output-based detection when fix_plan is active', () => { // Use setWorkingDir to activate file-authoritative mode // (this sets _fixPlanPath, making isFileAuthoritative true) const tmpDir = '/tmp/ralph-test-' + Date.now(); tracker.setWorkingDir(tmpDir); // Manually import todos (simulating fix_plan.md loaded) tracker.importFixPlanMarkdown( '# Fix Plan\n\n## Tasks\n- [ ] Pending task from file\n' ); expect(tracker.todos).toHaveLength(1); expect(tracker.todos[0].status).toBe('pending'); // "All tasks complete" in output should NOT change status // (file is authoritative, not terminal output) tracker.processTerminalData('All tasks completed successfully\n'); tracker.flushPendingEvents(); // Still pending because file hasn't changed expect(tracker.todos[0].status).toBe('pending'); }); }); // ========================================================================== // Auto-enable behavior // ========================================================================== describe('Auto-enable with enableAutoEnable()', () => { it('should auto-enable on various Ralph patterns', () => { const patterns = [ '/ralph-loop:ralph-loop', 'TEST', 'Iteration 5/50', '- [ ] New task item', '☐ Native todo item', 'Loop started at 2026-01-01', '✔ Task #1 created: Something', 'All tasks completed', ]; for (const pattern of patterns) { const fresh = new RalphTracker(); fresh.enableAutoEnable(); fresh.processTerminalData(pattern + '\n'); expect(fresh.enabled).toBe(true); fresh.clear(); } }); it('should NOT auto-enable on regular text', () => { const fresh = new RalphTracker(); fresh.enableAutoEnable(); fresh.processTerminalData('This is just regular output\n'); fresh.processTerminalData('function foo() { return 42; }\n'); fresh.processTerminalData('Error: Connection timeout\n'); expect(fresh.enabled).toBe(false); fresh.clear(); }); }); // ========================================================================== // Debouncing // ========================================================================== describe('Event debouncing', () => { it('should batch rapid todo updates', () => { const todoHandler = vi.fn(); tracker.on('todoUpdate', todoHandler); // Rapid-fire 5 todo lines with distinct content tracker.processTerminalData('- [ ] Implement the authentication module\n'); tracker.processTerminalData('- [ ] Write unit tests for payment flow\n'); tracker.processTerminalData('- [ ] Fix the database connection pooling\n'); tracker.processTerminalData('- [ ] Refactor session management code\n'); tracker.processTerminalData('- [ ] Update the deployment configuration\n'); // Before flush, debounced events haven't fired yet // (they're on a 50ms timer) // Force flush tracker.flushPendingEvents(); // After flush, should have fewer emissions than 5 (debounced) // At minimum 1 after flush expect(todoHandler).toHaveBeenCalled(); expect(tracker.todos).toHaveLength(5); }); }); });