Merge pull request #455

fix(input): make sure a prompt sent through the API actually leaves the composer
This commit is contained in:
Ark0N
2026-09-19 12:18:13 +02:00
committed by GitHub
11 changed files with 632 additions and 94 deletions
+177
View File
@@ -0,0 +1,177 @@
/**
* @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);
});
});