@danieltmn/tamnora 0.8.0 → 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 (57) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/harness/packages/cli/src/cli.js +1 -1
  3. package/harness/packages/cli/src/commands/bridge.js +180 -119
  4. package/harness/packages/cli/src/commands/server.js +26 -50
  5. package/harness/packages/cli/src/lib/daemon.js +92 -40
  6. package/harness/packages/cli/test/release.test.js +9 -0
  7. package/harness/packages/core/src/agent/modes.js +1 -1
  8. package/harness/packages/core/src/index.js +14 -1
  9. package/harness/packages/core/src/open.js +43 -0
  10. package/harness/packages/core/src/pid.js +35 -0
  11. package/harness/packages/core/src/tools/executors.js +16 -0
  12. package/harness/packages/core/src/tools/labels.js +2 -0
  13. package/harness/packages/core/src/tools/permissions.js +1 -0
  14. package/harness/packages/core/src/tools/schema.js +17 -0
  15. package/harness/packages/core/src/tts/edge.js +232 -0
  16. package/harness/packages/core/src/tunnel.js +180 -19
  17. package/harness/packages/core/test/open.test.js +19 -0
  18. package/harness/packages/core/test/tools.test.js +12 -0
  19. package/harness/packages/core/test/tts.test.js +41 -0
  20. package/harness/packages/server/public/app.js +4715 -3975
  21. package/harness/packages/server/public/index.html +63 -5
  22. package/harness/packages/server/public/reconcile.js +121 -0
  23. package/harness/packages/server/public/relayClient.js +170 -0
  24. package/harness/packages/server/public/runStream.js +207 -0
  25. package/harness/packages/server/public/styles.css +142 -5
  26. package/harness/packages/server/src/auth.js +29 -5
  27. package/harness/packages/server/src/lib/agentRun.js +77 -0
  28. package/harness/packages/server/src/lib/announcer.js +92 -0
  29. package/harness/packages/server/src/lib/approvals.js +25 -3
  30. package/harness/packages/server/src/lib/channels/telegram.js +85 -20
  31. package/harness/packages/server/src/lib/ffmpeg.js +48 -0
  32. package/harness/packages/server/src/lib/procs.js +3 -18
  33. package/harness/packages/server/src/lib/runManager.js +24 -2
  34. package/harness/packages/server/src/lib/sessionView.js +43 -0
  35. package/harness/packages/server/src/lib/tasks.js +4 -9
  36. package/harness/packages/server/src/lib/ttsSettings.js +42 -0
  37. package/harness/packages/server/src/lib/workspace.js +37 -1
  38. package/harness/packages/server/src/routes/chat.js +48 -47
  39. package/harness/packages/server/src/routes/events.js +35 -0
  40. package/harness/packages/server/src/routes/files.js +22 -26
  41. package/harness/packages/server/src/routes/folders.js +9 -17
  42. package/harness/packages/server/src/routes/git.js +6 -14
  43. package/harness/packages/server/src/routes/procs.js +14 -16
  44. package/harness/packages/server/src/routes/sessions.js +12 -19
  45. package/harness/packages/server/src/routes/shell.js +5 -9
  46. package/harness/packages/server/src/routes/state.js +32 -0
  47. package/harness/packages/server/src/routes/tts.js +82 -0
  48. package/harness/packages/server/src/server.js +21 -1
  49. package/harness/packages/server/test/announcer.test.js +51 -0
  50. package/harness/packages/server/test/reconcile.test.js +146 -0
  51. package/harness/packages/server/test/relay.test.js +219 -0
  52. package/harness/packages/server/test/runStream.test.js +245 -0
  53. package/harness/packages/server/test/security.test.js +252 -0
  54. package/harness/packages/server/test/server.test.js +511 -1
  55. package/harness/packages/server/test/telegram-voz.test.js +156 -0
  56. package/harness/packages/server/test/tts.test.js +133 -0
  57. package/package.json +1 -1
@@ -66,9 +66,18 @@ test('serves health, providers and the console', async () => {
66
66
  'id="slash-menu"',
67
67
  'id="statusbar"',
68
68
  'id="sb-activity"',
69
+ // Nueva sesion tambien en el topbar: en movil el sidebar es un drawer y
70
+ // crear una sesion eran varios toques.
71
+ 'id="new-session-top"',
69
72
  ]) {
70
73
  assert.ok(html.includes(hook), `falta ${hook}`);
71
74
  }
