Files
Codeman/test/respawn-patterns.test.ts
Codeman maintainer b03780dfd2 fix(session): decide working/idle from the pane, not the composer redraw
Every working Claude session reported `status: "idle"` about two seconds
into its turn. Measured on live workers: two sessions mid-tool-call at 13
and 17 minutes both read `idle` while their panes showed
`✻ Actualizing… (13m 23s · ↓ 47.5k tokens)`.

Two things had drifted apart:

1. The working indicator changed. Claude animates the glyph through
   `· ✢ ✳ ∗ ✻ ✽` and randomizes the gerund per turn, so neither
   SPINNER_PATTERN (braille, no longer drawn) nor the keyword list
   (Thinking/Writing/Reading/Running) matches a turn anymore.
2. A `❯` sighting is not the end of a turn. Claude redraws the composer
   roughly once a second all the way through one, and that redraw armed
   the "2s later, call it idle" timer.

Matching the new status line in the STREAM does not fix it either: tmux
ships partial repaints, so the complete line reached the PTY about once
every 20 seconds while the `❯` arrived every second.

So the decision moves off the stream:

- An unbroken run of repaints marks a turn as started. Sampled once a
  second for 12s over six live sessions, the two working ones produced
  output in 12/12 windows and the four idle ones in 0/12. Pure helpers in
  session-activity.ts carry the thresholds.
- Idle now needs the pane to go quiet AND the screen to agree.
  `_confirmIdle()` asks tmux what is rendered (new `capturePaneText()`,
  one plain `capture-pane`, floored at 1.5s per session and only ever at
  a transition) and re-checks every 5s while the screen still shows work.
  A turn can sit silent for tens of seconds inside one tool call, so
  silence alone proves nothing.
- The same screen check vetoes keystroke echo, which is a steady stream
  of repaints too but is not work.

CLAUDE_WORKING_LINE_PATTERN matches the `… (elapsed)` shape rather than
the glyph, because the FINISHED line (`✻ Cooked for 2m 49s`) carries the
same glyph and would otherwise pin a session at working forever.

Claude mode only. An external CLI has no `❯`, so nothing would arm the
confirmation and such a session would latch busy.

respawn-patterns.hasWorkingPattern() had the same blind spot (its gerund
list cannot see "Actualizing"), so it takes the pattern as an extra
signal. That can only make respawn less eager, never more.

Idle now lands about 3 to 5 seconds after a turn ends instead of 2
seconds into one. Verified end to end against a live worker, sampled
against the CLI's own "esc to interrupt" footer as independent ground
truth: busy for all 25s of a turn, idle 3s after it ended.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 15:31:02 +02:00

271 lines
9.1 KiB
TypeScript

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('current Claude status line', () => {
it('should detect the randomized gerund by the elapsed timer', () => {
// Live captures on Claude Code 2.1.220. The word changes every turn, so the
// WORKING_PATTERNS list above cannot see any of these.
expect(hasWorkingPattern('✻ Actualizing… (15m 17s · ↓ 47.5k tokens)')).toBe(true);
expect(hasWorkingPattern('· Finagling… (4m 45s · ↓ 13.3k tokens)')).toBe(true);
expect(hasWorkingPattern('✽ Herding… (3s · esc to interrupt)')).toBe(true);
});
it('should NOT treat the completion line as working', () => {
expect(hasWorkingPattern('✻ Cooked for 2m 49s')).toBe(false);
expect(hasWorkingPattern('✻ Brewed for 18m 41s')).toBe(false);
});
});
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');
});
});