Skip to content

Commit b40357d

Browse files
authored
Merge pull request #657 from cluesmith/builder/bugfix-584-af-send-multi-line-messages-3-
[Bugfix #584] Pace multi-line afx send messages to prevent paste detection
2 parents bbfa70f + 9868345 commit b40357d

6 files changed

Lines changed: 290 additions & 32 deletions

File tree

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/**
2+
* Regression test for Bugfix #584: afx send multi-line messages (>3 lines)
3+
* treated as paste, final Enter swallowed.
4+
*
5+
* Verifies that writeMessageToSession paces multi-line output line-by-line
6+
* with delays to prevent paste detection, while short messages are still
7+
* written in a single call. Also tests delayOffset serialization to prevent
8+
* interleaved writes when multiple messages flush to the same session.
9+
*/
10+
11+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
12+
import { writeMessageToSession } from '../servers/message-write.js';
13+
import type { PtySession } from '../../terminal/pty-session.js';
14+
15+
function makeSession(): PtySession & { writeCalls: string[] } {
16+
const writeCalls: string[] = [];
17+
return {
18+
write: vi.fn((data: string) => writeCalls.push(data)),
19+
writeCalls,
20+
} as unknown as PtySession & { writeCalls: string[] };
21+
}
22+
23+
describe('writeMessageToSession (Bugfix #584)', () => {
24+
beforeEach(() => {
25+
vi.useFakeTimers();
26+
});
27+
28+
afterEach(() => {
29+
vi.useRealTimers();
30+
});
31+
32+
it('writes short messages (≤3 lines) in a single call', () => {
33+
const session = makeSession();
34+
const msg = 'line1\nline2\nline3';
35+
36+
const endTime = writeMessageToSession(session, msg, false);
37+
38+
// Message written in one shot
39+
expect(session.writeCalls).toEqual([msg]);
40+
41+
// Enter arrives after 50ms
42+
vi.advanceTimersByTime(50);
43+
expect(session.writeCalls).toEqual([msg, '\r']);
44+
expect(endTime).toBe(50);
45+
});
46+
47+
it('paces multi-line messages (>3 lines) line-by-line with delays', () => {
48+
const session = makeSession();
49+
const msg = 'line1\nline2\nline3\nline4';
50+
51+
const endTime = writeMessageToSession(session, msg, false);
52+
53+
// First line written immediately
54+
expect(session.writeCalls).toEqual(['line1\n']);
55+
56+
// Lines 2-4 arrive with 10ms, 20ms, 30ms delays
57+
vi.advanceTimersByTime(10);
58+
expect(session.writeCalls).toEqual(['line1\n', 'line2\n']);
59+
60+
vi.advanceTimersByTime(10);
61+
expect(session.writeCalls).toEqual(['line1\n', 'line2\n', 'line3\n']);
62+
63+
vi.advanceTimersByTime(10);
64+
expect(session.writeCalls).toEqual(['line1\n', 'line2\n', 'line3\n', 'line4']);
65+
66+
// Enter arrives after totalPacing (30ms) + 80ms = 110ms from start
67+
vi.advanceTimersByTime(80);
68+
expect(session.writeCalls).toEqual(['line1\n', 'line2\n', 'line3\n', 'line4', '\r']);
69+
expect(endTime).toBe(110);
70+
});
71+
72+
it('respects noEnter=true for short messages', () => {
73+
const session = makeSession();
74+
const endTime = writeMessageToSession(session, 'short', true);
75+
76+
vi.advanceTimersByTime(200);
77+
expect(session.writeCalls).toEqual(['short']);
78+
expect(endTime).toBe(50); // duration still reported
79+
});
80+
81+
it('respects noEnter=true for multi-line messages', () => {
82+
const session = makeSession();
83+
const msg = 'l1\nl2\nl3\nl4\nl5';
84+
85+
const endTime = writeMessageToSession(session, msg, true);
86+
vi.advanceTimersByTime(500);
87+
88+
// All lines written, but no \r
89+
expect(session.writeCalls).toEqual(['l1\n', 'l2\n', 'l3\n', 'l4\n', 'l5']);
90+
expect(endTime).toBe(40); // (5-1) * 10 = 40ms for last line
91+
});
92+
93+
it('handles formatted architect message (realistic multi-line)', () => {
94+
const session = makeSession();
95+
// Realistic formatted message: header + 2 content lines + footer = 4 lines
96+
const msg = '### [ARCHITECT INSTRUCTION | 2026-04-04T00:00:00.000Z] ###\nDo this thing\nAnd that thing\n###############################';
97+
98+
const endTime = writeMessageToSession(session, msg, false);
99+
100+
// First line immediately
101+
expect(session.writeCalls[0]).toBe('### [ARCHITECT INSTRUCTION | 2026-04-04T00:00:00.000Z] ###\n');
102+
103+
// All lines delivered after enough time
104+
vi.advanceTimersByTime(30);
105+
expect(session.writeCalls).toHaveLength(4);
106+
107+
// Enter delivered after pacing + 80ms
108+
vi.advanceTimersByTime(80);
109+
expect(session.writeCalls[session.writeCalls.length - 1]).toBe('\r');
110+
expect(endTime).toBe(110); // 30ms pacing + 80ms enter
111+
});
112+
113+
it('single-line message written in one shot without pacing', () => {
114+
const session = makeSession();
115+
const endTime = writeMessageToSession(session, 'hello', false);
116+
117+
expect(session.writeCalls).toEqual(['hello']);
118+
vi.advanceTimersByTime(50);
119+
expect(session.writeCalls).toEqual(['hello', '\r']);
120+
expect(endTime).toBe(50);
121+
});
122+
123+
describe('delayOffset serialization (prevents interleaving)', () => {
124+
it('short message with delayOffset defers the initial write', () => {
125+
const session = makeSession();
126+
const endTime = writeMessageToSession(session, 'hello', false, 100);
127+
128+
// Nothing written yet
129+
expect(session.writeCalls).toEqual([]);
130+
131+
// Message arrives at offset
132+
vi.advanceTimersByTime(100);
133+
expect(session.writeCalls).toEqual(['hello']);
134+
135+
// Enter arrives at offset + 50ms
136+
vi.advanceTimersByTime(50);
137+
expect(session.writeCalls).toEqual(['hello', '\r']);
138+
expect(endTime).toBe(150);
139+
});
140+
141+
it('multi-line message with delayOffset defers all lines', () => {
142+
const session = makeSession();
143+
const msg = 'a\nb\nc\nd';
144+
const endTime = writeMessageToSession(session, msg, false, 200);
145+
146+
// Nothing written before offset
147+
expect(session.writeCalls).toEqual([]);
148+
149+
// First line at 200ms
150+
vi.advanceTimersByTime(200);
151+
expect(session.writeCalls).toEqual(['a\n']);
152+
153+
// Remaining lines at 210, 220, 230ms
154+
vi.advanceTimersByTime(30);
155+
expect(session.writeCalls).toEqual(['a\n', 'b\n', 'c\n', 'd']);
156+
157+
// Enter at 230 + 80 = 310ms from start
158+
vi.advanceTimersByTime(80);
159+
expect(session.writeCalls).toEqual(['a\n', 'b\n', 'c\n', 'd', '\r']);
160+
expect(endTime).toBe(310);
161+
});
162+
163+
it('two multi-line messages in sequence do not interleave', () => {
164+
const session = makeSession();
165+
const msg1 = 'A1\nA2\nA3\nA4';
166+
const msg2 = 'B1\nB2\nB3\nB4';
167+
168+
// Simulate what SendBuffer.flush does: chain offsets
169+
const end1 = writeMessageToSession(session, msg1, false, 0);
170+
const end2 = writeMessageToSession(session, msg2, false, end1);
171+
172+
// Advance through all timers
173+
vi.advanceTimersByTime(end2 + 100);
174+
175+
// Verify message 1 lines come before message 2 lines
176+
const writes = session.writeCalls;
177+
const a4Idx = writes.indexOf('A4');
178+
const enterAfterA = writes.indexOf('\r');
179+
const b1Idx = writes.indexOf('B1\n');
180+
181+
expect(a4Idx).toBeLessThan(enterAfterA);
182+
expect(enterAfterA).toBeLessThan(b1Idx);
183+
184+
// Both messages fully delivered with their own Enters
185+
const enterCount = writes.filter(w => w === '\r').length;
186+
expect(enterCount).toBe(2);
187+
});
188+
});
189+
});

packages/codev/src/agent-farm/__tests__/send-buffer.test.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ describe('SendBuffer', () => {
5959

6060
it('delivers messages when session is idle', () => {
6161
const session = makeSession(true);
62-
const deliver = vi.fn();
62+
const deliver = vi.fn().mockReturnValue(0);
6363
const log = vi.fn();
6464

6565
buf.start(() => session, deliver, log);
@@ -76,7 +76,7 @@ describe('SendBuffer', () => {
7676

7777
it('does NOT deliver messages when session is actively typing', () => {
7878
const session = makeSession(false); // not idle
79-
const deliver = vi.fn();
79+
const deliver = vi.fn().mockReturnValue(0);
8080
const log = vi.fn();
8181

8282
buf.start(() => session, deliver, log);
@@ -90,7 +90,7 @@ describe('SendBuffer', () => {
9090

9191
it('delivers when max buffer age is exceeded even if user is typing', () => {
9292
const session = makeSession(false); // not idle
93-
const deliver = vi.fn();
93+
const deliver = vi.fn().mockReturnValue(0);
9494
const log = vi.fn();
9595

9696
buf.start(() => session, deliver, log);
@@ -109,8 +109,9 @@ describe('SendBuffer', () => {
109109
it('delivers all messages in order within a session', () => {
110110
const session = makeSession(true);
111111
const deliveredMsgs: string[] = [];
112-
const deliver = (_s: PtySession, msg: BufferedMessage) => {
112+
const deliver = (_s: PtySession, msg: BufferedMessage): number => {
113113
deliveredMsgs.push(msg.formattedMessage);
114+
return 0;
114115
};
115116
const log = vi.fn();
116117

@@ -126,7 +127,7 @@ describe('SendBuffer', () => {
126127
});
127128

128129
it('discards messages for dead sessions with warning', () => {
129-
const deliver = vi.fn();
130+
const deliver = vi.fn().mockReturnValue(0);
130131
const log = vi.fn();
131132

132133
buf.start(() => undefined, deliver, log); // session gone
@@ -141,7 +142,7 @@ describe('SendBuffer', () => {
141142

142143
it('stop() delivers all remaining messages (force flush)', () => {
143144
const session = makeSession(false); // not idle — normally wouldn't deliver
144-
const deliver = vi.fn();
145+
const deliver = vi.fn().mockReturnValue(0);
145146
const log = vi.fn();
146147

147148
buf.start(() => session, deliver, log);
@@ -158,7 +159,7 @@ describe('SendBuffer', () => {
158159
it('handles multiple sessions independently', () => {
159160
const idleSession = makeSession(true);
160161
const typingSession = makeSession(false);
161-
const deliver = vi.fn();
162+
const deliver = vi.fn().mockReturnValue(0);
162163
const log = vi.fn();
163164

164165
buf.start(
@@ -196,7 +197,7 @@ describe('SendBuffer', () => {
196197
// Bugfix #492: composing gets stuck true after non-Enter keystrokes (Ctrl+C,
197198
// arrows, Tab). Idle threshold alone is sufficient for delivery.
198199
const session = makeSession(true, true); // idle=true, composing=true
199-
const deliver = vi.fn();
200+
const deliver = vi.fn().mockReturnValue(0);
200201
const log = vi.fn();
201202

202203
buf.start(() => session, deliver, log);
@@ -210,7 +211,7 @@ describe('SendBuffer', () => {
210211

211212
it('delivers when session is idle and NOT composing', () => {
212213
const session = makeSession(true, false); // idle=true, composing=false
213-
const deliver = vi.fn();
214+
const deliver = vi.fn().mockReturnValue(0);
214215
const log = vi.fn();
215216

216217
buf.start(() => session, deliver, log);
@@ -224,7 +225,7 @@ describe('SendBuffer', () => {
224225

225226
it('delivers when composing but max buffer age exceeded', () => {
226227
const session = makeSession(false, true); // not idle, composing
227-
const deliver = vi.fn();
228+
const deliver = vi.fn().mockReturnValue(0);
228229
const log = vi.fn();
229230

230231
buf.start(() => session, deliver, log);
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Paced message writing for PTY sessions (Bugfix #584).
3+
*
4+
* Extracted to a shared module to avoid circular imports between
5+
* tower-routes.ts and tower-cron.ts.
6+
*/
7+
8+
/** Minimal writable session interface — avoids coupling to PtySession. */
9+
export interface WritableSession {
10+
write(data: string): void;
11+
}
12+
13+
// Messages longer than this threshold are written line-by-line with delays
14+
// to prevent the receiving terminal from classifying the input as a paste
15+
// and swallowing the final Enter.
16+
const PACED_WRITE_LINE_THRESHOLD = 4;
17+
const INTER_LINE_DELAY_MS = 10;
18+
const PACED_ENTER_DELAY_MS = 80;
19+
const SIMPLE_ENTER_DELAY_MS = 50;
20+
21+
/**
22+
* Write a message to a PTY session, pacing multi-line output to prevent
23+
* the terminal from treating it as a paste (Bugfix #584).
24+
*
25+
* Short messages (≤3 lines): single write + delayed Enter.
26+
* Long messages (>3 lines): line-by-line writes with 10ms gaps, then Enter
27+
* after all lines are delivered.
28+
*
29+
* @param delayOffset ms offset for all scheduled writes (used to serialize
30+
* multiple messages to the same session without interleaving)
31+
* @returns ms timestamp (from call time) when all writes complete
32+
*/
33+
export function writeMessageToSession(
34+
session: WritableSession, message: string, noEnter: boolean, delayOffset = 0,
35+
): number {
36+
const lines = message.split('\n');
37+
38+
if (lines.length < PACED_WRITE_LINE_THRESHOLD) {
39+
// Short messages: single write (existing behavior, works fine)
40+
if (delayOffset === 0) {
41+
session.write(message);
42+
} else {
43+
setTimeout(() => session.write(message), delayOffset);
44+
}
45+
const enterTime = delayOffset + SIMPLE_ENTER_DELAY_MS;
46+
if (!noEnter) {
47+
setTimeout(() => session.write('\r'), enterTime);
48+
}
49+
return enterTime;
50+
}
51+
52+
// Multi-line: pace output line-by-line to avoid paste detection.
53+
// Writing all lines in a single write() causes the terminal to treat it
54+
// as a paste, swallowing the final Enter.
55+
for (let i = 0; i < lines.length; i++) {
56+
const text = i < lines.length - 1 ? lines[i] + '\n' : lines[i];
57+
const lineDelay = delayOffset + i * INTER_LINE_DELAY_MS;
58+
if (lineDelay === 0) {
59+
session.write(text);
60+
} else {
61+
setTimeout(() => session.write(text), lineDelay);
62+
}
63+
}
64+
65+
const lastLineTime = delayOffset + (lines.length - 1) * INTER_LINE_DELAY_MS;
66+
if (!noEnter) {
67+
const enterTime = lastLineTime + PACED_ENTER_DELAY_MS;
68+
setTimeout(() => session.write('\r'), enterTime);
69+
return enterTime;
70+
}
71+
return lastLineTime;
72+
}

packages/codev/src/agent-farm/servers/send-buffer.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ export interface BufferedMessage {
2525
}
2626

2727
export type GetSessionFn = (id: string) => PtySession | undefined;
28-
export type DeliverFn = (session: PtySession, msg: BufferedMessage) => void;
28+
/** Deliver function returns ms timestamp when all writes complete (for serialization). */
29+
export type DeliverFn = (session: PtySession, msg: BufferedMessage, delayOffset?: number) => number;
2930
export type LogFn = (level: 'INFO' | 'ERROR' | 'WARN', message: string) => void;
3031

3132
const DEFAULT_IDLE_THRESHOLD_MS = 3000;
@@ -99,9 +100,12 @@ export class SendBuffer {
99100
// Bugfix #492: removed composing check — it gets stuck true after non-Enter
100101
// keystrokes (Ctrl+C, arrows, Tab), causing messages to wait 60s max age.
101102
if (forceAll || isIdle || maxAgeExceeded) {
102-
// Deliver all messages in order
103+
// Deliver all messages in order, serializing paced writes (Bugfix #584).
104+
// Each delivery returns the ms when its writes complete; the next message
105+
// starts after that to prevent interleaved lines.
106+
let offset = 0;
103107
for (const msg of messages) {
104-
this.deliver(session, msg);
108+
offset = this.deliver(session, msg, offset);
105109
if (this.log && msg.logMessage) {
106110
this.log('INFO', msg.logMessage);
107111
}

0 commit comments

Comments
 (0)