75
+ // El boton del topbar tiene que estar en el header, no escondido en el
76
+ // drawer (que es donde vive `#new-session`).
77
+ const header = html.slice(0, html.indexOf('<main'));
78
+ assert.ok(header.includes('id="new-session-top"'), 'nueva sesion deberia estar en el topbar');
79
+ assert.equal((html.match(/id="new-session-top"/g) ?? []).length, 1);
80
+ assert.equal((html.match(/id="new-session"/g) ?? []).length, 1);
72
81
  // Los controles de agente se declaran una sola vez (se mueven entre slots).
73
82
  assert.equal((html.match(/id="mode"/g) ?? []).length, 1);
74
83
 
@@ -253,6 +262,507 @@ test('keeps a run alive after the client disconnects and can stop it', async ()
253
262
  }
254
263
  });
255
264
 
265
+ /** Lee un SSE hasta que `until(data)` se cumpla, y devuelve lo acumulado. */
266
+ async function readSse(url, options, until, timeoutMs = 5000) {
267
+ const controller = new AbortController();
268
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
269
+ const frames = [];
270
+ try {
271
+ const res = await fetch(url, { ...options, signal: controller.signal });
272
+ const reader = res.body.getReader();
273
+ const decoder = new TextDecoder();
274
+ let buffer = '';
275
+ for (;;) {
276
+ const { value, done } = await reader.read();
277
+ if (done) break;
278
+ buffer += decoder.decode(value, { stream: true });
279
+ let index;
280
+ while ((index = buffer.indexOf('\n\n')) !== -1) {
281
+ const frame = buffer.slice(0, index);
282
+ buffer = buffer.slice(index + 2);
283
+ let event = null;
284
+ let data = '';
285
+ for (const line of frame.split('\n')) {
286
+ if (line.startsWith('event:')) event = line.slice(6).trim();
287
+ else if (line.startsWith('data:')) data += line.slice(5).trim();
288
+ }
289
+ if (!event) continue;
290
+ const parsed = data ? JSON.parse(data) : {};
291
+ frames.push({ event, data: parsed });
292
+ if (until(parsed, event)) return frames;
293
+ }
294
+ }
295
+ } catch (error) {
296
+ if (error?.name !== 'AbortError') throw error;
297
+ } finally {
298
+ clearTimeout(timer);
299
+ }
300
+ return frames;
301
+ }
302
+
303
+ test('the global channel announces run_start and run_end to another client', async () => {
304
+ const { instance, base } = await startServer();
305
+ const json = { 'Content-Type': 'application/json' };
306
+ try {
307
+ const created = await (
308
+ await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ model: 'm' }) })
309
+ ).json();
310
+ const id = created.session.id;
311
+
312
+ // Cliente B: abre el canal global antes de que arranque nada.
313
+ const events = readSse(`${base}/api/events`, {}, (data, event) => event === 'run_end');
314
+ await new Promise((resolve) => setTimeout(resolve, 50));
315
+
316
+ // Cliente A: pide una tarea.
317
+ await (
318
+ await fetch(`${base}/api/chat`, {
319
+ method: 'POST',
320
+ headers: json,
321
+ body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
322
+ })
323
+ ).text();
324
+
325
+ const frames = await events;
326
+ const names = frames.map((frame) => frame.event);
327
+ assert.deepEqual(names[0], 'snapshot');
328
+ assert.ok(names.includes('run_start'), 'esperaba run_start');
329
+ assert.ok(names.includes('run_end'), 'esperaba run_end');
330
+
331
+ const start = frames.find((frame) => frame.event === 'run_start');
332
+ const end = frames.find((frame) => frame.event === 'run_end');
333
+ assert.equal(start.data.sessionId, id);
334
+ assert.equal(end.data.sessionId, id);
335
+ assert.ok(typeof end.data.durationMs === 'number');
336
+
337
+ // El canal global no debe filtrar el detalle del turno: eso va por el
338
+ // stream de la sesion, o seria duplicarlo.
339
+ for (const detail of ['start', 'user', 'text', 'tool_call', 'done']) {
340
+ assert.ok(!names.includes(detail), `el canal global no debe emitir ${detail}`);
341
+ }
342
+ } finally {
343
+ await instance.close();
344
+ }
345
+ });
346
+
347
+ test('connecting to the global channel mid-run returns it in the snapshot', async () => {
348
+ let release;
349
+ const gate = new Promise((resolve) => (release = resolve));
350
+ const slowProvider = {
351
+ id: 'slow',
352
+ async streamChat() {
353
+ await gate;
354
+ return { content: 'fin', reasoning: '', toolCalls: [] };
355
+ },
356
+ };
357
+ const { instance, base } = await startServer({ resolveProvider: () => ({ provider: slowProvider, config: {} }) });
358
+ const json = { 'Content-Type': 'application/json' };
359
+ try {
360
+ const created = await (
361
+ await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ model: 'm' }) })
362
+ ).json();
363
+ const id = created.session.id;
364
+
365
+ const chat = fetch(`${base}/api/chat`, {
366
+ method: 'POST',
367
+ headers: json,
368
+ body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
369
+ }).catch(() => null);
370
+ await new Promise((resolve) => setTimeout(resolve, 120));
371
+
372
+ // Cliente nuevo: el snapshot tiene que informarle que hay un run vivo, y
373
+ // por eso el cliente se engancha sin que nadie le avise.
374
+ const frames = await readSse(`${base}/api/events`, {}, (data, event) => event === 'snapshot');
375
+ assert.equal(frames[0].event, 'snapshot');
376
+ assert.ok(frames[0].data.active.includes(id), 'el snapshot deberia listar la sesion activa');
377
+
378
+ release();
379
+ await chat;
380
+ } finally {
381
+ release();
382
+ await instance.close();
383
+ }
384
+ });
385
+
386
+ test('the session list reports which sessions are running', async () => {
387
+ let release;
388
+ const gate = new Promise((resolve) => (release = resolve));
389
+ const slowProvider = {
390
+ id: 'slow',
391
+ async streamChat() {
392
+ await gate;
393
+ return { content: 'fin', reasoning: '', toolCalls: [] };
394
+ },
395
+ };
396
+ const { instance, base } = await startServer({ resolveProvider: () => ({ provider: slowProvider, config: {} }) });
397
+ const json = { 'Content-Type': 'application/json' };
398
+ try {
399
+ const created = await (
400
+ await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ model: 'm' }) })
401
+ ).json();
402
+ const id = created.session.id;
403
+
404
+ const idle = await (await fetch(`${base}/api/sessions`)).json();
405
+ assert.equal(idle.sessions.find((session) => session.id === id).running, false);
406
+
407
+ const chat = fetch(`${base}/api/chat`, {
408
+ method: 'POST',
409
+ headers: json,
410
+ body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
411
+ }).catch(() => null);
412
+ await new Promise((resolve) => setTimeout(resolve, 120));
413
+
414
+ const busy = await (await fetch(`${base}/api/sessions`)).json();
415
+ assert.equal(busy.sessions.find((session) => session.id === id).running, true);
416
+
417
+ release();
418
+ await chat;
419
+ } finally {
420
+ release();
421
+ await instance.close();
422
+ }
423
+ });
424
+
425
+ test('resuming a run from a cursor replays only what was missed', async () => {
426
+ const { instance, base } = await startServer();
427
+ const json = { 'Content-Type': 'application/json' };
428
+ try {
429
+ const created = await (
430
+ await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ model: 'm' }) })
431
+ ).json();
432
+ const id = created.session.id;
433
+ await (
434
+ await fetch(`${base}/api/chat`, {
435
+ method: 'POST',
436
+ headers: json,
437
+ body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
438
+ })
439
+ ).text();
440
+
441
+ const full = await (await fetch(`${base}/api/sessions/${id}/stream?from=0`)).text();
442
+ const ids = [...full.matchAll(/^id: (\d+)$/gm)].map((match) => Number(match[1]));
443
+ assert.ok(ids.length > 2, 'esperaba varios eventos con id');
444
+ const last = ids[ids.length - 1];
445
+
446
+ // Cliente que se cortó justo antes del ultimo evento: al volver no debe
447
+ // recibir lo que ya tenia.
448
+ const tail = await (await fetch(`${base}/api/sessions/${id}/stream?from=${last - 1}`)).text();
449
+ const tailIds = [...tail.matchAll(/^id: (\d+)$/gm)].map((match) => Number(match[1]));
450
+ assert.deepEqual(tailIds, [last]);
451
+
452
+ // Y el log del run no secontamination con el canal global.
453
+ assert.ok(!/event: run_start/.test(full));
454
+ assert.ok(!/event: run_end/.test(full));
455
+ } finally {
456
+ await instance.close();
457
+ }
458
+ });
459
+
460
+ test('a pending permission is visible to any client, not only the one streaming', async () => {
461
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-ask-'));
462
+ let calls = 0;
463
+ const provider = {
464
+ id: 'tooly',
465
+ async streamChat() {
466
+ calls += 1;
467
+ if (calls === 1) {
468
+ return {
469
+ content: '',
470
+ reasoning: '',
471
+ toolCalls: [{ id: 'call_1', function: { name: 'write', arguments: JSON.stringify({ path: 'nota.txt', content: 'hi' }) } }],
472
+ };
473
+ }
474
+ return { content: 'listo', reasoning: '', toolCalls: [] };
475
+ },
476
+ };
477
+ const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
478
+ const json = { 'Content-Type': 'application/json' };
479
+ try {
480
+ const created = await (
481
+ await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ model: 'm' }) })
482
+ ).json();
483
+ const id = created.session.id;
484
+
485
+ // Cliente B: canal global abierto, sin ningun stream de la sesion.
486
+ const seen = [];
487
+ const watching = readSse(`${base}/api/events`, {}, (data, event) => event === 'permission');
488
+ await new Promise((resolve) => setTimeout(resolve, 50));
489
+
490
+ // Cliente A: pide la tarea y se queda esperando el permiso.
491
+ const chat = fetch(`${base}/api/chat`, {
492
+ method: 'POST',
493
+ headers: json,
494
+ body: JSON.stringify({ input: 'crea nota.txt', model: 'm', sessionId: id }),
495
+ }).catch(() => null);
496
+ await new Promise((resolve) => setTimeout(resolve, 200));
497
+
498
+ const frames = await watching;
499
+ const permission = frames.find((frame) => frame.event === 'permission');
500
+ assert.ok(permission, 'el canal global deberia avisar del permiso');
501
+ assert.equal(permission.data.sessionId, id);
502
+ assert.equal(permission.data.name, 'write');
503
+ seen.push(permission.data.askId);
504
+
505
+ // Y tambien se puede consultar, que es lo que cubre una recarga de pagina.
506
+ const list = await (await fetch(`${base}/api/permissions`)).json();
507
+ assert.equal(list.pending.length, 1);
508
+ assert.equal(list.pending[0].askId, permission.data.askId);
509
+ assert.equal(list.pending[0].sessionId, id);
510
+ assert.deepEqual(list.pending[0].args, { path: 'nota.txt', content: 'hi' });
511
+
512
+ const scoped = await (await fetch(`${base}/api/permissions?sessionId=${id}`)).json();
513
+ assert.equal(scoped.pending.length, 1);
514
+ const other = await (await fetch(`${base}/api/permissions?sessionId=otra`)).json();
515
+ assert.equal(other.pending.length, 0);
516
+
517
+ const detail = await (await fetch(`${base}/api/sessions/${id}`)).json();
518
+ assert.equal(detail.permissions.length, 1, 'el detalle debe traer los pendientes para reengancharse');
519
+ const listSessions = await (await fetch(`${base}/api/sessions`)).json();
520
+ assert.equal(listSessions.sessions.find((s) => s.id === id).awaiting, 1);
521
+
522
+ const decision = await (
523
+ await fetch(`${base}/api/permissions/${permission.data.askId}`, {
524
+ method: 'POST',
525
+ headers: json,
526
+ body: JSON.stringify({ allow: true }),
527
+ })
528
+ ).json();
529
+ assert.equal(decision.ok, true);
530
+
531
+ const after = await (await fetch(`${base}/api/permissions`)).json();
532
+ assert.equal(after.pending.length, 0);
533
+ await chat;
534
+ } finally {
535
+ await instance.close();
536
+ }
537
+ });
538
+
539
+ /**
540
+ * Espera a que la sesion se quede sin run.
541
+ *
542
+ * Hace falta antes de `instance.close()`: un run que sigue vivo emite `aborted`
543
+ * en otro tick y escribe sobre la base ya cerrada.
544
+ */
545
+ async function waitIdle(base, id, attempts = 60) {
546
+ for (let i = 0; i < attempts; i += 1) {
547
+ const detail = await (await fetch(`${base}/api/sessions/${id}`)).json();
548
+ if (!detail.running) return true;
549
+ await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' }).catch(() => {});
550
+ await new Promise((resolve) => setTimeout(resolve, 100));
551
+ }
552
+ return false;
553
+ }
554
+
555
+ test('/api/state carries liveness and pending permissions in one request', async () => {
556
+ let release;
557
+ const gate = new Promise((resolve) => (release = resolve));
558
+ let calls = 0;
559
+ const provider = {
560
+ id: 'tooly',
561
+ async streamChat() {
562
+ calls += 1;
563
+ if (calls === 1) {
564
+ return {
565
+ content: '',
566
+ reasoning: '',
567
+ toolCalls: [{ id: 'call_1', function: { name: 'write', arguments: JSON.stringify({ path: 'nota.txt', content: 'hi' }) } }],
568
+ };
569
+ }
570
+ await gate;
571
+ return { content: 'listo', reasoning: '', toolCalls: [] };
572
+ },
573
+ };
574
+ const { instance, base } = await startServer({ resolveProvider: () => ({ provider, config: {} }) });
575
+ const json = { 'Content-Type': 'application/json' };
576
+ try {
577
+ const created = await (
578
+ await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ model: 'm' }) })
579
+ ).json();
580
+ const id = created.session.id;
581
+
582
+ const idle = await (await fetch(`${base}/api/state`)).json();
583
+ assert.equal(idle.sessions.find((s) => s.id === id).running, false);
584
+ assert.equal(idle.sessions.find((s) => s.id === id).awaiting, 0);
585
+ assert.deepEqual(idle.permissions, []);
586
+ assert.equal(typeof idle.now, 'number');
587
+ // El snapshot no lleva mensajes: es lo que se puede pedir seguido.
588
+ assert.equal(idle.sessions[0].messages, undefined);
589
+
590
+ const chat = fetch(`${base}/api/chat`, {
591
+ method: 'POST',
592
+ headers: json,
593
+ body: JSON.stringify({ input: 'crea nota.txt', model: 'm', sessionId: id }),
594
+ }).catch(() => null);
595
+ await new Promise((resolve) => setTimeout(resolve, 200));
596
+
597
+ // Un permiso creado despues aparece en el siguiente read: esto es lo que
598
+ // hace que la consola se recupere sin ningun SSE.
599
+ const busy = await (await fetch(`${base}/api/state`)).json();
600
+ const session = busy.sessions.find((s) => s.id === id);
601
+ assert.equal(session.running, true);
602
+ assert.equal(session.awaiting, 1);
603
+ assert.equal(busy.permissions.length, 1);
604
+ assert.equal(busy.permissions[0].name, 'write');
605
+ assert.deepEqual(busy.permissions[0].args, { path: 'nota.txt', content: 'hi' });
606
+ assert.equal(busy.permissions[0].sessionId, id);
607
+
608
+ const askId = busy.permissions[0].askId;
609
+ await fetch(`${base}/api/permissions/${askId}`, {
610
+ method: 'POST',
611
+ headers: json,
612
+ body: JSON.stringify({ allow: true }),
613
+ });
614
+
615
+ const after = await (await fetch(`${base}/api/state`)).json();
616
+ assert.deepEqual(after.permissions, []);
617
+ assert.equal(after.sessions.find((s) => s.id === id).awaiting, 0);
618
+
619
+ release();
620
+ await chat;
621
+ await waitIdle(base, id);
622
+ } finally {
623
+ release();
624
+ await instance.close();
625
+ }
626
+ });
627
+
628
+ test('a client with no streams at all can find and resolve a permission', async () => {
629
+ // El escenario que reporto el celular: sin stream de sesion y sin canal global.
630
+ let release;
631
+ const gate = new Promise((resolve) => (release = resolve));
632
+ let calls = 0;
633
+ const provider = {
634
+ id: 'tooly',
635
+ async streamChat() {
636
+ calls += 1;
637
+ if (calls === 1) {
638
+ return {
639
+ content: '',
640
+ reasoning: '',
641
+ toolCalls: [{ id: 'call_1', function: { name: 'bash', arguments: JSON.stringify({ command: 'ls' }) } }],
642
+ };
643
+ }
644
+ await gate;
645
+ return { content: 'listo', reasoning: '', toolCalls: [] };
646
+ },
647
+ };
648
+ const { instance, base } = await startServer({ resolveProvider: () => ({ provider, config: {} }) });
649
+ const json = { 'Content-Type': 'application/json' };
650
+ try {
651
+ const created = await (
652
+ await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ model: 'm' }) })
653
+ ).json();
654
+ const id = created.session.id;
655
+
656
+ const chat = fetch(`${base}/api/chat`, {
657
+ method: 'POST',
658
+ headers: json,
659
+ body: JSON.stringify({ input: 'lista los archivos', model: 'm', sessionId: id }),
660
+ }).catch(() => null);
661
+ await new Promise((resolve) => setTimeout(resolve, 200));
662
+
663
+ // Solo requests cortos: es lo que sobrevive en un enlace movil.
664
+ const state = await (await fetch(`${base}/api/state`)).json();
665
+ assert.equal(state.permissions.length, 1);
666
+ assert.equal(state.permissions[0].name, 'bash');
667
+
668
+ const decision = await (
669
+ await fetch(`${base}/api/permissions/${state.permissions[0].askId}`, {
670
+ method: 'POST',
671
+ headers: json,
672
+ body: JSON.stringify({ allow: true, scope: 'once' }),
673
+ })
674
+ ).json();
675
+ assert.equal(decision.ok, true);
676
+ assert.deepEqual((await (await fetch(`${base}/api/state`)).json()).permissions, []);
677
+
678
+ release();
679
+ await chat;
680
+ await waitIdle(base, id);
681
+ } finally {
682
+ release();
683
+ await instance.close();
684
+ }
685
+ });
686
+
687
+ test('approving clears the permission instead of bringing it back', async () => {
688
+ // El sintoma era "le doy que si y me vuelve a pedir lo mismo". Dos causas
689
+ // distintas y esta las separa: el server vacia el estado al resolver, y una
690
+ // peticion nueva del agente es un `askId` nuevo (no el mismo permiso
691
+ // revivido).
692
+ let round = 0;
693
+ const provider = {
694
+ id: 'loopish',
695
+ async streamChat() {
696
+ round += 1;
697
+ if (round <= 3) {
698
+ return {
699
+ content: '',
700
+ reasoning: '',
701
+ toolCalls: [{ id: `c${round}`, function: { name: 'bash', arguments: JSON.stringify({ command: `echo paso-${round}` }) } }],
702
+ };
703
+ }
704
+ return { content: 'listo', reasoning: '', toolCalls: [] };
705
+ },
706
+ };
707
+ const { instance, base } = await startServer({ resolveProvider: () => ({ provider, config: {} }) });
708
+ const json = { 'Content-Type': 'application/json' };
709
+ try {
710
+ const created = await (
711
+ await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ model: 'm' }) })
712
+ ).json();
713
+ const id = created.session.id;
714
+ const chat = fetch(`${base}/api/chat`, {
715
+ method: 'POST',
716
+ headers: json,
717
+ body: JSON.stringify({ input: 'hacé tres pasos', model: 'm', sessionId: id }),
718
+ }).catch(() => null);
719
+
720
+ const approved = [];
721
+ for (let i = 0; i < 40 && approved.length < 3; i += 1) {
722
+ await new Promise((resolve) => setTimeout(resolve, 100));
723
+ const state = await (await fetch(`${base}/api/state`)).json();
724
+ for (const ask of state.permissions) {
725
+ const decision = await fetch(`${base}/api/permissions/${ask.askId}`, {
726
+ method: 'POST',
727
+ headers: json,
728
+ body: JSON.stringify({ allow: true }),
729
+ });
730
+ assert.equal(decision.status, 200);
731
+ approved.push(ask.askId);
732
+ // Inmediatamente despues de resolver, no queda pendiente: si volviera,
733
+ // el cliente lo re-pintaria en bucle.
734
+ const after = await (await fetch(`${base}/api/state`)).json();
735
+ assert.equal(after.permissions.length, 0, 'el permiso resuelto no debe reaparecer');
736
+ }
737
+ }
738
+ // Tres peticiones del agente -> tres askId distintos, ninguno revivido.
739
+ assert.equal(approved.length, 3);
740
+ assert.equal(new Set(approved).size, 3);
741
+
742
+ await waitIdle(base, id);
743
+ await chat;
744
+ } finally {
745
+ await instance.close();
746
+ }
747
+ });
748
+
749
+ test('resolving a permission that no longer exists reports 404', async () => {
750
+ const { instance, base } = await startServer();
751
+ const json = { 'Content-Type': 'application/json' };
752
+ try {
753
+ // Un askId inventado: la tarjeta era vieja. Con esto el cliente puede
754
+ // decir "ya no aplica" en vez de dejar el permiso esperando.
755
+ const res = await fetch(`${base}/api/permissions/inexistente`, {
756
+ method: 'POST',
757
+ headers: json,
758
+ body: JSON.stringify({ allow: true }),
759
+ });
760
+ assert.equal(res.status, 404);
761
+ } finally {
762
+ await instance.close();
763
+ }
764
+ });
765
+
256
766
  test('done reports max_rounds and the tool count when the model never answers', async () => {
257
767
  const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-loop-'));
258
768
  await writeFile(path.join(cwd, 'a.txt'), 'hola');
@@ -1248,7 +1758,7 @@ test('plan mode exposes only read-only tools and adds guidance', async () => {
1248
1758
  body: JSON.stringify({ input: 'planificá', model: 'm', mode: 'plan' }),
1249
1759
  })
1250
1760
  ).text();
1251
- assert.deepEqual(captured.tools, ['fetch_url', 'git_diff', 'git_log', 'git_status', 'glob', 'grep', 'list', 'read', 'task']);
1761
+ assert.deepEqual(captured.tools, ['fetch_url', 'git_diff', 'git_log', 'git_status', 'glob', 'grep', 'list', 'read', 'speak', 'task']);
1252
1762
  assert.match(captured.system, /modo PLAN/);
1253
1763
  } finally {
1254
1764
  await instance.close();
@@ -0,0 +1,156 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { Store } from '../../core/src/index.js';
7
+ import { createApprovalBroker } from '../src/lib/approvals.js';
8
+ import { startTelegramChannel } from '../src/lib/channels/telegram.js';
9
+
10
+ const reply = (data) => ({ ok: true, json: async () => data });
11
+
12
+ const FAKE_FRAME = new TextEncoder().encode('Path:audio\r\nFFF364MP3').buffer;
13
+
14
+ /** WebSocket falso: entrega un frame de audio y cierra con turn.end. */
15
+ class FakeWebSocket {
16
+ constructor() {
17
+ this.listeners = new Map();
18
+ this.binaryType = 'arraybuffer';
19
+ setTimeout(() => {
20
+ this.fire('open', {});
21
+ this.fire('message', FAKE_FRAME);
22
+ this.fire('message', 'X-RequestId:1\r\nPath:turn.end\r\n');
23
+ }, 0);
24
+ }
25
+ addEventListener(type, fn) {
26
+ if (!this.listeners.has(type)) this.listeners.set(type, new Set());
27
+ this.listeners.get(type).add(fn);
28
+ }
29
+ removeEventListener() {}
30
+ send() {}
31
+ close() {
32
+ this.fire('close', {});
33
+ }
34
+ fire(type, arg) {
35
+ for (const fn of this.listeners.get(type) ?? []) fn({ data: arg, message: 'fake' });
36
+ }
37
+ }
38
+
39
+ /** Mock de fetch que registra los envios multipart (sendVoice/sendAudio). */
40
+ function installVozFetchMock() {
41
+ const sent = [];
42
+ const multipart = [];
43
+ const updates = [];
44
+ const original = global.fetch;
45
+ global.fetch = async (url, opts = {}) => {
46
+ const method = String(url).split('/').pop();
47
+ if (opts.body instanceof FormData) {
48
+ multipart.push({ method, chatId: String(opts.body.get('chat_id') ?? '') });
49
+ return reply({ ok: true, result: true });
50
+ }
51
+ const body = opts.body ? JSON.parse(opts.body) : {};
52
+ if (method === 'getUpdates') {
53
+ const out = updates.splice(0, updates.length);
54
+ if (!out.length) await new Promise((resolve) => setTimeout(resolve, 20));
55
+ return reply({ ok: true, result: out });
56
+ }
57
+ if (method === 'sendMessage' || method === 'editMessageText') {
58
+ sent.push(body);
59
+ return reply({ ok: true, result: { message_id: sent.length } });
60
+ }
61
+ return reply({ ok: true, result: true });
62
+ };
63
+ return { sent, updates, multipart, restore: () => { global.fetch = original; } };
64
+ }
65
+
66
+ function makeCtx(cwd, app = {}) {
67
+ const store = new Store(':memory:');
68
+ const provider = {
69
+ id: 'fake',
70
+ async streamChat(_config, { onEvent }) {
71
+ onEvent?.({ type: 'text', delta: 'listo', full: 'listo' });
72
+ return { content: 'listo', reasoning: '', toolCalls: [] };
73
+ },
74
+ };
75
+ return {
76
+ cwd,
77
+ config: { maxRounds: 2, permissions: {}, telegram: {} },
78
+ app,
79
+ store,
80
+ approvals: createApprovalBroker({ timeoutMs: 1000 }),
81
+ effectiveProviderId: () => 'fake',
82
+ effectiveModel: () => 'm',
83
+ effectiveMode: () => 'agent',
84
+ providerSettings: () => ({ baseUrl: 'http://127.0.0.1:1', apiKey: '' }),
85
+ apiKeyFor: () => '',
86
+ customProviderDefs: () => [],
87
+ resolveProvider: () => ({ provider, config: {} }),
88
+ getMcp: async () => null,
89
+ systemPromptFor: async () => 'sys',
90
+ agentTools: { definitions: [], handles: () => false, execute: async () => '' },
91
+ };
92
+ }
93
+
94
+ async function waitFor(fn, timeout = 3000) {
95
+ const start = Date.now();
96
+ while (Date.now() - start < timeout) {
97
+ if (fn()) return;
98
+ await new Promise((resolve) => setTimeout(resolve, 10));
99
+ }
100
+ throw new Error('timeout esperando la condición');
101
+ }
102
+
103
+ test('con la voz activada, el turno de Telegram termina con una nota de voz', async () => {
104
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tgvoz-'));
105
+ const ctx = makeCtx(cwd, { tts: { enabled: true, voice: 'es-AR-ElenaNeural', rate: '+0%', announce: { end: true } } });
106
+ const mock = installVozFetchMock();
107
+ const original = global.WebSocket;
108
+ global.WebSocket = FakeWebSocket;
109
+ const channel = startTelegramChannel(ctx, { token: 'x', allowedChatIds: ['7'], log: () => {} });
110
+ try {
111
+ mock.updates.push({ update_id: 1, message: { chat: { id: 7 }, text: 'hola' } });
112
+ await waitFor(() => mock.multipart.length > 0);
113
+ const nota = mock.multipart[0];
114
+ assert.equal(nota.chatId, '7');
115
+ assert.match(nota.method, /^send(Voice|Audio)$/);
116
+ } finally {
117
+ global.WebSocket = original;
118
+ channel.stop();
119
+ mock.restore();
120
+ }
121
+ });
122
+
123
+ test('con la voz apagada, el turno de Telegram termina sin audios', async () => {
124
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tgvoz-'));
125
+ const ctx = makeCtx(cwd, {});
126
+ const mock = installVozFetchMock();
127
+ const channel = startTelegramChannel(ctx, { token: 'x', allowedChatIds: ['7'], log: () => {} });
128
+ try {
129
+ mock.updates.push({ update_id: 1, message: { chat: { id: 7 }, text: 'hola' } });
130
+ await waitFor(() => mock.sent.some((m) => /✓ terminado/.test(m.text ?? '')));
131
+ assert.equal(mock.multipart.length, 0);
132
+ } finally {
133
+ channel.stop();
134
+ mock.restore();
135
+ }
136
+ });
137
+
138
+ test('la tool speak manda una nota de voz aunque la narración esté apagada', async () => {
139
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tgspeak-'));
140
+ const ctx = makeCtx(cwd, {});
141
+ const mock = installVozFetchMock();
142
+ const original = global.WebSocket;
143
+ global.WebSocket = FakeWebSocket;
144
+ const channel = startTelegramChannel(ctx, { token: 'x', allowedChatIds: ['7'], log: () => {} });
145
+ try {
146
+ ctx.store.setSetting('telegram:session:sess-1', '7');
147
+ channel.notify('sess-1', { type: 'tool_call', data: { name: 'speak', args: { texto: 'Hola, esto es una prueba' } } });
148
+ await waitFor(() => mock.multipart.length > 0);
149
+ assert.equal(mock.multipart[0].chatId, '7');
150
+ assert.match(mock.multipart[0].method, /^send(Voice|Audio)$/);
151
+ } finally {
152
+ global.WebSocket = original;
153
+ channel.stop();
154
+ mock.restore();
155
+ }
156
+ });