From 49588dd1a6ad3652ac22d10d3b45dea3b90bf31c Mon Sep 17 00:00:00 2001 From: M Waleed Kadous Date: Sat, 4 Apr 2026 22:51:57 -0700 Subject: [PATCH 1/4] [Bugfix #584] Fix: Pace multi-line message writes to prevent paste detection When afx send delivers messages >3 lines, write line-by-line with 10ms delays instead of a single write() call. This prevents the receiving terminal from classifying the input as a paste and swallowing the final Enter. --- .../src/agent-farm/servers/tower-routes.ts | 63 +++++++++++++++---- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/packages/codev/src/agent-farm/servers/tower-routes.ts b/packages/codev/src/agent-farm/servers/tower-routes.ts index 2f0c82fa..318311b0 100644 --- a/packages/codev/src/agent-farm/servers/tower-routes.ts +++ b/packages/codev/src/agent-farm/servers/tower-routes.ts @@ -86,13 +86,56 @@ const overviewCache = new OverviewCache(); // Singleton send buffer for typing-aware message delivery (Spec 403) const sendBuffer = new SendBuffer(); +// Bugfix #584: Multi-line message pacing constants. +// 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 + 50ms delayed Enter. + * Long messages (>3 lines): line-by-line writes with 10ms gaps, then Enter + * after all lines are delivered. + */ +export function writeMessageToSession(session: PtySession, message: string, noEnter: boolean): void { + const lines = message.split('\n'); + + if (lines.length < PACED_WRITE_LINE_THRESHOLD) { + // Short messages: single write (existing behavior, works fine) + session.write(message); + if (!noEnter) { + setTimeout(() => session.write('\r'), SIMPLE_ENTER_DELAY_MS); + } + return; + } + + // 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]; + if (i === 0) { + session.write(text); + } else { + setTimeout(() => session.write(text), i * INTER_LINE_DELAY_MS); + } + } + + if (!noEnter) { + const totalPacingMs = (lines.length - 1) * INTER_LINE_DELAY_MS; + setTimeout(() => session.write('\r'), totalPacingMs + PACED_ENTER_DELAY_MS); + } +} + /** 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); - } + writeMessageToSession(session, msg.formattedMessage, msg.noEnter); broadcastMessage(msg.broadcastPayload as Parameters[0]); } @@ -917,14 +960,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); } From 731c741097935fd85d190c2f474b2554fff75db1 Mon Sep 17 00:00:00 2001 From: M Waleed Kadous Date: Sat, 4 Apr 2026 22:52:00 -0700 Subject: [PATCH 2/4] [Bugfix #584] Test: Add regression test for multi-line message pacing --- .../bugfix-584-send-multiline-pacing.test.ts | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 packages/codev/src/agent-farm/__tests__/bugfix-584-send-multiline-pacing.test.ts 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..17bb30db --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/bugfix-584-send-multiline-pacing.test.ts @@ -0,0 +1,116 @@ +/** + * 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. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { writeMessageToSession } from '../servers/tower-routes.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'; + + 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']); + }); + + it('paces multi-line messages (>3 lines) line-by-line with delays', () => { + const session = makeSession(); + const msg = 'line1\nline2\nline3\nline4'; + + 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 = at 110ms from start + // We're at 30ms now, so advance 80ms more + vi.advanceTimersByTime(80); + expect(session.writeCalls).toEqual(['line1\n', 'line2\n', 'line3\n', 'line4', '\r']); + }); + + it('respects noEnter=true for short messages', () => { + const session = makeSession(); + writeMessageToSession(session, 'short', true); + + vi.advanceTimersByTime(200); + expect(session.writeCalls).toEqual(['short']); + }); + + it('respects noEnter=true for multi-line messages', () => { + const session = makeSession(); + const msg = 'l1\nl2\nl3\nl4\nl5'; + + 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']); + }); + + 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###############################'; + + 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'); + }); + + it('single-line message written in one shot without pacing', () => { + const session = makeSession(); + writeMessageToSession(session, 'hello', false); + + expect(session.writeCalls).toEqual(['hello']); + vi.advanceTimersByTime(50); + expect(session.writeCalls).toEqual(['hello', '\r']); + }); +}); From 36556338355bbdf51d75382332d4e5ca1696401d Mon Sep 17 00:00:00 2001 From: M Waleed Kadous Date: Sat, 4 Apr 2026 22:58:00 -0700 Subject: [PATCH 3/4] [Bugfix #584] Fix: Serialize paced writes to prevent interleaving When multiple multi-line messages are buffered for the same session, writeMessageToSession now accepts a delayOffset and returns the end time so SendBuffer.flush() can chain messages without interleaving. --- .../bugfix-584-send-multiline-pacing.test.ts | 91 +++++++++++++++++-- .../agent-farm/__tests__/send-buffer.test.ts | 21 +++-- .../src/agent-farm/servers/send-buffer.ts | 10 +- .../src/agent-farm/servers/tower-routes.ts | 41 ++++++--- 4 files changed, 129 insertions(+), 34 deletions(-) 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 index 17bb30db..0b3f5f37 100644 --- 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 @@ -4,7 +4,8 @@ * * 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. + * 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'; @@ -32,7 +33,7 @@ describe('writeMessageToSession (Bugfix #584)', () => { const session = makeSession(); const msg = 'line1\nline2\nline3'; - writeMessageToSession(session, msg, false); + const endTime = writeMessageToSession(session, msg, false); // Message written in one shot expect(session.writeCalls).toEqual([msg]); @@ -40,13 +41,14 @@ describe('writeMessageToSession (Bugfix #584)', () => { // 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'; - writeMessageToSession(session, msg, false); + const endTime = writeMessageToSession(session, msg, false); // First line written immediately expect(session.writeCalls).toEqual(['line1\n']); @@ -61,29 +63,31 @@ describe('writeMessageToSession (Bugfix #584)', () => { vi.advanceTimersByTime(10); expect(session.writeCalls).toEqual(['line1\n', 'line2\n', 'line3\n', 'line4']); - // Enter arrives after totalPacing (30ms) + 80ms = at 110ms from start - // We're at 30ms now, so advance 80ms more + // 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(); - writeMessageToSession(session, 'short', true); + 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'; - writeMessageToSession(session, msg, true); + 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)', () => { @@ -91,7 +95,7 @@ describe('writeMessageToSession (Bugfix #584)', () => { // 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###############################'; - writeMessageToSession(session, msg, false); + const endTime = writeMessageToSession(session, msg, false); // First line immediately expect(session.writeCalls[0]).toBe('### [ARCHITECT INSTRUCTION | 2026-04-04T00:00:00.000Z] ###\n'); @@ -103,14 +107,83 @@ describe('writeMessageToSession (Bugfix #584)', () => { // 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(); - writeMessageToSession(session, 'hello', false); + 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/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-routes.ts b/packages/codev/src/agent-farm/servers/tower-routes.ts index 318311b0..d7822e67 100644 --- a/packages/codev/src/agent-farm/servers/tower-routes.ts +++ b/packages/codev/src/agent-farm/servers/tower-routes.ts @@ -99,20 +99,31 @@ 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 + 50ms delayed Enter. + * 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: PtySession, message: string, noEnter: boolean): void { +export function writeMessageToSession( + session: PtySession, 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) - session.write(message); + 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'), SIMPLE_ENTER_DELAY_MS); + setTimeout(() => session.write('\r'), enterTime); } - return; + return enterTime; } // Multi-line: pace output line-by-line to avoid paste detection. @@ -120,23 +131,29 @@ export function writeMessageToSession(session: PtySession, message: string, noEn // 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]; - if (i === 0) { + const lineDelay = delayOffset + i * INTER_LINE_DELAY_MS; + if (lineDelay === 0) { session.write(text); } else { - setTimeout(() => session.write(text), i * INTER_LINE_DELAY_MS); + setTimeout(() => session.write(text), lineDelay); } } + const lastLineTime = delayOffset + (lines.length - 1) * INTER_LINE_DELAY_MS; if (!noEnter) { - const totalPacingMs = (lines.length - 1) * INTER_LINE_DELAY_MS; - setTimeout(() => session.write('\r'), totalPacingMs + PACED_ENTER_DELAY_MS); + const enterTime = lastLineTime + PACED_ENTER_DELAY_MS; + setTimeout(() => session.write('\r'), enterTime); + return enterTime; } + return lastLineTime; } -/** Deliver a buffered message to a session (write + broadcast + log). */ -function deliverBufferedMessage(session: PtySession, msg: BufferedMessage): void { - writeMessageToSession(session, msg.formattedMessage, msg.noEnter); +/** 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). */ From 9868345b3849c7e3f706550a23b8d9b18549f97c Mon Sep 17 00:00:00 2001 From: M Waleed Kadous Date: Sat, 4 Apr 2026 23:00:12 -0700 Subject: [PATCH 4/4] [Bugfix #584] Refactor: Extract writeMessageToSession to shared module Move pacing logic to message-write.ts to avoid circular dependency between tower-routes.ts and tower-cron.ts. Also fix tower-cron.ts cron message delivery to use the same pacing. --- .../bugfix-584-send-multiline-pacing.test.ts | 2 +- .../src/agent-farm/servers/message-write.ts | 72 +++++++++++++++++++ .../src/agent-farm/servers/tower-cron.ts | 7 +- .../src/agent-farm/servers/tower-routes.ts | 63 +--------------- 4 files changed, 77 insertions(+), 67 deletions(-) create mode 100644 packages/codev/src/agent-farm/servers/message-write.ts 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 index 0b3f5f37..aa37942b 100644 --- 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 @@ -9,7 +9,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { writeMessageToSession } from '../servers/tower-routes.js'; +import { writeMessageToSession } from '../servers/message-write.js'; import type { PtySession } from '../../terminal/pty-session.js'; function makeSession(): PtySession & { writeCalls: string[] } { 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/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 d7822e67..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,68 +87,6 @@ const overviewCache = new OverviewCache(); // Singleton send buffer for typing-aware message delivery (Spec 403) const sendBuffer = new SendBuffer(); -// Bugfix #584: Multi-line message pacing constants. -// 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: PtySession, 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; -} - /** 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 {