@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.
- package/dist/__fixtures__/claude-outputs.d.ts +49 -0
- package/dist/__fixtures__/claude-outputs.d.ts.map +1 -0
- package/dist/__fixtures__/claude-outputs.js +443 -0
- package/dist/__fixtures__/claude-outputs.js.map +1 -0
- package/dist/__fixtures__/codex-outputs.d.ts +9 -0
- package/dist/__fixtures__/codex-outputs.d.ts.map +1 -0
- package/dist/__fixtures__/codex-outputs.js +94 -0
- package/dist/__fixtures__/codex-outputs.js.map +1 -0
- package/dist/__fixtures__/gemini-outputs.d.ts +19 -0
- package/dist/__fixtures__/gemini-outputs.d.ts.map +1 -0
- package/dist/__fixtures__/gemini-outputs.js +144 -0
- package/dist/__fixtures__/gemini-outputs.js.map +1 -0
- package/dist/__fixtures__/index.d.ts +68 -0
- package/dist/__fixtures__/index.d.ts.map +1 -0
- package/dist/__fixtures__/index.js +44 -0
- package/dist/__fixtures__/index.js.map +1 -0
- package/dist/auth-detection.d.ts +49 -0
- package/dist/auth-detection.d.ts.map +1 -0
- package/dist/auth-detection.js +199 -0
- package/dist/auth-detection.js.map +1 -0
- package/dist/base-wrapper.d.ts +225 -0
- package/dist/base-wrapper.d.ts.map +1 -0
- package/dist/base-wrapper.js +572 -0
- package/dist/base-wrapper.js.map +1 -0
- package/dist/client.d.ts +254 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +801 -0
- package/dist/client.js.map +1 -0
- package/dist/id-generator.d.ts +35 -0
- package/dist/id-generator.d.ts.map +1 -0
- package/dist/id-generator.js +60 -0
- package/dist/id-generator.js.map +1 -0
- package/dist/idle-detector.d.ts +110 -0
- package/dist/idle-detector.d.ts.map +1 -0
- package/dist/idle-detector.js +304 -0
- package/dist/idle-detector.js.map +1 -0
- package/dist/inbox.d.ts +37 -0
- package/dist/inbox.d.ts.map +1 -0
- package/dist/inbox.js +73 -0
- package/dist/inbox.js.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -0
- package/dist/parser.d.ts +236 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +1238 -0
- package/dist/parser.js.map +1 -0
- package/dist/prompt-composer.d.ts +67 -0
- package/dist/prompt-composer.d.ts.map +1 -0
- package/dist/prompt-composer.js +168 -0
- package/dist/prompt-composer.js.map +1 -0
- package/dist/relay-pty-orchestrator.d.ts +407 -0
- package/dist/relay-pty-orchestrator.d.ts.map +1 -0
- package/dist/relay-pty-orchestrator.js +1885 -0
- package/dist/relay-pty-orchestrator.js.map +1 -0
- package/dist/shared.d.ts +201 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +341 -0
- package/dist/shared.js.map +1 -0
- package/dist/stuck-detector.d.ts +161 -0
- package/dist/stuck-detector.d.ts.map +1 -0
- package/dist/stuck-detector.js +402 -0
- package/dist/stuck-detector.js.map +1 -0
- package/dist/tmux-resolver.d.ts +55 -0
- package/dist/tmux-resolver.d.ts.map +1 -0
- package/dist/tmux-resolver.js +175 -0
- package/dist/tmux-resolver.js.map +1 -0
- package/dist/tmux-wrapper.d.ts +345 -0
- package/dist/tmux-wrapper.d.ts.map +1 -0
- package/dist/tmux-wrapper.js +1747 -0
- package/dist/tmux-wrapper.js.map +1 -0
- package/dist/trajectory-integration.d.ts +292 -0
- package/dist/trajectory-integration.d.ts.map +1 -0
- package/dist/trajectory-integration.js +979 -0
- package/dist/trajectory-integration.js.map +1 -0
- package/dist/wrapper-types.d.ts +41 -0
- package/dist/wrapper-types.d.ts.map +1 -0
- package/dist/wrapper-types.js +7 -0
- package/dist/wrapper-types.js.map +1 -0
- package/package.json +63 -0
- package/src/__fixtures__/claude-outputs.ts +471 -0
- package/src/__fixtures__/codex-outputs.ts +99 -0
- package/src/__fixtures__/gemini-outputs.ts +151 -0
- package/src/__fixtures__/index.ts +47 -0
- package/src/auth-detection.ts +244 -0
- package/src/base-wrapper.test.ts +540 -0
- package/src/base-wrapper.ts +741 -0
- package/src/client.test.ts +262 -0
- package/src/client.ts +984 -0
- package/src/id-generator.test.ts +71 -0
- package/src/id-generator.ts +69 -0
- package/src/idle-detector.test.ts +390 -0
- package/src/idle-detector.ts +370 -0
- package/src/inbox.test.ts +233 -0
- package/src/inbox.ts +89 -0
- package/src/index.ts +170 -0
- package/src/parser.regression.test.ts +251 -0
- package/src/parser.test.ts +1359 -0
- package/src/parser.ts +1477 -0
- package/src/prompt-composer.test.ts +219 -0
- package/src/prompt-composer.ts +231 -0
- package/src/relay-pty-orchestrator.test.ts +1027 -0
- package/src/relay-pty-orchestrator.ts +2270 -0
- package/src/shared.test.ts +221 -0
- package/src/shared.ts +454 -0
- package/src/stuck-detector.test.ts +303 -0
- package/src/stuck-detector.ts +511 -0
- package/src/tmux-resolver.test.ts +104 -0
- package/src/tmux-resolver.ts +207 -0
- package/src/tmux-wrapper.test.ts +316 -0
- package/src/tmux-wrapper.ts +2010 -0
- package/src/trajectory-detection.test.ts +151 -0
- package/src/trajectory-integration.ts +1261 -0
- 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
|
+
});
|