@danieltmn/tamnora 0.7.1 → 0.9.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 (66) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +9 -0
  3. package/SECURITY.md +11 -6
  4. package/harness/packages/cli/src/cli.js +1 -1
  5. package/harness/packages/cli/src/commands/bridge.js +180 -119
  6. package/harness/packages/cli/src/commands/run.js +1 -1
  7. package/harness/packages/cli/src/commands/server.js +26 -50
  8. package/harness/packages/cli/src/commands/tui.js +1 -1
  9. package/harness/packages/cli/src/lib/daemon.js +92 -40
  10. package/harness/packages/cli/test/release.test.js +9 -0
  11. package/harness/packages/core/src/agent/modes.js +1 -1
  12. package/harness/packages/core/src/agent/runAgent.js +3 -0
  13. package/harness/packages/core/src/git/index.js +177 -0
  14. package/harness/packages/core/src/index.js +16 -1
  15. package/harness/packages/core/src/open.js +43 -0
  16. package/harness/packages/core/src/pid.js +35 -0
  17. package/harness/packages/core/src/procs/kill.js +95 -0
  18. package/harness/packages/core/src/tools/executors.js +16 -0
  19. package/harness/packages/core/src/tools/labels.js +2 -0
  20. package/harness/packages/core/src/tools/permissions.js +1 -0
  21. package/harness/packages/core/src/tools/schema.js +17 -0
  22. package/harness/packages/core/src/tts/edge.js +232 -0
  23. package/harness/packages/core/src/tunnel.js +180 -19
  24. package/harness/packages/core/test/open.test.js +19 -0
  25. package/harness/packages/core/test/procs.test.js +50 -0
  26. package/harness/packages/core/test/tools.test.js +12 -0
  27. package/harness/packages/core/test/tts.test.js +41 -0
  28. package/harness/packages/server/public/app.js +4715 -3669
  29. package/harness/packages/server/public/index.html +94 -5
  30. package/harness/packages/server/public/landing/index.html +5 -5
  31. package/harness/packages/server/public/reconcile.js +121 -0
  32. package/harness/packages/server/public/relayClient.js +170 -0
  33. package/harness/packages/server/public/runStream.js +207 -0
  34. package/harness/packages/server/public/styles.css +196 -23
  35. package/harness/packages/server/src/auth.js +29 -5
  36. package/harness/packages/server/src/lib/agentRun.js +77 -0
  37. package/harness/packages/server/src/lib/announcer.js +92 -0
  38. package/harness/packages/server/src/lib/approvals.js +25 -3
  39. package/harness/packages/server/src/lib/channels/telegram.js +85 -20
  40. package/harness/packages/server/src/lib/ffmpeg.js +48 -0
  41. package/harness/packages/server/src/lib/procs.js +3 -18
  42. package/harness/packages/server/src/lib/runManager.js +24 -2
  43. package/harness/packages/server/src/lib/sessionView.js +43 -0
  44. package/harness/packages/server/src/lib/tasks.js +4 -9
  45. package/harness/packages/server/src/lib/ttsSettings.js +42 -0
  46. package/harness/packages/server/src/lib/workspace.js +37 -1
  47. package/harness/packages/server/src/routes/chat.js +51 -47
  48. package/harness/packages/server/src/routes/events.js +35 -0
  49. package/harness/packages/server/src/routes/files.js +22 -26
  50. package/harness/packages/server/src/routes/folders.js +9 -17
  51. package/harness/packages/server/src/routes/git.js +35 -0
  52. package/harness/packages/server/src/routes/procs.js +25 -15
  53. package/harness/packages/server/src/routes/sessions.js +12 -19
  54. package/harness/packages/server/src/routes/shell.js +19 -0
  55. package/harness/packages/server/src/routes/state.js +32 -0
  56. package/harness/packages/server/src/routes/tts.js +82 -0
  57. package/harness/packages/server/src/server.js +25 -1
  58. package/harness/packages/server/test/announcer.test.js +51 -0
  59. package/harness/packages/server/test/reconcile.test.js +146 -0
  60. package/harness/packages/server/test/relay.test.js +219 -0
  61. package/harness/packages/server/test/runStream.test.js +245 -0
  62. package/harness/packages/server/test/security.test.js +252 -0
  63. package/harness/packages/server/test/server.test.js +617 -1
  64. package/harness/packages/server/test/telegram-voz.test.js +156 -0
  65. package/harness/packages/server/test/tts.test.js +133 -0
  66. package/package.json +1 -1
