@agent-relay/wrapper 0.1.0

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 (115) hide show
  1. package/dist/__fixtures__/claude-outputs.d.ts +49 -0
  2. package/dist/__fixtures__/claude-outputs.d.ts.map +1 -0
  3. package/dist/__fixtures__/claude-outputs.js +443 -0
  4. package/dist/__fixtures__/claude-outputs.js.map +1 -0
  5. package/dist/__fixtures__/codex-outputs.d.ts +9 -0
  6. package/dist/__fixtures__/codex-outputs.d.ts.map +1 -0
  7. package/dist/__fixtures__/codex-outputs.js +94 -0
  8. package/dist/__fixtures__/codex-outputs.js.map +1 -0
  9. package/dist/__fixtures__/gemini-outputs.d.ts +19 -0
  10. package/dist/__fixtures__/gemini-outputs.d.ts.map +1 -0
  11. package/dist/__fixtures__/gemini-outputs.js +144 -0
  12. package/dist/__fixtures__/gemini-outputs.js.map +1 -0
  13. package/dist/__fixtures__/index.d.ts +68 -0
  14. package/dist/__fixtures__/index.d.ts.map +1 -0
  15. package/dist/__fixtures__/index.js +44 -0
  16. package/dist/__fixtures__/index.js.map +1 -0
  17. package/dist/auth-detection.d.ts +49 -0
  18. package/dist/auth-detection.d.ts.map +1 -0
  19. package/dist/auth-detection.js +199 -0
  20. package/dist/auth-detection.js.map +1 -0
  21. package/dist/base-wrapper.d.ts +225 -0
  22. package/dist/base-wrapper.d.ts.map +1 -0
  23. package/dist/base-wrapper.js +572 -0
  24. package/dist/base-wrapper.js.map +1 -0
  25. package/dist/client.d.ts +254 -0
  26. package/dist/client.d.ts.map +1 -0
  27. package/dist/client.js +801 -0
  28. package/dist/client.js.map +1 -0
  29. package/dist/id-generator.d.ts +35 -0
  30. package/dist/id-generator.d.ts.map +1 -0
  31. package/dist/id-generator.js +60 -0
  32. package/dist/id-generator.js.map +1 -0
  33. package/dist/idle-detector.d.ts +110 -0
  34. package/dist/idle-detector.d.ts.map +1 -0
  35. package/dist/idle-detector.js +304 -0
  36. package/dist/idle-detector.js.map +1 -0
  37. package/dist/inbox.d.ts +37 -0
  38. package/dist/inbox.d.ts.map +1 -0
  39. package/dist/inbox.js +73 -0
  40. package/dist/inbox.js.map +1 -0
  41. package/dist/index.d.ts +37 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +47 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/parser.d.ts +236 -0
  46. package/dist/parser.d.ts.map +1 -0
  47. package/dist/parser.js +1238 -0
  48. package/dist/parser.js.map +1 -0
  49. package/dist/prompt-composer.d.ts +67 -0
  50. package/dist/prompt-composer.d.ts.map +1 -0
  51. package/dist/prompt-composer.js +168 -0
  52. package/dist/prompt-composer.js.map +1 -0
  53. package/dist/relay-pty-orchestrator.d.ts +407 -0
  54. package/dist/relay-pty-orchestrator.d.ts.map +1 -0
  55. package/dist/relay-pty-orchestrator.js +1885 -0
  56. package/dist/relay-pty-orchestrator.js.map +1 -0
  57. package/dist/shared.d.ts +201 -0
  58. package/dist/shared.d.ts.map +1 -0
  59. package/dist/shared.js +341 -0
  60. package/dist/shared.js.map +1 -0
  61. package/dist/stuck-detector.d.ts +161 -0
  62. package/dist/stuck-detector.d.ts.map +1 -0
  63. package/dist/stuck-detector.js +402 -0
  64. package/dist/stuck-detector.js.map +1 -0
  65. package/dist/tmux-resolver.d.ts +55 -0
  66. package/dist/tmux-resolver.d.ts.map +1 -0
  67. package/dist/tmux-resolver.js +175 -0
  68. package/dist/tmux-resolver.js.map +1 -0
  69. package/dist/tmux-wrapper.d.ts +345 -0
  70. package/dist/tmux-wrapper.d.ts.map +1 -0
  71. package/dist/tmux-wrapper.js +1747 -0
  72. package/dist/tmux-wrapper.js.map +1 -0
  73. package/dist/trajectory-integration.d.ts +292 -0
  74. package/dist/trajectory-integration.d.ts.map +1 -0
  75. package/dist/trajectory-integration.js +979 -0
  76. package/dist/trajectory-integration.js.map +1 -0
  77. package/dist/wrapper-types.d.ts +41 -0
  78. package/dist/wrapper-types.d.ts.map +1 -0
  79. package/dist/wrapper-types.js +7 -0
  80. package/dist/wrapper-types.js.map +1 -0
  81. package/package.json +63 -0
  82. package/src/__fixtures__/claude-outputs.ts +471 -0
  83. package/src/__fixtures__/codex-outputs.ts +99 -0
  84. package/src/__fixtures__/gemini-outputs.ts +151 -0
  85. package/src/__fixtures__/index.ts +47 -0
  86. package/src/auth-detection.ts +244 -0
  87. package/src/base-wrapper.test.ts +540 -0
  88. package/src/base-wrapper.ts +741 -0
  89. package/src/client.test.ts +262 -0
  90. package/src/client.ts +984 -0
  91. package/src/id-generator.test.ts +71 -0
  92. package/src/id-generator.ts +69 -0
  93. package/src/idle-detector.test.ts +390 -0
  94. package/src/idle-detector.ts +370 -0
  95. package/src/inbox.test.ts +233 -0
  96. package/src/inbox.ts +89 -0
  97. package/src/index.ts +170 -0
  98. package/src/parser.regression.test.ts +251 -0
  99. package/src/parser.test.ts +1359 -0
  100. package/src/parser.ts +1477 -0
  101. package/src/prompt-composer.test.ts +219 -0
  102. package/src/prompt-composer.ts +231 -0
  103. package/src/relay-pty-orchestrator.test.ts +1027 -0
  104. package/src/relay-pty-orchestrator.ts +2270 -0
  105. package/src/shared.test.ts +221 -0
  106. package/src/shared.ts +454 -0
  107. package/src/stuck-detector.test.ts +303 -0
  108. package/src/stuck-detector.ts +511 -0
  109. package/src/tmux-resolver.test.ts +104 -0
  110. package/src/tmux-resolver.ts +207 -0
  111. package/src/tmux-wrapper.test.ts +316 -0
  112. package/src/tmux-wrapper.ts +2010 -0
  113. package/src/trajectory-detection.test.ts +151 -0
  114. package/src/trajectory-integration.ts +1261 -0
  115. package/src/wrapper-types.ts +45 -0
