Files
Codeman/test/session-awaiting-workers.test.ts
T
Codeman maintainer 7659ca8b44 fix(session): keep a tab working while Claude waits for its own workers
When Claude hands work to an ultracode workflow or background agents, it
ends its own turn and closes it with `✻ Waiting for 1 dynamic workflow to
finish` instead of `✻ Brewed for 1m 18s`, then resumes by itself when the
workers report back. The pane sits quiet with the composer up, so the idle
probe called the session idle for the whole wait. At phone width the
workflow's progress row also drops its ticking timer, so nothing on screen
changes for minutes.

A new optional registry field, `capabilities.workDetect.awaitingLine`,
names that closing row, and `_probePaneWorking()` counts it as work.
Claude renders the row once from a snapshot and never redraws it, so the
same words stay on screen after the workers finish. `isAwaitingWorkers()`
therefore tests only the newest column-0 row directly above the composer,
never the whole pane and never the PTY stream; a follow-up turn always
puts rows of its own there. The column-0 anchor also keeps an agent from
holding its own tab busy by printing the sentence.

Verified against the live Mac mini pane that reported the bug (2.1.283),
and end to end on an isolated instance: an ultracode session running a
90 s workflow at 46 columns stayed busy through the wait and the
follow-up turn, then went idle 6 s after that turn closed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:18:27 +02:00

