Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
21 changes: 11 additions & 10 deletions packages/codev/src/agent-farm/__tests__/send-buffer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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();

Expand All @@ -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
Expand All @@ -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);
Expand All @@ -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(
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down
72 changes: 72 additions & 0 deletions packages/codev/src/agent-farm/servers/message-write.ts
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 7 additions & 3 deletions packages/codev/src/agent-farm/servers/send-buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
Loading
Loading