agent-relay 2.0.33 → 2.0.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +44 -0
  2. package/dist/index.cjs +10323 -15012
  3. package/package.json +26 -19
  4. package/packages/api-types/.trajectories/active/traj_xbsvuzogscey.json +15 -0
  5. package/packages/api-types/.trajectories/index.json +12 -0
  6. package/packages/api-types/package.json +1 -1
  7. package/packages/benchmark/package.json +4 -4
  8. package/packages/bridge/dist/spawner.d.ts.map +1 -1
  9. package/packages/bridge/dist/spawner.js +127 -0
  10. package/packages/bridge/dist/spawner.js.map +1 -1
  11. package/packages/bridge/package.json +8 -8
  12. package/packages/bridge/src/spawner.ts +137 -0
  13. package/packages/cli-tester/package.json +1 -1
  14. package/packages/config/package.json +2 -2
  15. package/packages/continuity/package.json +1 -1
  16. package/packages/daemon/package.json +12 -12
  17. package/packages/hooks/package.json +4 -4
  18. package/packages/mcp/package.json +3 -3
  19. package/packages/memory/package.json +2 -2
  20. package/packages/policy/package.json +2 -2
  21. package/packages/protocol/package.json +1 -1
  22. package/packages/resiliency/package.json +1 -1
  23. package/packages/sdk/package.json +2 -2
  24. package/packages/spawner/package.json +1 -1
  25. package/packages/state/package.json +1 -1
  26. package/packages/storage/package.json +2 -2
  27. package/packages/telemetry/package.json +1 -1
  28. package/packages/trajectory/package.json +2 -2
  29. package/packages/user-directory/package.json +2 -2
  30. package/packages/utils/package.json +2 -2
  31. package/packages/wrapper/dist/base-wrapper.d.ts.map +1 -1
  32. package/packages/wrapper/dist/base-wrapper.js +27 -7
  33. package/packages/wrapper/dist/base-wrapper.js.map +1 -1
  34. package/packages/wrapper/dist/client.d.ts +27 -0
  35. package/packages/wrapper/dist/client.d.ts.map +1 -1
  36. package/packages/wrapper/dist/client.js +116 -0
  37. package/packages/wrapper/dist/client.js.map +1 -1
  38. package/packages/wrapper/dist/index.d.ts +3 -0
  39. package/packages/wrapper/dist/index.d.ts.map +1 -1
  40. package/packages/wrapper/dist/index.js +6 -0
  41. package/packages/wrapper/dist/index.js.map +1 -1
  42. package/packages/wrapper/dist/opencode-api.d.ts +106 -0
  43. package/packages/wrapper/dist/opencode-api.d.ts.map +1 -0
  44. package/packages/wrapper/dist/opencode-api.js +219 -0
  45. package/packages/wrapper/dist/opencode-api.js.map +1 -0
  46. package/packages/wrapper/dist/opencode-wrapper.d.ts +157 -0
  47. package/packages/wrapper/dist/opencode-wrapper.d.ts.map +1 -0
  48. package/packages/wrapper/dist/opencode-wrapper.js +414 -0
  49. package/packages/wrapper/dist/opencode-wrapper.js.map +1 -0
  50. package/packages/wrapper/dist/relay-pty-orchestrator.d.ts.map +1 -1
  51. package/packages/wrapper/dist/relay-pty-orchestrator.js +18 -0
  52. package/packages/wrapper/dist/relay-pty-orchestrator.js.map +1 -1
  53. package/packages/wrapper/dist/wrapper-events.d.ts +489 -0
  54. package/packages/wrapper/dist/wrapper-events.d.ts.map +1 -0
  55. package/packages/wrapper/dist/wrapper-events.js +252 -0
  56. package/packages/wrapper/dist/wrapper-events.js.map +1 -0
  57. package/packages/wrapper/package.json +7 -6
  58. package/packages/wrapper/src/base-wrapper.ts +23 -7
  59. package/packages/wrapper/src/client.test.ts +92 -3
  60. package/packages/wrapper/src/client.ts +163 -0
  61. package/packages/wrapper/src/index.ts +29 -0
  62. package/packages/wrapper/src/opencode-api.test.ts +292 -0
  63. package/packages/wrapper/src/opencode-api.ts +285 -0
  64. package/packages/wrapper/src/opencode-wrapper.ts +513 -0
  65. package/packages/wrapper/src/relay-pty-orchestrator.test.ts +176 -0
  66. package/packages/wrapper/src/relay-pty-orchestrator.ts +20 -0
  67. package/packages/wrapper/src/wrapper-events.ts +395 -0
