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