Merge pull request #670: fix(input): keep the line breaks of a multi-line mux prompt, pasted as one block

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-22 19:23:17 +02:00
commit b67018b318
39 files changed
+618 -135

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
/**
* @fileoverview `sendwait` in the packaged agent preamble sends one line.
*
* Runs the REAL `skills/codeman/preamble.sh` in bash with `CURL` replaced by a stub
* that records the request body, so it checks what the function puts on the wire,
* not a copy of its logic. The server pastes a multi-line prompt as one block, and
* neither sendwait's Enter loop nor the server's submit check can recognise a pasted
* block still sitting on the composer, so the prompt must arrive as one line, the
* way `toSingleLine` joins it for the other unattended senders.
*
* Port: N/A (no server; the stub answers every request).
*/
import { describe, it, expect } from 'vitest';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { toSingleLine } from '../src/utils/single-line.js';
const PREAMBLE = join(__dirname, '..', 'skills', 'codeman', 'preamble.sh');
// The preamble builds every request body with jq.
let hasJq = true;
try {
execFileSync('jq', ['--version'], { stdio: 'ignore' });
} catch {
hasJq = false;
}
/** The `input` field of the first body sendwait posts for `prompt`. */
function sentInput(prompt: string): string {
// sendwait calls CURL inside $(...), a subshell, so the stub records to a file.
const dir = mkdtempSync(join(tmpdir(), 'codeman-sendwait-'));
const bodies = join(dir, 'bodies');
const script = `
. "$PREAMBLE" >/dev/null
stub() {
while [ "$#" -gt 0 ]; do
if [ "$1" = --data-binary ]; then printf '%s\\n' "$2" >> "$BODIES"; shift; fi
shift
done
printf '%s' '{"data":{"delivered":true,"wait":{"timedOut":false}}}'
}
CURL=(stub)
sendwait worker1234 "$PROMPT" 7 >/dev/null
`;
try {
execFileSync('bash', ['-c', script], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
PATH: process.env.PATH,
HOME: process.env.HOME,
PREAMBLE,
PROMPT: prompt,
BODIES: bodies,
CODEMAN_API_URL: 'http://127.0.0.1:9',
CODEMAN_SESSION_ID: 'caller-session-0000',
},
});
const first = readFileSync(bodies, 'utf-8').split('\n')[0];
return (JSON.parse(first) as { input: string }).input;
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
describe.skipIf(!hasJq)('preamble sendwait', () => {
it('joins a multi-line prompt onto one line before sending it', () => {
expect(sentInput('Review this:\n- item 1\n- item 2')).toBe('Review this: - item 1 - item 2\r');
});
it('joins the same way as toSingleLine (trimmed lines, blank lines dropped, \\r\\n as a break)', () => {
const prompt = ' first line \r\n\n\t second\n\n \nthird\n';
expect(sentInput(prompt)).toBe(toSingleLine(prompt) + '\r');
});
it('refuses a prompt made only of blank lines instead of sending a bare Enter', () => {
expect(() => sentInput(' \n \t\n')).toThrow(/sendwait: empty prompt/);
});
it('leaves a single-line prompt, glob characters included, as it was', () => {
expect(sentInput('fix * in ?.ts ')).toBe('fix * in ?.ts \r');
});
});
+110 -4
View File
@@ -11,16 +11,27 @@
* a one-line prompt). With `paste-buffer -p` the paste is marked and the separate
* Enter submits.
*
* Also the line breaks of a multi-line `sendInput`: kept, and pasted as ONE block
* with or without the Enter, where they used to be stripped and the lines joined.
*
* Strategy: pure command shape, plus a round trip through a real shell with a fake
* `tmux` that prints its argv, so quoting and tmux's trailing-`;` rule are checked
* on the bytes a shell actually hands over. `pasteText` runs against a recording
* runner. No real tmux.
* runner, and `sendInput` against a subclass whose `inputRunner()` records the
* commands instead of running them. No real tmux.
*
* Port: N/A (no server / no real tmux)
*/
import { execSync } from 'node:child_process';
import { describe, expect, it } from 'vitest';
import { buildPasteTextCommands, escapeTmuxTrailingSemicolon, pasteText, submitsAsKeys } from '../src/tmux-manager.js';
import type { MuxSession } from '../src/mux-interface.js';
import {
TmuxManager,
buildPasteTextCommands,
escapeTmuxTrailingSemicolon,
pasteText,
submitsAsKeys,
} from '../src/tmux-manager.js';
const FAKE_TMUX = `${JSON.stringify(process.execPath)} -e 'console.log(JSON.stringify(process.argv.slice(1)))' --`;
@@ -136,8 +147,8 @@ describe('pasteText', () => {
});
describe('sendInput wiring', () => {
// sendInput() returns before touching tmux under vitest (IS_TEST_MODE), so the
// branch that submits text is pinned on the source instead.
// These pin the branch shapes on the source; the commands sendInput actually runs
// are recorded in 'sendInput line breaks' below.
async function source(): Promise<string> {
const { readFileSync } = await import('node:fs');
return readFileSync(new URL('../src/tmux-manager.ts', import.meta.url), 'utf-8');
@@ -178,3 +189,98 @@ describe('sendInput wiring', () => {
for (const cmd of typedHere) expect(cmd).toContain('escapeTmuxTrailingSemicolon(textPart)');
});
});
describe('sendInput line breaks', () => {
class RecordingManager extends TmuxManager {
ran: string[] = [];
protected override inputRunner(): (cmd: string) => Promise<unknown> {
return async (cmd: string) => {
this.ran.push(cmd);
};
}
}
function managerWithSession(): RecordingManager {
const manager = new RecordingManager();
manager.registerSession({
sessionId: 's1',
muxName: 'codeman-abc12345',
pid: 100,
createdAt: 0,
workingDir: '/tmp',
mode: 'claude',
attached: true,
} as MuxSession);
return manager;
}
/** The text a recorded `set-buffer` command hands to tmux, as a shell delivers it. */
function bufferText(setBufferCmd: string): string {
return argvOf(setBufferCmd.replace(/^.*? set-buffer /, `${FAKE_TMUX} set-buffer `)).at(-1) as string;
}
it('submits a multi-line prompt as one paste with its line breaks, then Enter', async () => {
const manager = managerWithSession();
expect(await manager.sendInput('s1', 'line one\nline two\n\nparagraph three\r')).toBe(true);
expect(manager.ran).toHaveLength(3);
const [set, paste, enter] = manager.ran;
expect(bufferText(set)).toBe('line one\nline two\n\nparagraph three');
expect(paste).toMatch(/ paste-buffer -p -d -b \S+ -t 'codeman-abc12345'$/);
expect(enter).toMatch(/ send-keys -t "codeman-abc12345" Enter$/);
});
it('turns CRLF line endings into plain line breaks', async () => {
const manager = managerWithSession();
await manager.sendInput('s1', 'one\r\ntwo\r');
expect(bufferText(manager.ran[0])).toBe('one\ntwo');
});
it('pastes multi-line text without Enter too, and never types it as keys', async () => {
const manager = managerWithSession();
await manager.sendInput('s1', 'one\ntwo');
expect(manager.ran).toHaveLength(2);
expect(bufferText(manager.ran[0])).toBe('one\ntwo');
expect(manager.ran[1]).toContain(' paste-buffer -p ');
for (const cmd of manager.ran) expect(cmd).not.toContain('send-keys');
});
it('drops trailing line breaks instead of pasting an empty last line', async () => {
const manager = managerWithSession();
await manager.sendInput('s1', 'one\ntwo\n\n\r');
expect(bufferText(manager.ran[0])).toBe('one\ntwo');
});
it('drops leading line breaks instead of pasting an empty first line', async () => {
const single = managerWithSession();
await single.sendInput('s1', '\nfix it\r');
expect(bufferText(single.ran[0])).toBe('fix it');
const multi = managerWithSession();
await multi.sendInput('s1', '\r\n\r\none\ntwo\r');
expect(bufferText(multi.ran[0])).toBe('one\ntwo');
// A blank first line made of spaces is dropped too; the first real line keeps its indentation.
const spaced = managerWithSession();
await spaced.sendInput('s1', ' \n\t\n fix it\nnow\r');
expect(bufferText(spaced.ran[0])).toBe(' fix it\nnow');
// Without an Enter, what is left is one line, so it is typed like any other.
const typed = managerWithSession();
await typed.sendInput('s1', '\n\njust type');
expect(typed.ran).toEqual([expect.stringMatching(/ send-keys -t "codeman-abc12345" -l 'just type'$/)]);
});
it('leaves single-line input as it was: pasted with Enter, typed without', async () => {
const submitted = managerWithSession();
await submitted.sendInput('s1', 'read your inbox\r');
expect(bufferText(submitted.ran[0])).toBe('read your inbox');
expect(submitted.ran.at(-1)).toMatch(/ send-keys -t "codeman-abc12345" Enter$/);
const typed = managerWithSession();
await typed.sendInput('s1', 'just type');
expect(typed.ran).toHaveLength(1);
expect(typed.ran[0]).toMatch(/ send-keys -t "codeman-abc12345" -l 'just type'$/);
});
});
+1 -1
View File
@@ -327,7 +327,7 @@ describe('TuiClient.sendInput', () => {
expect(JSON.parse(recorded[0].body)).toMatchObject({ input: 'hello world\r', useMux: true });
});
it('collapses embedded newlines into spaces (multi-line breaks Ink)', async () => {
it('collapses embedded newlines into spaces (a multi-line paste cannot be confirmed as submitted)', async () => {
await client().sendInput('abc', 'echo A\necho B\r\nline three ');
expect(JSON.parse(recorded[0].body).input).toBe('echo A echo B line three\r');
});
+147
View File
@@ -0,0 +1,147 @@
/**
* The senders that run unattended write a prompt as ONE line, whatever the config holds.
*
* Respawn's update and kickstart prompts are textareas in the settings, and the
* auto-compact focus prompt's schema accepts line breaks, so a saved config can hold
* several lines. `TmuxManager.sendInput()` used to strip every `\n`, which ran the
* lines together; it now pastes multi-line text as one block. These senders run
* unattended, and the check that re-presses a stranded Enter cannot reliably
* recognise a multi-line paste in the composer, so they join the lines with a space
* before the write (`toSingleLine()`) instead of leaning on the mux to drop them.
*
* Strategy: drive the respawn step methods against a MockSession, and feed a real
* Session a token count over its auto-compact threshold, then read what reached
* `writeViaMux`. No real tmux.
*
* Port: N/A (no server / no real tmux)
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('node:child_process', async (orig) => {
const actual = await orig<typeof import('node:child_process')>();
return { ...actual, exec: vi.fn((_cmd: string, cb?: (e: Error | null, o: string) => void) => cb?.(null, '')) };
});
import { RespawnController } from '../src/respawn-controller.js';
import { toSingleLine } from '../src/utils/single-line.js';
import { Session } from '../src/session.js';
import { MockSession } from './mocks/index.js';
describe('toSingleLine', () => {
it('joins lines with one space, folding the whitespace around each break', () => {
expect(toSingleLine('update the docs\n\n then CLAUDE.md\r\nand continue')).toBe(
'update the docs then CLAUDE.md and continue'
);
});
it('returns a single-line prompt unchanged', () => {
expect(toSingleLine(' keep "quotes" and $vars ')).toBe(' keep "quotes" and $vars ');
});
it('drops leading and trailing blank lines instead of leaving a space there', () => {
expect(toSingleLine('\n compact the log \n\n')).toBe('compact the log');
});
it('stays linear on a long whitespace run', () => {
// The run sits inside a line, so the result is the same either way; only the time
// differs. A regex that folds the whitespace around each break retries the run from
// every start position: 50,000 spaces took over a second that way. The split takes
// well under a millisecond, so the bound leaves room either side.
const run = ' '.repeat(50_000);
const started = performance.now();
const joined = toSingleLine(`a${run}b\nc`);
const elapsed = performance.now() - started;
expect(joined).toBe(`a${run}b c`);
expect(elapsed).toBeLessThan(250);
});
});
describe('respawn steps send a multi-line prompt as one line', () => {
let controller: RespawnController | null = null;
afterEach(() => controller?.stop());
/** Run one step method and return what it wrote through the mux. */
async function written(
config: { updatePrompt?: string; kickstartPrompt?: string },
step: 'sendUpdateDocs' | 'sendKickstart',
recommendation?: string
): Promise<string[]> {
const session = new MockSession();
if (recommendation !== undefined) {
(session as unknown as { ralphTracker: unknown }).ralphTracker = { lastStatusBlock: { recommendation } };
}
controller = new RespawnController(session as unknown as Session, {
interStepDelayMs: 10,
completionConfirmMs: 10,
noOutputTimeoutMs: 500,
aiIdleCheckEnabled: false,
...config,
});
(controller as never as { _state: string })._state = 'watching';
(controller as never as Record<typeof step, () => void>)[step]();
// The step schedules its write on the step-delay timer.
await new Promise((r) => setTimeout(r, 40));
return session.writeBuffer;
}
it('update prompt', async () => {
const writes = await written(
{ updatePrompt: 'summarise progress\nin CLAUDE.md\n\nthen continue' },
'sendUpdateDocs'
);
expect(writes).toEqual(['summarise progress in CLAUDE.md then continue\r']);
});
it('update prompt with a recommendation appended', async () => {
const writes = await written({ updatePrompt: 'summarise\nprogress' }, 'sendUpdateDocs', 'fix the\nlogin test');
expect(writes).toEqual(["summarise progress (Claude's last recommendation: fix the login test)\r"]);
});
it('kickstart prompt', async () => {
const writes = await written(
{ kickstartPrompt: 'read @fix_plan.md\r\ncontinue with the next task' },
'sendKickstart'
);
expect(writes).toEqual(['read @fix_plan.md continue with the next task\r']);
});
it('a single-line prompt is sent as before', async () => {
const writes = await written({ updatePrompt: 'update all the docs and CLAUDE.md' }, 'sendUpdateDocs');
expect(writes).toEqual(['update all the docs and CLAUDE.md\r']);
});
});
describe('auto-compact sends a multi-line focus prompt as one line', () => {
afterEach(() => vi.useRealTimers());
/**
* Feed a Session a status line over the threshold and return what its auto-compact
* wrote through `writeViaMux`: the session's own token parser, the trigger and its
* timers, and the `writeCommand` wiring all run as in production.
*/
async function compactWrites(prompt?: string): Promise<string[]> {
vi.useFakeTimers();
const session = new Session({ workingDir: '/tmp' });
const writeViaMux = vi.spyOn(session, 'writeViaMux').mockResolvedValue(true);
session.setAutoCompact(true, 1_000, prompt);
(session as never as { parseTokensFromStatusLine(s: string): void }).parseTokensFromStatusLine('5.0k tokens');
// The trigger waits AUTO_INITIAL_DELAY_MS (1 s) before it writes.
await vi.advanceTimersByTimeAsync(1_500);
return writeViaMux.mock.calls.map((call) => call[0]);
}
it('focus prompt with line breaks', async () => {
expect(await compactWrites('keep the open tasks\n\n and the test results\r\nfrom today')).toEqual([
'/compact keep the open tasks and the test results from today\r',
]);
});
it('a single-line focus prompt is sent as before', async () => {
expect(await compactWrites('keep the open tasks')).toEqual(['/compact keep the open tasks\r']);
});
it('no focus prompt sends a bare /compact', async () => {
expect(await compactWrites()).toEqual(['/compact\r']);
});
});