mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
Add InnerLoopTracker to detect and expose internal Claude Code state running inside claudeman sessions by parsing terminal output patterns. Detection patterns: - Completion phrases: <promise>PHRASE</promise> - Todo items: checkbox format, indicator icons, status parentheses - Loop status: cycle counts, elapsed time, start/completion New features: - Inner state panel UI (collapsible, below session tabs) - SSE events: innerLoopUpdate, innerTodoUpdate, innerCompletionDetected - API endpoint: GET /api/sessions/:id/inner-state - State persistence to ~/.claudeman/state-inner.json Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
348 lines
11 KiB
TypeScript
348 lines
11 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { InnerLoopTracker } from '../src/inner-loop-tracker.js';
|
|
import { InnerLoopState, InnerTodoItem } from '../src/types.js';
|
|
|
|
/**
|
|
* InnerLoopTracker Tests
|
|
*
|
|
* Tests the detection of Ralph Wiggum loops and todo lists from terminal output
|
|
* running inside Claude Code sessions.
|
|
*/
|
|
|
|
describe('InnerLoopTracker', () => {
|
|
let tracker: InnerLoopTracker;
|
|
|
|
beforeEach(() => {
|
|
tracker = new InnerLoopTracker();
|
|
});
|
|
|
|
describe('Initialization', () => {
|
|
it('should start with inactive loop state', () => {
|
|
const state = tracker.loopState;
|
|
expect(state.active).toBe(false);
|
|
expect(state.completionPhrase).toBeNull();
|
|
expect(state.startedAt).toBeNull();
|
|
expect(state.cycleCount).toBe(0);
|
|
});
|
|
|
|
it('should start with empty todos', () => {
|
|
expect(tracker.todos).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('Completion Phrase Detection', () => {
|
|
it('should detect <promise>COMPLETE</promise> pattern', () => {
|
|
const completionHandler = vi.fn();
|
|
tracker.on('completionDetected', completionHandler);
|
|
|
|
tracker.processTerminalData('<promise>COMPLETE</promise>\n');
|
|
|
|
expect(completionHandler).toHaveBeenCalledWith('COMPLETE');
|
|
expect(tracker.loopState.completionPhrase).toBe('COMPLETE');
|
|
});
|
|
|
|
it('should detect <promise>TIME_COMPLETE</promise> pattern', () => {
|
|
const completionHandler = vi.fn();
|
|
tracker.on('completionDetected', completionHandler);
|
|
|
|
tracker.processTerminalData('Output: <promise>TIME_COMPLETE</promise>\n');
|
|
|
|
expect(completionHandler).toHaveBeenCalledWith('TIME_COMPLETE');
|
|
});
|
|
|
|
it('should detect custom completion phrases', () => {
|
|
const completionHandler = vi.fn();
|
|
tracker.on('completionDetected', completionHandler);
|
|
|
|
tracker.processTerminalData('<promise>MY_CUSTOM_PHRASE_123</promise>\n');
|
|
|
|
expect(completionHandler).toHaveBeenCalledWith('MY_CUSTOM_PHRASE_123');
|
|
expect(tracker.loopState.completionPhrase).toBe('MY_CUSTOM_PHRASE_123');
|
|
});
|
|
|
|
it('should mark loop as inactive when completion detected', () => {
|
|
// Start a loop first
|
|
tracker.startLoop('TEST_PHRASE');
|
|
expect(tracker.loopState.active).toBe(true);
|
|
|
|
// Detect completion
|
|
tracker.processTerminalData('<promise>TEST_PHRASE</promise>\n');
|
|
|
|
expect(tracker.loopState.active).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Loop Status Detection', () => {
|
|
it('should detect loop start patterns', () => {
|
|
const loopHandler = vi.fn();
|
|
tracker.on('loopUpdate', loopHandler);
|
|
|
|
tracker.processTerminalData('Loop started at 2024-01-15\n');
|
|
|
|
expect(loopHandler).toHaveBeenCalled();
|
|
expect(tracker.loopState.active).toBe(true);
|
|
expect(tracker.loopState.startedAt).not.toBeNull();
|
|
});
|
|
|
|
it('should detect elapsed time pattern', () => {
|
|
const loopHandler = vi.fn();
|
|
tracker.on('loopUpdate', loopHandler);
|
|
|
|
tracker.processTerminalData('Elapsed: 2.5 hours\n');
|
|
|
|
expect(tracker.loopState.elapsedHours).toBe(2.5);
|
|
});
|
|
|
|
it('should detect cycle count pattern', () => {
|
|
const loopHandler = vi.fn();
|
|
tracker.on('loopUpdate', loopHandler);
|
|
|
|
tracker.processTerminalData('Starting cycle #5\n');
|
|
|
|
expect(tracker.loopState.cycleCount).toBe(5);
|
|
});
|
|
|
|
it('should detect respawn cycle pattern', () => {
|
|
tracker.processTerminalData('respawn cycle #10\n');
|
|
expect(tracker.loopState.cycleCount).toBe(10);
|
|
});
|
|
});
|
|
|
|
describe('Todo Detection - Checkbox Format', () => {
|
|
it('should detect pending checkbox todos', () => {
|
|
const todoHandler = vi.fn();
|
|
tracker.on('todoUpdate', todoHandler);
|
|
|
|
tracker.processTerminalData('- [ ] First task\n');
|
|
|
|
expect(todoHandler).toHaveBeenCalled();
|
|
const todos = tracker.todos;
|
|
expect(todos).toHaveLength(1);
|
|
expect(todos[0].content).toBe('First task');
|
|
expect(todos[0].status).toBe('pending');
|
|
});
|
|
|
|
it('should detect completed checkbox todos', () => {
|
|
tracker.processTerminalData('- [x] Completed task\n');
|
|
|
|
const todos = tracker.todos;
|
|
expect(todos).toHaveLength(1);
|
|
expect(todos[0].content).toBe('Completed task');
|
|
expect(todos[0].status).toBe('completed');
|
|
});
|
|
|
|
it('should detect uppercase X as completed', () => {
|
|
tracker.processTerminalData('- [X] Also completed\n');
|
|
|
|
const todos = tracker.todos;
|
|
expect(todos[0].status).toBe('completed');
|
|
});
|
|
|
|
it('should handle asterisk bullets', () => {
|
|
tracker.processTerminalData('* [ ] Asterisk task\n');
|
|
|
|
const todos = tracker.todos;
|
|
expect(todos).toHaveLength(1);
|
|
expect(todos[0].content).toBe('Asterisk task');
|
|
});
|
|
});
|
|
|
|
describe('Todo Detection - Indicator Format', () => {
|
|
it('should detect pending indicator todos', () => {
|
|
tracker.processTerminalData('Todo: ☐ Pending task\n');
|
|
|
|
const todos = tracker.todos;
|
|
expect(todos).toHaveLength(1);
|
|
expect(todos[0].content).toBe('Pending task');
|
|
expect(todos[0].status).toBe('pending');
|
|
});
|
|
|
|
it('should detect in-progress indicator todos', () => {
|
|
tracker.processTerminalData('Todo: ◐ Working on this\n');
|
|
|
|
const todos = tracker.todos;
|
|
expect(todos[0].status).toBe('in_progress');
|
|
});
|
|
|
|
it('should detect completed indicator todos', () => {
|
|
tracker.processTerminalData('Todo: ✓ Done task\n');
|
|
|
|
const todos = tracker.todos;
|
|
expect(todos[0].status).toBe('completed');
|
|
});
|
|
|
|
it('should detect checkmark emoji as completed', () => {
|
|
tracker.processTerminalData('Todo: ✅ Also done\n');
|
|
|
|
const todos = tracker.todos;
|
|
expect(todos[0].status).toBe('completed');
|
|
});
|
|
|
|
it('should detect hourglass as in-progress', () => {
|
|
tracker.processTerminalData('Todo: ⏳ Still working\n');
|
|
|
|
const todos = tracker.todos;
|
|
expect(todos[0].status).toBe('in_progress');
|
|
});
|
|
});
|
|
|
|
describe('Todo Detection - Status Parentheses Format', () => {
|
|
it('should detect pending status', () => {
|
|
tracker.processTerminalData('- Task name (pending)\n');
|
|
|
|
const todos = tracker.todos;
|
|
expect(todos).toHaveLength(1);
|
|
expect(todos[0].content).toBe('Task name');
|
|
expect(todos[0].status).toBe('pending');
|
|
});
|
|
|
|
it('should detect in_progress status', () => {
|
|
tracker.processTerminalData('- Working task (in_progress)\n');
|
|
|
|
const todos = tracker.todos;
|
|
expect(todos[0].status).toBe('in_progress');
|
|
});
|
|
|
|
it('should detect completed status', () => {
|
|
tracker.processTerminalData('- Done task (completed)\n');
|
|
|
|
const todos = tracker.todos;
|
|
expect(todos[0].status).toBe('completed');
|
|
});
|
|
});
|
|
|
|
describe('Todo Updates', () => {
|
|
it('should update existing todos by content', () => {
|
|
// Add pending todo
|
|
tracker.processTerminalData('- [ ] My task\n');
|
|
expect(tracker.todos[0].status).toBe('pending');
|
|
|
|
// Update to completed
|
|
tracker.processTerminalData('- [x] My task\n');
|
|
expect(tracker.todos).toHaveLength(1); // Still just 1 todo
|
|
expect(tracker.todos[0].status).toBe('completed');
|
|
});
|
|
|
|
it('should emit todoUpdate on changes', () => {
|
|
const todoHandler = vi.fn();
|
|
tracker.on('todoUpdate', todoHandler);
|
|
|
|
tracker.processTerminalData('- [ ] Task 1\n');
|
|
tracker.processTerminalData('- [ ] Task 2\n');
|
|
|
|
expect(todoHandler).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe('Todo Stats', () => {
|
|
it('should calculate correct stats', () => {
|
|
tracker.processTerminalData('- [ ] Pending 1\n');
|
|
tracker.processTerminalData('- [ ] Pending 2\n');
|
|
tracker.processTerminalData('Todo: ◐ In progress\n');
|
|
tracker.processTerminalData('- [x] Completed 1\n');
|
|
tracker.processTerminalData('- [x] Completed 2\n');
|
|
tracker.processTerminalData('- [x] Completed 3\n');
|
|
|
|
const stats = tracker.getTodoStats();
|
|
expect(stats.total).toBe(6);
|
|
expect(stats.pending).toBe(2);
|
|
expect(stats.inProgress).toBe(1);
|
|
expect(stats.completed).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('Manual Control', () => {
|
|
it('should start loop manually', () => {
|
|
const loopHandler = vi.fn();
|
|
tracker.on('loopUpdate', loopHandler);
|
|
|
|
tracker.startLoop('MANUAL_PHRASE');
|
|
|
|
expect(tracker.loopState.active).toBe(true);
|
|
expect(tracker.loopState.completionPhrase).toBe('MANUAL_PHRASE');
|
|
expect(loopHandler).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should stop loop manually', () => {
|
|
tracker.startLoop();
|
|
expect(tracker.loopState.active).toBe(true);
|
|
|
|
tracker.stopLoop();
|
|
expect(tracker.loopState.active).toBe(false);
|
|
});
|
|
|
|
it('should clear all state', () => {
|
|
tracker.startLoop('TEST');
|
|
tracker.processTerminalData('- [ ] Task\n');
|
|
|
|
expect(tracker.loopState.active).toBe(true);
|
|
expect(tracker.todos).toHaveLength(1);
|
|
|
|
tracker.clear();
|
|
|
|
expect(tracker.loopState.active).toBe(false);
|
|
expect(tracker.loopState.completionPhrase).toBeNull();
|
|
expect(tracker.todos).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('State Restoration', () => {
|
|
it('should restore state from persisted data', () => {
|
|
const loopState: InnerLoopState = {
|
|
active: true,
|
|
completionPhrase: 'RESTORED',
|
|
startedAt: Date.now() - 1000,
|
|
cycleCount: 5,
|
|
lastActivity: Date.now(),
|
|
elapsedHours: 1.5,
|
|
};
|
|
|
|
const todos: InnerTodoItem[] = [
|
|
{ id: 'todo-1', content: 'Task 1', status: 'completed', detectedAt: Date.now() },
|
|
{ id: 'todo-2', content: 'Task 2', status: 'in_progress', detectedAt: Date.now() },
|
|
];
|
|
|
|
tracker.restoreState(loopState, todos);
|
|
|
|
expect(tracker.loopState.active).toBe(true);
|
|
expect(tracker.loopState.completionPhrase).toBe('RESTORED');
|
|
expect(tracker.loopState.cycleCount).toBe(5);
|
|
expect(tracker.todos).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe('ANSI Escape Handling', () => {
|
|
it('should strip ANSI escape codes before parsing', () => {
|
|
// ANSI colored output
|
|
tracker.processTerminalData('\x1b[32m- [x] Colored task\x1b[0m\n');
|
|
|
|
const todos = tracker.todos;
|
|
expect(todos).toHaveLength(1);
|
|
expect(todos[0].content).toBe('Colored task');
|
|
});
|
|
});
|
|
|
|
describe('Buffer Management', () => {
|
|
it('should handle incomplete lines across multiple calls', () => {
|
|
// Split across two calls
|
|
tracker.processTerminalData('- [x] Split');
|
|
tracker.processTerminalData(' task\n');
|
|
|
|
const todos = tracker.todos;
|
|
expect(todos).toHaveLength(1);
|
|
expect(todos[0].content).toBe('Split task');
|
|
});
|
|
});
|
|
|
|
describe('Maximum Todo Limit', () => {
|
|
it('should limit to max 50 todos', () => {
|
|
// Add 55 todos
|
|
for (let i = 0; i < 55; i++) {
|
|
tracker.processTerminalData(`- [ ] Task ${i}\n`);
|
|
}
|
|
|
|
expect(tracker.todos.length).toBeLessThanOrEqual(50);
|
|
});
|
|
});
|
|
});
|