@@ -0,0 +1,82 @@
1
+ // Voz (TTS): sintesis bajo demanda de la consola + ajustes persistidos en
2
+ // app.json (la casa). La config no necesita recarga: cada consumidor la lee
3
+ // cuando va a hablar.
4
+
5
+ import { sintetizar, listarVoces, MAX_TEXTO } from '../lib/core.js';
6
+ import { readApp, writeApp } from '../lib/core.js';
7
+ import { ffmpegDisponible } from '../lib/ffmpeg.js';
8
+ import { resolveTts, sanearVoz, sanearRate } from '../lib/ttsSettings.js';
9
+ import { sendJson, sendError, readJson } from '../lib/http.js';
10
+
11
+ const cacheVoces = { at: 0, data: null, filtro: 'es-' };
12
+
13
+ async function catalogoVoces(filtro) {
14
+ const now = Date.now();
15
+ if (cacheVoces.data && cacheVoces.filtro === filtro && now - cacheVoces.at < 600000) {
16
+ return cacheVoces.data;
17
+ }
18
+ const voces = await listarVoces(filtro);
19
+ cacheVoces.at = now;
20
+ cacheVoces.data = voces;
21
+ cacheVoces.filtro = filtro;
22
+ return voces;
23
+ }
24
+
25
+ export function registerTts(route, ctx) {
26
+ route('GET', '/api/tts/settings', async ({ res }) => {
27
+ const config = resolveTts(ctx.app);
28
+ sendJson(res, 200, { ...config, ffmpeg: await ffmpegDisponible() });
29
+ });
30
+
31
+ route('PUT', '/api/tts/settings', async ({ req, res }) => {
32
+ const body = await readJson(req);
33
+ const app = readApp();
34
+ const actual = resolveTts(ctx.app);
35
+ const next = { ...actual };
36
+
37
+ if ('enabled' in body) next.enabled = body.enabled === true;
38
+ if ('voice' in body) next.voice = sanearVoz(body.voice, actual.voice);
39
+ if ('rate' in body) next.rate = sanearRate(body.rate, actual.rate);
40
+ if (body.announce && typeof body.announce === 'object') {
41
+ const announce = { ...actual.announce };
42
+ for (const key of ['start', 'tools', 'end']) {
43
+ if (key in body.announce) announce[key] = body.announce[key] === true;
44
+ }
45
+ next.announce = announce;
46
+ }
47
+
48
+ app.tts = next;
49
+ writeApp(app);
50
+ ctx.app = app;
51
+ sendJson(res, 200, { ...next, ffmpeg: await ffmpegDisponible() });
52
+ });
53
+
54
+ route('GET', '/api/tts/voices', async ({ res, query }) => {
55
+ const filtro = /^[a-zA-Z]{2}-?$/.test(String(query.get('filter') ?? 'es-')) ? query.get('filter') : 'es-';
56
+ try {
57
+ const voces = await catalogoVoces(filtro);
58
+ sendJson(res, 200, { voices: voces });
59
+ } catch (error) {
60
+ sendError(res, 502, `No pude listar las voces: ${error?.message ?? error}`);
61
+ }
62
+ });
63
+
64
+ route('GET', '/api/tts', async ({ res, query }) => {
65
+ const config = resolveTts(ctx.app);
66
+ const texto = String(query.get('texto') ?? '').trim().slice(0, MAX_TEXTO);
67
+ if (!texto) return sendError(res, 400, 'Falta "texto".');
68
+ const voz = sanearVoz(query.get('voice'), config.voice);
69
+ const rate = sanearRate(query.get('rate'), config.rate);
70
+ try {
71
+ const mp3 = await sintetizar(texto, { voice: voz, rate });
72
+ res.writeHead(200, {
73
+ 'Content-Type': 'audio/mpeg',
74
+ 'Content-Length': mp3.length,
75
+ 'Cache-Control': 'no-store',
76
+ });
77
+ res.end(mp3);
78
+ } catch (error) {
79
+ sendError(res, 502, `No pude sintetizar: ${error?.message ?? error}`);
80
+ }
81
+ });
82
+ }
@@ -28,14 +28,19 @@ import { registerAuth } from './routes/auth.js';
28
28
  import { registerSessions } from './routes/sessions.js';
