@dmsdc-ai/aigentry-telepty 0.1.4 → 0.1.6

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.
@@ -0,0 +1,415 @@
1
+ 'use strict';
2
+
3
+ const { afterEach, beforeEach, test } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const { createSessionId, delay, startTestDaemon, waitFor } = require('../test-support/daemon-harness');
6
+
7
+ let harness;
8
+
9
+ function collectJsonMessages(ws) {
10
+ const messages = [];
11
+ ws.on('message', (chunk) => {
12
+ try {
13
+ messages.push(JSON.parse(chunk.toString()));
14
+ } catch {
15
+ // Ignore malformed payloads in tests.
16
+ }
17
+ });
18
+ return messages;
19
+ }
20
+
21
+ beforeEach(async () => {
22
+ harness = await startTestDaemon();
23
+ });
24
+
25
+ afterEach(async () => {
26
+ await harness.stop();
27
+ });
28
+
29
+ test('GET /api/sessions returns an empty array on a fresh daemon', async () => {
30
+ const result = await harness.request('/api/sessions');
31
+ assert.equal(result.status, 200);
32
+ assert.deepEqual(result.body, []);
33
+ });
34
+
35
+ test('spawned sessions appear in the list and duplicate IDs are rejected', async () => {
36
+ const sessionId = createSessionId('spawn');
37
+ const first = await harness.spawnSession(sessionId);
38
+ assert.equal(first.status, 201);
39
+ assert.equal(first.body.session_id, sessionId);
40
+
41
+ const duplicate = await harness.spawnSession(sessionId);
42
+ assert.equal(duplicate.status, 409);
43
+ assert.match(duplicate.body.error, /already active/i);
44
+
45
+ const list = await harness.request('/api/sessions');
46
+ assert.equal(list.status, 200);
47
+ assert.equal(list.body.length, 1);
48
+ assert.equal(list.body[0].id, sessionId);
49
+ assert.equal(list.body[0].active_clients, 0);
50
+ });
51
+
52
+ test('PATCH /api/sessions/:id renames the session and publishes a bus event', async () => {
53
+ const originalId = createSessionId('rename');
54
+ const newId = `${originalId}-renamed`;
55
+ await harness.spawnSession(originalId);
56
+
57
+ const bus = await harness.connectBus();
58
+ const messages = collectJsonMessages(bus);
59
+
60
+ const rename = await harness.request(`/api/sessions/${encodeURIComponent(originalId)}`, {
61
+ method: 'PATCH',
62
+ body: { new_id: newId }
63
+ });
64
+
65
+ assert.equal(rename.status, 200);
66
+ assert.equal(rename.body.new_id, newId);
67
+
68
+ await waitFor(() => messages.find((message) => (
69
+ message.type === 'session_rename' &&
70
+ message.old_id === originalId &&
71
+ message.new_id === newId
72
+ )), { description: 'rename bus event' });
73
+
74
+ const list = await harness.request('/api/sessions');
75
+ assert.equal(list.status, 200);
76
+ assert.equal(list.body.length, 1);
77
+ assert.equal(list.body[0].id, newId);
78
+
79
+ bus.close();
80
+ });
81
+
82
+ test('inject and multicast endpoints report success and partial failure correctly', async () => {
83
+ const sessionId = createSessionId('inject');
84
+ const missingId = createSessionId('missing');
85
+ await harness.spawnSession(sessionId);
86
+
87
+ const inject = await harness.request(`/api/sessions/${encodeURIComponent(sessionId)}/inject`, {
88
+ method: 'POST',
89
+ body: { prompt: 'echo injected' }
90
+ });
91
+ assert.equal(inject.status, 200);
92
+ assert.equal(inject.body.success, true);
93
+
94
+ const injectMissingPrompt = await harness.request(`/api/sessions/${encodeURIComponent(sessionId)}/inject`, {
95
+ method: 'POST',
96
+ body: {}
97
+ });
98
+ assert.equal(injectMissingPrompt.status, 400);
99
+
100
+ const multicast = await harness.request('/api/sessions/multicast/inject', {
101
+ method: 'POST',
102
+ body: {
103
+ session_ids: [sessionId, missingId],
104
+ prompt: 'echo multicast'
105
+ }
106
+ });
107
+
108
+ assert.equal(multicast.status, 200);
109
+ assert.deepEqual(multicast.body.results.successful, [sessionId]);
110
+ assert.equal(multicast.body.results.failed.length, 1);
111
+ assert.equal(multicast.body.results.failed[0].id, missingId);
112
+ });
113
+
114
+ test('broadcast inject publishes a single bus event with all successful target IDs', async () => {
115
+ const sessionA = createSessionId('broadcast-a');
116
+ const sessionB = createSessionId('broadcast-b');
117
+ await harness.spawnSession(sessionA);
118
+ await harness.spawnSession(sessionB);
119
+
120
+ const bus = await harness.connectBus();
121
+ const messages = collectJsonMessages(bus);
122
+
123
+ const prompt = `echo ${createSessionId('broadcast-token')}`;
124
+ const broadcast = await harness.request('/api/sessions/broadcast/inject', {
125
+ method: 'POST',
126
+ body: { prompt }
127
+ });
128
+
129
+ assert.equal(broadcast.status, 200);
130
+ assert.equal(broadcast.body.results.successful.length, 2);
131
+
132
+ await waitFor(() => messages.filter((message) => (
133
+ message.type === 'injection' &&
134
+ message.target_agent === 'all' &&
135
+ message.content === prompt
136
+ )).length === 1, { description: 'single broadcast bus event' });
137
+
138
+ await delay(100);
139
+
140
+ const event = messages.find((message) => message.type === 'injection' && message.content === prompt);
141
+ assert.equal(messages.filter((message) => message.type === 'injection' && message.content === prompt).length, 1);
142
+ assert.deepEqual(event.session_ids.slice().sort(), [sessionA, sessionB].sort());
143
+
144
+ bus.close();
145
+ });
146
+
147
+ test('session WebSocket updates active client counts and relays PTY output', async () => {
148
+ const sessionId = createSessionId('ws');
149
+ await harness.spawnSession(sessionId);
150
+
151
+ const firstClient = await harness.connectSession(sessionId);
152
+ const secondClient = await harness.connectSession(sessionId);
153
+ const outputs = collectJsonMessages(firstClient);
154
+
155
+ await waitFor(async () => {
156
+ const list = await harness.request('/api/sessions');
157
+ const session = list.body.find((item) => item.id === sessionId);
158
+ return session && session.active_clients === 2;
159
+ }, { description: 'two attached websocket clients' });
160
+
161
+ const token = createSessionId('ws-output');
162
+ firstClient.send(JSON.stringify({ type: 'input', data: `echo ${token}\r` }));
163
+
164
+ await waitFor(() => outputs.some((message) => (
165
+ message.type === 'output' && String(message.data).includes(token)
166
+ )), { timeoutMs: 7000, description: 'PTY output over websocket' });
167
+
168
+ secondClient.close();
169
+
170
+ await waitFor(async () => {
171
+ const list = await harness.request('/api/sessions');
172
+ const session = list.body.find((item) => item.id === sessionId);
173
+ return session && session.active_clients === 1;
174
+ }, { description: 'one attached websocket client after close' });
175
+
176
+ firstClient.close();
177
+
178
+ await waitFor(async () => {
179
+ const list = await harness.request('/api/sessions');
180
+ const session = list.body.find((item) => item.id === sessionId);
181
+ return session && session.active_clients === 0;
182
+ }, { description: 'zero attached websocket clients after close' });
183
+ });
184
+
185
+ test('DELETE /api/sessions/:id closes the session without crashing the daemon', async () => {
186
+ const sessionId = createSessionId('delete');
187
+ await harness.spawnSession(sessionId);
188
+
189
+ const destroy = await harness.request(`/api/sessions/${encodeURIComponent(sessionId)}`, {
190
+ method: 'DELETE'
191
+ });
192
+ assert.equal(destroy.status, 200);
193
+ assert.equal(destroy.body.status, 'closing');
194
+
195
+ await waitFor(async () => {
196
+ const list = await harness.request('/api/sessions');
197
+ return list.status === 200 && !list.body.some((session) => session.id === sessionId);
198
+ }, { description: 'session removal after delete' });
199
+
200
+ await delay(200);
201
+ assert.equal(harness.isAlive(), true, harness.getLogs().stderr || harness.getLogs().stdout);
202
+
203
+ const healthCheck = await harness.request('/api/sessions');
204
+ assert.equal(healthCheck.status, 200);
205
+ });
206
+
207
+ // --- Wrapped session (register) tests ---
208
+
209
+ test('POST /api/sessions/register creates a wrapped session with correct type', async () => {
210
+ const sessionId = createSessionId('register');
211
+ const result = await harness.registerSession(sessionId);
212
+ assert.equal(result.status, 201);
213
+ assert.equal(result.body.session_id, sessionId);
214
+ assert.equal(result.body.type, 'wrapped');
215
+
216
+ const list = await harness.request('/api/sessions');
217
+ assert.equal(list.body.length, 1);
218
+ assert.equal(list.body[0].id, sessionId);
219
+ assert.equal(list.body[0].type, 'wrapped');
220
+ });
221
+
222
+ test('register rejects missing session_id and duplicate IDs', async () => {
223
+ const noId = await harness.registerSession(undefined, { session_id: undefined });
224
+ assert.equal(noId.status, 400);
225
+
226
+ const sessionId = createSessionId('dup-reg');
227
+ await harness.registerSession(sessionId);
228
+ const duplicate = await harness.registerSession(sessionId);
229
+ assert.equal(duplicate.status, 409);
230
+ assert.match(duplicate.body.error, /already active/i);
231
+ });
232
+
233
+ test('register and spawn share the same namespace (cross-type duplicate rejection)', async () => {
234
+ const sessionId = createSessionId('cross');
235
+ await harness.spawnSession(sessionId);
236
+ const dup = await harness.registerSession(sessionId);
237
+ assert.equal(dup.status, 409);
238
+
239
+ const sessionId2 = createSessionId('cross2');
240
+ await harness.registerSession(sessionId2);
241
+ const dup2 = await harness.spawnSession(sessionId2);
242
+ assert.equal(dup2.status, 409);
243
+ });
244
+
245
+ test('register publishes a session_register bus event', async () => {
246
+ const bus = await harness.connectBus();
247
+ const messages = collectJsonMessages(bus);
248
+
249
+ const sessionId = createSessionId('bus-reg');
250
+ await harness.registerSession(sessionId);
251
+
252
+ await waitFor(() => messages.find((message) => (
253
+ message.type === 'session_register' &&
254
+ message.session_id === sessionId
255
+ )), { description: 'register bus event' });
256
+
257
+ bus.close();
258
+ });
259
+
260
+ test('inject on wrapped session without owner returns 503', async () => {
261
+ const sessionId = createSessionId('no-owner');
262
+ await harness.registerSession(sessionId);
263
+
264
+ const inject = await harness.request(`/api/sessions/${encodeURIComponent(sessionId)}/inject`, {
265
+ method: 'POST',
266
+ body: { prompt: 'hello' }
267
+ });
268
+ assert.equal(inject.status, 503);
269
+ assert.match(inject.body.error, /not connected/i);
270
+ });
271
+
272
+ test('inject on wrapped session forwards to owner WebSocket', async () => {
273
+ const sessionId = createSessionId('owner-inject');
274
+ await harness.registerSession(sessionId);
275
+
276
+ // First WebSocket connector becomes owner
277
+ const ownerWs = await harness.connectSession(sessionId);
278
+ const ownerMessages = collectJsonMessages(ownerWs);
279
+
280
+ const inject = await harness.request(`/api/sessions/${encodeURIComponent(sessionId)}/inject`, {
281
+ method: 'POST',
282
+ body: { prompt: 'injected-text' }
283
+ });
284
+ assert.equal(inject.status, 200);
285
+ assert.equal(inject.body.success, true);
286
+
287
+ await waitFor(() => ownerMessages.find((message) => (
288
+ message.type === 'inject' && String(message.data).includes('injected-text')
289
+ )), { description: 'inject message forwarded to owner' });
290
+
291
+ ownerWs.close();
292
+ });
293
+
294
+ test('wrapped session owner output broadcasts to attached clients', async () => {
295
+ const sessionId = createSessionId('owner-broadcast');
296
+ await harness.registerSession(sessionId);
297
+
298
+ const ownerWs = await harness.connectSession(sessionId);
299
+ const viewerWs = await harness.connectSession(sessionId);
300
+ const viewerMessages = collectJsonMessages(viewerWs);
301
+
302
+ // Owner sends output
303
+ ownerWs.send(JSON.stringify({ type: 'output', data: 'hello-viewer' }));
304
+
305
+ await waitFor(() => viewerMessages.find((message) => (
306
+ message.type === 'output' && String(message.data).includes('hello-viewer')
307
+ )), { description: 'owner output relayed to viewer' });
308
+
309
+ ownerWs.close();
310
+ viewerWs.close();
311
+ });
312
+
313
+ test('wrapped session non-owner input forwards to owner as inject', async () => {
314
+ const sessionId = createSessionId('viewer-input');
315
+ await harness.registerSession(sessionId);
316
+
317
+ const ownerWs = await harness.connectSession(sessionId);
318
+ const ownerMessages = collectJsonMessages(ownerWs);
319
+ const viewerWs = await harness.connectSession(sessionId);
320
+
321
+ viewerWs.send(JSON.stringify({ type: 'input', data: 'viewer-typing' }));
322
+
323
+ await waitFor(() => ownerMessages.find((message) => (
324
+ message.type === 'inject' && String(message.data).includes('viewer-typing')
325
+ )), { description: 'viewer input forwarded to owner as inject' });
326
+
327
+ ownerWs.close();
328
+ viewerWs.close();
329
+ });
330
+
331
+ test('DELETE on wrapped session removes it without crashing the daemon', async () => {
332
+ const sessionId = createSessionId('del-wrap');
333
+ await harness.registerSession(sessionId);
334
+
335
+ const destroy = await harness.request(`/api/sessions/${encodeURIComponent(sessionId)}`, {
336
+ method: 'DELETE'
337
+ });
338
+ assert.equal(destroy.status, 200);
339
+ assert.equal(destroy.body.status, 'closing');
340
+
341
+ const list = await harness.request('/api/sessions');
342
+ assert.equal(list.body.some((s) => s.id === sessionId), false);
343
+
344
+ await delay(200);
345
+ assert.equal(harness.isAlive(), true, harness.getLogs().stderr || harness.getLogs().stdout);
346
+ });
347
+
348
+ test('wrapped session auto-cleans when owner disconnects and no other clients remain', async () => {
349
+ const sessionId = createSessionId('auto-clean');
350
+ await harness.registerSession(sessionId);
351
+
352
+ const ownerWs = await harness.connectSession(sessionId);
353
+
354
+ await waitFor(async () => {
355
+ const list = await harness.request('/api/sessions');
356
+ const session = list.body.find((s) => s.id === sessionId);
357
+ return session && session.active_clients === 1;
358
+ }, { description: 'owner connected' });
359
+
360
+ ownerWs.close();
361
+
362
+ await waitFor(async () => {
363
+ const list = await harness.request('/api/sessions');
364
+ return !list.body.some((s) => s.id === sessionId);
365
+ }, { description: 'wrapped session auto-removed after owner disconnect' });
366
+ });
367
+
368
+ test('multicast inject handles mixed spawned and wrapped sessions', async () => {
369
+ const spawnedId = createSessionId('multi-spawn');
370
+ const wrappedId = createSessionId('multi-wrap');
371
+ await harness.spawnSession(spawnedId);
372
+ await harness.registerSession(wrappedId);
373
+
374
+ // Wrapped session without owner should fail
375
+ const result = await harness.request('/api/sessions/multicast/inject', {
376
+ method: 'POST',
377
+ body: { session_ids: [spawnedId, wrappedId], prompt: 'echo mixed' }
378
+ });
379
+
380
+ assert.equal(result.status, 200);
381
+ assert.deepEqual(result.body.results.successful, [spawnedId]);
382
+ assert.equal(result.body.results.failed.length, 1);
383
+ assert.equal(result.body.results.failed[0].id, wrappedId);
384
+ assert.match(result.body.results.failed[0].error, /not connected/i);
385
+ });
386
+
387
+ test('spawned shells strip parent Claude session markers from the environment', async () => {
388
+ const marker = createSessionId('claude-env');
389
+ const localHarness = await startTestDaemon({ env: { CLAUDECODE: marker } });
390
+
391
+ try {
392
+ const sessionId = createSessionId('env');
393
+ await localHarness.spawnSession(sessionId);
394
+ const ws = await localHarness.connectSession(sessionId);
395
+ const outputs = collectJsonMessages(ws);
396
+
397
+ const command = process.platform === 'win32'
398
+ ? "if ($env:CLAUDECODE) { Write-Output $env:CLAUDECODE } else { Write-Output '__unset__' }\r"
399
+ : "if [ -n \"${CLAUDECODE}\" ]; then printf '%s\\n' \"$CLAUDECODE\"; else printf '__unset__\\n'; fi\r";
400
+
401
+ ws.send(JSON.stringify({ type: 'input', data: command }));
402
+
403
+ await waitFor(() => outputs.some((message) => (
404
+ message.type === 'output' && String(message.data).includes('__unset__')
405
+ )), { timeoutMs: 7000, description: 'sanitized Claude session marker' });
406
+
407
+ assert.equal(outputs.some((message) => (
408
+ message.type === 'output' && String(message.data).includes(marker)
409
+ )), false);
410
+
411
+ ws.close();
412
+ } finally {
413
+ await localHarness.stop();
414
+ }
415
+ });