diff --git a/packages/codev/src/agent-farm/__tests__/bugfix-584-send-multiline-pacing.test.ts b/packages/codev/src/agent-farm/__tests__/bugfix-584-send-multiline-pacing.test.ts new file mode 100644 index 00000000..aa37942b --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/bugfix-584-send-multiline-pacing.test.ts @@ -0,0 +1,189 @@ +/** + * Regression test for Bugfix #584: afx send multi-line messages (>3 lines) + * treated as paste, final Enter swallowed. + * + * Verifies that writeMessageToSession paces multi-line output line-by-line + * with delays to prevent paste detection, while short messages are still + * written in a single call. Also tests delayOffset serialization to prevent + * interleaved writes when multiple messages flush to the same session. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { writeMessageToSession } from '../servers/message-write.js'; +import type { PtySession } from '../../terminal/pty-session.js'; + +function makeSession(): PtySession & { writeCalls: string[] } { + const writeCalls: string[] = []; + return { + write: vi.fn((data: string) => writeCalls.push(data)), + writeCalls, + } as unknown as PtySession & { writeCalls: string[] }; +} + +describe('writeMessageToSession (Bugfix #584)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('writes short messages (≤3 lines) in a single call', () => { + const session = makeSession(); + const msg = 'line1\nline2\nline3'; + + const endTime = writeMessageToSession(session, msg, false); + + // Message written in one shot + expect(session.writeCalls).toEqual([msg]); + + // Enter arrives after 50ms + vi.advanceTimersByTime(50); + expect(session.writeCalls).toEqual([msg, '\r']); + expect(endTime).toBe(50); + }); + + it('paces multi-line messages (>3 lines) line-by-line with delays', () => { + const session = makeSession(); + const msg = 'line1\nline2\nline3\nline4'; + + const endTime = writeMessageToSession(session, msg, false); + + // First line written immediately + expect(session.writeCalls).toEqual(['line1\n']); + + // Lines 2-4 arrive with 10ms, 20ms, 30ms delays + vi.advanceTimersByTime(10); + expect(session.writeCalls).toEqual(['line1\n', 'line2\n']); + + vi.advanceTimersByTime(10); + expect(session.writeCalls).toEqual(['line1\n', 'line2\n', 'line3\n']); + + vi.advanceTimersByTime(10); + expect(session.writeCalls).toEqual(['line1\n', 'line2\n', 'line3\n', 'line4']); + + // Enter arrives after totalPacing (30ms) + 80ms = 110ms from start + vi.advanceTimersByTime(80); + expect(session.writeCalls).toEqual(['line1\n', 'line2\n', 'line3\n', 'line4', '\r']); + expect(endTime).toBe(110); + }); + + it('respects noEnter=true for short messages', () => { + const session = makeSession(); + const endTime = writeMessageToSession(session, 'short', true); + + vi.advanceTimersByTime(200); + expect(session.writeCalls).toEqual(['short']); + expect(endTime).toBe(50); // duration still reported + }); + + it('respects noEnter=true for multi-line messages', () => { + const session = makeSession(); + const msg = 'l1\nl2\nl3\nl4\nl5'; + + const endTime = writeMessageToSession(session, msg, true); + vi.advanceTimersByTime(500); + + // All lines written, but no \r + expect(session.writeCalls).toEqual(['l1\n', 'l2\n', 'l3\n', 'l4\n', 'l5']); + expect(endTime).toBe(40); // (5-1) * 10 = 40ms for last line + }); + + it('handles formatted architect message (realistic multi-line)', () => { + const session = makeSession(); + // Realistic formatted message: header + 2 content lines + footer = 4 lines + const msg = '### [ARCHITECT INSTRUCTION | 2026-04-04T00:00:00.000Z] ###\nDo this thing\nAnd that thing\n###############################'; + + const endTime = writeMessageToSession(session, msg, false); + + // First line immediately + expect(session.writeCalls[0]).toBe('### [ARCHITECT INSTRUCTION | 2026-04-04T00:00:00.000Z] ###\n'); + + // All lines delivered after enough time + vi.advanceTimersByTime(30); + expect(session.writeCalls).toHaveLength(4); + + // Enter delivered after pacing + 80ms + vi.advanceTimersByTime(80); + expect(session.writeCalls[session.writeCalls.length - 1]).toBe('\r'); + expect(endTime).toBe(110); // 30ms pacing + 80ms enter + }); + + it('single-line message written in one shot without pacing', () => { + const session = makeSession(); + const endTime = writeMessageToSession(session, 'hello', false); + + expect(session.writeCalls).toEqual(['hello']); + vi.advanceTimersByTime(50); + expect(session.writeCalls).toEqual(['hello', '\r']); + expect(endTime).toBe(50); + }); + + describe('delayOffset serialization (prevents interleaving)', () => { + it('short message with delayOffset defers the initial write', () => { + const session = makeSession(); + const endTime = writeMessageToSession(session, 'hello', false, 100); + + // Nothing written yet + expect(session.writeCalls).toEqual([]); + + // Message arrives at offset + vi.advanceTimersByTime(100); + expect(session.writeCalls).toEqual(['hello']); + + // Enter arrives at offset + 50ms + vi.advanceTimersByTime(50); + expect(session.writeCalls).toEqual(['hello', '\r']); + expect(endTime).toBe(150); + }); + + it('multi-line message with delayOffset defers all lines', () => { + const session = makeSession(); + const msg = 'a\nb\nc\nd'; + const endTime = writeMessageToSession(session, msg, false, 200); + + // Nothing written before offset + expect(session.writeCalls).toEqual([]); + + // First line at 200ms + vi.advanceTimersByTime(200); + expect(session.writeCalls).toEqual(['a\n']); + + // Remaining lines at 210, 220, 230ms + vi.advanceTimersByTime(30); + expect(session.writeCalls).toEqual(['a\n', 'b\n', 'c\n', 'd']); + + // Enter at 230 + 80 = 310ms from start + vi.advanceTimersByTime(80); + expect(session.writeCalls).toEqual(['a\n', 'b\n', 'c\n', 'd', '\r']); + expect(endTime).toBe(310); + }); + + it('two multi-line messages in sequence do not interleave', () => { + const session = makeSession(); + const msg1 = 'A1\nA2\nA3\nA4'; + const msg2 = 'B1\nB2\nB3\nB4'; + + // Simulate what SendBuffer.flush does: chain offsets + const end1 = writeMessageToSession(session, msg1, false, 0); + const end2 = writeMessageToSession(session, msg2, false, end1); + + // Advance through all timers + vi.advanceTimersByTime(end2 + 100); + + // Verify message 1 lines come before message 2 lines + const writes = session.writeCalls; + const a4Idx = writes.indexOf('A4'); + const enterAfterA = writes.indexOf('\r'); + const b1Idx = writes.indexOf('B1\n'); + + expect(a4Idx).toBeLessThan(enterAfterA); + expect(enterAfterA).toBeLessThan(b1Idx); + + // Both messages fully delivered with their own Enters + const enterCount = writes.filter(w => w === '\r').length; + expect(enterCount).toBe(2); + }); + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/send-buffer.test.ts b/packages/codev/src/agent-farm/__tests__/send-buffer.test.ts index b74697b4..a313547d 100644 --- a/packages/codev/src/agent-farm/__tests__/send-buffer.test.ts +++ b/packages/codev/src/agent-farm/__tests__/send-buffer.test.ts @@ -59,7 +59,7 @@ describe('SendBuffer', () => { it('delivers messages when session is idle', () => { const session = makeSession(true); - const deliver = vi.fn(); + const deliver = vi.fn().mockReturnValue(0); const log = vi.fn(); buf.start(() => session, deliver, log); @@ -76,7 +76,7 @@ describe('SendBuffer', () => { it('does NOT deliver messages when session is actively typing', () => { const session = makeSession(false); // not idle - const deliver = vi.fn(); + const deliver = vi.fn().mockReturnValue(0); const log = vi.fn(); buf.start(() => session, deliver, log); @@ -90,7 +90,7 @@ describe('SendBuffer', () => { it('delivers when max buffer age is exceeded even if user is typing', () => { const session = makeSession(false); // not idle - const deliver = vi.fn(); + const deliver = vi.fn().mockReturnValue(0); const log = vi.fn(); buf.start(() => session, deliver, log); @@ -109,8 +109,9 @@ describe('SendBuffer', () => { it('delivers all messages in order within a session', () => { const session = makeSession(true); const deliveredMsgs: string[] = []; - const deliver = (_s: PtySession, msg: BufferedMessage) => { + const deliver = (_s: PtySession, msg: BufferedMessage): number => { deliveredMsgs.push(msg.formattedMessage); + return 0; }; const log = vi.fn(); @@ -126,7 +127,7 @@ describe('SendBuffer', () => { }); it('discards messages for dead sessions with warning', () => { - const deliver = vi.fn(); + const deliver = vi.fn().mockReturnValue(0); const log = vi.fn(); buf.start(() => undefined, deliver, log); // session gone @@ -141,7 +142,7 @@ describe('SendBuffer', () => { it('stop() delivers all remaining messages (force flush)', () => { const session = makeSession(false); // not idle — normally wouldn't deliver - const deliver = vi.fn(); + const deliver = vi.fn().mockReturnValue(0); const log = vi.fn(); buf.start(() => session, deliver, log); @@ -158,7 +159,7 @@ describe('SendBuffer', () => { it('handles multiple sessions independently', () => { const idleSession = makeSession(true); const typingSession = makeSession(false); - const deliver = vi.fn(); + const deliver = vi.fn().mockReturnValue(0); const log = vi.fn(); buf.start( @@ -196,7 +197,7 @@ describe('SendBuffer', () => { // Bugfix #492: composing gets stuck true after non-Enter keystrokes (Ctrl+C, // arrows, Tab). Idle threshold alone is sufficient for delivery. const session = makeSession(true, true); // idle=true, composing=true - const deliver = vi.fn(); + const deliver = vi.fn().mockReturnValue(0); const log = vi.fn(); buf.start(() => session, deliver, log); @@ -210,7 +211,7 @@ describe('SendBuffer', () => { it('delivers when session is idle and NOT composing', () => { const session = makeSession(true, false); // idle=true, composing=false - const deliver = vi.fn(); + const deliver = vi.fn().mockReturnValue(0); const log = vi.fn(); buf.start(() => session, deliver, log); @@ -224,7 +225,7 @@ describe('SendBuffer', () => { it('delivers when composing but max buffer age exceeded', () => { const session = makeSession(false, true); // not idle, composing - const deliver = vi.fn(); + const deliver = vi.fn().mockReturnValue(0); const log = vi.fn(); buf.start(() => session, deliver, log); diff --git a/packages/codev/src/agent-farm/servers/message-write.ts b/packages/codev/src/agent-farm/servers/message-write.ts new file mode 100644 index 00000000..365e0acc --- /dev/null +++ b/packages/codev/src/agent-farm/servers/message-write.ts @@ -0,0 +1,72 @@ +/** + * Paced message writing for PTY sessions (Bugfix #584). + * + * Extracted to a shared module to avoid circular imports between + * tower-routes.ts and tower-cron.ts. + */ + +/** Minimal writable session interface — avoids coupling to PtySession. */ +export interface WritableSession { + write(data: string): void; +} + +// Messages longer than this threshold are written line-by-line with delays +// to prevent the receiving terminal from classifying the input as a paste +// and swallowing the final Enter. +const PACED_WRITE_LINE_THRESHOLD = 4; +const INTER_LINE_DELAY_MS = 10; +const PACED_ENTER_DELAY_MS = 80; +const SIMPLE_ENTER_DELAY_MS = 50; + +/** + * Write a message to a PTY session, pacing multi-line output to prevent + * the terminal from treating it as a paste (Bugfix #584). + * + * Short messages (≤3 lines): single write + delayed Enter. + * Long messages (>3 lines): line-by-line writes with 10ms gaps, then Enter + * after all lines are delivered. + * + * @param delayOffset ms offset for all scheduled writes (used to serialize + * multiple messages to the same session without interleaving) + * @returns ms timestamp (from call time) when all writes complete + */ +export function writeMessageToSession( + session: WritableSession, message: string, noEnter: boolean, delayOffset = 0, +): number { + const lines = message.split('\n'); + + if (lines.length < PACED_WRITE_LINE_THRESHOLD) { + // Short messages: single write (existing behavior, works fine) + if (delayOffset === 0) { + session.write(message); + } else { + setTimeout(() => session.write(message), delayOffset); + } + const enterTime = delayOffset + SIMPLE_ENTER_DELAY_MS; + if (!noEnter) { + setTimeout(() => session.write('\r'), enterTime); + } + return enterTime; + } + + // Multi-line: pace output line-by-line to avoid paste detection. + // Writing all lines in a single write() causes the terminal to treat it + // as a paste, swallowing the final Enter. + for (let i = 0; i < lines.length; i++) { + const text = i < lines.length - 1 ? lines[i] + '\n' : lines[i]; + const lineDelay = delayOffset + i * INTER_LINE_DELAY_MS; + if (lineDelay === 0) { + session.write(text); + } else { + setTimeout(() => session.write(text), lineDelay); + } + } + + const lastLineTime = delayOffset + (lines.length - 1) * INTER_LINE_DELAY_MS; + if (!noEnter) { + const enterTime = lastLineTime + PACED_ENTER_DELAY_MS; + setTimeout(() => session.write('\r'), enterTime); + return enterTime; + } + return lastLineTime; +} diff --git a/packages/codev/src/agent-farm/servers/send-buffer.ts b/packages/codev/src/agent-farm/servers/send-buffer.ts index b1737e37..90ba2ace 100644 --- a/packages/codev/src/agent-farm/servers/send-buffer.ts +++ b/packages/codev/src/agent-farm/servers/send-buffer.ts @@ -25,7 +25,8 @@ export interface BufferedMessage { } export type GetSessionFn = (id: string) => PtySession | undefined; -export type DeliverFn = (session: PtySession, msg: BufferedMessage) => void; +/** Deliver function returns ms timestamp when all writes complete (for serialization). */ +export type DeliverFn = (session: PtySession, msg: BufferedMessage, delayOffset?: number) => number; export type LogFn = (level: 'INFO' | 'ERROR' | 'WARN', message: string) => void; const DEFAULT_IDLE_THRESHOLD_MS = 3000; @@ -99,9 +100,12 @@ export class SendBuffer { // Bugfix #492: removed composing check — it gets stuck true after non-Enter // keystrokes (Ctrl+C, arrows, Tab), causing messages to wait 60s max age. if (forceAll || isIdle || maxAgeExceeded) { - // Deliver all messages in order + // Deliver all messages in order, serializing paced writes (Bugfix #584). + // Each delivery returns the ms when its writes complete; the next message + // starts after that to prevent interleaved lines. + let offset = 0; for (const msg of messages) { - this.deliver(session, msg); + offset = this.deliver(session, msg, offset); if (this.log && msg.logMessage) { this.log('INFO', msg.logMessage); } diff --git a/packages/codev/src/agent-farm/servers/tower-cron.ts b/packages/codev/src/agent-farm/servers/tower-cron.ts index 8aa36473..5ec01626 100644 --- a/packages/codev/src/agent-farm/servers/tower-cron.ts +++ b/packages/codev/src/agent-farm/servers/tower-cron.ts @@ -13,6 +13,7 @@ import { parseCronExpression, isDue } from './tower-cron-parser.js'; import type { CronSchedule } from './tower-cron-parser.js'; import { formatBuilderMessage } from '../utils/message-format.js'; import { broadcastMessage } from './tower-messages.js'; +import { writeMessageToSession } from './message-write.js'; import { getGlobalDb } from '../db/index.js'; // ============================================================================ @@ -318,10 +319,8 @@ function deliverMessage(task: CronTask, message: string): void { } const formatted = formatBuilderMessage('af-cron', message); - // Write message, then Enter separately after delay so PTY processes the - // multi-line paste before receiving the submission keystroke (Bugfix #492) - session.write(formatted); - setTimeout(() => session.write('\r'), 50); + // Bugfix #584: pace multi-line output to avoid paste detection. + writeMessageToSession(session, formatted, false); broadcastMessage({ type: 'message', diff --git a/packages/codev/src/agent-farm/servers/tower-routes.ts b/packages/codev/src/agent-farm/servers/tower-routes.ts index 2f0c82fa..ffa556b0 100644 --- a/packages/codev/src/agent-farm/servers/tower-routes.ts +++ b/packages/codev/src/agent-farm/servers/tower-routes.ts @@ -45,6 +45,7 @@ import { formatArchitectMessage, formatBuilderMessage } from '../utils/message-f import { SendBuffer } from './send-buffer.js'; import type { BufferedMessage } from './send-buffer.js'; import type { PtySession } from '../../terminal/pty-session.js'; +import { writeMessageToSession } from './message-write.js'; import { getKnownWorkspacePaths, getInstances, @@ -86,14 +87,12 @@ const overviewCache = new OverviewCache(); // Singleton send buffer for typing-aware message delivery (Spec 403) const sendBuffer = new SendBuffer(); -/** Deliver a buffered message to a session (write + broadcast + log). */ -function deliverBufferedMessage(session: PtySession, msg: BufferedMessage): void { - // Write message, then Enter after delay — see handleSend for rationale (Bugfix #492) - session.write(msg.formattedMessage); - if (!msg.noEnter) { - setTimeout(() => session.write('\r'), 50); - } +/** Deliver a buffered message to a session (write + broadcast + log). + * Returns the ms timestamp when all writes complete (for serialization). */ +function deliverBufferedMessage(session: PtySession, msg: BufferedMessage, delayOffset = 0): number { + const endTime = writeMessageToSession(session, msg.formattedMessage, msg.noEnter, delayOffset); broadcastMessage(msg.broadcastPayload as Parameters[0]); + return endTime; } /** Start the send buffer flush timer (called from tower-server during init). */ @@ -917,14 +916,8 @@ async function handleSend( ctx.log('INFO', `Message deferred (user typing): ${from ?? 'unknown'} → ${result.agent} (terminal ${result.terminalId.slice(0, 8)}...)`); } else { // User is idle (or interrupt) — deliver immediately. - // Write message first, then Enter separately after a short delay. - // Multi-line formatted messages contain embedded \n which the PTY processes - // as line breaks. A trailing \r in the same write submits an empty line after - // the footer, not the message. Delayed \r lets the PTY process the paste first. - session.write(formattedMessage); - if (!noEnter) { - setTimeout(() => session.write('\r'), 50); - } + // Bugfix #584: paces multi-line output to avoid paste detection. + writeMessageToSession(session, formattedMessage, noEnter); broadcastMessage(broadcastPayload); ctx.log('INFO', logMessage); }