29
29
  import { registerFolders } from './routes/folders.js';
30
30
  import { registerFiles } from './routes/files.js';
31
+ import { registerGit } from './routes/git.js';
31
32
  import { registerModels } from './routes/models.js';
32
33
  import { registerProviders } from './routes/providers.js';
33
34
  import { registerMcp } from './routes/mcp.js';
34
35
  import { registerSkills } from './routes/skills.js';
35
36
  import { registerChannels } from './routes/channels.js';
37
+ import { registerTts } from './routes/tts.js';
36
38
  import { registerProcs } from './routes/procs.js';
39
+ import { registerShell } from './routes/shell.js';
37
40
  import { registerPreview } from './routes/preview.js';
38
41
  import { registerChat } from './routes/chat.js';
42
+ import { registerEvents } from './routes/events.js';
43
+ import { registerState } from './routes/state.js';
39
44
  import { registerEval } from './routes/eval.js';
40
45
 
41
46
  const VERSION = readPackageVersion();
@@ -212,14 +217,19 @@ export async function createTamnoraServer({
212
217
  registerSessions(route, ctx);
213
218
  registerFolders(route, ctx);
214
219
  registerFiles(route, ctx);
220
+ registerGit(route, ctx);
215
221
  registerModels(route, ctx);
216
222
  registerProviders(route, ctx);
217
223
  registerMcp(route, ctx);
218
224
  registerSkills(route, ctx);
219
225
  registerChannels(route, ctx);
226
+ registerTts(route, ctx);
220
227
  registerProcs(route, ctx);
228
+ registerShell(route, ctx);
221
229
  const preview = registerPreview(route, ctx);
222
230
  registerChat(route, ctx);
231
+ registerEvents(route, ctx);
232
+ registerState(route, ctx);
223
233
  registerEval(route, ctx);
224
234
 
225
235
  function matchRoute(method, pathname) {
@@ -243,6 +253,10 @@ export async function createTamnoraServer({
243
253
 
244
254
  const serveStatic = createStaticHandler(staticDir ?? DEFAULT_PUBLIC_DIR);
245
255
 
256
+ // Los SSE (turno, stream de sesion, canal global) son conexiones que no
257
+ // terminan solas: sin registrarlas, `server.close()` espera indefinidamente y
258
+ // `tamnora server --stop` queda colgado.
259
+ const sockets = new Set();
246
260
  const server = http.createServer(async (req, res) => {
247
261
  const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
248
262
 
@@ -282,6 +296,11 @@ export async function createTamnoraServer({
282
296
  }
283
297
  });
284
298
 
299
+ server.on('connection', (socket) => {
300
+ sockets.add(socket);
301
+ socket.on('close', () => sockets.delete(socket));
302
+ });
303
+
285
304
  const listen = (options = {}) =>
286
305
  new Promise((resolve, reject) => {
287
306
  server.once('error', reject);
@@ -311,12 +330,17 @@ export async function createTamnoraServer({
311
330
  } catch {
312
331
  // ignore MCP shutdown errors
313
332
  }
333
+ // Cortar las conexiones abiertas ANTES de esperar al server, y la base
334
+ // DESPUES: un run abortado termina en otro tick y emite `aborted`.
335
+ for (const socket of sockets) socket.destroy();
336
+ sockets.clear();
337
+ await new Promise((resolve) => server.close(() => resolve()));
338
+ await new Promise((resolve) => setTimeout(resolve, 50));
314
339
  try {
315
340
  store.close();
316
341
  } catch {
317
342
  // already closed
318
343
  }
319
- await new Promise((resolve) => server.close(() => resolve()));
320
344
  };
321
345
 
322
346
  return { server, config, store, ctx, listen, close, staticDir: staticDir ?? DEFAULT_PUBLIC_DIR };
@@ -0,0 +1,51 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { fraseTool, fraseInicio, fraseFin, fraseError, createFiltroTools } from '../src/lib/announcer.js';
4
+
5
+ test('fraseTool arma verbos con el resumen de la tool', () => {
6
+ assert.equal(fraseTool('read', 'src/app.js'), 'Leyendo src/app.js');
7
+ assert.equal(fraseTool('write', 'a/b.txt'), 'Escribiendo a/b.txt');
8
+ assert.equal(fraseTool('bash', 'npm test'), 'Ejecutando npm test');
9
+ assert.equal(fraseTool('grep', '"foo" *.js'), 'Buscando "foo" *.js');
10
+ });
11
+
12
+ test('fraseTool sin resumen, tools mudas y MCP', () => {
13
+ assert.equal(fraseTool('read', ''), 'Leyendo');
14
+ assert.equal(fraseTool('desconocida', 'x'), '');
15
+ assert.equal(fraseTool('git_status', ''), 'Mirando el estado de git');
16
+ assert.equal(fraseTool('git_commit', 'feat: algo'), 'Commiteando: feat: algo');
17
+ assert.equal(fraseTool('mcp__db__query', 'x'), 'Usando una herramienta de db');
18
+ });
19
+
20
+ test('fraseTool recorta frases largas sin cortar palabras', () => {
21
+ const frase = fraseTool('bash', 'x'.repeat(400));
22
+ assert.ok(frase.length <= 200, frase.length);
23
+ assert.ok(frase.endsWith('…'));
24
+ });
25
+
26
+ test('fraseFin resume el cierre del turno', () => {
27
+ assert.equal(fraseFin({ stopReason: 'aborted' }), 'Cancelado.');
28
+ assert.match(fraseFin({ stopReason: 'end_turn', rounds: 3, toolCalls: 5, durationMs: 12000 }), /^Listo\. 3 rondas, 5 herramientas, 12 segundos\.$/);
29
+ assert.equal(fraseFin({ stopReason: 'end_turn', rounds: 1, toolCalls: 0, durationMs: 500 }), 'Listo. Respondí sin usar herramientas.');
30
+ assert.match(
31
+ fraseFin({ stopReason: 'max_rounds', rounds: 8, toolCalls: 12, durationMs: 60000 }),
32
+ /corte de seguridad tras 8 rondas, 60 segundos/
33
+ );
34
+ assert.match(fraseInicio(), /Empecé/);
35
+ assert.match(fraseError(), /error/);
36
+ });
37
+
38
+ test('createFiltroTools deduplica y aplica la ventana entre frases', () => {
39
+ const filtro = createFiltroTools({ minGapMs: 60000 });
40
+ assert.equal(filtro('read', 'a.js'), 'Leyendo a.js');
41
+ assert.equal(filtro('read', 'b.js'), '');
42
+ assert.equal(filtro('write', 'b.js'), '');
43
+ const ahora = Date.now();
44
+ const original = Date.now;
45
+ Date.now = () => ahora + 61000;
46
+ try {
47
+ assert.equal(filtro('write', 'b.js'), 'Escribiendo b.js');
48
+ } finally {
49
+ Date.now = original;
50
+ }
51
+ });
@@ -0,0 +1,146 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { diffState, pollDelayMs, localState, summarizeArgs, humanSize } from '../public/reconcile.js';
4
+
5
+ const pane = (over = {}) => ({ working: false, attached: false, ...over });
6
+ const ask = (over = {}) => ({ askId: 'a1', sessionId: 's1', name: 'bash', args: {}, requestedAt: 1, ...over });
7
+
8
+ test('engancha una sesion abierta que esta trabajando sin stream propio', () => {
9
+ const diff = diffState({ panes: [{ id: 's1', working: false, attached: false }], asks: [] }, {
10
+ sessions: [{ id: 's1', running: true, awaiting: 0 }],
11
+ permissions: [],
12
+ });
13
+ assert.deepEqual(diff.toAttach, ['s1']);
14
+ });
15
+
16
+ test('engancha aunque no haya run si hay un permiso pendiente', () => {
17
+ // El permiso no se reconstruye desde los mensajes: vive en /api/state.
18
+ const diff = diffState({ panes: [{ id: 's1', working: false, attached: false }], asks: [] }, {
19
+ sessions: [{ id: 's1', running: false, awaiting: 1 }],
20
+ permissions: [ask()],
21
+ });
22
+ assert.deepEqual(diff.toAttach, ['s1']);
23
+ });
24
+
25
+ test('desengancha una sesion que figuraba activa y ya no', () => {
26
+ const diff = diffState({ panes: [{ id: 's1', working: true, attached: true }], asks: [] }, {
27
+ sessions: [{ id: 's1', running: false, awaiting: 0 }],
28
+ permissions: [],
29
+ });
30
+ assert.deepEqual(diff.toDetach, ['s1']);
31
+ });
32
+
33
+ test('no toca una sesion que ya esta enganchada', () => {
34
+ const diff = diffState({ panes: [{ id: 's1', working: true, attached: true }], asks: [] }, {
35
+ sessions: [{ id: 's1', running: true, awaiting: 0 }],
36
+ permissions: [],
37
+ });
38
+ assert.deepEqual(diff.toAttach, []);
39
+ assert.deepEqual(diff.toDetach, []);
40
+ });
41
+
42
+ test('detecta permisos nuevos y resueltos', () => {
43
+ const diff = diffState({ panes: [], asks: ['a1', 'a2'] }, {
44
+ sessions: [],
45
+ permissions: [ask({ askId: 'a1' }), ask({ askId: 'a3' })],
46
+ });
47
+ assert.deepEqual(diff.newAsks.map((a) => a.askId), ['a3']);
48
+ assert.deepEqual(diff.resolvedAsks, ['a2']);
49
+ });
50
+
51
+ test('un permiso repetido no vuelve a notificarse', () => {
52
+ const state = { panes: [], asks: [] };
53
+ const remote = { sessions: [], permissions: [ask()] };
54
+ const first = diffState(state, remote);
55
+ assert.equal(first.newAsks.length, 1);
56
+ const second = diffState({ panes: [], asks: ['a1'] }, remote);
57
+ assert.equal(second.newAsks.length, 0);
58
+ });
59
+
60
+ test('noPropone ninguna accion cuando nada cambio', () => {
61
+ // Esto importa: un re-render por nada borra la tarjeta de permiso.
62
+ const local = { panes: [{ id: 's1', working: true, attached: true }], asks: ['a1'] };
63
+ const remote = { sessions: [{ id: 's1', running: true, awaiting: 1 }], permissions: [ask()] };
64
+ const diff = diffState(local, remote);
65
+ assert.deepEqual(diff.toAttach, []);
66
+ assert.deepEqual(diff.toDetach, []);
67
+ assert.deepEqual(diff.gone, []);
68
+ assert.deepEqual(diff.newAsks, []);
69
+ assert.deepEqual(diff.resolvedAsks, []);
70
+ });
71
+
72
+ test('avisa las sesiones que el server ya no conoce', () => {
73
+ const diff = diffState({ panes: [{ id: 'borrada', working: false, attached: false }], asks: [] }, {
74
+ sessions: [{ id: 's1', running: false, awaiting: 0 }],
75
+ permissions: [],
76
+ });
77
+ assert.deepEqual(diff.gone, ['borrada']);
78
+ });
79
+
80
+ test('ignora las sesiones que el usuario no tiene abiertas', () => {
81
+ const diff = diffState({ panes: [], asks: [] }, {
82
+ sessions: [{ id: 's1', running: true, awaiting: 0 }],
83
+ permissions: [],
84
+ });
85
+ assert.deepEqual(diff.toAttach, []);
86
+ assert.deepEqual(diff.remoteActive, ['s1']);
87
+ });
88
+
89
+ test('la cadencia se apura cuando algo espera tu decision', () => {
90
+ const idle = { sessions: [{ running: false, awaiting: 0 }], permissions: [] };
91
+ const running = { sessions: [{ running: true, awaiting: 0 }], permissions: [] };
92
+ const waiting = { sessions: [{ running: false, awaiting: 1 }], permissions: [ask()] };
93
+ assert.equal(pollDelayMs(idle), 25000);
94
+ assert.equal(pollDelayMs(running), 10000);
95
+ assert.equal(pollDelayMs(waiting), 5000);
96
+ });
97
+
98
+ test('localState aplana los Map del cliente', () => {
99
+ const flat = localState({ panes: new Map([['s1', pane({ working: true, attached: true })]]), asks: new Set(['a1']) });
100
+ assert.deepEqual(flat, { panes: [{ id: 's1', working: true, attached: true }], asks: ['a1'] });
101
+ });
102
+
103
+ test('tolera un remoto vacio o corrupto sin romper', () => {
104
+ const diff = diffState({ panes: [{ id: 's1', working: false, attached: false }], asks: [] }, {});
105
+ assert.deepEqual(diff.toAttach, []);
106
+ assert.deepEqual(diff.pending, []);
107
+ });
108
+
109
+ test('el resumen de un permiso muestra el comando, no el JSON', () => {
110
+ const command = 'powershell -NoProfile -Command "Get-ChildItem -Recurse"';
111
+ assert.equal(summarizeArgs('bash', { command }), command);
112
+ });
113
+
114
+ test('el resumen de write muestra ruta y tamano, no el archivo entero', () => {
115
+ // Esto era medio archivo en pantalla con los \n escapados a la vista.
116
+ const summary = summarizeArgs('write', { path: 'AGENTS.md', content: 'x'.repeat(1840) });
117
+ assert.equal(summary, 'AGENTS.md · 1.8 kB');
118
+ assert.ok(!summary.includes('\\n'));
119
+ assert.ok(summary.length < 60);
120
+ });
121
+
122
+ test('el resumen de read y fetch_url muestra la ruta y la url', () => {
123
+ assert.equal(summarizeArgs('read', { path: 'src/app.js' }), 'src/app.js');
124
+ assert.equal(summarizeArgs('fetch_url', { url: 'https://x.dev/a' }), 'https://x.dev/a');
125
+ });
126
+
127
+ test('el resumen de task usa la descripcion', () => {
128
+ assert.equal(summarizeArgs('task', { description: 'auditar el repo', prompt: 'busca TODOs' }), 'auditar el repo');
129
+ });
130
+
131
+ test('una tool desconocida cae a JSON en una sola linea', () => {
132
+ const summary = summarizeArgs('git_diff', { path: 'a.js', staged: false, note: 'uno\ndos' });
133
+ assert.ok(!summary.includes('\\n'), 'no debe mostrar saltos escapados');
134
+ assert.match(summary, /git_diff|a\.js/);
135
+ });
136
+
137
+ test('sin argumentos el resumen queda vacio', () => {
138
+ assert.equal(summarizeArgs('git_status', {}), '');
139
+ assert.equal(summarizeArgs('git_status', null), '');
140
+ });
141
+
142
+ test('humanSize usa la unidad que corresponde', () => {
143
+ assert.equal(humanSize('hola'), '4 B');
144
+ assert.equal(humanSize('x'.repeat(2048)), '2.0 kB');
145
+ assert.equal(humanSize('x'.repeat(3 * 1024 * 1024)), '3.0 MB');
146
+ });
@@ -0,0 +1,219 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawn } from 'node:child_process';
4
+ import { mkdtemp } from 'node:fs/promises';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { tmpdir } from 'node:os';
7
+ import path from 'node:path';
8
+
9
+
10
+ import { createTamnoraServer } from '../src/index.js';
11
+ import { setHome, startRelay, randomToken } from '../../core/src/index.js';
12
+
13
+ const HAS_PHP = (() => {
14
+ try {
15
+ execFileSync('php', ['-v'], { stdio: 'ignore' });
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ })();
21
+
22
+ const fakeProvider = {
23
+ id: 'fake',
24
+ async streamChat(_config, { onEvent }) {
25
+ onEvent?.({ type: 'text', delta: 'hola', full: 'hola' });
26
+ return { content: 'hola', reasoning: '', toolCalls: [] };
27
+ },
28
+ };
29
+
30
+ /** SSE crudo por GET crudo (fetch con streaming del lado Node). */
31
+ async function sseGet(url, { expectFrames = 1, timeoutMs = 30000 } = {}) {
32
+ const frames = [];
33
+ const controller = new AbortController();
34
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
35
+ try {
36
+ const res = await fetch(url, { signal: controller.signal });
37
+ const reader = res.body.getReader();
38
+ const decoder = new TextDecoder();
39
+ let buf = '';
40
+ while (frames.length < expectFrames) {
41
+ const { value, done } = await reader.read();
42
+ if (done) break;
43
+ buf += decoder.decode(value, { stream: true });
44
+ let index;
45
+ while ((index = buf.indexOf('\n\n')) !== -1) {
46
+ const frame = buf.slice(0, index);
47
+ buf = buf.slice(index + 2);
48
+ let event = null;
49
+ let data = '';
50
+ let id = null;
51
+ for (const line of frame.split('\n')) {
52
+ if (line.startsWith('event:')) event = line.slice(6).trim();
53
+ else if (line.startsWith('data:')) data += line.slice(5).trim();
54
+ else if (line.startsWith('id:')) id = line.slice(3).trim();
55
+ }
56
+ if (!event && !data) continue;
57
+ let parsed = {};
58
+ try {
59
+ parsed = JSON.parse(data);
60
+ } catch {
61
+ parsed = { raw: data };
62
+ }
63
+ frames.push({ event, data: parsed, id });
64
+ if (frames.length >= expectFrames) {
65
+ controller.abort();
66
+ break;
67
+ }
68
+ }
69
+ }
70
+ } catch (error) {
71
+ if (error?.name !== 'AbortError' && frames.length < expectFrames) throw error;
72
+ } finally {
73
+ clearTimeout(timer);
74
+ }
75
+ return frames;
76
+ }
77
+
78
+ async function phpServer(docroot) {
79
+ const port = 44000 + Math.floor(Math.random() * 4000);
80
+ const args = ['-S', `127.0.0.1:${port}`, '-t', docroot];
81
+ const child = spawn('php', args, {
82
+ env: { ...process.env },
83
+ stdio: ['ignore', 'pipe', 'pipe'],
84
+ windowsHide: true,
85
+ });
86
+ let stderr = '';
87
+ child.stderr.on('data', (d) => (stderr += String(d)));
88
+ let exited = null;
89
+ child.on('exit', (code) => (exited = code));
90
+ const base = `http://127.0.0.1:${port}/index.php`;
91
+ // esperar a que escuche
92
+ let lastError = '';
93
+ for (let i = 0; i < 60; i++) {
94
+ try {
95
+ await fetch(`${base}?a=status`, { signal: AbortSignal.timeout(1000) });
96
+ return { child, base };
97
+ } catch (error) {
98
+ lastError = error?.message ?? String(error);
99
+ if (exited !== null) break;
100
+ await new Promise((r) => setTimeout(r, 250));
101
+ }
102
+ }
103
+ throw new Error(
104
+ `php server no arranco: ${JSON.stringify({ docroot, exited, lastError, stderr: stderr.slice(0, 400) })}`
105
+ );
106
+ }
107
+
108
+ /**
109
+ * El PHP built-in en Windows es monohilo (PHP_CLI_SERVER_WORKERS no existe
110
+ * alla); send/poll largos se bloquearian mutuamente. Para el test corremos
111
+ * DOS servers sobre la misma SQLite (WAL es multi-proceso): el agente usa
112
+ * uno, el browser el otro. En cPanel con FPM esto no hace falta.
113
+ */
114
+ async function relayStack(docroot) {
115
+ const agent = await phpServer(docroot);
116
+ const browser = await phpServer(docroot);
117
+ return { children: [agent.child, browser.child], relayBase: agent.base, browserBase: browser.base };
118
+ }
119
+
120
+ test('el relay PHP mailbox carga y hace GC', async (t) => {
121
+ if (!HAS_PHP) return t.skip('sin PHP disponible');
122
+ const docroot = path.join(import.meta.dirname, '..', '..', '..', '..', 'relay');
123
+ const { child, base } = await phpServer(docroot);
124
+ try {
125
+ const status = await (await fetch(`${base}?a=status`)).json();
126
+ assert.equal(status.ok, true);
127
+
128
+ const token = randomToken(24);
129
+ const reg = await (
130
+ await fetch(`${base}?a=register`, {
131
+ method: 'POST',
132
+ headers: { 'Content-Type': 'application/json' },
133
+ body: JSON.stringify({ token }),
134
+ })
135
+ ).json();
136
+ assert.equal(reg.ok, true);
137
+ assert.equal(reg.cursor, 0);
138
+
139
+ const bad = await fetch(`${base}?a=send&t=${token}`, {
140
+ method: 'POST',
141
+ body: JSON.stringify({ path: 'nope' }),
142
+ });
143
+ assert.equal(bad.status, 400);
144
+ } finally {
145
+ child.kill('SIGTERM');
146
+ }
147
+ });
148
+
149
+ test('end-to-end: request no-stream y stream de chat via relay', async (t) => {
150
+ if (!HAS_PHP) return t.skip('sin PHP disponible');
151
+
152
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-relay-'));
153
+ setHome(cwd);
154
+ const instance = await createTamnoraServer({
155
+ cwd,
156
+ dbPath: path.join(cwd, 'tamnora.db'),
157
+ loadMcp: false,
158
+ resolveProvider: () => ({ provider: fakeProvider, config: {} }),
159
+ });
160
+ const local = await instance.listen({ port: 0 });
161
+
162
+ const docroot = path.join(import.meta.dirname, '..', '..', '..', '..', 'relay');
163
+ const { children, relayBase, browserBase } = await relayStack(docroot);
164
+ const token = randomToken(24);
165
+ const logs = [];
166
+ const pump = startRelay(local.port, { relayUrl: relayBase, token, log: (line) => logs.push(line) });
167
+
168
+ try {
169
+ // 1) request sincrono: /api/health pasa entero
170
+ const health = await (
171
+ await fetch(`${browserBase}?a=send&t=${token}`, {
172
+ method: 'POST',
173
+ headers: { 'Content-Type': 'application/json' },
174
+ body: JSON.stringify({ method: 'GET', path: '/api/health' }),
175
+ })
176
+ ).json();
177
+ assert.equal(health.status, 200);
178
+ assert.equal(JSON.parse(Buffer.from(health.body, 'base64').toString('utf8')).ok, true);
179
+
180
+ // 2) crear sesion y correr un turno SSE via mailbox
181
+ const session = await (
182
+ await fetch(`${browserBase}?a=send&t=${token}`, {
183
+ method: 'POST',
184
+ headers: { 'Content-Type': 'application/json' },
185
+ body: JSON.stringify({ method: 'POST', path: '/api/sessions', body: JSON.stringify({ title: 'relay' }) }),
186
+ })
187
+ ).json();
188
+ assert.equal(session.status, 201, `sesion: ${session.body && Buffer.from(session.body, 'base64').toString('utf8')}`);
189
+ const sessionId = JSON.parse(Buffer.from(session.body, 'base64').toString('utf8')).session.id;
190
+
191
+ const opened = await (
192
+ await fetch(`${browserBase}?a=send&t=${token}`, {
193
+ method: 'POST',
194
+ headers: { 'Content-Type': 'application/json' },
195
+ body: JSON.stringify({
196
+ method: 'POST',
197
+ path: '/api/chat',
198
+ body: JSON.stringify({ input: 'hola', sessionId, model: 'm' }),
199
+ stream: true,
200
+ }),
201
+ })
202
+ ).json();
203
+ assert.ok(opened.rid, 'a=send stream devuelve rid');
204
+
205
+ const frames = await sseGet(`${browserBase}?a=stream&t=${token}&rid=${opened.rid}&from=0`, { expectFrames: 20 });
206
+ const events = frames.filter((f) => f.event === 'chunk').map((f) => f.data.payload?.ev);
207
+ assert.ok(events.includes('start'), `start en ${JSON.stringify(events)}`);
208
+ assert.ok(events.includes('text'), `text en ${JSON.stringify(events)}`);
209
+ const doneRow = frames.some((f) => f.event === 'meta' && f.data.payload?.done === true);
210
+ assert.ok(doneRow, 'la fila final done cierra el stream');
211
+ } catch (error) {
212
+ error.logs = logs.slice();
213
+ throw error;
214
+ } finally {
215
+ pump.stop();
216
+ for (const child of children) child.kill('SIGTERM');
217
+ await instance.close();
218
+ }
219
+ });