Files
Codeman/test/session-activity.test.ts
T
Codeman maintainer f1b7283393 fix(cli-registry): guard workDetect.workingLine like every other config regex
#385 made the composer glyph and the working status line per-CLI registry
data, which is right, but `workingLine` arrived as a config-supplied regex
validated with a bare `new RegExp()`. That skips `compileVersionRegex()`,
the helper the registry uses for exactly this: a `~/.codeman/clis.json`
override can set the field, the compiled pattern is run against every
accumulated PTY chunk and every pane capture, and a nested quantifier there
backtracks on the event loop for the whole server rather than one session.

Route it through the helper in both places, which are not redundant: the
schema refine rejects the entry at LOAD time so a bad pattern never reaches
a session, and `_workingLinePattern()` compiles through the same helper so
the runtime cannot hold a pattern the schema would have refused. The helper
returns null instead of throwing, so the Claude-pattern fallback stops being
a try/catch and becomes structural. Both shipped patterns compile unchanged,
and Claude's is behaviourally identical to CLAUDE_WORKING_LINE_PATTERN.

Also match the Codex footer case-insensitively on the E. It was
characterised against codex-cli 0.152.1, which prints a lowercase `esc`;
a version capitalising it would make the whole fix silently inert, since
the pane would simply never look like it was working.

Docs: CLAUDE.md, architecture-invariants and cli-registry.md all still
stated the Claude-mode-only rule this PR retires, and none of them named
the new capability or the regex guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 22:42:20 +02:00