234 lines
9.9 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.
/**
* A session whose turn ended waiting for workers it started counts as working.
*
* The bug this pins: when Claude hands work to background agents or an ultracode
* workflow, it ends its turn and closes it with `✻ Waiting for 1 dynamic workflow to
* finish` instead of `✻ Brewed for 1m 18s`. The pane goes quiet with the composer up, so
* every other signal called the session idle while it was plainly busy, and it resumes
* on its own the moment the workers report back.
*
* The chrome rows below (the closing row, the right-aligned hint, the composer rules, the
* footer, the workflow progress row) are verbatim from a live Claude Code 2.1.283 pane on
* 2026-09-28 (`tmux -L codeman capture-pane -p`, 64 columns). The prose and the names are
* invented.
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { Session } from '../src/session.js';
import { getCli } from '../src/config/cli-registry/index.js';
import { compileVersionRegex } from '../src/config/cli-registry/patterns.js';
import { isAwaitingWorkers, AWAITING_SEARCH_ROWS, IDLE_SILENCE_MS } from '../src/session-activity.js';
/** The registry's own pattern, which is what every consumer runs. */
const CLAUDE_AWAITING = compileVersionRegex(getCli('claude')!.capabilities.workDetect!.awaitingLine!)!;
const RULE = '────────────────────────────────────────────────────────────────';
const NAMED_RULE = '──────────────────────────────────────────────────── w1-demo ─';
/** Everything Claude draws from the composer down while a workflow runs. */
const COMPOSER_AND_FOOTER = [
NAMED_RULE,
'❯ sounds good, go ahead',
RULE,
' Opus 5.5 (1M context) in:285,618 out:581 ctx:29%',
' ⏵⏵ bypass permissions on (shift+tab to cycle) · ← 2 agents',
'',
' ◯ docs-research ▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ ↓ 1.1m',
];
/** A pane whose newest turn closed with `closing`, then the optional hint row. */
function frame(closing: string, { hint = true, body = [] as string[] } = {}): string {
return [
'⏺ The research is running now: three tracks, each checked by',
' a second agent.',
'',
' When it is done I will rewrite the plan.',
'',
...body,
closing,
...(hint ? [' 286199 tokens'] : []),
...COMPOSER_AND_FOOTER,
'',
].join('\n');
}
const WAITING_WORKFLOW = frame('✻ Waiting for 1 dynamic workflow to finish');
const WAITING_BOTH = frame('✻ Waiting for 2 background agents and 1 dynamic workflow to finish');
const WAITING_AGENT = frame('✻ Waiting for 1 background agent to finish', { hint: false });
const DONE = frame('✻ Brewed for 1m 18s · done 3:04 PM');
/**
* The same screen after the workers reported back and the follow-up turn ended. The
* waiting row is a snapshot Claude never redraws, so it is STILL on screen, just no longer
* the newest row.
*/
const FOLLOW_UP_DONE = frame('✻ Cooked for 12s', {
body: [
'✻ Waiting for 1 dynamic workflow to finish',
'',
'⏺ All three tracks are back. The plan is rewritten and pushed.',
'',
],
});
describe('isAwaitingWorkers', () => {
it('reads the closing row of a turn that handed off to a workflow', () => {
expect(isAwaitingWorkers(WAITING_WORKFLOW, CLAUDE_AWAITING, '❯')).toBe(true);
});
it('reads every form Claude builds the row in', () => {
expect(isAwaitingWorkers(WAITING_BOTH, CLAUDE_AWAITING, '❯')).toBe(true);
expect(isAwaitingWorkers(WAITING_AGENT, CLAUDE_AWAITING, '❯')).toBe(true);
expect(isAwaitingWorkers(frame('✻ Waiting for 3 background agents to finish'), CLAUDE_AWAITING, '❯')).toBe(true);
expect(isAwaitingWorkers(frame('✻ Waiting for 2 dynamic workflows to finish'), CLAUDE_AWAITING, '❯')).toBe(true);
});
it('leaves an ordinary turn end alone', () => {
expect(isAwaitingWorkers(DONE, CLAUDE_AWAITING, '❯')).toBe(false);
});
it('ignores a stale waiting row once a newer turn has closed below it', () => {
// The trap the whole positional walk exists for: matching the words anywhere on the
// screen would pin the session busy until they scrolled away.
expect(FOLLOW_UP_DONE).toContain('Waiting for 1 dynamic workflow to finish');
expect(isAwaitingWorkers(FOLLOW_UP_DONE, CLAUDE_AWAITING, '❯')).toBe(false);
});
it('refuses the words when the agent wrote them', () => {
// Claude's own rows start in column 0; the agent's prose sits behind `⏺ ` or is
// indented, so an agent cannot keep itself busy by printing the sentence.
expect(isAwaitingWorkers(frame('⏺ ✻ Waiting for 1 background agent to finish'), CLAUDE_AWAITING, '❯')).toBe(false);
expect(isAwaitingWorkers(frame(' ✻ Waiting for 1 background agent to finish'), CLAUDE_AWAITING, '❯')).toBe(false);
});
it('says nothing about a screen with no composer on it', () => {
const noComposer = WAITING_WORKFLOW.replace('❯ sounds good, go ahead', ' 1. Yes 2. No');
expect(isAwaitingWorkers(noComposer, CLAUDE_AWAITING, '❯')).toBe(false);
expect(isAwaitingWorkers('', CLAUDE_AWAITING, '❯')).toBe(false);
expect(isAwaitingWorkers(null, CLAUDE_AWAITING, '❯')).toBe(false);
});
it('finds the composer in the boxed layout too', () => {
const boxed = [
'✻ Waiting for 1 dynamic workflow to finish',
'╭──────────────────────────────────────╮',
'│ ❯ │',
'╰──────────────────────────────────────╯',
' ⏵⏵ bypass permissions on · ← 1 agent',
].join('\n');
expect(isAwaitingWorkers(boxed, CLAUDE_AWAITING, '❯')).toBe(true);
});
it('reads a coloured capture', () => {
const coloured = WAITING_WORKFLOW.replace(
'✻ Waiting for 1 dynamic workflow to finish',
'\u001b[2m✻\u001b[0m \u001b[2mWaiting for \u001b[1m1\u001b[22m dynamic workflow to finish\u001b[0m'
);
expect(isAwaitingWorkers(coloured, CLAUDE_AWAITING, '❯')).toBe(true);
});
it('stops looking a few rows above the composer', () => {
const farAway = [
'✻ Waiting for 1 dynamic workflow to finish',
...Array.from({ length: AWAITING_SEARCH_ROWS }, () => ''),
...COMPOSER_AND_FOOTER,
].join('\n');
expect(isAwaitingWorkers(farAway, CLAUDE_AWAITING, '❯')).toBe(false);
});
it('survives a pattern handed to it with the global flag set', () => {
const global = new RegExp(CLAUDE_AWAITING.source, 'g');
expect(isAwaitingWorkers(WAITING_WORKFLOW, global, '❯')).toBe(true);
expect(isAwaitingWorkers(WAITING_WORKFLOW, global, '❯')).toBe(true);
});
});
/** 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';
type SessionInternals = {
_handleTerminalOutput(data: string): void;
_detectInteractiveActivity(data: string): void;
};
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 scripted screen for the pane probe to read. */
function withFakePane(read: () => string, mode: 'claude' | 'codex' = 'claude'): Session {
const mux = {
isAvailable: () => true,
capturePaneText: () => read(),
} as unknown as NonNullable<ConstructorParameters<typeof Session>[0]>['mux'];
return new Session({
workingDir: '/tmp',
mode,
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
} as ConstructorParameters<typeof Session>[0]);
}
/** Run one turn and let it end, which is when the probe reads the screen. */
function runAndSettle(session: Session, repaint: string = COMPOSER_REPAINT): void {
for (let i = 0; i < 3; i++) {
feed(session, repaint);
vi.advanceTimersByTime(1000);
}
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
}
describe('Session status while its workers run', () => {
afterEach(() => {
vi.useRealTimers();
});
it('stays working when the turn ends waiting for a workflow', () => {
vi.useFakeTimers();
const session = withFakePane(() => WAITING_WORKFLOW);
runAndSettle(session);
expect(session.status).toBe('busy');
expect(session.isWorking).toBe(true);
});
it('goes idle once the follow-up turn closes, although the old row is still on screen', () => {
vi.useFakeTimers();
const screen = { text: WAITING_WORKFLOW };
const session = withFakePane(() => screen.text);
runAndSettle(session);
expect(session.status).toBe('busy');
screen.text = FOLLOW_UP_DONE;
// The probe keeps re-reading the screen on its own slow cadence while it says busy,
// with no PTY output needed to trigger it.
vi.advanceTimersByTime(IDLE_SILENCE_MS + 10_000);
expect(session.status).toBe('idle');
expect(session.isWorking).toBe(false);
});
it('reports an ordinary turn end as idle, as before', () => {
vi.useFakeTimers();
const session = withFakePane(() => DONE);
runAndSettle(session);
expect(session.status).toBe('idle');
});
it('does not apply to a CLI whose registry entry declares no awaitingLine', () => {
vi.useFakeTimers();
expect(getCli('codex')?.capabilities.workDetect?.awaitingLine).toBeUndefined();
const codexScreen = ['✻ Waiting for 1 dynamic workflow to finish', '', '› Ask Codex to do anything', ''].join('\n');
const session = withFakePane(() => codexScreen, 'codex');
runAndSettle(session, '\x1b[31;1H\x1b[38;5;246m›\xa0\x1b[39m\x1b[0m');
expect(session.status).toBe('idle');
});
});