@@ -0,0 +1,1027 @@
1
+ /**
2
+ * Tests for RelayPtyOrchestrator
3
+ *
4
+ * Tests the TypeScript orchestrator that manages the relay-pty Rust binary.
5
+ * Uses mocks for child process and socket communication.
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
9
+ import { EventEmitter } from 'node:events';
10
+ import type { ChildProcess } from 'node:child_process';
11
+ import type { Socket } from 'node:net';
12
+ import { createHash } from 'node:crypto';
13
+
14
+ // Mock modules before importing the class
15
+ vi.mock('node:child_process', () => ({
16
+ spawn: vi.fn(),
17
+ }));
18
+
19
+ vi.mock('node:net', () => ({
20
+ createConnection: vi.fn(),
21
+ }));
22
+
23
+ const { mockExistsSync } = vi.hoisted(() => ({
24
+ mockExistsSync: vi.fn((path: string) => {
25
+ // Simulate relay-pty binary exists at any relay-pty path
26
+ return typeof path === 'string' && path.includes('relay-pty');
27
+ }),
28
+ }));
29
+
30
+ vi.mock('node:fs', async (importOriginal) => {
31
+ const actual = await importOriginal<typeof import('node:fs')>();
32
+ return {
33
+ ...actual,
34
+ existsSync: mockExistsSync,
35
+ default: {
36
+ ...actual,
37
+ existsSync: mockExistsSync,
38
+ },
39
+ };
40
+ });
41
+
42
+ // Mock the client module
43
+ vi.mock('./client.js', () => ({
44
+ RelayClient: vi.fn().mockImplementation((options: any) => ({
45
+ name: options.agentName,
46
+ state: 'READY' as string,
47
+ connect: vi.fn().mockResolvedValue(undefined),
48
+ sendMessage: vi.fn().mockReturnValue(true),
49
+ sendLog: vi.fn(),
50
+ destroy: vi.fn(),
51
+ onMessage: null,
52
+ onChannelMessage: null,
53
+ })),
54
+ }));
55
+
56
+ // Mock continuity
57
+ vi.mock('@agent-relay/continuity', () => ({
58
+ getContinuityManager: vi.fn(() => null),
59
+ parseContinuityCommand: vi.fn(),
60
+ hasContinuityCommand: vi.fn(() => false),
61
+ }));
62
+
63
+ // Now import after mocks
64
+ import { spawn } from 'node:child_process';
65
+ import { createConnection } from 'node:net';
66
+ import { RelayPtyOrchestrator } from './relay-pty-orchestrator.js';
67
+
68
+ /**
69
+ * Create a mock ChildProcess
70
+ */
71
+ function createMockProcess(): ChildProcess {
72
+ const proc = new EventEmitter() as ChildProcess;
73
+ proc.stdout = new EventEmitter() as any;
74
+ proc.stderr = new EventEmitter() as any;
75
+ proc.stdin = { write: vi.fn() } as any;
76
+ proc.pid = 12345;
77
+ proc.killed = false;
78
+ proc.kill = vi.fn(() => {
79
+ proc.killed = true;
80
+ setTimeout(() => proc.emit('exit', 0, null), 0);
81
+ return true;
82
+ });
83
+ proc.exitCode = null;
84
+ return proc;
85
+ }
86
+
87
+ /**
88
+ * Create a mock Socket
89
+ */
90
+ function createMockSocket(): Socket {
91
+ const socket = new EventEmitter() as Socket;
92
+ socket.write = vi.fn((data: any, cb?: any) => {
93
+ if (typeof cb === 'function') cb();
94
+ return true;
95
+ });
96
+ socket.destroy = vi.fn();
97
+ (socket as any).destroyed = false;
98
+ return socket;
99
+ }
100
+
101
+ describe('RelayPtyOrchestrator', () => {
102
+ let orchestrator: RelayPtyOrchestrator;
103
+ let mockProcess: ChildProcess;
104
+ let mockSocket: Socket;
105
+ const mockSpawn = spawn as unknown as ReturnType<typeof vi.fn>;
106
+ const mockCreateConnection = createConnection as unknown as ReturnType<typeof vi.fn>;
107
+
108
+ // Save original WORKSPACE_ID to restore after each test
109
+ let originalWorkspaceId: string | undefined;
110
+
111
+ beforeEach(() => {
112
+ vi.clearAllMocks();
113
+
114
+ // Save and clear WORKSPACE_ID to test legacy paths by default
115
+ // Tests that need workspace namespacing can set it explicitly
116
+ originalWorkspaceId = process.env.WORKSPACE_ID;
117
+ delete process.env.WORKSPACE_ID;
118
+
119
+ // Reset existsSync mock to default implementation
120
+ mockExistsSync.mockImplementation((path: string) => {
121
+ return typeof path === 'string' && path.includes('relay-pty');
122
+ });
123
+
124
+ // Set up mock process
125
+ mockProcess = createMockProcess();
126
+ mockSpawn.mockReturnValue(mockProcess);
127
+
128
+ // Set up mock socket
129
+ mockSocket = createMockSocket();
130
+ mockCreateConnection.mockImplementation((_path: string, callback: () => void) => {
131
+ setTimeout(() => callback(), 10);
132
+ return mockSocket;
133
+ });
134
+ });
135
+
136
+ afterEach(async () => {
137
+ if (orchestrator?.isRunning) {
138
+ await orchestrator.stop();
139
+ }
140
+
141
+ // Restore original WORKSPACE_ID
142
+ if (originalWorkspaceId !== undefined) {
143
+ process.env.WORKSPACE_ID = originalWorkspaceId;
144
+ } else {
145
+ delete process.env.WORKSPACE_ID;
146
+ }
147
+ });
148
+
149
+ describe('constructor', () => {
150
+ it('sets socket path based on agent name', () => {
151
+ orchestrator = new RelayPtyOrchestrator({
152
+ name: 'TestAgent',
153
+ command: 'claude',
154
+ });
155
+
156
+ // Local mode uses ~/.agent-relay paths
157
+ expect(orchestrator.getSocketPath()).toContain('.agent-relay');
158
+ expect(orchestrator.getSocketPath()).toContain('TestAgent.sock');
159
+ });
160
+
161
+ it('uses workspace-namespaced paths when WORKSPACE_ID is in config.env', () => {
162
+ orchestrator = new RelayPtyOrchestrator({
163
+ name: 'TestAgent',
164
+ command: 'claude',
165
+ env: { WORKSPACE_ID: 'ws-12345' },
166
+ });
167
+
168
+ expect(orchestrator.getSocketPath()).toBe('/tmp/relay/ws-12345/sockets/TestAgent.sock');
169
+ expect(orchestrator.outboxPath).toBe('/tmp/relay/ws-12345/outbox/TestAgent');
170
+ });
171
+
172
+ it('hashes workspace id when socket path is too long', () => {
173
+ const longWorkspaceId = `ws-${'a'.repeat(140)}`;
174
+ const hashedWorkspaceId = createHash('sha256').update(longWorkspaceId).digest('hex').slice(0, 12);
175
+
176
+ orchestrator = new RelayPtyOrchestrator({
177
+ name: 'LongAgent',
178
+ command: 'claude',
179
+ env: { WORKSPACE_ID: longWorkspaceId },
180
+ });
181
+
182
+ expect(orchestrator.getSocketPath()).toBe(`/tmp/relay/${hashedWorkspaceId}/sockets/LongAgent.sock`);
183
+ expect(orchestrator.outboxPath).toBe(`/tmp/relay/${hashedWorkspaceId}/outbox/LongAgent`);
184
+ });
185
+
186
+ it('uses workspace-namespaced paths when WORKSPACE_ID is in process.env', () => {
187
+ const originalEnv = process.env.WORKSPACE_ID;
188
+ process.env.WORKSPACE_ID = 'ws-cloud-99';
189
+
190
+ try {
191
+ orchestrator = new RelayPtyOrchestrator({
192
+ name: 'CloudAgent',
193
+ command: 'claude',
194
+ });
195
+
196
+ expect(orchestrator.getSocketPath()).toBe('/tmp/relay/ws-cloud-99/sockets/CloudAgent.sock');
197
+ expect(orchestrator.outboxPath).toBe('/tmp/relay/ws-cloud-99/outbox/CloudAgent');
198
+ } finally {
199
+ if (originalEnv === undefined) {
200
+ delete process.env.WORKSPACE_ID;
201
+ } else {
202
+ process.env.WORKSPACE_ID = originalEnv;
203
+ }
204
+ }
205
+ });
206
+
207
+ it('uses canonical ~/.agent-relay paths when WORKSPACE_ID is not set', () => {
208
+ // beforeEach already clears WORKSPACE_ID
209
+ orchestrator = new RelayPtyOrchestrator({
210
+ name: 'LocalAgent',
211
+ command: 'claude',
212
+ });
213
+
214
+ // Local mode uses ~/.agent-relay paths, not /tmp
215
+ expect(orchestrator.getSocketPath()).toContain('.agent-relay');
216
+ expect(orchestrator.getSocketPath()).toContain('LocalAgent.sock');
217
+ expect(orchestrator.outboxPath).toContain('.agent-relay');
218
+ expect(orchestrator.outboxPath).toContain('outbox/LocalAgent');
219
+ expect(orchestrator.outboxPath).not.toContain('/tmp/');
220
+ });
221
+ });
222
+
223
+ describe('binary detection', () => {
224
+ it('finds binary at release path', async () => {
225
+ orchestrator = new RelayPtyOrchestrator({
226
+ name: 'TestAgent',
227
+ command: 'claude',
228
+ });
229
+
230
+ await orchestrator.start();
231
+
232
+ expect(mockSpawn).toHaveBeenCalled();
233
+ const spawnCall = mockSpawn.mock.calls[0];
234
+ expect(spawnCall[0]).toContain('relay-pty');
235
+ });
236
+
237
+ it('uses custom binary path if provided', async () => {
238
+ // Update mock to accept custom path
239
+ mockExistsSync.mockImplementation((path: string) => {
240
+ return path === '/custom/path/relay-pty' || (typeof path === 'string' && path.includes('relay-pty'));
241
+ });
242
+
243
+ orchestrator = new RelayPtyOrchestrator({
244
+ name: 'TestAgent',
245
+ command: 'claude',
246
+ relayPtyPath: '/custom/path/relay-pty',
247
+ });
248
+
249
+ await orchestrator.start();
250
+
251
+ const spawnCall = mockSpawn.mock.calls[0];
252
+ expect(spawnCall[0]).toBe('/custom/path/relay-pty');
253
+
254
+ // Reset mock
255
+ mockExistsSync.mockImplementation((path: string) => {
256
+ return typeof path === 'string' && path.includes('relay-pty');
257
+ });
258
+ });
259
+ });
260
+
261
+ describe('process management', () => {
262
+ it('spawns relay-pty with correct arguments', async () => {
263
+ orchestrator = new RelayPtyOrchestrator({
264
+ name: 'TestAgent',
265
+ command: 'claude',
266
+ args: ['--model', 'opus'],
267
+ idleBeforeInjectMs: 1000,
268
+ });
269
+
270
+ await orchestrator.start();
271
+
272
+ const spawnCall = mockSpawn.mock.calls[0];
273
+ const args = spawnCall[1] as string[];
274
+
275
+ expect(args).toContain('--name');
276
+ expect(args).toContain('TestAgent');
277
+ expect(args).toContain('--socket');
278
+ // Socket path should be in ~/.agent-relay for local mode
279
+ const socketArg = args[args.indexOf('--socket') + 1];
280
+ expect(socketArg).toContain('.agent-relay');
281
+ expect(socketArg).toContain('TestAgent.sock');
282
+ expect(args).toContain('--idle-timeout');
283
+ expect(args).toContain('1000');
284
+ expect(args).toContain('--');
285
+ expect(args).toContain('claude');
286
+ expect(args).toContain('--model');
287
+ expect(args).toContain('opus');
288
+ });
289
+
290
+ it('sets environment variables', async () => {
291
+ orchestrator = new RelayPtyOrchestrator({
292
+ name: 'TestAgent',
293
+ command: 'claude',
294
+ env: { CUSTOM_VAR: 'value' },
295
+ });
296
+
297
+ await orchestrator.start();
298
+
299
+ const spawnCall = mockSpawn.mock.calls[0];
300
+ const options = spawnCall[2];
301
+
302
+ expect(options.env.AGENT_RELAY_NAME).toBe('TestAgent');
303
+ expect(options.env.TERM).toBe('xterm-256color');
304
+ expect(options.env.CUSTOM_VAR).toBe('value');
305
+ });
306
+
307
+ it('emits exit event when process exits', async () => {
308
+ orchestrator = new RelayPtyOrchestrator({
309
+ name: 'TestAgent',
310
+ command: 'claude',
311
+ });
312
+
313
+ const exitHandler = vi.fn();
314
+ orchestrator.on('exit', exitHandler);
315
+
316
+ await orchestrator.start();
317
+
318
+ // Simulate process exit
319
+ mockProcess.emit('exit', 0, null);
320
+
321
+ expect(exitHandler).toHaveBeenCalledWith(0);
322
+ });
323
+
324
+ it('calls onExit callback', async () => {
325
+ const onExit = vi.fn();
326
+ orchestrator = new RelayPtyOrchestrator({
327
+ name: 'TestAgent',
328
+ command: 'claude',
329
+ onExit,
330
+ });
331
+
332
+ await orchestrator.start();
333
+ mockProcess.emit('exit', 1, null);
334
+
335
+ expect(onExit).toHaveBeenCalledWith(1);
336
+ });
337
+ });
338
+
339
+ describe('socket communication', () => {
340
+ it('connects to socket after spawn', async () => {
341
+ orchestrator = new RelayPtyOrchestrator({
342
+ name: 'TestAgent',
343
+ command: 'claude',
344
+ });
345
+
346
+ await orchestrator.start();
347
+
348
+ // Should connect to the socket at ~/.agent-relay path for local mode
349
+ expect(mockCreateConnection).toHaveBeenCalled();
350
+ const socketPath = mockCreateConnection.mock.calls[0][0];
351
+ expect(socketPath).toContain('.agent-relay');
352
+ expect(socketPath).toContain('TestAgent.sock');
353
+ });
354
+
355
+ it('retries socket connection on failure', async () => {
356
+ orchestrator = new RelayPtyOrchestrator({
357
+ name: 'TestAgent',
358
+ command: 'claude',
359
+ socketConnectTimeoutMs: 100,
360
+ socketReconnectAttempts: 3,
361
+ });
362
+
363
+ // First two attempts fail, third succeeds
364
+ let attempts = 0;
365
+ mockCreateConnection.mockImplementation((_path: string, callback: () => void) => {
366
+ const sock = createMockSocket();
367
+ attempts++;
368
+ if (attempts < 3) {
369
+ setTimeout(() => sock.emit('error', new Error('Connection refused')), 10);
370
+ } else {
371
+ setTimeout(() => callback(), 10);
372
+ }
373
+ return sock;
374
+ });
375
+
376
+ await orchestrator.start();
377
+
378
+ expect(mockCreateConnection).toHaveBeenCalledTimes(3);
379
+ });
380
+
381
+ it('handles socket close', async () => {
382
+ orchestrator = new RelayPtyOrchestrator({
383
+ name: 'TestAgent',
384
+ command: 'claude',
385
+ });
386
+
387
+ await orchestrator.start();
388
+ mockSocket.emit('close');
389
+
390
+ // Socket should be marked as disconnected
391
+ // (Internal state, verified by inability to inject)
392
+ });
393
+ });
394
+
395
+ describe('output handling', () => {
396
+ it('emits output event for stdout data', async () => {
397
+ orchestrator = new RelayPtyOrchestrator({
398
+ name: 'TestAgent',
399
+ command: 'claude',
400
+ });
401
+
402
+ const outputHandler = vi.fn();
403
+ orchestrator.on('output', outputHandler);
404
+
405
+ await orchestrator.start();
406
+ mockProcess.stdout!.emit('data', Buffer.from('Hello from agent'));
407
+
408
+ expect(outputHandler).toHaveBeenCalledWith('Hello from agent');
409
+ });
410
+
411
+ it('accumulates raw output buffer', async () => {
412
+ orchestrator = new RelayPtyOrchestrator({
413
+ name: 'TestAgent',
414
+ command: 'claude',
415
+ });
416
+
417
+ await orchestrator.start();
418
+ mockProcess.stdout!.emit('data', Buffer.from('Line 1\n'));
419
+ mockProcess.stdout!.emit('data', Buffer.from('Line 2\n'));
420
+
421
+ expect(orchestrator.getRawOutput()).toContain('Line 1');
422
+ expect(orchestrator.getRawOutput()).toContain('Line 2');
423
+ });
424
+
425
+ it('parses relay commands from output', async () => {
426
+ orchestrator = new RelayPtyOrchestrator({
427
+ name: 'TestAgent',
428
+ command: 'claude',
429
+ });
430
+
431
+ await orchestrator.start();
432
+
433
+ // Access the client mock to verify sendMessage calls
434
+ const client = (orchestrator as any).client;
435
+
436
+ // Emit output containing a relay command
437
+ mockProcess.stdout!.emit('data', Buffer.from('->relay:Bob Hello Bob!\n'));
438
+
439
+ // Allow async parsing
440
+ await new Promise(resolve => setTimeout(resolve, 10));
441
+
442
+ expect(client.sendMessage).toHaveBeenCalled();
443
+ });
444
+ });
445
+
446
+ describe('message injection', () => {
447
+ it('processes queued messages when ready', async () => {
448
+ orchestrator = new RelayPtyOrchestrator({
449
+ name: 'TestAgent',
450
+ command: 'claude',
451
+ });
452
+
453
+ await orchestrator.start();
454
+
455
+ // Trigger message handler (normally done by RelayClient)
456
+ const handler = (orchestrator as any).handleIncomingMessage.bind(orchestrator);
457
+ handler('Sender', { body: 'Test message', kind: 'message' }, 'msg-123');
458
+
459
+ // Allow async processing
460
+ await new Promise(resolve => setTimeout(resolve, 100));
461
+
462
+ // Verify socket write was called with inject request
463
+ expect(mockSocket.write).toHaveBeenCalled();
464
+ const writeCall = (mockSocket.write as ReturnType<typeof vi.fn>).mock.calls[0][0];
465
+ expect(writeCall).toContain('"type":"inject"');
466
+ expect(writeCall).toContain('msg-123');
467
+ });
468
+
469
+ it('handles inject_result responses', async () => {
470
+ orchestrator = new RelayPtyOrchestrator({
471
+ name: 'TestAgent',
472
+ command: 'claude',
473
+ });
474
+
475
+ await orchestrator.start();
476
+
477
+ // Trigger a message to inject
478
+ const handler = (orchestrator as any).handleIncomingMessage.bind(orchestrator);
479
+ handler('Sender', { body: 'Test message', kind: 'message' }, 'msg-456');
480
+
481
+ await new Promise(resolve => setTimeout(resolve, 50));
482
+
483
+ // Simulate output containing the injected message pattern
484
+ // This is needed because handleInjectResult now verifies the message appeared in output
485
+ mockProcess.stdout?.emit('data', Buffer.from(
486
+ 'Relay message from Sender [msg-456]: Test message\n'
487
+ ));
488
+
489
+ await new Promise(resolve => setTimeout(resolve, 50));
490
+
491
+ // Simulate successful delivery response
492
+ mockSocket.emit('data', Buffer.from(JSON.stringify({
493
+ type: 'inject_result',
494
+ id: 'msg-456',
495
+ status: 'delivered',
496
+ timestamp: Date.now(),
497
+ }) + '\n'));
498
+
499
+ // Allow time for async verification (verifyInjection polls for up to 2 seconds)
500
+ await new Promise(resolve => setTimeout(resolve, 200));
501
+
502
+ // Check metrics
503
+ const metrics = orchestrator.getInjectionMetrics();
504
+ expect(metrics.total).toBeGreaterThan(0);
505
+ expect(metrics.successFirstTry).toBeGreaterThan(0);
506
+ });
507
+
508
+ it('handles backpressure', async () => {
509
+ orchestrator = new RelayPtyOrchestrator({
510
+ name: 'TestAgent',
511
+ command: 'claude',
512
+ });
513
+
514
+ const backpressureHandler = vi.fn();
515
+ orchestrator.on('backpressure', backpressureHandler);
516
+
517
+ await orchestrator.start();
518
+
519
+ // Simulate backpressure response
520
+ mockSocket.emit('data', Buffer.from(JSON.stringify({
521
+ type: 'backpressure',
522
+ queue_length: 50,
523
+ accept: false,
524
+ }) + '\n'));
525
+
526
+ expect(backpressureHandler).toHaveBeenCalledWith({
527
+ queueLength: 50,
528
+ accept: false,
529
+ });
530
+ expect(orchestrator.isBackpressureActive()).toBe(true);
531
+
532
+ // Clear backpressure
533
+ mockSocket.emit('data', Buffer.from(JSON.stringify({
534
+ type: 'backpressure',
535
+ queue_length: 5,
536
+ accept: true,
537
+ }) + '\n'));
538
+
539
+ expect(orchestrator.isBackpressureActive()).toBe(false);
540
+ });
541
+ });
542
+
543
+ describe('lifecycle', () => {
544
+ it('tracks running state', async () => {
545
+ orchestrator = new RelayPtyOrchestrator({
546
+ name: 'TestAgent',
547
+ command: 'claude',
548
+ });
549
+
550
+ expect(orchestrator.isRunning).toBe(false);
551
+
552
+ await orchestrator.start();
553
+ expect(orchestrator.isRunning).toBe(true);
554
+
555
+ await orchestrator.stop();
556
+ expect(orchestrator.isRunning).toBe(false);
557
+ });
558
+
559
+ it('sends shutdown command on stop', async () => {
560
+ orchestrator = new RelayPtyOrchestrator({
561
+ name: 'TestAgent',
562
+ command: 'claude',
563
+ });
564
+
565
+ await orchestrator.start();
566
+ await orchestrator.stop();
567
+
568
+ // Verify shutdown request was sent
569
+ const writeCalls = (mockSocket.write as ReturnType<typeof vi.fn>).mock.calls;
570
+ const shutdownCall = writeCalls.find((call: any[]) =>
571
+ call[0].includes('"type":"shutdown"')
572
+ );
573
+ expect(shutdownCall).toBeDefined();
574
+ });
575
+
576
+ it('kills process on stop', async () => {
577
+ orchestrator = new RelayPtyOrchestrator({
578
+ name: 'TestAgent',
579
+ command: 'claude',
580
+ });
581
+
582
+ await orchestrator.start();
583
+
584
+ // Simulate process not exiting gracefully
585
+ const stopPromise = orchestrator.stop();
586
+
587
+ // Emit exit after kill
588
+ setTimeout(() => mockProcess.emit('exit', 0, null), 100);
589
+
590
+ await stopPromise;
591
+
592
+ expect(mockProcess.kill).toHaveBeenCalled();
593
+ });
594
+
595
+ it('returns PID', async () => {
596
+ orchestrator = new RelayPtyOrchestrator({
597
+ name: 'TestAgent',
598
+ command: 'claude',
599
+ });
600
+
601
+ await orchestrator.start();
602
+
603
+ expect(orchestrator.pid).toBe(12345);
604
+ });
605
+ });
606
+
607
+ describe('summary and session end detection', () => {
608
+ it('emits summary event', async () => {
609
+ orchestrator = new RelayPtyOrchestrator({
610
+ name: 'TestAgent',
611
+ command: 'claude',
612
+ });
613
+
614
+ const summaryHandler = vi.fn();
615
+ orchestrator.on('summary', summaryHandler);
616
+
617
+ await orchestrator.start();
618
+
619
+ // Emit output with summary block
620
+ mockProcess.stdout!.emit('data', Buffer.from(
621
+ '[[SUMMARY]]{"currentTask": "Test task", "completedTasks": ["Task 1"]}[[/SUMMARY]]'
622
+ ));
623
+
624
+ await new Promise(resolve => setTimeout(resolve, 10));
625
+
626
+ expect(summaryHandler).toHaveBeenCalled();
627
+ expect(summaryHandler.mock.calls[0][0].agentName).toBe('TestAgent');
628
+ });
629
+
630
+ it('emits session-end event', async () => {
631
+ orchestrator = new RelayPtyOrchestrator({
632
+ name: 'TestAgent',
633
+ command: 'claude',
634
+ });
635
+
636
+ const sessionEndHandler = vi.fn();
637
+ orchestrator.on('session-end', sessionEndHandler);
638
+
639
+ await orchestrator.start();
640
+
641
+ // Emit output with session end
642
+ mockProcess.stdout!.emit('data', Buffer.from(
643
+ '[[SESSION_END]]Work complete.[[/SESSION_END]]'
644
+ ));
645
+
646
+ await new Promise(resolve => setTimeout(resolve, 10));
647
+
648
+ expect(sessionEndHandler).toHaveBeenCalled();
649
+ expect(sessionEndHandler.mock.calls[0][0].agentName).toBe('TestAgent');
650
+ });
651
+ });
652
+
653
+ describe('spawn with auto-send task', () => {
654
+ let fetchMock: ReturnType<typeof vi.fn>;
655
+ let originalFetch: typeof globalThis.fetch;
656
+
657
+ beforeEach(() => {
658
+ originalFetch = globalThis.fetch;
659
+ fetchMock = vi.fn();
660
+ globalThis.fetch = fetchMock;
661
+ });
662
+
663
+ afterEach(() => {
664
+ globalThis.fetch = originalFetch;
665
+ });
666
+
667
+ it('calls spawn API when dashboard port is configured', async () => {
668
+ // Mock successful spawn API response
669
+ fetchMock.mockResolvedValueOnce({
670
+ ok: true,
671
+ json: () => Promise.resolve({ success: true }),
672
+ });
673
+
674
+ orchestrator = new RelayPtyOrchestrator({
675
+ name: 'LeadAgent',
676
+ command: 'claude',
677
+ dashboardPort: 3000,
678
+ });
679
+
680
+ await orchestrator.start();
681
+
682
+ // Access the private method via prototype - simulate spawn command detection
683
+ // We'll trigger it by emitting spawn command in output
684
+ // Note: Use "DevWorker" instead of "Worker" since "worker" is a placeholder target
685
+ mockProcess.stdout!.emit('data', Buffer.from(
686
+ '->relay:spawn DevWorker claude "Implement feature X"\n'
687
+ ));
688
+
689
+ // Wait for async spawn processing
690
+ await new Promise(resolve => setTimeout(resolve, 50));
691
+
692
+ // Verify spawn API was called with task included
693
+ // Note: The spawner (not orchestrator) sends the initial task after waitUntilCliReady()
694
+ expect(fetchMock).toHaveBeenCalledWith(
695
+ 'http://localhost:3000/api/spawn',
696
+ expect.objectContaining({
697
+ method: 'POST',
698
+ headers: { 'Content-Type': 'application/json' },
699
+ body: JSON.stringify({ name: 'DevWorker', cli: 'claude', task: 'Implement feature X' }),
700
+ })
701
+ );
702
+ });
703
+
704
+ it('calls onSpawn callback with task when no dashboard port', async () => {
705
+ const onSpawnMock = vi.fn().mockResolvedValue(undefined);
706
+
707
+ orchestrator = new RelayPtyOrchestrator({
708
+ name: 'LeadAgent',
709
+ command: 'claude',
710
+ onSpawn: onSpawnMock,
711
+ });
712
+
713
+ await orchestrator.start();
714
+
715
+ // Trigger spawn command
716
+ // Note: Use "CodeDev" instead of "Developer" to avoid any potential placeholder filtering
717
+ mockProcess.stdout!.emit('data', Buffer.from(
718
+ '->relay:spawn CodeDev claude "Fix the bug"\n'
719
+ ));
720
+
721
+ // Wait for async spawn processing
722
+ await new Promise(resolve => setTimeout(resolve, 50));
723
+
724
+ // Verify onSpawn was called with task included
725
+ // Note: The callback is responsible for sending the initial task
726
+ expect(onSpawnMock).toHaveBeenCalledWith('CodeDev', 'claude', 'Fix the bug');
727
+ });
728
+
729
+ it('does not send task message when task is empty', async () => {
730
+ const onSpawnMock = vi.fn().mockResolvedValue(undefined);
731
+
732
+ orchestrator = new RelayPtyOrchestrator({
733
+ name: 'LeadAgent',
734
+ command: 'claude',
735
+ onSpawn: onSpawnMock,
736
+ });
737
+
738
+ await orchestrator.start();
739
+
740
+ // Clear any previous calls
741
+ const { RelayClient } = await import('./client.js');
742
+ const mockClientInstance = (RelayClient as any).mock.results[0].value;
743
+ mockClientInstance.sendMessage.mockClear();
744
+
745
+ // Trigger spawn command with empty task (using fenced format with whitespace only)
746
+ // Note: Use "DevAgent" instead of "Worker" since "worker" is a placeholder target
747
+ mockProcess.stdout!.emit('data', Buffer.from(
748
+ '->relay:spawn DevAgent claude ""\n'
749
+ ));
750
+
751
+ // Wait for async spawn processing
752
+ await new Promise(resolve => setTimeout(resolve, 50));
753
+
754
+ // Verify no task message was sent (empty task)
755
+ expect(mockClientInstance.sendMessage).not.toHaveBeenCalled();
756
+ });
757
+
758
+ it('deduplicates spawn commands (only spawns once)', async () => {
759
+ const onSpawnMock = vi.fn().mockResolvedValue(undefined);
760
+
761
+ orchestrator = new RelayPtyOrchestrator({
762
+ name: 'LeadAgent',
763
+ command: 'claude',
764
+ onSpawn: onSpawnMock,
765
+ });
766
+
767
+ await orchestrator.start();
768
+
769
+ // Trigger same spawn command twice
770
+ // Note: Use "TaskAgent" instead of "Worker" since "worker" is a placeholder target
771
+ mockProcess.stdout!.emit('data', Buffer.from(
772
+ '->relay:spawn TaskAgent claude "Task A"\n'
773
+ ));
774
+ mockProcess.stdout!.emit('data', Buffer.from(
775
+ '->relay:spawn TaskAgent claude "Task A"\n'
776
+ ));
777
+
778
+ // Wait for async spawn processing
779
+ await new Promise(resolve => setTimeout(resolve, 50));
780
+
781
+ // onSpawn should only be called once (deduplication)
782
+ expect(onSpawnMock).toHaveBeenCalledTimes(1);
783
+ });
784
+ });
785
+
786
+ describe('queue monitor', () => {
787
+ it('starts queue monitor on start()', async () => {
788
+ orchestrator = new RelayPtyOrchestrator({
789
+ name: 'TestAgent',
790
+ command: 'claude',
791
+ });
792
+
793
+ // Spy on setInterval
794
+ const setIntervalSpy = vi.spyOn(global, 'setInterval');
795
+
796
+ await orchestrator.start();
797
+
798
+ // Queue monitor should be started (30 second interval)
799
+ expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 30000);
800
+
801
+ setIntervalSpy.mockRestore();
802
+ });
803
+
804
+ it('stops queue monitor on stop()', async () => {
805
+ orchestrator = new RelayPtyOrchestrator({
806
+ name: 'TestAgent',
807
+ command: 'claude',
808
+ });
809
+
810
+ const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
811
+
812
+ await orchestrator.start();
813
+ await orchestrator.stop();
814
+
815
+ // Queue monitor should be cleared
816
+ expect(clearIntervalSpy).toHaveBeenCalled();
817
+
818
+ clearIntervalSpy.mockRestore();
819
+ });
820
+
821
+ it('triggers processMessageQueue when queue has stuck messages and agent is idle', async () => {
822
+ orchestrator = new RelayPtyOrchestrator({
823
+ name: 'TestAgent',
824
+ command: 'claude',
825
+ });
826
+
827
+ await orchestrator.start();
828
+
829
+ // Directly add a message to the queue (simulating a message that got stuck)
830
+ (orchestrator as any).messageQueue.push({
831
+ from: 'Alice',
832
+ body: 'Test message',
833
+ messageId: 'msg-123',
834
+ kind: 'message',
835
+ });
836
+
837
+ // Verify message is in queue
838
+ expect(orchestrator.pendingMessageCount).toBe(1);
839
+
840
+ // Spy on processMessageQueue to verify it gets called
841
+ const processQueueSpy = vi.spyOn(orchestrator as any, 'processMessageQueue');
842
+
843
+ // Simulate time passing (agent becomes idle - need 2000ms silence for checkForStuckQueue)
844
+ // Mock the idle detector to report idle
845
+ const idleDetector = (orchestrator as any).idleDetector;
846
+ vi.spyOn(idleDetector, 'checkIdle').mockReturnValue({
847
+ isIdle: true,
848
+ confidence: 0.9,
849
+ signals: [{ source: 'output_silence', confidence: 0.9, timestamp: Date.now() }],
850
+ });
851
+
852
+ // Manually trigger the queue check (simulating timer firing)
853
+ (orchestrator as any).checkForStuckQueue();
854
+
855
+ // processMessageQueue should have been called
856
+ expect(processQueueSpy).toHaveBeenCalled();
857
+
858
+ processQueueSpy.mockRestore();
859
+ });
860
+
861
+ it('does not trigger processing when agent is busy', async () => {
862
+ orchestrator = new RelayPtyOrchestrator({
863
+ name: 'TestAgent',
864
+ command: 'claude',
865
+ });
866
+
867
+ await orchestrator.start();
868
+
869
+ // Set isInjecting to true (agent is busy)
870
+ (orchestrator as any).isInjecting = true;
871
+
872
+ // Add a message to the queue directly
873
+ (orchestrator as any).messageQueue.push({
874
+ from: 'Bob',
875
+ body: 'Test message 2',
876
+ messageId: 'msg-456',
877
+ kind: 'message',
878
+ });
879
+
880
+ // Mock idle detector to report idle (to isolate the isInjecting check)
881
+ const idleDetector = (orchestrator as any).idleDetector;
882
+ vi.spyOn(idleDetector, 'checkIdle').mockReturnValue({
883
+ isIdle: true,
884
+ confidence: 0.9,
885
+ signals: [],
886
+ });
887
+
888
+ // Spy on processMessageQueue
889
+ const processQueueSpy = vi.spyOn(orchestrator as any, 'processMessageQueue');
890
+
891
+ // Trigger queue check while busy
892
+ (orchestrator as any).checkForStuckQueue();
893
+
894
+ // processMessageQueue should NOT be called because isInjecting=true
895
+ expect(processQueueSpy).not.toHaveBeenCalled();
896
+
897
+ // Reset
898
+ (orchestrator as any).isInjecting = false;
899
+ processQueueSpy.mockRestore();
900
+ });
901
+
902
+ it('does not trigger processing when backpressure is active', async () => {
903
+ orchestrator = new RelayPtyOrchestrator({
904
+ name: 'TestAgent',
905
+ command: 'claude',
906
+ });
907
+
908
+ await orchestrator.start();
909
+
910
+ // Simulate backpressure
911
+ mockSocket.emit('data', Buffer.from(JSON.stringify({
912
+ type: 'backpressure',
913
+ accept: false,
914
+ queue_length: 50,
915
+ }) + '\n'));
916
+
917
+ expect(orchestrator.isBackpressureActive()).toBe(true);
918
+
919
+ // Add a message to the queue
920
+ (orchestrator as any).messageQueue.push({
921
+ from: 'Carol',
922
+ body: 'Test message 3',
923
+ messageId: 'msg-789',
924
+ kind: 'message',
925
+ });
926
+
927
+ // Mock idle detector to report idle (to isolate the backpressure check)
928
+ const idleDetector = (orchestrator as any).idleDetector;
929
+ vi.spyOn(idleDetector, 'checkIdle').mockReturnValue({
930
+ isIdle: true,
931
+ confidence: 0.9,
932
+ signals: [],
933
+ });
934
+
935
+ // Spy on processMessageQueue
936
+ const processQueueSpy = vi.spyOn(orchestrator as any, 'processMessageQueue');
937
+
938
+ // Trigger queue check with backpressure active
939
+ (orchestrator as any).checkForStuckQueue();
940
+
941
+ // processMessageQueue should NOT be called because backpressure is active
942
+ expect(processQueueSpy).not.toHaveBeenCalled();
943
+
944
+ processQueueSpy.mockRestore();
945
+ });
946
+
947
+ it('does not trigger processing when queue is empty', async () => {
948
+ orchestrator = new RelayPtyOrchestrator({
949
+ name: 'TestAgent',
950
+ command: 'claude',
951
+ });
952
+
953
+ await orchestrator.start();
954
+
955
+ // Queue should be empty
956
+ expect(orchestrator.pendingMessageCount).toBe(0);
957
+
958
+ // Spy on processMessageQueue
959
+ const processQueueSpy = vi.spyOn(orchestrator as any, 'processMessageQueue');
960
+
961
+ // Trigger queue check with empty queue
962
+ (orchestrator as any).checkForStuckQueue();
963
+
964
+ // processMessageQueue should not be called
965
+ expect(processQueueSpy).not.toHaveBeenCalled();
966
+
967
+ processQueueSpy.mockRestore();
968
+ });
969
+
970
+ it('does not trigger processing when agent is not idle', async () => {
971
+ orchestrator = new RelayPtyOrchestrator({
972
+ name: 'TestAgent',
973
+ command: 'claude',
974
+ });
975
+
976
+ await orchestrator.start();
977
+
978
+ // Add a message to the queue
979
+ (orchestrator as any).messageQueue.push({
980
+ from: 'Dave',
981
+ body: 'Test message 4',
982
+ messageId: 'msg-999',
983
+ kind: 'message',
984
+ });
985
+
986
+ // Mock idle detector to report NOT idle (agent is still working)
987
+ const idleDetector = (orchestrator as any).idleDetector;
988
+ vi.spyOn(idleDetector, 'checkIdle').mockReturnValue({
989
+ isIdle: false,
990
+ confidence: 0.3,
991
+ signals: [],
992
+ });
993
+
994
+ // Spy on processMessageQueue
995
+ const processQueueSpy = vi.spyOn(orchestrator as any, 'processMessageQueue');
996
+
997
+ // Trigger queue check while agent is active
998
+ (orchestrator as any).checkForStuckQueue();
999
+
1000
+ // processMessageQueue should NOT be called because agent is not idle
1001
+ expect(processQueueSpy).not.toHaveBeenCalled();
1002
+
1003
+ processQueueSpy.mockRestore();
1004
+ });
1005
+ });
1006
+ });
1007
+
1008
+ describe('RelayPtyOrchestrator integration', () => {
1009
+ // Integration tests would require the actual relay-pty binary
1010
+ // These are placeholder tests that would be run with:
1011
+ // npm test -- --testNamePattern="integration" --runInBand
1012
+
1013
+ it.skip('spawns real relay-pty with echo', async () => {
1014
+ // This test requires the relay-pty binary to be built
1015
+ const orchestrator = new RelayPtyOrchestrator({
1016
+ name: 'IntegrationTest',
1017
+ command: 'cat', // Simple command that echoes input
1018
+ });
1019
+
1020
+ await orchestrator.start();
1021
+
1022
+ // Inject a message
1023
+ // ... verify it appears in output
1024
+
1025
+ await orchestrator.stop();
1026
+ });
1027
+ });