mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
233 lines
9.9 KiB
TypeScript
233 lines
9.9 KiB
TypeScript
/**
|
||||
|
|
* 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');
|
|||
|
|
});
|
|||
|
|
});
|