366 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Working/idle detection for an interactive agent pane, Claude's and Codex's.
*
* The bug this pins: Claude redraws the composer (`❯`) about once a second all
* the way through a turn, so the old "saw a ❯, wait 2s, call it idle" rule
* flipped a busy session to idle two seconds into every turn. Measured on a live
* worker: `GET /api/sessions` reported `idle` for a session that had been
* running for 17 minutes and was mid-tool-call.
*
* A second bug this pins: work detection read Claude's glyph and Claude's status line
* for every CLI, so a Codex session reported itself idle through an entire turn. Each CLI
* now names its own pair in `capabilities.workDetect`, and a CLI that names none reports
* work exactly as before.
*
* The status-line fixtures below are verbatim captures from live panes
* (`tmux -L codeman capture-pane -p`) on Claude Code 2.1.220 and Codex CLI 0.152.1.
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { Session } from '../src/session.js';
import { getCli } from '../src/config/cli-registry/index.js';
import { CLAUDE_WORKING_LINE_PATTERN } from '../src/utils/regex-patterns.js';
import {
trackActivityStreak,
isSustainedActivity,
isPaneQuiet,
ACTIVITY_GAP_MS,
WORKING_STREAK_MS,
IDLE_SILENCE_MS,
} from '../src/session-activity.js';
type SessionInternals = {
_handleTerminalOutput(data: string): void;
_detectInteractiveActivity(data: string): void;
};
/** One PTY chunk: what the pane emitted, exactly as the interactive handler sees it. */
function feed(session: Session, data: string): void {
const internals = session as unknown as SessionInternals;
internals._handleTerminalOutput(data);
internals._detectInteractiveActivity(data);
}
/**
* A session whose mux reports a fixed (or scripted) screen, so the pane probe has
* something to read. Only `capturePaneText` is exercised by these paths.
*/
function withFakePane(screen: string | (() => string), mode: 'claude' | 'codex' = 'claude'): Session {
const read = typeof screen === 'function' ? screen : () => screen;
const mux = {
isAvailable: () => true,
capturePaneText: () => read(),
} as unknown as NonNullable<Parameters<typeof Session.prototype.constructor>[0]>['mux'];
return new Session({
workingDir: '/tmp',
mode,
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
} as ConstructorParameters<typeof Session>[0]);
}
/**
* Codex's pane, verbatim, while a turn runs and once it has finished. Codex draws `›` on
* its composer row through the whole turn, exactly as Claude draws `❯`, and prints
* `esc to interrupt` only while the turn is live.
*/
const CODEX_WORKING =
'Working (2m 49s • esc to interrupt)\n› Ask Codex to do anything\n' +
' gpt-5.6-sol high · Context 59% left · ~/innovi/irisplus-ent-2 · main\n';
const CODEX_FINISHED =
'─ Worked for 3m 47s ────────────────────\n› Ask Codex to do anything\n' +
' gpt-5.6-sol high · Context 57% left · ~/innovi/irisplus-ent-2 · main\n';
/** Codex's own composer repaint, the frame that arms the idle confirmation. */
const CODEX_COMPOSER_REPAINT = '\x1b[31;1H\x1b[38;5;246m›\xa0\x1b[39m\x1b[0m';
/** A composer repaint: the frame Claude ships roughly once a second while working. */
const COMPOSER_REPAINT =
'\x1b[31;1H\x1b[38;5;246m❯\xa0\x1b[39m\x1b[0m\x1b[33;1H \x1b[38;5;246mOpus 5 in:143,699 out:669 ctx:14%\x1b[39m';
describe('CLAUDE_WORKING_LINE_PATTERN', () => {
it('matches the live status line, whatever the glyph and gerund are', () => {
// Captured from three different live panes: the glyph animates through
// `· ✢ ✳ ∗ ✻ ✽` and the gerund is randomized per turn, so neither is matchable.
expect(CLAUDE_WORKING_LINE_PATTERN.test('✻ Actualizing… (15m 17s · ↓ 47.5k tokens)')).toBe(true);
expect(CLAUDE_WORKING_LINE_PATTERN.test('* Implementing the backend… (18m 59s · ↓ 69.9k tokens)')).toBe(true);
expect(CLAUDE_WORKING_LINE_PATTERN.test('· Finagling… (4m 45s · ↓ 13.3k tokens)')).toBe(true);
expect(CLAUDE_WORKING_LINE_PATTERN.test('✽ Herding… (3s · esc to interrupt)')).toBe(true);
});
it('does not match the FINISHED line, which carries the same glyph', () => {
// `✻ Cooked for 2m 49s` sits on screen for the whole idle period afterwards.
// Matching the glyph alone would pin such a session at "working" forever.
expect(CLAUDE_WORKING_LINE_PATTERN.test('✻ Cooked for 2m 49s')).toBe(false);
expect(CLAUDE_WORKING_LINE_PATTERN.test('✻ Brewed for 18m 41s')).toBe(false);
expect(CLAUDE_WORKING_LINE_PATTERN.test('✻ Worked for 2m 46s')).toBe(false);
});
it('ignores ordinary prose and the idle footer', () => {
expect(CLAUDE_WORKING_LINE_PATTERN.test(COMPOSER_REPAINT)).toBe(false);
expect(CLAUDE_WORKING_LINE_PATTERN.test(' ⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents')).toBe(
false
);
expect(CLAUDE_WORKING_LINE_PATTERN.test('the build took 45s to finish')).toBe(false);
});
});
describe('activity streak helpers', () => {
it('extends a streak while chunks keep arriving', () => {
let streak = trackActivityStreak(null, 1000);
streak = trackActivityStreak(streak, 2000);
streak = trackActivityStreak(streak, 3000);
expect(streak).toEqual({ startedAt: 1000, lastAt: 3000 });
});
it('restarts the streak after a gap', () => {
const first = trackActivityStreak(null, 1000);
const after = trackActivityStreak(first, 1000 + ACTIVITY_GAP_MS + 1);
expect(after.startedAt).toBe(1000 + ACTIVITY_GAP_MS + 1);
});
it('calls it working only once the streak spans the threshold', () => {
expect(isSustainedActivity(null)).toBe(false);
expect(isSustainedActivity({ startedAt: 0, lastAt: WORKING_STREAK_MS - 1 })).toBe(false);
expect(isSustainedActivity({ startedAt: 0, lastAt: WORKING_STREAK_MS })).toBe(true);
});
it('measures the streak on its own span, so a stale streak cannot age into working', () => {
// A single old chunk stays a single chunk no matter how much later we ask.
const oneChunk = { startedAt: 0, lastAt: 0 };
expect(isSustainedActivity(oneChunk)).toBe(false);
});
it('calls the pane quiet only after the silence window', () => {
expect(isPaneQuiet(1000, 1000 + IDLE_SILENCE_MS - 1)).toBe(false);
expect(isPaneQuiet(1000, 1000 + IDLE_SILENCE_MS)).toBe(true);
});
});
describe('Session interactive idle detection', () => {
afterEach(() => {
vi.useRealTimers();
});
it('stays busy through a long turn of composer repaints', () => {
vi.useFakeTimers();
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
const events: string[] = [];
session.on('idle', () => events.push('idle'));
session.on('working', () => events.push('working'));
// 30 seconds of the once-a-second repaint a working pane emits. Every one of
// these carries a ❯; the old rule went idle after the first two seconds.
for (let i = 0; i < 30; i++) {
feed(session, COMPOSER_REPAINT);
vi.advanceTimersByTime(1000);
}
expect(events).toEqual(['working']);
expect(session.status).toBe('busy');
});
it('goes idle once the pane falls silent', () => {
vi.useFakeTimers();
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
const events: string[] = [];
session.on('idle', () => events.push('idle'));
for (let i = 0; i < 5; i++) {
feed(session, COMPOSER_REPAINT);
vi.advanceTimersByTime(1000);
}
expect(events).toEqual([]);
// Turn over: nothing more is emitted.
vi.advanceTimersByTime(IDLE_SILENCE_MS + 1000);
expect(events).toEqual(['idle']);
expect(session.status).toBe('idle');
});
it('emits idle once, not once per re-check', () => {
vi.useFakeTimers();
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
const events: string[] = [];
session.on('idle', () => events.push('idle'));
for (let i = 0; i < 4; i++) {
feed(session, COMPOSER_REPAINT);
vi.advanceTimersByTime(1000);
}
vi.advanceTimersByTime(60_000);
expect(events).toEqual(['idle']);
});
it('refuses to go idle while the screen still shows the working line', () => {
vi.useFakeTimers();
// A turn can go completely silent inside one tool call (measured at 20+
// seconds on a live worker) while `✻ Elucidating… (39s · ↓ 2.0k tokens)`
// sits on screen the whole time. Silence alone must not end the turn.
const session = withFakePane('✻ Elucidating… (39s · ↓ 2.0k tokens)\n❯ \n');
const events: string[] = [];
session.on('idle', () => events.push('idle'));
for (let i = 0; i < 3; i++) {
feed(session, COMPOSER_REPAINT);
vi.advanceTimersByTime(1000);
}
vi.advanceTimersByTime(60_000); // silent for a minute
expect(events).toEqual([]);
expect(session.status).toBe('busy');
});
it('goes idle once the working line leaves the screen', () => {
vi.useFakeTimers();
const pane = { text: '✻ Elucidating… (39s · ↓ 2.0k tokens)\n❯ \n' };
const session = withFakePane(() => pane.text);
const events: string[] = [];
session.on('idle', () => events.push('idle'));
for (let i = 0; i < 3; i++) {
feed(session, COMPOSER_REPAINT);
vi.advanceTimersByTime(1000);
}
vi.advanceTimersByTime(20_000);
expect(events).toEqual([]);
// Turn over: the same glyph remains, on the FINISHED line this time.
pane.text = '✻ Cooked for 2m 49s\n❯ \n';
vi.advanceTimersByTime(20_000);
expect(events).toEqual(['idle']);
expect(session.status).toBe('idle');
});
it('does not call typing into the composer "working"', () => {
vi.useFakeTimers();
// Keystroke echo is a steady stream of repaints too, so the streak alone
// would call it work. The screen has no working line, which vetoes it.
const session = withFakePane('❯ some prompt being typed\n');
const events: string[] = [];
session.on('working', () => events.push('working'));
for (let i = 0; i < 10; i++) {
feed(session, '\x1b[31;3Hx');
vi.advanceTimersByTime(300);
}
expect(events).toEqual([]);
expect(session.status).toBe('idle');
});
it('does not mark an uncharacterised CLI working off raw activity', () => {
vi.useFakeTimers();
// Gemini and OpenCode render their own TUIs, and Codeman knows neither one's glyph,
// so nothing would arm the idle confirmation and a session marked working here would
// never recover. A CLI that names no glyph therefore reports no work at all.
expect(getCli('gemini')?.capabilities.workDetect).toBeUndefined();
const session = new Session({ workingDir: '/tmp', mode: 'gemini' });
const events: string[] = [];
session.on('working', () => events.push('working'));
for (let i = 0; i < 10; i++) {
feed(session, '\x1b[2K▌ Working (12s)');
vi.advanceTimersByTime(1000);
}
expect(events).toEqual([]);
});
it('marks a Codex pane working, and lets the turn end', () => {
vi.useFakeTimers();
let screen = CODEX_WORKING;
const session = withFakePane(() => screen, 'codex');
const events: string[] = [];
session.on('working', () => events.push('working'));
session.on('idle', () => events.push('idle'));
for (let i = 0; i < 3; i++) {
feed(session, CODEX_COMPOSER_REPAINT);
vi.advanceTimersByTime(1000);
}
vi.advanceTimersByTime(20_000);
// The old code reported this session idle for the whole turn.
expect(events).toEqual(['working']);
expect(session.status).toBe('busy');
// Turn over: the working footer gives way to the finished line, which must NOT
// read as work — it sits on screen for the whole idle period afterwards.
screen = CODEX_FINISHED;
vi.advanceTimersByTime(20_000);
expect(events).toEqual(['working', 'idle']);
expect(session.status).toBe('idle');
});
});
describe("codex's work-detection descriptor", () => {
const codex = getCli('codex')?.capabilities.workDetect;
it('matches the footer Codex prints while a turn runs', () => {
expect(new RegExp(codex!.workingLine).test(CODEX_WORKING)).toBe(true);
});
it('does not match the finished line, nor the idle footer', () => {
expect(new RegExp(codex!.workingLine).test(CODEX_FINISHED)).toBe(false);
});
it('matches the footer case-insensitively on the E', () => {
// Characterised on codex-cli 0.152.1, which prints a lowercase `esc`. A future
// version capitalising it would otherwise make the whole fix silently inert:
// the pane would simply never look like it was working.
expect(new RegExp(codex!.workingLine).test(CODEX_WORKING.replace('esc to interrupt', 'Esc to interrupt'))).toBe(
true
);
});
it('names the glyph Codex actually draws on its composer row', () => {
expect(CODEX_COMPOSER_REPAINT).toContain(codex!.promptGlyph);
expect(CODEX_WORKING).toContain(codex!.promptGlyph);
});
});
describe('wire activity stamp across recovery', () => {
// The stamp both home screens sort the quiet group on. Recovery restores the
// previous run's value, and the settle window keeps the boot attach repaint
// (ordinary PTY output, arriving within seconds of construction) from
// restamping every session "now": measured live, a restart left 17 of 17
// sessions with an identical lastActivityAt, which flattens the ordering to
// tab order after every deploy.
const OLD = 1_700_000_000_000;
const restored = () =>
new Session({ workingDir: '/tmp', mode: 'claude', lastActivityAt: OLD } as ConstructorParameters<
typeof Session
>[0]);
it('restores the previous-run stamp and holds it through attach-repaint output', () => {
const session = restored();
expect(session.lastActivityAt).toBe(OLD);
(session as unknown as SessionInternals)._handleTerminalOutput('attach repaint bytes');
expect(session.lastActivityAt).toBe(OLD);
expect(session.toState().lastActivityAt).toBe(OLD);
});
it('a real action writes through the settle window', () => {
const session = restored();
session.assignTask('t1');
expect(session.lastActivityAt).toBeGreaterThan(OLD);
});
it('output after the window moves the stamp normally', () => {
const session = restored();
(session as unknown as { _wireActivitySettleUntil: number })._wireActivitySettleUntil = Date.now() - 1;
(session as unknown as SessionInternals)._handleTerminalOutput('real output');
expect(session.lastActivityAt).toBeGreaterThan(OLD);
});
it('a fresh session has no window: first output stamps immediately', () => {
const before = Date.now();
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
(session as unknown as SessionInternals)._handleTerminalOutput('x');
expect(session.lastActivityAt).toBeGreaterThanOrEqual(before);
});
});