mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
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:
commit
b67018b318
39 files changed
+618
-135
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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'$/);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user