Files
Codeman/test/session-submit-verifier.test.ts
Codeman maintainer 19ffe9b7a8 fix(input): make sure a prompt sent through the API actually leaves the composer
Claude Code 2.1.277 takes typed text the moment its composer paints but
ignores Enter for the first 30 to 50 seconds after it (measured 2026-09-19
through the input route: an Enter at 28 s stranded the prompt, one at 51 s
submitted it). The text+Enter pair `sendInput` sends 50 ms apart therefore
left every programmatic prompt sitting unsent, and every waiter burned its
timeout on a turn that never started.

Server: `SubmitVerifier` (session-submit-verifier.ts), armed from
`writeViaMux` for every mux write that carried a carriage return, reads the
pane on a 2 s to 60 s schedule and re-sends Enter only while the last
composer line (the CLI's own prompt glyph) still holds the head of what was
sent. An empty composer, other text, or no composer line at all ends it; a
newer write replaces the schedule.

Skill: `sendwait` gets the same loop (`_composer_text`, no-break space
stripped by its bytes for BSD sed) for servers that predate this, and the
preamble version moves to 1.30.1 so seeded agents pick up the fresh copy.
SKILL.md's heredoc and the plugin mirror are regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 11:38:59 +02:00

178 lines
6.7 KiB
TypeScript
Raw Permalink 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.
/**
* @fileoverview A prompt sent through the input route must actually leave the composer.
*
* Claude Code 2.1.277 ignores Enter for the first 30 to 50 seconds after the composer
* paints while still taking typed text, so text+Enter 50 ms apart left every
* programmatic prompt sitting unsent (measured 2026-09-19). These pin the recovery:
* the verifier reads the last glyph line, re-sends Enter only while the prompt is
* verifiably still there, stops the moment it is gone, never acts on a pane with no
* composer, is capped, and is cancelled by a newer write or teardown.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SubmitVerifier, promptStillInComposer, SUBMIT_VERIFY_DELAYS_MS } from '../src/session-submit-verifier.js';
const PROMPT =
'Read /home/arkon/.codeman/pr-bot/jobs/pr-439/brief.md and carry out the review it describes. Do not ask questions.';
/** Measured 2026-09-19: typed, wrapped, a no-break space after the glyph, never sent. */
const STRANDED = [
' ▐▛███▛█ Claude Code v2.1.278',
'──────────────────────────────────────── prbot-439 ─',
'❯ Read /home/arkon/.codeman/pr-bot/jobs/pr-439/brief.md and carry out the review it describes. Do not ask',
' questions.',
'────────────────────────────────────────────────────',
' Opus 5 (1M context) in:0 out:0',
' ⏵⏵ bypass permissions on (shift+tab to cycle)',
].join('\n');
/** The same pane once taken: echoed in the transcript, composer empty. */
const TAKEN = [
'❯ Read /home/arkon/.codeman/pr-bot/jobs/pr-439/brief.md and carry out the review it describes.',
'● Reading the brief.',
'✻ Actioning… (48s · ↓ 6.6k tokens)',
'──────────────────────────────────────── prbot-439 ─',
'❯',
'────────────────────────────────────────────────────',
' Opus 5 (1M context) in:192,963 out:371 ctx:19%',
].join('\n');
describe('promptStillInComposer', () => {
it('sees the prompt sitting in the composer, no-break space and wrapping included', () => {
expect(promptStillInComposer(STRANDED, PROMPT, '❯')).toBe(true);
});
it('is not fooled by the transcript echo once the composer is empty', () => {
expect(promptStillInComposer(TAKEN, PROMPT, '❯')).toBe(false);
});
it('treats other text in the composer as not ours', () => {
expect(promptStillInComposer(STRANDED, 'Summarise the changelog', '❯')).toBe(false);
});
it('answers undefined for a pane with no composer line, or no glyph', () => {
expect(promptStillInComposer('$ ls\nfoo bar\n$ ', PROMPT, '❯')).toBeUndefined();
expect(promptStillInComposer(STRANDED, PROMPT, '')).toBeUndefined();
});
it('honours the CLI glyph (Codex draws ›)', () => {
expect(promptStillInComposer('› Reply with PONG\n', 'Reply with PONG', '›')).toBe(true);
expect(promptStillInComposer('›\n', 'Reply with PONG', '›')).toBe(false);
});
it('reads through ANSI colour codes', () => {
expect(promptStillInComposer('\x1b[1m❯\x1b[0m \x1b[36mReply with PONG\x1b[0m', 'Reply with PONG', '❯')).toBe(true);
});
});
describe('SubmitVerifier', () => {
let screen: string;
let sends: number;
let logs: string[];
const make = (delaysMs?: readonly number[]) =>
new SubmitVerifier({
capture: () => screen,
sendEnter: () => {
sends++;
},
glyph: () => '❯',
log: (m) => logs.push(m),
delaysMs,
});
beforeEach(() => {
vi.useFakeTimers();
screen = STRANDED;
sends = 0;
logs = [];
});
afterEach(() => {
vi.useRealTimers();
});
it('re-sends Enter while the prompt is still there and stops once it is taken', async () => {
const v = make([1_000, 1_000, 1_000, 1_000]);
v.arm(PROMPT);
await vi.advanceTimersByTimeAsync(1_000);
expect(sends).toBe(1);
await vi.advanceTimersByTimeAsync(1_000);
expect(sends).toBe(2);
screen = TAKEN; // Claude Code finally honoured one
await vi.advanceTimersByTimeAsync(5_000);
expect(sends).toBe(2);
expect(logs[0]).toContain('still in the composer after 1s');
expect(logs[1]).toContain('(2/4)');
});
it('costs one capture and no Enter when the prompt was taken on the first try', async () => {
let captures = 0;
const v = new SubmitVerifier({
capture: () => {
captures++;
return TAKEN;
},
sendEnter: () => {
sends++;
},
glyph: () => '❯',
});
v.arm(PROMPT);
await vi.advanceTimersByTimeAsync(120_000);
expect(captures).toBe(1);
expect(sends).toBe(0);
});
it('never presses Enter into a pane with no composer line', async () => {
screen = '$ npm test\n... running ...\n';
const v = make([1_000, 1_000]);
v.arm(PROMPT);
await vi.advanceTimersByTimeAsync(10_000);
expect(sends).toBe(0);
});
it('is capped at the schedule length when the prompt never leaves', async () => {
const v = make();
v.arm(PROMPT);
await vi.advanceTimersByTimeAsync(10 * 60_000);
expect(sends).toBe(SUBMIT_VERIFY_DELAYS_MS.length);
});
it('production schedule reaches past the measured window', () => {
const total = SUBMIT_VERIFY_DELAYS_MS.reduce((a, b) => a + b, 0);
expect(SUBMIT_VERIFY_DELAYS_MS[0]).toBeLessThanOrEqual(2_000);
expect(total).toBeGreaterThanOrEqual(60_000);
});
it('a newer write replaces the schedule, so an old prompt never submits a new one', async () => {
const v = make([1_000, 1_000, 1_000]);
v.arm(PROMPT);
await vi.advanceTimersByTimeAsync(1_000);
expect(sends).toBe(1);
screen = '❯ Something the user typed next';
v.arm('Something the user typed next');
await vi.advanceTimersByTimeAsync(1_000);
expect(sends).toBe(2); // for the NEW prompt, which is what the composer holds
screen = '❯';
await vi.advanceTimersByTimeAsync(5_000);
expect(sends).toBe(2);
});
it('cancel() stops everything', async () => {
const v = make([1_000, 1_000]);
v.arm(PROMPT);
v.cancel();
await vi.advanceTimersByTimeAsync(10_000);
expect(sends).toBe(0);
});
it('keeps checking when sendEnter throws', async () => {
let calls = 0;
const v = new SubmitVerifier({
capture: () => screen,
sendEnter: () => {
calls++;
throw new Error('tmux hiccup');
},
glyph: () => '❯',
delaysMs: [1_000, 1_000],
});
v.arm(PROMPT);
await vi.advanceTimersByTimeAsync(3_000);
expect(calls).toBe(2);
});
});