From cf999c1296e723aa978ec67cc3cf357da82ef333 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:06:42 +0200 Subject: [PATCH] fix(auto-compact): send a multi-line focus prompt as one line Auto-compact writes `/compact \r` through writeViaMux when the token count crosses the threshold, unattended. AutoCompactSchema.prompt accepts line breaks, so a prompt set through the API can hold several lines. The mux used to strip them and run the lines together; it now pastes multi-line text as one block, and the check that re-presses a stranded Enter cannot recognise that paste in the composer. That is the case respawn's update and kickstart prompts were already joined for. The join moves out of respawn-controller into a shared toSingleLine() (src/utils/single-line.ts), used by both respawn steps and by auto-compact. Behaviour for respawn is unchanged. The respawn test file becomes test/unattended-single-line-prompt.test.ts and gains an auto-compact block that feeds a real Session a token count over its threshold and reads what reaches writeViaMux. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/multiline-input-paste.md | 2 +- src/respawn-controller.ts | 27 +++----- src/session-auto-ops.ts | 7 ++- src/utils/index.ts | 1 + src/utils/single-line.ts | 20 ++++++ ... => unattended-single-line-prompt.test.ts} | 61 +++++++++++++++---- 6 files changed, 87 insertions(+), 31 deletions(-) create mode 100644 src/utils/single-line.ts rename test/{respawn-single-line-prompt.test.ts => unattended-single-line-prompt.test.ts} (53%) diff --git a/.changeset/multiline-input-paste.md b/.changeset/multiline-input-paste.md index 3d3afaa6..8d63d181 100644 --- a/.changeset/multiline-input-paste.md +++ b/.changeset/multiline-input-paste.md @@ -2,4 +2,4 @@ 'aicodeman': patch --- -A multi-line prompt sent through the mux (`POST /api/sessions/:id/input` with `useMux`) now keeps its line breaks. `sendInput` used to strip every `\n` before the text reached tmux, so `"line one\nline two\r"` arrived as `line oneline two`. The lines now go in as one bracketed paste, the same paste a single-line prompt already uses, and the Enter after it submits them together; multi-line text without an Enter is pasted too instead of typed. A shell runs each line. The submit check that re-presses a stranded Enter does not yet recognise a multi-line paste in the composer, so the unattended senders stay single-line: cron still rejects multi-line prompts, respawn now joins the lines of a multi-line update or kickstart prompt with a space (they used to run together), and `codeman agent send` and the TUI keep refusing or joining multi-line input. +A multi-line prompt sent through the mux (`POST /api/sessions/:id/input` with `useMux`) now keeps its line breaks. `sendInput` used to strip every `\n` before the text reached tmux, so `"line one\nline two\r"` arrived as `line oneline two`. The lines now go in as one bracketed paste, the same paste a single-line prompt already uses, and the Enter after it submits them together; multi-line text without an Enter is pasted too instead of typed. A shell runs each line. The submit check that re-presses a stranded Enter does not yet recognise a multi-line paste in the composer, so the unattended senders stay single-line: cron still rejects multi-line prompts, respawn's update and kickstart prompts and the auto-compact focus prompt now have their lines joined with a space (they used to run together), and `codeman agent send` and the TUI keep refusing or joining multi-line input. diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index 33e7c277..c1b775ee 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -49,7 +49,13 @@ import { Session } from './session.js'; import { AiIdleChecker, type AiCheckState } from './ai-idle-checker.js'; import { AiPlanChecker } from './ai-plan-checker.js'; import type { TeamWatcher } from './team-watcher.js'; -import { BufferAccumulator, ANSI_ESCAPE_PATTERN_SIMPLE, assertNever, CleanupManager } from './utils/index.js'; +import { + BufferAccumulator, + ANSI_ESCAPE_PATTERN_SIMPLE, + assertNever, + CleanupManager, + toSingleLine, +} from './utils/index.js'; import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js'; import { isCompletionMessage, @@ -503,19 +509,6 @@ function formatRemainingSeconds(ms: number): number { return Math.max(0, Math.ceil(ms / 1000)); } -/** - * A respawn prompt as the one line it is sent as: every run of line breaks, with the - * whitespace around it, becomes a single space; a single-line prompt is returned as - * is. The config may hold several lines (the settings fields are textareas), but the - * steps run unattended through `writeViaMux`, and the check that re-presses a stranded - * Enter cannot recognise a multi-line paste in the composer. Cron rejects multi-line - * prompts for the same reason; respawn joins them instead, so a saved multi-line - * config is still sent rather than refused. - */ -export function singleLineRespawnPrompt(prompt: string): string { - return prompt.replace(/\s*[\r\n]+\s*/g, ' '); -} - /** Default configuration values */ const DEFAULT_CONFIG: RespawnConfig = { idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback) @@ -1634,7 +1627,7 @@ export class RespawnController extends EventEmitter { this.config.interStepDelayMs, async () => { if (this._state === 'stopped') return; - const prompt = singleLineRespawnPrompt(this.config.kickstartPrompt!); + const prompt = toSingleLine(this.config.kickstartPrompt!); this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`); await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI // COD-51: stop() may have run during the await; re-check before reviving the @@ -2839,11 +2832,11 @@ export class RespawnController extends EventEmitter { // Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config const statusBlock = this.session.ralphTracker?.lastStatusBlock; - let updatePrompt = singleLineRespawnPrompt(this.config.updatePrompt); + let updatePrompt = toSingleLine(this.config.updatePrompt); if (statusBlock?.recommendation) { // Append RECOMMENDATION to the update prompt, on the same single line - // (see singleLineRespawnPrompt) + // (see toSingleLine) const rec = statusBlock.recommendation.replace(/\n/g, ' ').substring(0, 200); updatePrompt = `${updatePrompt} (Claude's last recommendation: ${rec})`; this.logAction('ralph', `Using RECOMMENDATION: ${rec.substring(0, 50)}...`); diff --git a/src/session-auto-ops.ts b/src/session-auto-ops.ts index 28f061c7..74c3ee47 100644 --- a/src/session-auto-ops.ts +++ b/src/session-auto-ops.ts @@ -20,6 +20,7 @@ import { EventEmitter } from 'node:events'; import { detectUsageLimitPause } from './usage-limit-patterns.js'; +import { toSingleLine } from './utils/single-line.js'; // ============================================================================ // Timing Constants @@ -408,7 +409,11 @@ export class SessionAutoOps extends EventEmitter { ); const action = async () => { - const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r'; + // One line: this fires unattended, and a multi-line focus prompt would go out + // as a multi-line paste (see toSingleLine). + const compactCmd = this._autoCompactPrompt + ? `/compact ${toSingleLine(this._autoCompactPrompt)}\r` + : '/compact\r'; await this.callbacks.writeCommand(compactCmd); this.emit('autoCompact', { tokens: totalTokens, diff --git a/src/utils/index.ts b/src/utils/index.ts index 95fab94e..717cb1dc 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -27,6 +27,7 @@ export { MAX_SESSION_TOKENS } from './token-validation.js'; export { isSafePushEndpoint } from './push-endpoint-validation.js'; export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js'; export { assertNever } from './type-safety.js'; +export { toSingleLine } from './single-line.js'; export { wrapWithNice } from './nice-wrapper.js'; export { resolveLocalShell, loginShellArgs } from './shell-resolver.js'; export { diff --git a/src/utils/single-line.ts b/src/utils/single-line.ts new file mode 100644 index 00000000..21cbc3db --- /dev/null +++ b/src/utils/single-line.ts @@ -0,0 +1,20 @@ +/** + * @fileoverview Collapse a prompt onto the one line an unattended sender writes. + * + * @module utils/single-line + */ + +/** + * A prompt as one line: every run of line breaks, with the whitespace around it, + * becomes a single space; a single-line prompt is returned as is. + * + * For the senders that run unattended through `writeViaMux` (respawn's update and + * kickstart steps, auto-compact). The mux pastes a multi-line prompt as one block, + * and the check that re-presses a stranded Enter cannot recognise a multi-line paste + * in the composer, so nobody would notice a prompt left unsubmitted. Cron rejects + * multi-line prompts for the same reason; these senders join them instead, so a + * saved multi-line setting is still sent rather than refused. + */ +export function toSingleLine(prompt: string): string { + return prompt.replace(/\s*[\r\n]+\s*/g, ' '); +} diff --git a/test/respawn-single-line-prompt.test.ts b/test/unattended-single-line-prompt.test.ts similarity index 53% rename from test/respawn-single-line-prompt.test.ts rename to test/unattended-single-line-prompt.test.ts index f976e2d5..2ea1fb80 100644 --- a/test/respawn-single-line-prompt.test.ts +++ b/test/unattended-single-line-prompt.test.ts @@ -1,15 +1,17 @@ /** - * Respawn prompts go out as ONE line, whatever the config holds. + * The senders that run unattended write a prompt as ONE line, whatever the config holds. * - * The update and kickstart prompts are textareas in the settings, 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. Respawn runs + * 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 recognise a - * multi-line paste in the composer, so the controller joins the lines with a space - * before the write instead of leaning on the mux to drop them. + * 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 step methods directly against a MockSession and read what - * reached `writeViaMux`. No real tmux. + * 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) */ @@ -20,19 +22,20 @@ vi.mock('node:child_process', async (orig) => { return { ...actual, exec: vi.fn((_cmd: string, cb?: (e: Error | null, o: string) => void) => cb?.(null, '')) }; }); -import { RespawnController, singleLineRespawnPrompt } from '../src/respawn-controller.js'; +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('singleLineRespawnPrompt', () => { +describe('toSingleLine', () => { it('joins lines with one space, folding the whitespace around each break', () => { - expect(singleLineRespawnPrompt('update the docs\n\n then CLAUDE.md\r\nand continue')).toBe( + 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(singleLineRespawnPrompt(' keep "quotes" and $vars ')).toBe(' keep "quotes" and $vars '); + expect(toSingleLine(' keep "quotes" and $vars ')).toBe(' keep "quotes" and $vars '); }); }); @@ -91,3 +94,37 @@ describe('respawn steps send a multi-line prompt as one line', () => { 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 { + 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']); + }); +});