@@ -0,0 +1,513 @@
1
+ /**
2
+ * OpenCodeWrapper - Wrapper for opencode CLI with HTTP API support
3
+ *
4
+ * This wrapper supports two modes of message injection:
5
+ * 1. HTTP API mode: Uses opencode serve's /tui/append-prompt endpoint
6
+ * 2. PTY fallback: Falls back to PTY-based injection when HTTP is unavailable
7
+ *
8
+ * The wrapper automatically detects which mode to use based on:
9
+ * - Whether `opencode serve` is running (checks localhost:4096)
10
+ * - Configuration options (httpApi.enabled, httpApi.fallbackToPty)
11
+ *
12
+ * Usage with HTTP API:
13
+ * ```
14
+ * const wrapper = new OpenCodeWrapper({
15
+ * name: 'MyAgent',
16
+ * command: 'opencode',
17
+ * httpApi: {
18
+ * enabled: true,
19
+ * baseUrl: 'http://localhost:4096',
20
+ * }
21
+ * });
22
+ * await wrapper.start();
23
+ * ```
24
+ *
25
+ * @see https://github.com/anomalyco/opencode
26
+ */
27
+
28
+ import { spawn, type ChildProcess } from 'node:child_process';
29
+ import { BaseWrapper, type BaseWrapperConfig } from './base-wrapper.js';
30
+ import { OpenCodeApi, type OpenCodeApiConfig } from './opencode-api.js';
31
+ import { OutputParser, type ParsedCommand } from './parser.js';
32
+ import { buildInjectionString, type QueuedMessage } from './shared.js';
33
+
34
+ export interface OpenCodeWrapperConfig extends BaseWrapperConfig {
35
+ /** HTTP API configuration */
36
+ httpApi?: OpenCodeApiConfig & {
37
+ /** Enable HTTP API mode (default: true when wrapping opencode) */
38
+ enabled?: boolean;
39
+ /** Fall back to PTY injection if HTTP is unavailable (default: true) */
40
+ fallbackToPty?: boolean;
41
+ /** Auto-start opencode serve if not running (default: false) */
42
+ autoStartServe?: boolean;
43
+ /** Wait for serve to be available in milliseconds (default: 5000) */
44
+ waitForServeMs?: number;
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Wrapper for opencode CLI with HTTP API support
50
+ */
51
+ export class OpenCodeWrapper extends BaseWrapper {
52
+ protected override config: OpenCodeWrapperConfig;
53
+
54
+ // OpenCode API client
55
+ private api: OpenCodeApi;
56
+ private httpApiAvailable = false;
57
+
58
+ // Process management (for PTY fallback mode)
59
+ private process?: ChildProcess;
60
+ private outputBuffer = '';
61
+
62
+ // Output parser for relay commands
63
+ private parser: OutputParser;
64
+
65
+ // Serve process (if auto-started)
66
+ private serveProcess?: ChildProcess;
67
+
68
+ constructor(config: OpenCodeWrapperConfig) {
69
+ // Default to opencode CLI type
70
+ super({ ...config, cliType: config.cliType ?? 'opencode' });
71
+ this.config = config;
72
+
73
+ // Initialize API client
74
+ this.api = new OpenCodeApi({
75
+ baseUrl: config.httpApi?.baseUrl,
76
+ password: config.httpApi?.password,
77
+ timeout: config.httpApi?.timeout,
78
+ });
79
+
80
+ // Initialize parser with relay prefix
81
+ this.parser = new OutputParser({
82
+ prefix: this.relayPrefix,
83
+ });
84
+ }
85
+
86
+ // =========================================================================
87
+ // Lifecycle
88
+ // =========================================================================
89
+
90
+ async start(): Promise<void> {
91
+ if (this.running) return;
92
+
93
+ // Try to use HTTP API mode first (if enabled)
94
+ if (this.config.httpApi?.enabled !== false) {
95
+ // Check if opencode serve is available
96
+ this.httpApiAvailable = await this.api.isAvailable();
97
+
98
+ if (!this.httpApiAvailable && this.config.httpApi?.autoStartServe) {
99
+ // Auto-start opencode serve
100
+ await this.startServe();
101
+ this.httpApiAvailable = await this.api.waitForAvailable(
102
+ this.config.httpApi?.waitForServeMs ?? 5000
103
+ );
104
+ }
105
+
106
+ if (this.httpApiAvailable) {
107
+ console.log('[OpenCodeWrapper] Using HTTP API mode');
108
+ await this.startHttpMode();
109
+ return;
110
+ }
111
+
112
+ // Check if we should fall back to PTY
113
+ if (this.config.httpApi?.fallbackToPty === false) {
114
+ throw new Error(
115
+ 'OpenCode serve is not available and fallbackToPty is disabled. ' +
116
+ 'Start opencode serve or enable fallbackToPty.'
117
+ );
118
+ }
119
+
120
+ console.log('[OpenCodeWrapper] HTTP API unavailable, falling back to PTY mode');
121
+ }
122
+
123
+ // Fall back to PTY mode
124
+ await this.startPtyMode();
125
+ }
126
+
127
+ async stop(): Promise<void> {
128
+ this.running = false;
129
+
130
+ // Disconnect from relay daemon
131
+ this.client.disconnect();
132
+
133
+ // Stop the main process
134
+ if (this.process) {
135
+ this.process.kill();
136
+ this.process = undefined;
137
+ }
138
+
139
+ // Stop the serve process if we started it
140
+ if (this.serveProcess) {
141
+ this.serveProcess.kill();
142
+ this.serveProcess = undefined;
143
+ }
144
+ }
145
+
146
+ // =========================================================================
147
+ // HTTP API Mode
148
+ // =========================================================================
149
+
150
+ /**
151
+ * Start in HTTP API mode
152
+ * In this mode, we don't spawn opencode ourselves - we communicate via HTTP API
153
+ */
154
+ private async startHttpMode(): Promise<void> {
155
+ this.running = true;
156
+
157
+ // Connect to relay daemon
158
+ await this.client.connect();
159
+
160
+ // Subscribe to opencode events for output parsing
161
+ this.subscribeToEvents();
162
+
163
+ // Show a toast to indicate relay is connected
164
+ await this.api.showToast('Agent Relay connected', { variant: 'success', duration: 2000 });
165
+ }
166
+
167
+ /**
168
+ * Subscribe to opencode SSE events for output parsing
169
+ */
170
+ private subscribeToEvents(): void {
171
+ this.api.subscribeToEvents(
172
+ event => {
173
+ // Parse events for relay commands
174
+ if (event.type === 'message' || event.type === 'assistant_message') {
175
+ const content = typeof event.data === 'string' ? event.data : JSON.stringify(event.data);
176
+ this.handleOutput(content);
177
+ }
178
+ },
179
+ error => {
180
+ console.error('[OpenCodeWrapper] SSE error:', error.message);
181
+ // Attempt to reconnect after a delay
182
+ setTimeout(() => {
183
+ if (this.running && this.httpApiAvailable) {
184
+ this.subscribeToEvents();
185
+ }
186
+ }, 5000);
187
+ }
188
+ );
189
+ }
190
+
191
+ // =========================================================================
192
+ // PTY Fallback Mode
193
+ // =========================================================================
194
+
195
+ /**
196
+ * Start in PTY mode (fallback when HTTP is unavailable)
197
+ */
198
+ private async startPtyMode(): Promise<void> {
199
+ this.running = true;
200
+
201
+ // Connect to relay daemon
202
+ await this.client.connect();
203
+
204
+ // Spawn opencode process
205
+ const args = this.config.args ?? [];
206
+ this.process = spawn(this.config.command, args, {
207
+ cwd: this.config.cwd,
208
+ env: { ...process.env, ...this.config.env },
209
+ stdio: ['pipe', 'pipe', 'pipe'],
210
+ });
211
+
212
+ // Handle stdout
213
+ this.process.stdout?.on('data', (data: Buffer) => {
214
+ const text = data.toString();
215
+ this.outputBuffer += text;
216
+ this.handleOutput(text);
217
+ });
218
+
219
+ // Handle stderr
220
+ this.process.stderr?.on('data', (data: Buffer) => {
221
+ const text = data.toString();
222
+ this.outputBuffer += text;
223
+ });
224
+
225
+ // Handle exit
226
+ this.process.on('exit', (code, signal) => {
227
+ this.running = false;
228
+ this.emit('exit', code, signal);
229
+ });
230
+ }
231
+
232
+ // =========================================================================
233
+ // Output Handling
234
+ // =========================================================================
235
+
236
+ /**
237
+ * Handle output from opencode (either via SSE or PTY)
238
+ */
239
+ private handleOutput(text: string): void {
240
+ // Feed to idle detector
241
+ this.idleDetector.onOutput(text);
242
+
243
+ // Feed to stuck detector
244
+ this.stuckDetector.onOutput(text);
245
+
246
+ // Parse for relay commands
247
+ const result = this.parser.parse(text);
248
+ for (const cmd of result.commands) {
249
+ this.handleParsedCommand(cmd);
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Handle a parsed relay command
255
+ */
256
+ private handleParsedCommand(cmd: ParsedCommand): void {
257
+ // Send message via relay client
258
+ this.client.sendMessage(
259
+ cmd.to,
260
+ cmd.body,
261
+ cmd.kind,
262
+ cmd.data,
263
+ cmd.thread
264
+ );
265
+ }
266
+
267
+ // =========================================================================
268
+ // Message Injection
269
+ // =========================================================================
270
+
271
+ protected async performInjection(content: string): Promise<void> {
272
+ if (this.httpApiAvailable) {
273
+ await this.performHttpInjection(content);
274
+ } else {
275
+ await this.performPtyInjection(content);
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Inject content via HTTP API
281
+ */
282
+ private async performHttpInjection(content: string): Promise<void> {
283
+ const result = await this.api.appendPrompt(content);
284
+ if (!result.success) {
285
+ throw new Error(`HTTP injection failed: ${result.error}`);
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Inject content via PTY stdin
291
+ */
292
+ private async performPtyInjection(content: string): Promise<void> {
293
+ if (!this.process?.stdin) {
294
+ throw new Error('PTY stdin not available');
295
+ }
296
+
297
+ // Write to stdin
298
+ this.process.stdin.write(content + '\n');
299
+ }
300
+
301
+ protected getCleanOutput(): string {
302
+ return this.outputBuffer;
303
+ }
304
+
305
+ // =========================================================================
306
+ // Serve Process Management
307
+ // =========================================================================
308
+
309
+ /**
310
+ * Auto-start opencode serve
311
+ */
312
+ private async startServe(): Promise<void> {
313
+ console.log('[OpenCodeWrapper] Auto-starting opencode serve...');
314
+
315
+ this.serveProcess = spawn('opencode', ['serve'], {
316
+ cwd: this.config.cwd,
317
+ env: { ...process.env, ...this.config.env },
318
+ stdio: 'ignore',
319
+ detached: true,
320
+ });
321
+
322
+ // Don't wait for serve process - it runs in background
323
+ this.serveProcess.unref();
324
+ }
325
+
326
+ // =========================================================================
327
+ // Message Queue Processing (override for HTTP mode optimization)
328
+ // =========================================================================
329
+
330
+ /**
331
+ * Process the message queue
332
+ * Override to use HTTP API's direct injection when available
333
+ */
334
+ protected async processMessageQueue(): Promise<void> {
335
+ if (this.isInjecting || this.messageQueue.length === 0) {
336
+ return;
337
+ }
338
+
339
+ // Check if we should wait for idle (only in PTY mode)
340
+ if (!this.httpApiAvailable) {
341
+ const idleResult = this.idleDetector.checkIdle();
342
+ if (!idleResult.isIdle) {
343
+ // In PTY mode, wait for idle before injection
344
+ return;
345
+ }
346
+ }
347
+
348
+ this.isInjecting = true;
349
+
350
+ try {
351
+ // Sort by importance (higher first) and process
352
+ const sortedQueue = [...this.messageQueue].sort(
353
+ (a, b) => (b.importance ?? 0) - (a.importance ?? 0)
354
+ );
355
+
356
+ for (const msg of sortedQueue) {
357
+ const injectionString = buildInjectionString(msg);
358
+ await this.performInjection(injectionString);
359
+
360
+ // Remove from queue
361
+ const index = this.messageQueue.indexOf(msg);
362
+ if (index !== -1) {
363
+ this.messageQueue.splice(index, 1);
364
+ }
365
+
366
+ // Update metrics
367
+ this.injectionMetrics.successFirstTry++;
368
+ this.injectionMetrics.total++;
369
+ }
370
+ } catch (error) {
371
+ this.injectionMetrics.failed++;
372
+ this.injectionMetrics.total++;
373
+ console.error('[OpenCodeWrapper] Injection error:', error);
374
+ } finally {
375
+ this.isInjecting = false;
376
+ }
377
+ }
378
+
379
+ // =========================================================================
380
+ // Spawner Compatibility Methods
381
+ // =========================================================================
382
+
383
+ /**
384
+ * Get the process ID (undefined in HTTP mode, defined in PTY mode)
385
+ */
386
+ get pid(): number | undefined {
387
+ return this.process?.pid;
388
+ }
389
+
390
+ /**
391
+ * Log path (not applicable for OpenCodeWrapper)
392
+ */
393
+ get logPath(): string | undefined {
394
+ return undefined;
395
+ }
396
+
397
+ /**
398
+ * Kill the wrapper (alias for stop())
399
+ */
400
+ async kill(): Promise<void> {
401
+ await this.stop();
402
+ }
403
+
404
+ /**
405
+ * Write to the process stdin (PTY mode only)
406
+ */
407
+ write(data: string): void {
408
+ if (this.process?.stdin) {
409
+ this.process.stdin.write(data);
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Inject a task into the agent
415
+ * @param task - The task description to inject
416
+ * @param _from - The sender name (used for formatting)
417
+ * @returns true if injection succeeded
418
+ */
419
+ async injectTask(task: string, _from?: string): Promise<boolean> {
420
+ try {
421
+ await this.performInjection(task);
422
+ return true;
423
+ } catch (error) {
424
+ console.error('[OpenCodeWrapper] Task injection failed:', error);
425
+ return false;
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Get output lines (for compatibility with spawner)
431
+ */
432
+ getOutput(limit?: number): string[] {
433
+ const lines = this.outputBuffer.split('\n');
434
+ return limit ? lines.slice(-limit) : lines;
435
+ }
436
+
437
+ /**
438
+ * Get raw output (for compatibility with spawner)
439
+ */
440
+ getRawOutput(): string {
441
+ return this.outputBuffer;
442
+ }
443
+
444
+ /**
445
+ * Wait until the wrapper is ready to receive messages
446
+ * In HTTP mode, this checks if the API is available
447
+ * In PTY mode, this waits for the process to start
448
+ */
449
+ async waitUntilReadyForMessages(timeoutMs = 15000, _pollMs = 100): Promise<boolean> {
450
+ if (!this.running) {
451
+ return false;
452
+ }
453
+
454
+ if (this.httpApiAvailable) {
455
+ // HTTP mode: check if API responds
456
+ return await this.api.waitForAvailable(timeoutMs);
457
+ }
458
+
459
+ // PTY mode: process is ready if stdin is available
460
+ return this.process?.stdin !== null;
461
+ }
462
+
463
+ /**
464
+ * Wait until CLI is ready (alias for waitUntilReadyForMessages)
465
+ */
466
+ async waitUntilCliReady(timeoutMs = 15000, pollMs = 100): Promise<boolean> {
467
+ return this.waitUntilReadyForMessages(timeoutMs, pollMs);
468
+ }
469
+
470
+ // =========================================================================
471
+ // Public API
472
+ // =========================================================================
473
+
474
+ /**
475
+ * Check if HTTP API mode is active
476
+ */
477
+ get isHttpApiMode(): boolean {
478
+ return this.httpApiAvailable;
479
+ }
480
+
481
+ /**
482
+ * Get the OpenCode API client for advanced operations
483
+ */
484
+ get openCodeApi(): OpenCodeApi {
485
+ return this.api;
486
+ }
487
+
488
+ /**
489
+ * Switch to a specific session
490
+ */
491
+ async switchSession(sessionId: string): Promise<boolean> {
492
+ if (!this.httpApiAvailable) {
493
+ console.warn('[OpenCodeWrapper] Cannot switch sessions in PTY mode');
494
+ return false;
495
+ }
496
+
497
+ const result = await this.api.selectSession(sessionId);
498
+ return result.success;
499
+ }
500
+
501
+ /**
502
+ * List available sessions
503
+ */
504
+ async listSessions(): Promise<{ id: string; title?: string }[]> {
505
+ if (!this.httpApiAvailable) {
506
+ console.warn('[OpenCodeWrapper] Cannot list sessions in PTY mode');
507
+ return [];
508
+ }
509
+
510
+ const result = await this.api.listSessions();
511
+ return result.data ?? [];
512
+ }
513
+ }
@@ -1180,6 +1180,182 @@ describe('RelayPtyOrchestrator', () => {
1180
1180
  processQueueSpy.mockRestore();
1181
1181
  });
1182
1182
  });
1183
+
1184
+ describe('output buffer management', () => {
1185
+ it('trims rawBuffer when it exceeds MAX_OUTPUT_BUFFER_SIZE', async () => {
1186
+ orchestrator = new RelayPtyOrchestrator({
1187
+ name: 'TestAgent',
1188
+ command: 'claude',
1189
+ });
1190
+
1191
+ await orchestrator.start();
1192
+
1193
+ // Access private MAX_OUTPUT_BUFFER_SIZE constant (10MB)
1194
+ const MAX_SIZE = 10 * 1024 * 1024;
1195
+
1196
+ // Generate data larger than the max size
1197
+ const chunkSize = 1024 * 1024; // 1MB chunks
1198
+ const numChunks = 12; // 12MB total (exceeds 10MB limit)
1199
+
1200
+ for (let i = 0; i < numChunks; i++) {
1201
+ const chunk = `chunk-${i}-${'x'.repeat(chunkSize - 10)}\n`;
1202
+ mockProcess.stdout!.emit('data', Buffer.from(chunk));
1203
+ }
1204
+
1205
+ // Buffer should be trimmed to MAX_SIZE
1206
+ const rawOutput = orchestrator.getRawOutput();
1207
+ expect(rawOutput.length).toBeLessThanOrEqual(MAX_SIZE);
1208
+
1209
+ // Should contain the most recent chunks (tail of buffer)
1210
+ expect(rawOutput).toContain('chunk-11'); // Last chunk
1211
+ expect(rawOutput).not.toContain('chunk-0'); // First chunk should be trimmed
1212
+ });
1213
+
1214
+ it('adjusts lastParsedLength when buffer is trimmed', async () => {
1215
+ orchestrator = new RelayPtyOrchestrator({
1216
+ name: 'TestAgent',
1217
+ command: 'claude',
1218
+ });
1219
+
1220
+ await orchestrator.start();
1221
+
1222
+ // Access lastParsedLength via reflection
1223
+ const getLastParsedLength = () => (orchestrator as any).lastParsedLength;
1224
+
1225
+ // Emit some initial output to set lastParsedLength
1226
+ const initialData = 'Initial output that sets parse position\n';
1227
+ mockProcess.stdout!.emit('data', Buffer.from(initialData));
1228
+
1229
+ // Allow parsing to run
1230
+ await new Promise(resolve => setTimeout(resolve, 10));
1231
+
1232
+ const initialParsedLength = getLastParsedLength();
1233
+ expect(initialParsedLength).toBeGreaterThan(0);
1234
+
1235
+ // Now emit a lot of data to trigger buffer trimming
1236
+ const MAX_SIZE = 10 * 1024 * 1024;
1237
+ const largeData = 'x'.repeat(MAX_SIZE + 1000);
1238
+ mockProcess.stdout!.emit('data', Buffer.from(largeData));
1239
+
1240
+ // lastParsedLength should be adjusted to stay in sync
1241
+ const adjustedParsedLength = getLastParsedLength();
1242
+
1243
+ // After trimming, lastParsedLength should be reduced by the trim amount
1244
+ // or set to 0 if the trim amount exceeds the previous value
1245
+ expect(adjustedParsedLength).toBeLessThanOrEqual(MAX_SIZE);
1246
+ });
1247
+
1248
+ it('still detects relay commands after buffer trimming', async () => {
1249
+ orchestrator = new RelayPtyOrchestrator({
1250
+ name: 'TestAgent',
1251
+ command: 'claude',
1252
+ });
1253
+
1254
+ await orchestrator.start();
1255
+
1256
+ // Access the client mock
1257
+ const client = (orchestrator as any).client;
1258
+ client.sendMessage.mockClear();
1259
+
1260
+ // Emit a lot of data to fill the buffer
1261
+ const MAX_SIZE = 10 * 1024 * 1024;
1262
+ const filler = 'x'.repeat(MAX_SIZE + 1000);
1263
+ mockProcess.stdout!.emit('data', Buffer.from(filler));
1264
+
1265
+ // Now emit a relay command after the buffer was trimmed
1266
+ mockProcess.stdout!.emit('data', Buffer.from('\n->relay:Bob Post-trim message\n'));
1267
+
1268
+ // Allow parsing
1269
+ await new Promise(resolve => setTimeout(resolve, 50));
1270
+
1271
+ // The relay command should still be detected
1272
+ expect(client.sendMessage).toHaveBeenCalled();
1273
+ });
1274
+
1275
+ it('still detects summary blocks after buffer trimming', async () => {
1276
+ orchestrator = new RelayPtyOrchestrator({
1277
+ name: 'TestAgent',
1278
+ command: 'claude',
1279
+ });
1280
+
1281
+ const summaryHandler = vi.fn();
1282
+ orchestrator.on('summary', summaryHandler);
1283
+
1284
+ await orchestrator.start();
1285
+
1286
+ // Emit a lot of data to trigger trimming
1287
+ const MAX_SIZE = 10 * 1024 * 1024;
1288
+ const filler = 'x'.repeat(MAX_SIZE + 1000);
1289
+ mockProcess.stdout!.emit('data', Buffer.from(filler));
1290
+
1291
+ // Now emit a summary block
1292
+ mockProcess.stdout!.emit('data', Buffer.from(
1293
+ '\n[[SUMMARY]]{"currentTask": "Post-trim task"}[[/SUMMARY]]\n'
1294
+ ));
1295
+
1296
+ await new Promise(resolve => setTimeout(resolve, 50));
1297
+
1298
+ // Summary should still be detected
1299
+ expect(summaryHandler).toHaveBeenCalled();
1300
+ });
1301
+
1302
+ it('does not lose data during buffer trimming', async () => {
1303
+ orchestrator = new RelayPtyOrchestrator({
1304
+ name: 'TestAgent',
1305
+ command: 'claude',
1306
+ });
1307
+
1308
+ await orchestrator.start();
1309
+
1310
+ // Emit data in chunks, each with a unique marker
1311
+ const markers: string[] = [];
1312
+ for (let i = 0; i < 20; i++) {
1313
+ const marker = `MARKER_${i}_${Date.now()}`;
1314
+ markers.push(marker);
1315
+ // Each chunk is 1MB
1316
+ const chunk = marker + '_' + 'x'.repeat(1024 * 1024 - marker.length - 2) + '\n';
1317
+ mockProcess.stdout!.emit('data', Buffer.from(chunk));
1318
+ }
1319
+
1320
+ const rawOutput = orchestrator.getRawOutput();
1321
+
1322
+ // The most recent markers should be present (within the 10MB limit)
1323
+ // Approximately the last 10 markers should be there
1324
+ const recentMarkers = markers.slice(-10);
1325
+ for (const marker of recentMarkers) {
1326
+ expect(rawOutput).toContain(marker);
1327
+ }
1328
+
1329
+ // The oldest markers should be trimmed
1330
+ const oldMarkers = markers.slice(0, 5);
1331
+ for (const marker of oldMarkers) {
1332
+ expect(rawOutput).not.toContain(marker);
1333
+ }
1334
+ });
1335
+
1336
+ it('handles rapid output without memory exhaustion', async () => {
1337
+ orchestrator = new RelayPtyOrchestrator({
1338
+ name: 'TestAgent',
1339
+ command: 'claude',
1340
+ });
1341
+
1342
+ await orchestrator.start();
1343
+
1344
+ // Simulate rapid output (like a build log or test output)
1345
+ const iterations = 100;
1346
+ const chunkSize = 100 * 1024; // 100KB chunks
1347
+
1348
+ for (let i = 0; i < iterations; i++) {
1349
+ const chunk = `iteration-${i}: ${'log'.repeat(chunkSize / 3)}\n`;
1350
+ mockProcess.stdout!.emit('data', Buffer.from(chunk));
1351
+ }
1352
+
1353
+ // Should not throw RangeError
1354
+ const rawOutput = orchestrator.getRawOutput();
1355
+ const MAX_SIZE = 10 * 1024 * 1024;
1356
+ expect(rawOutput.length).toBeLessThanOrEqual(MAX_SIZE);
1357
+ });
1358
+ });
1183
1359
  });
1184
1360
 
1185
1361
  describe('RelayPtyOrchestrator integration', () => {