Files
Codeman/test/ralph-loop-deep.test.ts
arkonandClaude Opus 4.6 fe1f665531 fix: ralph tracker — 3 bugs: first-occurrence completion, double-counting, TodoWrite pattern
Bug 1 (CRITICAL): canonicalCount >= 1 always fired on first <promise> tag
(prompt echo). Changed to >= 2 so only 2nd+ occurrence triggers completion.

Bug 2: checkMultiLinePatterns() re-detected complete tags already handled
by processLine(), double-counting. Now only tries completion when partial
buffer is non-empty (cross-chunk scenario).

Bug 3: TodoWrite ✔ patterns required "Task #N" but real Claude Code output
is plain "✔ content". Added TODO_PLAIN_CHECKMARK_PATTERN fallback.

Includes 71 new deep tests + real-life verification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 14:00:10 +01:00

1176 lines
43 KiB
TypeScript

/**
* @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 <promise> 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: <promise>ALL_TASKS_COMPLETE</promise>\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 <promise> occurrence SHOULD fire completionDetected', () => {
const completionHandler = vi.fn();
tracker.on('completionDetected', completionHandler);
// First occurrence (from prompt) — store phrase
tracker.processTerminalData('<promise>ALL_TASKS_COMPLETE</promise>\n');
tracker.flushPendingEvents();
completionHandler.mockClear();
// Second occurrence (actual completion signal)
tracker.processTerminalData('<promise>ALL_TASKS_COMPLETE</promise>\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('<promise>MY_PHRASE</promise>\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('<promise>DONE_SIGNAL</promise>\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('<promise>UNIQUE_PHRASE_XYZ</promise>\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 <promise>CROSS_');
tracker.processTerminalData('CHUNK</promise> 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: <promise>BUFF');
// No completion yet
expect(completionHandler).not.toHaveBeenCalled();
// Complete in next chunk
tracker.processTerminalData('ERED</promise>\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 <promise> 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('<promise>WORK_DONE</promise>\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',
'<promise>TARGET_PHRASE</promise>'
);
// 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: <promise>MY_PHRASE</promise>'
);
// 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('<promise>DONE</promise>\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('<promise>AB</promise>\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('<promise>123456</promise>\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('<promise>ALL_TASKS_FINISHED_SUCCESSFULLY</promise>\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('<promise>BACKUP_DONE</promise>\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('<promise>WORK_COMPLETE</promise>\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('<promise>LOOP_1_DONE</promise>\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',
'<promise>TEST</promise>',
'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);
});
});
});