@danieltmn/tamnora 0.21.1 → 0.22.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 (58) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +10 -6
  3. package/SECURITY.md +13 -0
  4. package/harness/packages/core/src/agent/runAgent.js +9 -5
  5. package/harness/packages/core/src/browser/index.js +56 -2
  6. package/harness/packages/core/src/hooks/index.js +9 -5
  7. package/harness/packages/core/src/index.js +1 -0
  8. package/harness/packages/core/src/mcp/stdio.js +15 -0
  9. package/harness/packages/core/src/net/hosts.js +181 -0
  10. package/harness/packages/core/src/plugins/index.js +6 -4
  11. package/harness/packages/core/src/redact.js +2 -0
  12. package/harness/packages/core/src/tools/executors.js +54 -53
  13. package/harness/packages/core/src/tools/index.js +1 -1
  14. package/harness/packages/core/src/tools/paths.js +35 -5
  15. package/harness/packages/core/src/tools/permissions.js +41 -2
  16. package/harness/packages/core/src/tools/registry.js +8 -3
  17. package/harness/packages/core/src/tunnel.js +15 -5
  18. package/harness/packages/core/test/hooks.test.js +6 -3
  19. package/harness/packages/core/test/hosts.test.js +50 -0
  20. package/harness/packages/core/test/plugins.test.js +5 -3
  21. package/harness/packages/core/test/tools.test.js +98 -2
  22. package/harness/packages/server/public/app.js +343 -6729
  23. package/harness/packages/server/public/chat.js +1893 -0
  24. package/harness/packages/server/public/chrome.js +812 -0
  25. package/harness/packages/server/public/commands.js +403 -0
  26. package/harness/packages/server/public/core.js +209 -0
  27. package/harness/packages/server/public/explorer.js +625 -0
  28. package/harness/packages/server/public/index.html +19 -19
  29. package/harness/packages/server/public/sessions.js +274 -0
  30. package/harness/packages/server/public/settings.js +1076 -0
  31. package/harness/packages/server/public/styles.css +29 -2
  32. package/harness/packages/server/public/system.js +495 -0
  33. package/harness/packages/server/public/voice.js +1058 -0
  34. package/harness/packages/server/src/auth.js +12 -10
  35. package/harness/packages/server/src/lib/agentRun.js +6 -1
  36. package/harness/packages/server/src/lib/channels/telegram.js +1 -0
  37. package/harness/packages/server/src/lib/presence.js +12 -1
  38. package/harness/packages/server/src/lib/procs.js +30 -3
  39. package/harness/packages/server/src/lib/scheduler.js +11 -2
  40. package/harness/packages/server/src/lib/tasks.js +20 -3
  41. package/harness/packages/server/src/lib/workspace.js +90 -2
  42. package/harness/packages/server/src/routes/auth.js +8 -4
  43. package/harness/packages/server/src/routes/chat.js +15 -4
  44. package/harness/packages/server/src/routes/preview.js +10 -2
  45. package/harness/packages/server/src/routes/procs.js +2 -6
  46. package/harness/packages/server/src/routes/providers.js +12 -9
  47. package/harness/packages/server/src/server.js +28 -2
  48. package/harness/packages/server/test/chat.test.js +586 -0
  49. package/harness/packages/server/test/helpers.js +107 -0
  50. package/harness/packages/server/test/ops.test.js +511 -0
  51. package/harness/packages/server/test/procs-manager.test.js +43 -0
  52. package/harness/packages/server/test/runs.test.js +816 -0
  53. package/harness/packages/server/test/security.test.js +112 -9
  54. package/harness/packages/server/test/sessions.test.js +437 -0
  55. package/harness/packages/server/test/settings.test.js +465 -0
  56. package/harness/tamnora.example.json +1 -1
  57. package/package.json +1 -1
  58. package/harness/packages/server/test/server.test.js +0 -2878
@@ -1,2878 +0,0 @@
1
- import { test } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import http from 'node:http';
4
- import { execFileSync } from 'node:child_process';
5
- import { mkdtemp, mkdir, writeFile, readFile, stat } from 'node:fs/promises';
6
- import { tmpdir } from 'node:os';
7
- import path from 'node:path';
8
- import { createTamnoraServer } from '../src/index.js';
9
- import { setHome, ensureApp, readApp, writeApp, makeUser, screenshotsDir } from '../../core/src/index.js';
10
- import { completionFooter } from '../src/lib/channels/telegram.js';
11
-
12
- const fakeProvider = {
13
- id: 'fake',
14
- async streamChat(_config, { onEvent }) {
15
- onEvent?.({ type: 'text', delta: 'hola', full: 'hola' });
16
- return { content: 'hola', reasoning: '', toolCalls: [] };
17
- },
18
- };
19
-
20
- async function startServer(overrides = {}) {
21
- const cwd = overrides.cwd ?? (await mkdtemp(path.join(tmpdir(), 'tamnora-srv-')));
22
- setHome(cwd);
23
- const instance = await createTamnoraServer({
24
- cwd,
25
- dbPath: path.join(cwd, 'tamnora.db'),
26
- loadMcp: false,
27
- resolveProvider: () => ({ provider: fakeProvider, config: {} }),
28
- ...overrides,
29
- });
30
- const info = await instance.listen({ port: 0 });
31
- return { instance, base: info.url };
32
- }
33
-
34
- const HAS_GIT = (() => {
35
- try {
36
- execFileSync('git', ['--version'], { stdio: 'ignore' });
37
- return true;
38
- } catch {
39
- return false;
40
- }
41
- })();
42
-
43
- function gitInit(root) {
44
- execFileSync('git', ['init', '-q'], { cwd: root });
45
- execFileSync('git', ['config', 'user.email', 'test@tamnora.dev'], { cwd: root });
46
- execFileSync('git', ['config', 'user.name', 'Test'], { cwd: root });
47
- }
48
-
49
- test('serves health, providers and the console', async () => {
50
- const { instance, base } = await startServer();
51
- try {
52
- const health = await (await fetch(`${base}/api/health`)).json();
53
- assert.equal(health.ok, true);
54
- assert.equal(health.name, 'tamnora');
55
-
56
- const providers = await (await fetch(`${base}/api/providers`)).json();
57
- assert.ok(providers.providers.some((p) => p.id === 'openrouter'));
58
- assert.equal(providers.active.provider, 'openrouter');
59
-
60
- const html = await (await fetch(`${base}/`)).text();
61
- assert.match(html, /Tamnora Console/);
62
- for (const hook of [
63
- 'data-panel="agente"',
64
- 'data-panel="sesiones"',
65
- 'id="settings-controls-slot"',
66
- 'id="slash-menu"',
67
- 'id="statusbar"',
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"',
72
- ]) {
73
- assert.ok(html.includes(hook), `falta ${hook}`);
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);
81
- // Los controles de agente se declaran una sola vez (se mueven entre slots).
82
- assert.equal((html.match(/id="mode"/g) ?? []).length, 1);
83
-
84
- const missing = await fetch(`${base}/api/nope`);
85
- assert.equal(missing.status, 404);
86
- } finally {
87
- await instance.close();
88
- }
89
- });
90
-
91
- test('creates, lists, reads and deletes sessions', async () => {
92
- const { instance, base } = await startServer();
93
- try {
94
- const created = await (
95
- await fetch(`${base}/api/sessions`, {
96
- method: 'POST',
97
- headers: { 'Content-Type': 'application/json' },
98
- body: JSON.stringify({ title: 'Prueba', model: 'm' }),
99
- })
100
- ).json();
101
- assert.ok(created.session.id);
102
-
103
- const list = await (await fetch(`${base}/api/sessions`)).json();
104
- assert.equal(list.sessions.length, 1);
105
- assert.equal(list.sessions[0].title, 'Prueba');
106
-
107
- const detail = await (await fetch(`${base}/api/sessions/${created.session.id}`)).json();
108
- assert.deepEqual(detail.messages, []);
109
-
110
- const deleted = await fetch(`${base}/api/sessions/${created.session.id}`, { method: 'DELETE' });
111
- assert.equal(deleted.status, 200);
112
- const afterList = await (await fetch(`${base}/api/sessions`)).json();
113
- assert.equal(afterList.sessions.length, 0);
114
- } finally {
115
- await instance.close();
116
- }
117
- });
118
-
119
- test('lists and deletes sessions grouped by project (cwd)', async () => {
120
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-proj-'));
121
- const dirA = path.join(cwd, 'a');
122
- const dirB = path.join(cwd, 'b');
123
- await mkdir(dirA);
124
- await mkdir(dirB);
125
- const { instance, base } = await startServer({ cwd });
126
- const create = (body) =>
127
- fetch(`${base}/api/sessions`, {
128
- method: 'POST',
129
- headers: { 'Content-Type': 'application/json' },
130
- body: JSON.stringify(body),
131
- });
132
- try {
133
- await create({ title: 'a1', model: 'm', cwd: dirA });
134
- await create({ title: 'a2', model: 'm', cwd: dirA });
135
- await create({ title: 'b1', model: 'm', cwd: dirB });
136
-
137
- const projects = await (await fetch(`${base}/api/projects`)).json();
138
- assert.equal(projects.projects.find((p) => p.cwd === dirA).sessions, 2);
139
- assert.equal(projects.projects.find((p) => p.cwd === dirB).sessions, 1);
140
-
141
- const removed = await (
142
- await fetch(`${base}/api/projects?cwd=${encodeURIComponent(dirA)}`, { method: 'DELETE' })
143
- ).json();
144
- assert.equal(removed.sessions, 2);
145
-
146
- const list = await (await fetch(`${base}/api/sessions`)).json();
147
- assert.equal(list.sessions.length, 1);
148
- assert.equal(list.sessions[0].cwd, dirB);
149
-
150
- const missing = await fetch(`${base}/api/projects`, { method: 'DELETE' });
151
- assert.equal(missing.status, 400);
152
- } finally {
153
- await instance.close();
154
- }
155
- });
156
-
157
- test('cada sesión recuerda su propio tipo de agente (proveedor/modelo/modo)', async () => {
158
- const calls = [];
159
- const provider = {
160
- id: 'recorder',
161
- async streamChat(_config, { model }) {
162
- calls.push(model);
163
- return { content: 'ok', reasoning: '', toolCalls: [] };
164
- },
165
- };
166
- const { instance, base } = await startServer({ resolveProvider: () => ({ provider, config: {} }) });
167
- const json = { 'Content-Type': 'application/json' };
168
- const create = (body) =>
169
- fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify(body) }).then((r) => r.json());
170
- const chat = (sessionId, body = {}) =>
171
- fetch(`${base}/api/chat`, {
172
- method: 'POST',
173
- headers: json,
174
- body: JSON.stringify({ input: 'hola', sessionId, ...body }),
175
- }).then((r) => r.text());
176
- try {
177
- const a = await create({ title: 'A', provider: 'openrouter', model: 'modelo-a', mode: 'plan' });
178
- const b = await create({ title: 'B', provider: 'openai', model: 'modelo-b' });
179
- assert.equal(a.session.mode, 'plan');
180
-
181
- // Aunque el cuerpo no manda modelo, cada sesión corre con el suyo.
182
- await chat(a.session.id);
183
- await chat(b.session.id);
184
- assert.deepEqual(calls, ['modelo-a', 'modelo-b']);
185
-
186
- // El tipo de agente se puede fijar sin correr (PUT) y se persiste.
187
- const updated = await (
188
- await fetch(`${base}/api/sessions/${b.session.id}`, {
189
- method: 'PUT',
190
- headers: json,
191
- body: JSON.stringify({ provider: 'openrouter', model: 'modelo-c', mode: 'agent' }),
192
- })
193
- ).json();
194
- assert.equal(updated.session.model, 'modelo-c');
195
- assert.equal(updated.session.mode, 'agent');
196
-
197
- await chat(b.session.id);
198
- assert.equal(calls.at(-1), 'modelo-c');
199
-
200
- // El default global no se toca por cambiar el de una sesión.
201
- const settings = await (await fetch(`${base}/api/settings`)).json();
202
- assert.notEqual(settings.settings.model, 'modelo-c');
203
- } finally {
204
- await instance.close();
205
- }
206
- });
207
-
208
-
209
- test('streams a chat turn over SSE and persists the messages', async () => {
210
- const { instance, base } = await startServer();
211
- try {
212
- const res = await fetch(`${base}/api/chat`, {
213
- method: 'POST',
214
- headers: { 'Content-Type': 'application/json' },
215
- body: JSON.stringify({ input: 'hola', model: 'm' }),
216
- });
217
- assert.equal(res.status, 200);
218
- assert.match(res.headers.get('content-type'), /text\/event-stream/);
219
- const text = await res.text();
220
- assert.match(text, /event: start/);
221
- assert.match(text, /event: text/);
222
- assert.match(text, /event: done/);
223
-
224
- const list = await (await fetch(`${base}/api/sessions`)).json();
225
- assert.equal(list.sessions.length, 1);
226
- const detail = await (await fetch(`${base}/api/sessions/${list.sessions[0].id}`)).json();
227
- const assistant = detail.messages.find((m) => m.role === 'assistant');
228
- assert.equal(assistant.content, 'hola');
229
- } finally {
230
- await instance.close();
231
- }
232
- });
233
-
234
- test('persists the run log and replays it on the stream endpoint', async () => {
235
- const { instance, base } = await startServer();
236
- const json = { 'Content-Type': 'application/json' };
237
- try {
238
- await (
239
- await fetch(`${base}/api/chat`, {
240
- method: 'POST',
241
- headers: json,
242
- body: JSON.stringify({ input: 'hola', model: 'm' }),
243
- })
244
- ).text();
245
- const list = await (await fetch(`${base}/api/sessions`)).json();
246
- const id = list.sessions[0].id;
247
-
248
- const stream = await (await fetch(`${base}/api/sessions/${id}/stream?from=0`)).text();
249
- assert.match(stream, /event: start/);
250
- assert.match(stream, /event: text/);
251
- assert.match(stream, /event: done/);
252
- assert.match(stream, /event: idle/);
253
- assert.match(stream, /^id: \d+$/m);
254
- } finally {
255
- await instance.close();
256
- }
257
- });
258
-
259
- test('keeps a run alive after the client disconnects and can stop it', async () => {
260
- let release;
261
- const gate = new Promise((resolve) => (release = resolve));
262
- const slowProvider = {
263
- id: 'slow',
264
- async streamChat(_config, { onEvent, signal }) {
265
- onEvent?.({ type: 'text', delta: '…', full: '…' });
266
- await new Promise((resolve, reject) => {
267
- if (signal?.aborted) return reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
268
- signal?.addEventListener('abort', () =>
269
- reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))
270
- );
271
- gate.then(resolve);
272
- });
273
- return { content: 'fin', reasoning: '', toolCalls: [] };
274
- },
275
- };
276
- const { instance, base } = await startServer({ resolveProvider: () => ({ provider: slowProvider, config: {} }) });
277
- const json = { 'Content-Type': 'application/json' };
278
- try {
279
- const created = await (
280
- await fetch(`${base}/api/sessions`, {
281
- method: 'POST',
282
- headers: json,
283
- body: JSON.stringify({ model: 'm' }),
284
- })
285
- ).json();
286
- const id = created.session.id;
287
-
288
- const controller = new AbortController();
289
- const chat = fetch(`${base}/api/chat`, {
290
- method: 'POST',
291
- headers: json,
292
- body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
293
- signal: controller.signal,
294
- }).catch(() => null);
295
- await new Promise((resolve) => setTimeout(resolve, 80));
296
- controller.abort();
297
- await chat;
298
-
299
- const during = await (await fetch(`${base}/api/sessions/${id}`)).json();
300
- assert.equal(during.running, true);
301
-
302
- const stopped = await (
303
- await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST', headers: json })
304
- ).json();
305
- assert.equal(stopped.stopped, true);
306
-
307
- release();
308
- await new Promise((resolve) => setTimeout(resolve, 80));
309
- const after = await (await fetch(`${base}/api/sessions/${id}`)).json();
310
- assert.equal(after.running, false);
311
- } finally {
312
- release();
313
- await instance.close();
314
- }
315
- });
316
-
317
- test('interrupt cancels the running turn and starts the new message right away', async () => {
318
- const calls = { n: 0 };
319
- let abortedFirst = false;
320
- const provider = {
321
- id: 'interrupt',
322
- async streamChat(_config, { onEvent, signal }) {
323
- calls.n += 1;
324
- if (calls.n === 1) {
325
- onEvent?.({ type: 'text', delta: 'trabajando', full: 'trabajando' });
326
- await new Promise((resolve, reject) => {
327
- const timer = setTimeout(resolve, 5000);
328
- const onAbort = () => {
329
- clearTimeout(timer);
330
- abortedFirst = true;
331
- reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
332
- };
333
- if (signal?.aborted) return onAbort();
334
- signal?.addEventListener('abort', onAbort, { once: true });
335
- });
336
- }
337
- onEvent?.({ type: 'text', delta: 'listo', full: 'listo' });
338
- return { content: 'listo', reasoning: '', toolCalls: [] };
339
- },
340
- };
341
- const { instance, base } = await startServer({ resolveProvider: () => ({ provider, config: {} }) });
342
- const json = { 'Content-Type': 'application/json' };
343
- try {
344
- const created = await (
345
- await fetch(`${base}/api/sessions`, {
346
- method: 'POST',
347
- headers: json,
348
- body: JSON.stringify({ model: 'm' }),
349
- })
350
- ).json();
351
- const id = created.session.id;
352
-
353
- const first = fetch(`${base}/api/chat`, {
354
- method: 'POST',
355
- headers: json,
356
- body: JSON.stringify({ input: 'uno', model: 'm', sessionId: id }),
357
- })
358
- .then((res) => res.text())
359
- .catch(() => null);
360
- await new Promise((resolve) => setTimeout(resolve, 80));
361
-
362
- const second = await (
363
- await fetch(`${base}/api/chat`, {
364
- method: 'POST',
365
- headers: json,
366
- body: JSON.stringify({ input: 'dos', model: 'm', sessionId: id, interrupt: true }),
367
- })
368
- ).text();
369
-
370
- assert.match(second, /event: text/);
371
- assert.match(second, /listo/);
372
- assert.equal(abortedFirst, true);
373
- await first;
374
-
375
- await new Promise((resolve) => setTimeout(resolve, 60));
376
- const after = await (await fetch(`${base}/api/sessions/${id}`)).json();
377
- assert.equal(after.running, false);
378
- } finally {
379
- await instance.close();
380
- }
381
- });
382
-
383
- /** Lee un SSE hasta que `until(data)` se cumpla, y devuelve lo acumulado. */
384
- async function readSse(url, options, until, timeoutMs = 5000) {
385
- const controller = new AbortController();
386
- const timer = setTimeout(() => controller.abort(), timeoutMs);
387
- const frames = [];
388
- try {
389
- const res = await fetch(url, { ...options, signal: controller.signal });
390
- const reader = res.body.getReader();
391
- const decoder = new TextDecoder();
392
- let buffer = '';
393
- for (;;) {
394
- const { value, done } = await reader.read();
395
- if (done) break;
396
- buffer += decoder.decode(value, { stream: true });
397
- let index;
398
- while ((index = buffer.indexOf('\n\n')) !== -1) {
399
- const frame = buffer.slice(0, index);
400
- buffer = buffer.slice(index + 2);
401
- let event = null;
402
- let data = '';
403
- for (const line of frame.split('\n')) {
404
- if (line.startsWith('event:')) event = line.slice(6).trim();
405
- else if (line.startsWith('data:')) data += line.slice(5).trim();
406
- }
407
- if (!event) continue;
408
- const parsed = data ? JSON.parse(data) : {};
409
- frames.push({ event, data: parsed });
410
- if (until(parsed, event)) return frames;
411
- }
412
- }
413
- } catch (error) {
414
- if (error?.name !== 'AbortError') throw error;
415
- } finally {
416
- clearTimeout(timer);
417
- }
418
- return frames;
419
- }
420
-
421
- test('the global channel announces run_start and run_end to another client', async () => {
422
- const { instance, base } = await startServer();
423
- const json = { 'Content-Type': 'application/json' };
424
- try {
425
- const created = await (
426
- await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ model: 'm' }) })
427
- ).json();
428
- const id = created.session.id;
429
-
430
- // Cliente B: abre el canal global antes de que arranque nada.
431
- const events = readSse(`${base}/api/events`, {}, (data, event) => event === 'run_end');
432
- await new Promise((resolve) => setTimeout(resolve, 50));
433
-
434
- // Cliente A: pide una tarea.
435
- await (
436
- await fetch(`${base}/api/chat`, {
437
- method: 'POST',
438
- headers: json,
439
- body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
440
- })
441
- ).text();
442
-
443
- const frames = await events;
444
- const names = frames.map((frame) => frame.event);
445
- assert.deepEqual(names[0], 'snapshot');
446
- assert.ok(names.includes('run_start'), 'esperaba run_start');
447
- assert.ok(names.includes('run_end'), 'esperaba run_end');
448
-
449
- const start = frames.find((frame) => frame.event === 'run_start');
450
- const end = frames.find((frame) => frame.event === 'run_end');
451
- assert.equal(start.data.sessionId, id);
452
- assert.equal(end.data.sessionId, id);
453
- assert.ok(typeof end.data.durationMs === 'number');
454
-
455
- // El canal global no debe filtrar el detalle del turno: eso va por el
456
- // stream de la sesion, o seria duplicarlo.
457
- for (const detail of ['start', 'user', 'text', 'tool_call', 'done']) {
458
- assert.ok(!names.includes(detail), `el canal global no debe emitir ${detail}`);
459
- }
460
- } finally {
461
- await instance.close();
462
- }
463
- });
464
-
465
- test('connecting to the global channel mid-run returns it in the snapshot', async () => {
466
- let release;
467
- const gate = new Promise((resolve) => (release = resolve));
468
- const slowProvider = {
469
- id: 'slow',
470
- async streamChat() {
471
- await gate;
472
- return { content: 'fin', reasoning: '', toolCalls: [] };
473
- },
474
- };
475
- const { instance, base } = await startServer({ resolveProvider: () => ({ provider: slowProvider, config: {} }) });
476
- const json = { 'Content-Type': 'application/json' };
477
- try {
478
- const created = await (
479
- await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ model: 'm' }) })
480
- ).json();
481
- const id = created.session.id;
482
-
483
- const chat = fetch(`${base}/api/chat`, {
484
- method: 'POST',
485
- headers: json,
486
- body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
487
- }).catch(() => null);
488
- await new Promise((resolve) => setTimeout(resolve, 120));
489
-
490
- // Cliente nuevo: el snapshot tiene que informarle que hay un run vivo, y
491
- // por eso el cliente se engancha sin que nadie le avise.
492
- const frames = await readSse(`${base}/api/events`, {}, (data, event) => event === 'snapshot');
493
- assert.equal(frames[0].event, 'snapshot');
494
- assert.ok(frames[0].data.active.includes(id), 'el snapshot deberia listar la sesion activa');
495
-
496
- release();
497
- await chat;
498
- } finally {
499
- release();
500
- await instance.close();
501
- }
502
- });
503
-
504
- test('the session list reports which sessions are running', async () => {
505
- let release;
506
- const gate = new Promise((resolve) => (release = resolve));
507
- const slowProvider = {
508
- id: 'slow',
509
- async streamChat() {
510
- await gate;
511
- return { content: 'fin', reasoning: '', toolCalls: [] };
512
- },
513
- };
514
- const { instance, base } = await startServer({ resolveProvider: () => ({ provider: slowProvider, config: {} }) });
515
- const json = { 'Content-Type': 'application/json' };
516
- try {
517
- const created = await (
518
- await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ model: 'm' }) })
519
- ).json();
520
- const id = created.session.id;
521
-
522
- const idle = await (await fetch(`${base}/api/sessions`)).json();
523
- assert.equal(idle.sessions.find((session) => session.id === id).running, false);
524
-
525
- const chat = fetch(`${base}/api/chat`, {
526
- method: 'POST',
527
- headers: json,
528
- body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
529
- }).catch(() => null);
530
- await new Promise((resolve) => setTimeout(resolve, 120));
531
-
532
- const busy = await (await fetch(`${base}/api/sessions`)).json();
533
- assert.equal(busy.sessions.find((session) => session.id === id).running, true);
534
-
535
- release();
536
- await chat;
537
- } finally {
538
- release();
539
- await instance.close();
540
- }
541
- });
542
-
543
- test('resuming a run from a cursor replays only what was missed', async () => {
544
- const { instance, base } = await startServer();
545
- const json = { 'Content-Type': 'application/json' };
546
- try {
547
- const created = await (
548
- await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ model: 'm' }) })
549
- ).json();
550
- const id = created.session.id;
551
- await (
552
- await fetch(`${base}/api/chat`, {
553
- method: 'POST',
554
- headers: json,
555
- body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
556
- })
557
- ).text();
558
-
559
- const full = await (await fetch(`${base}/api/sessions/${id}/stream?from=0`)).text();
560
- const ids = [...full.matchAll(/^id: (\d+)$/gm)].map((match) => Number(match[1]));
561
- assert.ok(ids.length > 2, 'esperaba varios eventos con id');
562
- const last = ids[ids.length - 1];
563
-
564
- // Cliente que se cortó justo antes del ultimo evento: al volver no debe
565
- // recibir lo que ya tenia.
566
- const tail = await (await fetch(`${base}/api/sessions/${id}/stream?from=${last - 1}`)).text();
567
- const tailIds = [...tail.matchAll(/^id: (\d+)$/gm)].map((match) => Number(match[1]));
568
- assert.deepEqual(tailIds, [last]);
569
-
570
- // Y el log del run no secontamination con el canal global.
571
- assert.ok(!/event: run_start/.test(full));
572
- assert.ok(!/event: run_end/.test(full));
573
- } finally {
574
- await instance.close();
575
- }
576
- });
577
-
578
- test('a pending permission is visible to any client, not only the one streaming', async () => {
579
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-ask-'));
580
- let calls = 0;
581
- const provider = {
582
- id: 'tooly',
583
- async streamChat() {
584
- calls += 1;
585
- if (calls === 1) {
586
- return {
587
- content: '',
588
- reasoning: '',
589
- toolCalls: [{ id: 'call_1', function: { name: 'write', arguments: JSON.stringify({ path: 'nota.txt', content: 'hi' }) } }],
590
- };
591
- }
592
- return { content: 'listo', reasoning: '', toolCalls: [] };
593
- },
594
- };
595
- const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
596
- const json = { 'Content-Type': 'application/json' };
597
- try {
598
- const created = await (
599
- await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ model: 'm' }) })
600
- ).json();
601
- const id = created.session.id;
602
-
603
- // Cliente B: canal global abierto, sin ningun stream de la sesion.
604
- const seen = [];
605
- const watching = readSse(`${base}/api/events`, {}, (data, event) => event === 'permission');
606
- await new Promise((resolve) => setTimeout(resolve, 50));
607
-
608
- // Cliente A: pide la tarea y se queda esperando el permiso.
609
- const chat = fetch(`${base}/api/chat`, {
610
- method: 'POST',
611
- headers: json,
612
- body: JSON.stringify({ input: 'crea nota.txt', model: 'm', sessionId: id }),
613
- }).catch(() => null);
614
- await new Promise((resolve) => setTimeout(resolve, 200));
615
-
616
- const frames = await watching;
617
- const permission = frames.find((frame) => frame.event === 'permission');
618
- assert.ok(permission, 'el canal global deberia avisar del permiso');
619
- assert.equal(permission.data.sessionId, id);
620
- assert.equal(permission.data.name, 'write');
621
- seen.push(permission.data.askId);
622
-
623
- // Y tambien se puede consultar, que es lo que cubre una recarga de pagina.
624
- const list = await (await fetch(`${base}/api/permissions`)).json();
625
- assert.equal(list.pending.length, 1);
626
- assert.equal(list.pending[0].askId, permission.data.askId);
627
- assert.equal(list.pending[0].sessionId, id);
628
- assert.deepEqual(list.pending[0].args, { path: 'nota.txt', content: 'hi' });
629
-
630
- const scoped = await (await fetch(`${base}/api/permissions?sessionId=${id}`)).json();
631
- assert.equal(scoped.pending.length, 1);
632
- const other = await (await fetch(`${base}/api/permissions?sessionId=otra`)).json();
633
- assert.equal(other.pending.length, 0);
634
-
635
- const detail = await (await fetch(`${base}/api/sessions/${id}`)).json();
636
- assert.equal(detail.permissions.length, 1, 'el detalle debe traer los pendientes para reengancharse');
637
- const listSessions = await (await fetch(`${base}/api/sessions`)).json();
638
- assert.equal(listSessions.sessions.find((s) => s.id === id).awaiting, 1);
639
-
640
- const decision = await (
641
- await fetch(`${base}/api/permissions/${permission.data.askId}`, {
642
- method: 'POST',
643
- headers: json,
644
- body: JSON.stringify({ allow: true }),
645
- })
646
- ).json();
647
- assert.equal(decision.ok, true);
648
-
649
- const after = await (await fetch(`${base}/api/permissions`)).json();
650
- assert.equal(after.pending.length, 0);
651
- await chat;
652
- } finally {
653
- await instance.close();
654
- }
655
- });
656
-
657
- /**
658
- * Espera a que la sesion se quede sin run.
659
- *
660
- * Hace falta antes de `instance.close()`: un run que sigue vivo emite `aborted`
661
- * en otro tick y escribe sobre la base ya cerrada.
662
- */
663
- async function waitIdle(base, id, attempts = 60) {
664
- for (let i = 0; i < attempts; i += 1) {
665
- const detail = await (await fetch(`${base}/api/sessions/${id}`)).json();
666
- if (!detail.running) return true;
667
- await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' }).catch(() => {});
668
- await new Promise((resolve) => setTimeout(resolve, 100));
669
- }
670
- return false;
671
- }
672
-
673
- test('/api/state carries liveness and pending permissions in one request', async () => {
674
- let release;
675
- const gate = new Promise((resolve) => (release = resolve));
676
- let calls = 0;
677
- const provider = {
678
- id: 'tooly',
679
- async streamChat() {
680
- calls += 1;
681
- if (calls === 1) {
682
- return {
683
- content: '',
684
- reasoning: '',
685
- toolCalls: [{ id: 'call_1', function: { name: 'write', arguments: JSON.stringify({ path: 'nota.txt', content: 'hi' }) } }],
686
- };
687
- }
688
- await gate;
689
- return { content: 'listo', reasoning: '', toolCalls: [] };
690
- },
691
- };
692
- const { instance, base } = await startServer({ resolveProvider: () => ({ provider, config: {} }) });
693
- const json = { 'Content-Type': 'application/json' };
694
- try {
695
- const created = await (
696
- await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ model: 'm' }) })
697
- ).json();
698
- const id = created.session.id;
699
-
700
- const idle = await (await fetch(`${base}/api/state`)).json();
701
- assert.equal(idle.sessions.find((s) => s.id === id).running, false);
702
- assert.equal(idle.sessions.find((s) => s.id === id).awaiting, 0);
703
- assert.deepEqual(idle.permissions, []);
704
- assert.equal(typeof idle.now, 'number');
705
- // El snapshot no lleva mensajes: es lo que se puede pedir seguido.
706
- assert.equal(idle.sessions[0].messages, undefined);
707
-
708
- const chat = fetch(`${base}/api/chat`, {
709
- method: 'POST',
710
- headers: json,
711
- body: JSON.stringify({ input: 'crea nota.txt', model: 'm', sessionId: id }),
712
- }).catch(() => null);
713
- await new Promise((resolve) => setTimeout(resolve, 200));
714
-
715
- // Un permiso creado despues aparece en el siguiente read: esto es lo que
716
- // hace que la consola se recupere sin ningun SSE.
717
- const busy = await (await fetch(`${base}/api/state`)).json();
718
- const session = busy.sessions.find((s) => s.id === id);
719
- assert.equal(session.running, true);
720
- assert.equal(session.awaiting, 1);
721
- assert.equal(busy.permissions.length, 1);
722
- assert.equal(busy.permissions[0].name, 'write');
723
- assert.deepEqual(busy.permissions[0].args, { path: 'nota.txt', content: 'hi' });
724
- assert.equal(busy.permissions[0].sessionId, id);
725
-
726
- const askId = busy.permissions[0].askId;
727
- await fetch(`${base}/api/permissions/${askId}`, {
728
- method: 'POST',
729
- headers: json,
730
- body: JSON.stringify({ allow: true }),
731
- });
732
-
733
- const after = await (await fetch(`${base}/api/state`)).json();
734
- assert.deepEqual(after.permissions, []);
735
- assert.equal(after.sessions.find((s) => s.id === id).awaiting, 0);
736
-
737
- release();
738
- await chat;
739
- await waitIdle(base, id);
740
- } finally {
741
- release();
742
- await instance.close();
743
- }
744
- });
745
-
746
- test('a client with no streams at all can find and resolve a permission', async () => {
747
- // El escenario que reporto el celular: sin stream de sesion y sin canal global.
748
- let release;
749
- const gate = new Promise((resolve) => (release = resolve));
750
- let calls = 0;
751
- const provider = {
752
- id: 'tooly',
753
- async streamChat() {
754
- calls += 1;
755
- if (calls === 1) {
756
- return {
757
- content: '',
758
- reasoning: '',
759
- toolCalls: [{ id: 'call_1', function: { name: 'bash', arguments: JSON.stringify({ command: 'ls' }) } }],
760
- };
761
- }
762
- await gate;
763
- return { content: 'listo', reasoning: '', toolCalls: [] };
764
- },
765
- };
766
- const { instance, base } = await startServer({ resolveProvider: () => ({ provider, config: {} }) });
767
- const json = { 'Content-Type': 'application/json' };
768
- try {
769
- const created = await (
770
- await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ model: 'm' }) })
771
- ).json();
772
- const id = created.session.id;
773
-
774
- const chat = fetch(`${base}/api/chat`, {
775
- method: 'POST',
776
- headers: json,
777
- body: JSON.stringify({ input: 'lista los archivos', model: 'm', sessionId: id }),
778
- }).catch(() => null);
779
- await new Promise((resolve) => setTimeout(resolve, 200));
780
-
781
- // Solo requests cortos: es lo que sobrevive en un enlace movil.
782
- const state = await (await fetch(`${base}/api/state`)).json();
783
- assert.equal(state.permissions.length, 1);
784
- assert.equal(state.permissions[0].name, 'bash');
785
-
786
- const decision = await (
787
- await fetch(`${base}/api/permissions/${state.permissions[0].askId}`, {
788
- method: 'POST',
789
- headers: json,
790
- body: JSON.stringify({ allow: true, scope: 'once' }),
791
- })
792
- ).json();
793
- assert.equal(decision.ok, true);
794
- assert.deepEqual((await (await fetch(`${base}/api/state`)).json()).permissions, []);
795
-
796
- release();
797
- await chat;
798
- await waitIdle(base, id);
799
- } finally {
800
- release();
801
- await instance.close();
802
- }
803
- });
804
-
805
- test('approving clears the permission instead of bringing it back', async () => {
806
- // El sintoma era "le doy que si y me vuelve a pedir lo mismo". Dos causas
807
- // distintas y esta las separa: el server vacia el estado al resolver, y una
808
- // peticion nueva del agente es un `askId` nuevo (no el mismo permiso
809
- // revivido).
810
- let round = 0;
811
- const provider = {
812
- id: 'loopish',
813
- async streamChat() {
814
- round += 1;
815
- if (round <= 3) {
816
- return {
817
- content: '',
818
- reasoning: '',
819
- toolCalls: [{ id: `c${round}`, function: { name: 'bash', arguments: JSON.stringify({ command: `echo paso-${round}` }) } }],
820
- };
821
- }
822
- return { content: 'listo', reasoning: '', toolCalls: [] };
823
- },
824
- };
825
- const { instance, base } = await startServer({ resolveProvider: () => ({ provider, config: {} }) });
826
- const json = { 'Content-Type': 'application/json' };
827
- try {
828
- const created = await (
829
- await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ model: 'm' }) })
830
- ).json();
831
- const id = created.session.id;
832
- const chat = fetch(`${base}/api/chat`, {
833
- method: 'POST',
834
- headers: json,
835
- body: JSON.stringify({ input: 'hacé tres pasos', model: 'm', sessionId: id }),
836
- }).catch(() => null);
837
-
838
- const approved = [];
839
- for (let i = 0; i < 40 && approved.length < 3; i += 1) {
840
- await new Promise((resolve) => setTimeout(resolve, 100));
841
- const state = await (await fetch(`${base}/api/state`)).json();
842
- for (const ask of state.permissions) {
843
- const decision = await fetch(`${base}/api/permissions/${ask.askId}`, {
844
- method: 'POST',
845
- headers: json,
846
- body: JSON.stringify({ allow: true }),
847
- });
848
- assert.equal(decision.status, 200);
849
- approved.push(ask.askId);
850
- // Inmediatamente despues de resolver, ese permiso no queda pendiente: si
851
- // volviera, el cliente lo re-pintaria en bucle. (Puede haber otro askId
852
- // nuevo del siguiente paso, pero el resuelto no puede reaparecer.)
853
- const after = await (await fetch(`${base}/api/state`)).json();
854
- assert.ok(
855
- !after.permissions.some((p) => p.askId === ask.askId),
856
- 'el permiso resuelto no debe reaparecer'
857
- );
858
- }
859
- }
860
- // Tres peticiones del agente -> tres askId distintos, ninguno revivido.
861
- assert.equal(approved.length, 3);
862
- assert.equal(new Set(approved).size, 3);
863
-
864
- await waitIdle(base, id);
865
- await chat;
866
- } finally {
867
- await instance.close();
868
- }
869
- });
870
-
871
- test('resolving a permission that no longer exists reports 404', async () => {
872
- const { instance, base } = await startServer();
873
- const json = { 'Content-Type': 'application/json' };
874
- try {
875
- // Un askId inventado: la tarjeta era vieja. Con esto el cliente puede
876
- // decir "ya no aplica" en vez de dejar el permiso esperando.
877
- const res = await fetch(`${base}/api/permissions/inexistente`, {
878
- method: 'POST',
879
- headers: json,
880
- body: JSON.stringify({ allow: true }),
881
- });
882
- assert.equal(res.status, 404);
883
- } finally {
884
- await instance.close();
885
- }
886
- });
887
-
888
- test('done reports max_rounds and the tool count when the model never answers', async () => {
889
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-loop-'));
890
- await writeFile(path.join(cwd, 'a.txt'), 'hola');
891
- let seq = 0;
892
- const provider = {
893
- id: 'loop',
894
- async streamChat() {
895
- seq += 1;
896
- return {
897
- content: '',
898
- reasoning: '',
899
- toolCalls: [
900
- { id: `call_${seq}`, function: { name: 'read', arguments: JSON.stringify({ path: 'a.txt' }) } },
901
- ],
902
- };
903
- },
904
- };
905
- const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
906
- try {
907
- const sse = await (
908
- await fetch(`${base}/api/chat`, {
909
- method: 'POST',
910
- headers: { 'Content-Type': 'application/json' },
911
- body: JSON.stringify({ input: 'leé', model: 'm', maxRounds: 2 }),
912
- })
913
- ).text();
914
- assert.match(sse, /event: done/);
915
- assert.match(sse, /"stopReason":"max_rounds"/);
916
- const match = sse.match(/"toolCalls":(\d+)/);
917
- assert.ok(match && Number(match[1]) >= 2, 'el done debe reportar las tools usadas');
918
- assert.match(sse, /"durationMs":\d+/);
919
- } finally {
920
- await instance.close();
921
- }
922
- });
923
-
924
- test('completionFooter resume el cierre del turno', () => {
925
- assert.equal(completionFooter('end_turn', 1, 0, 0), '✓ terminado · 1 ronda');
926
- assert.equal(completionFooter('end_turn', 3, 5, 1200), '✓ terminado · 3 rondas · 5 herramientas · 1.2s');
927
- assert.match(completionFooter('max_rounds', 2, 2, 300), /^⚠ corte de seguridad · 2 rondas · 2 herramientas · 300ms$/);
928
- assert.match(completionFooter('no_progress', 2, 2, 300), /^⚠ corte de seguridad/);
929
- });
930
-
931
- test('configures Telegram from the console (masked token)', async () => {
932
- const { instance, base } = await startServer({ telegram: false });
933
- const json = { 'Content-Type': 'application/json' };
934
- try {
935
- let data = await (await fetch(`${base}/api/channels`)).json();
936
- assert.equal(data.telegram.tokenSet, false);
937
- assert.equal(data.telegram.transcribeModel, 'whisper-1');
938
-
939
- let res = await fetch(`${base}/api/channels/telegram`, {
940
- method: 'PUT',
941
- headers: json,
942
- body: JSON.stringify({ allowedChatIds: '123, 456', transcribeModel: '' }),
943
- });
944
- data = await res.json();
945
- assert.deepEqual(data.telegram.allowedChatIds, ['123', '456']);
946
- assert.equal(data.telegram.transcribeModel, 'whisper-1');
947
-
948
- res = await fetch(`${base}/api/channels/telegram`, {
949
- method: 'PUT',
950
- headers: json,
951
- body: JSON.stringify({ token: '123456:ABCDEFghij', transcribeProvider: 'openai' }),
952
- });
953
- data = await res.json();
954
- assert.equal(data.telegram.tokenSet, true);
955
- assert.equal(data.telegram.transcribeProvider, 'openai');
956
- assert.match(data.telegram.tokenHint, /ghij$/);
957
- assert.doesNotMatch(JSON.stringify(data), /ABCDEF/);
958
-
959
- // Mandar la mascara no pisa el token real.
960
- res = await fetch(`${base}/api/channels/telegram`, {
961
- method: 'PUT',
962
- headers: json,
963
- body: JSON.stringify({ token: data.telegram.tokenHint, allowedChatIds: ['1'] }),
964
- });
965
- data = await res.json();
966
- assert.equal(data.telegram.tokenSet, true);
967
- assert.match(data.telegram.tokenHint, /ghij$/);
968
- } finally {
969
- await instance.close();
970
- }
971
- });
972
-
973
- test('manages MCP servers from the console', async () => {
974
- const { instance, base } = await startServer();
975
- const json = { 'Content-Type': 'application/json' };
976
- try {
977
- let data = await (await fetch(`${base}/api/mcp`)).json();
978
- assert.deepEqual(data.servers, []);
979
-
980
- let res = await fetch(`${base}/api/mcp/servers/echo`, {
981
- method: 'PUT',
982
- headers: json,
983
- body: JSON.stringify({ name: 'Echo', transport: 'stdio', command: 'node', args: ['a.mjs'], enabled: true }),
984
- });
985
- assert.equal(res.status, 200);
986
- data = await res.json();
987
- const echo = data.servers.find((s) => s.id === 'echo');
988
- assert.equal(echo.source, 'store');
989
- assert.deepEqual(echo.args, ['a.mjs']);
990
-
991
- const bad = await fetch(`${base}/api/mcp/servers/bad`, {
992
- method: 'PUT',
993
- headers: json,
994
- body: JSON.stringify({ transport: 'http' }),
995
- });
996
- assert.equal(bad.status, 400);
997
-
998
- res = await fetch(`${base}/api/mcp/servers/echo`, { method: 'DELETE' });
999
- assert.equal(res.status, 200);
1000
- data = await res.json();
1001
- assert.equal(data.servers.length, 0);
1002
- } finally {
1003
- await instance.close();
1004
- }
1005
- });
1006
-
1007
- test('sirve el catálogo de MCP/skills y los instala deshabilitados', async () => {
1008
- const { instance, base } = await startServer();
1009
- const json = { 'Content-Type': 'application/json' };
1010
- try {
1011
- const catalog = await (await fetch(`${base}/api/mcp/catalog`)).json();
1012
- assert.ok(catalog.catalog.some((e) => e.id === 'playwright' && e.installed === false));
1013
-
1014
- const add = await fetch(`${base}/api/mcp/import`, {
1015
- method: 'POST',
1016
- headers: json,
1017
- body: JSON.stringify({ catalog: 'playwright' }),
1018
- });
1019
- assert.equal(add.status, 200);
1020
- const added = await add.json();
1021
- assert.equal(added.imported, 'playwright');
1022
- assert.equal(added.servers.find((s) => s.id === 'playwright').enabled, false);
1023
-
1024
- const again = await (await fetch(`${base}/api/mcp/catalog`)).json();
1025
- assert.equal(again.catalog.find((e) => e.id === 'playwright').installed, true);
1026
-
1027
- const skills = await (await fetch(`${base}/api/skills/catalog`)).json();
1028
- assert.ok(skills.catalog.some((e) => e.id === 'code-review' && e.installed === false));
1029
- const skillAdd = await fetch(`${base}/api/skills`, {
1030
- method: 'POST',
1031
- headers: json,
1032
- body: JSON.stringify({ id: 'code-review' }),
1033
- });
1034
- assert.equal(skillAdd.status, 200);
1035
- const skillData = await skillAdd.json();
1036
- assert.equal(skillData.installed, 'code-review');
1037
- assert.equal(skillData.skills.find((s) => s.id === 'code-review').enabled, false);
1038
-
1039
- const unknown = await fetch(`${base}/api/mcp/import`, {
1040
- method: 'POST',
1041
- headers: json,
1042
- body: JSON.stringify({ catalog: 'nope' }),
1043
- });
1044
- assert.equal(unknown.status, 404);
1045
- const unknownSkill = await fetch(`${base}/api/skills`, {
1046
- method: 'POST',
1047
- headers: json,
1048
- body: JSON.stringify({ id: 'nope' }),
1049
- });
1050
- assert.equal(unknownSkill.status, 404);
1051
- } finally {
1052
- await instance.close();
1053
- }
1054
- });
1055
-
1056
- test('los buscadores devuelven vacío sin query', async () => {
1057
- const { instance, base } = await startServer();
1058
- try {
1059
- const mcp = await (await fetch(`${base}/api/mcp/search`)).json();
1060
- assert.deepEqual(mcp.results, []);
1061
- const skills = await (await fetch(`${base}/api/skills/search`)).json();
1062
- assert.deepEqual(skills.results, []);
1063
- } finally {
1064
- await instance.close();
1065
- }
1066
- });
1067
-
1068
- test('MCP: el env se enmascara y no se pisa al guardar sin cambios', async () => {
1069
- const { instance, base } = await startServer();
1070
- const json = { 'Content-Type': 'application/json' };
1071
- try {
1072
- await fetch(`${base}/api/mcp/servers/db`, {
1073
- method: 'PUT',
1074
- headers: json,
1075
- body: JSON.stringify({
1076
- name: 'DB',
1077
- transport: 'stdio',
1078
- command: 'node',
1079
- args: ['db.mjs'],
1080
- env: { MYSQL_HOST: 'localhost', MYSQL_PASSWORD: 'supersecret' },
1081
- }),
1082
- });
1083
-
1084
- let data = await (await fetch(`${base}/api/mcp`)).json();
1085
- const db = data.servers.find((s) => s.id === 'db');
1086
- assert.equal(db.env.MYSQL_HOST, 'localhost');
1087
- assert.equal(db.env.MYSQL_PASSWORD, '••••cret');
1088
-
1089
- // Un toggle manda de vuelta el env enmascarado: no debe pisar el secreto.
1090
- await fetch(`${base}/api/mcp/servers/db`, {
1091
- method: 'PUT',
1092
- headers: json,
1093
- body: JSON.stringify({
1094
- name: db.name,
1095
- transport: 'stdio',
1096
- command: db.command,
1097
- args: db.args,
1098
- env: db.env,
1099
- enabled: false,
1100
- }),
1101
- });
1102
- const stored = instance.store.listMcpServers().find((s) => s.id === 'db');
1103
- assert.equal(stored.config.env.MYSQL_PASSWORD, 'supersecret');
1104
- assert.equal(stored.enabled, false);
1105
- } finally {
1106
- await instance.close();
1107
- }
1108
- });
1109
-
1110
- test('guarda los límites del agente desde Ajustes', async () => {
1111
- const { instance, base } = await startServer();
1112
- const json = { 'Content-Type': 'application/json' };
1113
- try {
1114
- const res = await fetch(`${base}/api/settings`, {
1115
- method: 'PUT',
1116
- headers: json,
1117
- body: JSON.stringify({ maxRounds: 20, maxTokens: 50000, maxDurationMs: 600000 }),
1118
- });
1119
- const data = await res.json();
1120
- assert.equal(data.settings.maxRounds, 20);
1121
- assert.equal(data.settings.maxTokens, 50000);
1122
- assert.equal(data.settings.maxDurationMs, 600000);
1123
-
1124
- const again = await (await fetch(`${base}/api/settings`)).json();
1125
- assert.equal(again.settings.maxRounds, 20);
1126
- } finally {
1127
- await instance.close();
1128
- }
1129
- });
1130
-
1131
- test('guarda el modo de carga y las tools fijas de MCP', async () => {
1132
- const { instance, base } = await startServer();
1133
- const json = { 'Content-Type': 'application/json' };
1134
- try {
1135
- const res = await fetch(`${base}/api/settings`, {
1136
- method: 'PUT',
1137
- headers: json,
1138
- body: JSON.stringify({ mcpMode: 'lazy', mcpPinned: 'mcp__postgres__query, postgres' }),
1139
- });
1140
- const data = await res.json();
1141
- assert.equal(data.settings.mcpMode, 'lazy');
1142
- assert.deepEqual(data.settings.mcpPinned, ['mcp__postgres__query', 'postgres']);
1143
-
1144
- const mcp = await (await fetch(`${base}/api/mcp`)).json();
1145
- assert.equal(mcp.mode, 'lazy');
1146
- assert.deepEqual(mcp.pinned, ['mcp__postgres__query', 'postgres']);
1147
- assert.equal(mcp.threshold, 12);
1148
- assert.equal(mcp.toolCount, null);
1149
-
1150
- const bad = await fetch(`${base}/api/settings`, {
1151
- method: 'PUT',
1152
- headers: json,
1153
- body: JSON.stringify({ mcpMode: 'nope' }),
1154
- });
1155
- assert.equal((await bad.json()).settings.mcpMode, 'auto');
1156
- } finally {
1157
- await instance.close();
1158
- }
1159
- });
1160
-
1161
- test('imports an MCP server from a JSON snippet', async () => {
1162
- const { instance, base } = await startServer();
1163
- const json = { 'Content-Type': 'application/json' };
1164
- try {
1165
- const res = await fetch(`${base}/api/mcp/import`, {
1166
- method: 'POST',
1167
- headers: json,
1168
- body: JSON.stringify({ json: '{"mcpServers":{"demo":{"command":"npx","args":["-y","pkg"]}}}' }),
1169
- });
1170
- assert.equal(res.status, 200);
1171
- const data = await res.json();
1172
- assert.equal(data.imported, 'demo');
1173
- assert.ok(data.servers.some((s) => s.id === 'demo' && s.transport === 'stdio'));
1174
- } finally {
1175
- await instance.close();
1176
- }
1177
- });
1178
-
1179
- test('rejects unknown import refs (skills and mcpservers.org)', async () => {
1180
- const { instance, base } = await startServer();
1181
- const json = { 'Content-Type': 'application/json' };
1182
- try {
1183
- const badSkill = await fetch(`${base}/api/skills/import`, {
1184
- method: 'POST',
1185
- headers: json,
1186
- body: JSON.stringify({ ref: 'nope' }),
1187
- });
1188
- assert.equal(badSkill.status, 400);
1189
-
1190
- const blocked = await fetch(`${base}/api/mcp/import`, {
1191
- method: 'POST',
1192
- headers: json,
1193
- body: JSON.stringify({ url: 'https://mcpservers.org/es/official' }),
1194
- });
1195
- assert.equal(blocked.status, 400);
1196
- } finally {
1197
- await instance.close();
1198
- }
1199
- });
1200
-
1201
- test('manages skills from the console', async () => {
1202
- const { instance, base } = await startServer();
1203
- const json = { 'Content-Type': 'application/json' };
1204
- const put = (id, body) => fetch(`${base}/api/skills/${id}`, { method: 'PUT', headers: json, body: JSON.stringify(body) });
1205
- try {
1206
- let res = await put('demo', { name: 'Demo', description: 'para pruebas', body: 'Hacé X.' });
1207
- assert.equal(res.status, 200);
1208
- let data = await res.json();
1209
- const demo = data.skills.find((s) => s.id === 'demo');
1210
- assert.equal(demo.enabled, true);
1211
- assert.equal(demo.removable, true);
1212
-
1213
- const detail = await (await fetch(`${base}/api/skills/demo`)).json();
1214
- assert.match(detail.skill.body, /Hacé X/);
1215
-
1216
- await fetch(`${base}/api/skills/demo/toggle`, { method: 'POST' });
1217
- data = await (await fetch(`${base}/api/skills`)).json();
1218
- assert.equal(data.skills.find((s) => s.id === 'demo').enabled, false);
1219
-
1220
- res = await fetch(`${base}/api/skills/demo`, { method: 'DELETE' });
1221
- assert.equal(res.status, 200);
1222
- data = await (await fetch(`${base}/api/skills`)).json();
1223
- assert.equal(data.skills.length, 0);
1224
-
1225
- res = await put('vacia', { body: ' ' });
1226
- assert.equal(res.status, 400);
1227
- } finally {
1228
- await instance.close();
1229
- }
1230
- });
1231
-
1232
- test('skills: indice lazy por defecto (sin body)', async () => {
1233
- let captured = '';
1234
- const captureProvider = {
1235
- id: 'capture',
1236
- async streamChat(_config, { messages }) {
1237
- captured = messages?.[0]?.content ?? '';
1238
- return { content: 'ok', reasoning: '', toolCalls: [] };
1239
- },
1240
- };
1241
- const { instance, base } = await startServer({ resolveProvider: () => ({ provider: captureProvider, config: {} }) });
1242
- const json = { 'Content-Type': 'application/json' };
1243
- const chat = (sessionId) =>
1244
- fetch(`${base}/api/chat`, { method: 'POST', headers: json, body: JSON.stringify({ sessionId, model: 'm', input: 'hola' }) });
1245
- const newSession = async () => {
1246
- const created = await (
1247
- await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm' }) })
1248
- ).json();
1249
- return created.session.id;
1250
- };
1251
- try {
1252
- await fetch(`${base}/api/skills/demo`, {
1253
- method: 'PUT',
1254
- headers: json,
1255
- body: JSON.stringify({ name: 'Demo', body: 'Regla secreta de la skill.' }),
1256
- });
1257
-
1258
- // Lazy: se inyecta el indice (id), no el cuerpo.
1259
- await (await chat(await newSession())).text();
1260
- assert.match(captured, /demo/);
1261
- assert.doesNotMatch(captured, /Regla secreta de la skill/);
1262
-
1263
- // Deshabilitada: no aparece ni en el indice.
1264
- await fetch(`${base}/api/skills/demo/toggle`, { method: 'POST' });
1265
- await (await chat(await newSession())).text();
1266
- assert.doesNotMatch(captured, /demo/);
1267
- } finally {
1268
- await instance.close();
1269
- }
1270
- });
1271
-
1272
- test('manages project memory from the console', async () => {
1273
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-mem-'));
1274
- const { instance, base } = await startServer({ cwd });
1275
- const json = { 'Content-Type': 'application/json' };
1276
- try {
1277
- let res = await fetch(`${base}/api/memory/arquitectura`, {
1278
- method: 'PUT',
1279
- headers: json,
1280
- body: JSON.stringify({ title: 'Arquitectura', body: 'Usa SQLite.' }),
1281
- });
1282
- assert.equal(res.status, 200);
1283
- let data = await res.json();
1284
- assert.equal(data.topics.length, 1);
1285
- assert.match(data.index, /arquitectura\.md/);
1286
-
1287
- const detail = await (await fetch(`${base}/api/memory/arquitectura`)).json();
1288
- assert.match(detail.topic.body, /Usa SQLite/);
1289
-
1290
- await fetch(`${base}/api/memory`, {
1291
- method: 'PUT',
1292
- headers: json,
1293
- body: JSON.stringify({ index: '# Memoria\n\n- propio' }),
1294
- });
1295
- data = await (await fetch(`${base}/api/memory`)).json();
1296
- assert.match(data.index, /propio/);
1297
-
1298
- res = await fetch(`${base}/api/memory/arquitectura`, { method: 'DELETE' });
1299
- assert.equal(res.status, 200);
1300
- data = await res.json();
1301
- assert.equal(data.topics.length, 0);
1302
- } finally {
1303
- await instance.close();
1304
- }
1305
- });
1306
-
1307
- test('project memory is injected into the system prompt', async () => {
1308
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-mem-prompt-'));
1309
- await mkdir(path.join(cwd, '.tamnora', 'memory'), { recursive: true });
1310
- await writeFile(path.join(cwd, '.tamnora', 'memory', 'MEMORY.md'), '# Memoria\n\n- [Dato](dato.md) — clave\n');
1311
- let captured = '';
1312
- const provider = {
1313
- id: 'capture',
1314
- async streamChat(_config, { messages }) {
1315
- captured = messages?.[0]?.content ?? '';
1316
- return { content: 'ok', reasoning: '', toolCalls: [] };
1317
- },
1318
- };
1319
- const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
1320
- const json = { 'Content-Type': 'application/json' };
1321
- try {
1322
- const created = await (
1323
- await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm', cwd }) })
1324
- ).json();
1325
- await (
1326
- await fetch(`${base}/api/chat`, {
1327
- method: 'POST',
1328
- headers: json,
1329
- body: JSON.stringify({ sessionId: created.session.id, model: 'm', input: 'hola' }),
1330
- })
1331
- ).text();
1332
- assert.match(captured, /Memoria del proyecto/);
1333
- assert.match(captured, /dato\.md/);
1334
- } finally {
1335
- await instance.close();
1336
- }
1337
- });
1338
-
1339
- test('the memory tool writes project memory', async () => {
1340
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-mem-tool-'));
1341
- await writeFile(path.join(cwd, 'tamnora.json'), JSON.stringify({ permissions: { memory: 'allow' } }));
1342
- const provider = {
1343
- id: 'memtool',
1344
- async streamChat(_config, { messages }) {
1345
- if (messages.some((m) => m.role === 'tool' && m.name === 'memory')) {
1346
- return { content: 'listo', reasoning: '', toolCalls: [] };
1347
- }
1348
- return {
1349
- content: '',
1350
- reasoning: '',
1351
- toolCalls: [
1352
- {
1353
- id: '1',
1354
- type: 'function',
1355
- function: { name: 'memory', arguments: JSON.stringify({ action: 'write', topic: 'nota', content: 'Hola memoria.' }) },
1356
- },
1357
- ],
1358
- };
1359
- },
1360
- };
1361
- const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
1362
- const json = { 'Content-Type': 'application/json' };
1363
- try {
1364
- const created = await (
1365
- await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm', cwd }) })
1366
- ).json();
1367
- await (
1368
- await fetch(`${base}/api/chat`, {
1369
- method: 'POST',
1370
- headers: json,
1371
- body: JSON.stringify({ sessionId: created.session.id, model: 'm', input: 'recordá esto' }),
1372
- })
1373
- ).text();
1374
- const file = await readFile(path.join(cwd, '.tamnora', 'memory', 'nota.md'), 'utf8');
1375
- assert.match(file, /Hola memoria/);
1376
- } finally {
1377
- await instance.close();
1378
- }
1379
- });
1380
-
1381
- test('rules by path are injected (transiently) when a tool touches a matching file', async () => {
1382
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-rules-inject-'));
1383
- await mkdir(path.join(cwd, '.tamnora', 'rules'), { recursive: true });
1384
- await writeFile(path.join(cwd, '.tamnora', 'rules', 'estilo.md'), '---\npaths: src/**\n---\nMARCADOR_ESTILO: usá 2 espacios.');
1385
- await mkdir(path.join(cwd, 'src'), { recursive: true });
1386
- await writeFile(path.join(cwd, 'src', 'index.js'), 'export const x = 1;\n');
1387
- let captured = null;
1388
- const provider = {
1389
- id: 'rules',
1390
- async streamChat(_config, { messages }) {
1391
- if (messages.some((m) => m.role === 'tool' && m.name === 'read')) {
1392
- captured = messages;
1393
- return { content: 'ok', reasoning: '', toolCalls: [] };
1394
- }
1395
- return {
1396
- content: '',
1397
- reasoning: '',
1398
- toolCalls: [
1399
- { id: 'r1', type: 'function', function: { name: 'read', arguments: JSON.stringify({ path: 'src/index.js' }) } },
1400
- ],
1401
- };
1402
- },
1403
- };
1404
- const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
1405
- const json = { 'Content-Type': 'application/json' };
1406
- try {
1407
- const created = await (
1408
- await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm', cwd }) })
1409
- ).json();
1410
- await (
1411
- await fetch(`${base}/api/chat`, {
1412
- method: 'POST',
1413
- headers: json,
1414
- body: JSON.stringify({ sessionId: created.session.id, model: 'm', input: 'mirá src/index.js' }),
1415
- })
1416
- ).text();
1417
- assert.ok(captured, 'el provider no recibió una segunda ronda');
1418
- assert.ok(
1419
- captured.some((m) => m.role === 'system' && /MARCADOR_ESTILO/.test(m.content)),
1420
- 'no se inyectó la regla por path'
1421
- );
1422
- // No se persiste: el historial guardado no incluye la regla efímera.
1423
- const session = await (await fetch(`${base}/api/sessions/${created.session.id}`)).json();
1424
- assert.ok(
1425
- session.messages.every((m) => !(m.role === 'system' && /MARCADOR_ESTILO/.test(String(m.content ?? '')))),
1426
- 'la regla efímera se persistió en el historial'
1427
- );
1428
- } finally {
1429
- await instance.close();
1430
- }
1431
- });
1432
-
1433
- test('manages scheduled tasks from the console', async () => {
1434
- const { instance, base } = await startServer();
1435
- const json = { 'Content-Type': 'application/json' };
1436
- const create = (body) => fetch(`${base}/api/schedules`, { method: 'POST', headers: json, body: JSON.stringify(body) });
1437
- try {
1438
- let res = await create({ id: 'nightly', type: 'agent', spec: 'daily 03:00', prompt: 'hacé el resumen' });
1439
- assert.equal(res.status, 201);
1440
- let data = await res.json();
1441
- assert.equal(data.schedule.id, 'nightly');
1442
- assert.equal(data.schedule.enabled, true);
1443
- assert.ok(data.schedule.nextRun);
1444
-
1445
- res = await create({ id: 'bad', spec: 'nope', prompt: 'x' });
1446
- assert.equal(res.status, 400);
1447
-
1448
- res = await create({ id: 'nightly', type: 'agent', spec: 'daily 03:00', prompt: 'otra' });
1449
- assert.equal(res.status, 409);
1450
-
1451
- res = await fetch(`${base}/api/schedules/nightly/toggle`, { method: 'POST' });
1452
- data = await res.json();
1453
- assert.equal(data.enabled, false);
1454
-
1455
- data = await (await fetch(`${base}/api/schedules`)).json();
1456
- assert.equal(data.schedules.length, 1);
1457
- assert.equal(data.schedules[0].enabled, false);
1458
-
1459
- res = await fetch(`${base}/api/schedules/nightly`, { method: 'DELETE' });
1460
- assert.equal(res.status, 200);
1461
- data = await res.json();
1462
- assert.equal(data.schedules.length, 0);
1463
- } finally {
1464
- await instance.close();
1465
- }
1466
- });
1467
-
1468
- test('runs a scheduled task on demand (shell and agent)', async () => {
1469
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-sched-'));
1470
- const provider = {
1471
- id: 'fake',
1472
- async streamChat(_config, { onEvent }) {
1473
- onEvent?.({ type: 'text', delta: 'ok', full: 'ok' });
1474
- return { content: 'ok', reasoning: '', toolCalls: [] };
1475
- },
1476
- };
1477
- const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
1478
- const json = { 'Content-Type': 'application/json' };
1479
- const create = (body) => fetch(`${base}/api/schedules`, { method: 'POST', headers: json, body: JSON.stringify(body) });
1480
- let data;
1481
- try {
1482
- await create({ id: 'ver', type: 'shell', spec: 'every 1 h', command: 'node', args: ['--version'] });
1483
- let res = await fetch(`${base}/api/schedules/ver/run`, { method: 'POST' });
1484
- assert.equal(res.status, 200);
1485
- assert.equal((await res.json()).ok, true);
1486
-
1487
- await create({ id: 'nope', type: 'shell', spec: 'every 1 h', command: 'definitely-not-allowed' });
1488
- res = await fetch(`${base}/api/schedules/nope/run`, { method: 'POST' });
1489
- assert.equal(res.status, 500);
1490
-
1491
- await create({ id: 'job', type: 'agent', spec: 'every 1 h', prompt: 'hacé algo útil', model: 'm' });
1492
- res = await fetch(`${base}/api/schedules/job/run`, { method: 'POST' });
1493
- data = await res.json();
1494
- assert.equal(res.status, 200, JSON.stringify(data));
1495
- assert.equal(data.ok, true, JSON.stringify(data));
1496
- assert.ok(data.result.sessionId);
1497
-
1498
- const sessions = await (await fetch(`${base}/api/sessions`)).json();
1499
- assert.ok(sessions.sessions.some((s) => s.title === 'Tarea: job'));
1500
- } finally {
1501
- await instance.close();
1502
- }
1503
- });
1504
-
1505
- test('rewind restores files and truncates the turn', async () => {
1506
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-rewind-'));
1507
- await writeFile(path.join(cwd, 'tamnora.json'), JSON.stringify({ permissions: { write: 'allow' } }));
1508
- await writeFile(path.join(cwd, 'nota.txt'), 'original');
1509
- const provider = {
1510
- id: 'rw',
1511
- async streamChat(_config, { messages }) {
1512
- if (messages.some((m) => m.role === 'tool' && m.name === 'write')) {
1513
- return { content: 'listo', reasoning: '', toolCalls: [] };
1514
- }
1515
- return {
1516
- content: '',
1517
- reasoning: '',
1518
- toolCalls: [
1519
- {
1520
- id: 'w1',
1521
- type: 'function',
1522
- function: { name: 'write', arguments: JSON.stringify({ path: 'nota.txt', content: 'cambiado' }) },
1523
- },
1524
- ],
1525
- };
1526
- },
1527
- };
1528
- const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
1529
- const json = { 'Content-Type': 'application/json' };
1530
- try {
1531
- const created = await (
1532
- await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm', cwd }) })
1533
- ).json();
1534
- const sid = created.session.id;
1535
- await (
1536
- await fetch(`${base}/api/chat`, {
1537
- method: 'POST',
1538
- headers: json,
1539
- body: JSON.stringify({ sessionId: sid, model: 'm', input: 'cambiá la nota' }),
1540
- })
1541
- ).text();
1542
- assert.equal(await readFile(path.join(cwd, 'nota.txt'), 'utf8'), 'cambiado');
1543
-
1544
- const detail = await (await fetch(`${base}/api/sessions/${sid}`)).json();
1545
- const userMsg = detail.messages.find((m) => m.role === 'user');
1546
- assert.ok(userMsg, 'no se persistió el mensaje del usuario');
1547
-
1548
- const cps = await (await fetch(`${base}/api/sessions/${sid}/checkpoints`)).json();
1549
- assert.ok(cps.turns.includes(userMsg.id), 'no se registró el checkpoint del turno');
1550
-
1551
- const res = await fetch(`${base}/api/sessions/${sid}/rewind`, {
1552
- method: 'POST',
1553
- headers: json,
1554
- body: JSON.stringify({ turn: userMsg.id }),
1555
- });
1556
- assert.equal(res.status, 200);
1557
- const out = await res.json();
1558
- assert.equal(out.restored, 1);
1559
- assert.equal(await readFile(path.join(cwd, 'nota.txt'), 'utf8'), 'original');
1560
-
1561
- const after = await (await fetch(`${base}/api/sessions/${sid}`)).json();
1562
- assert.equal(after.messages.length, 0);
1563
- } finally {
1564
- await instance.close();
1565
- }
1566
- });
1567
-
1568
- test('hooks observe tool events and plugins add custom tools', async () => {
1569
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-hooks-plugins-'));
1570
- await mkdir(path.join(cwd, '.tamnora', 'hooks'), { recursive: true });
1571
- await mkdir(path.join(cwd, '.tamnora', 'plugins'), { recursive: true });
1572
- await writeFile(
1573
- path.join(cwd, '.tamnora', 'hooks', 'audit.mjs'),
1574
- "import { appendFileSync } from 'node:fs';\nimport path from 'node:path';\n" +
1575
- "export default { name: 'audit', events: { 'tool.after': (event, ctx) => { appendFileSync(path.join(ctx.cwd, 'hook.log'), `${event.type}:${event.name}\\n`); } } };\n"
1576
- );
1577
- await writeFile(
1578
- path.join(cwd, '.tamnora', 'plugins', 'demo.mjs'),
1579
- "export default { name: 'demo', tools: [{ name: 'saludar', description: 'saluda', parameters: { quien: { type: 'string' } }, handler: (args) => `Hola ${args.quien}` }] };\n"
1580
- );
1581
- await writeFile(path.join(cwd, 'tamnora.json'), JSON.stringify({ permissions: { plugin__demo__saludar: 'allow' } }));
1582
-
1583
- let captured = null;
1584
- const provider = {
1585
- id: 'hp',
1586
- async streamChat(_config, { messages }) {
1587
- const toolMsg = messages.find((m) => m.role === 'tool' && String(m.name).startsWith('plugin__'));
1588
- if (toolMsg) {
1589
- captured = toolMsg.content;
1590
- return { content: 'ok', reasoning: '', toolCalls: [] };
1591
- }
1592
- return {
1593
- content: '',
1594
- reasoning: '',
1595
- toolCalls: [
1596
- {
1597
- id: 't1',
1598
- type: 'function',
1599
- function: { name: 'plugin__demo__saludar', arguments: JSON.stringify({ quien: 'mundo' }) },
1600
- },
1601
- ],
1602
- };
1603
- },
1604
- };
1605
- const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
1606
- const json = { 'Content-Type': 'application/json' };
1607
- try {
1608
- const hooks = await (await fetch(`${base}/api/hooks`)).json();
1609
- assert.ok(hooks.hooks.some((h) => h.id === 'audit'));
1610
-
1611
- const plugins = await (await fetch(`${base}/api/plugins`)).json();
1612
- assert.ok(plugins.plugins.some((p) => p.name === 'plugin__demo__saludar'));
1613
-
1614
- const created = await (
1615
- await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm', cwd }) })
1616
- ).json();
1617
- await (
1618
- await fetch(`${base}/api/chat`, {
1619
- method: 'POST',
1620
- headers: json,
1621
- body: JSON.stringify({ sessionId: created.session.id, model: 'm', input: 'saludá' }),
1622
- })
1623
- ).text();
1624
- assert.equal(captured, 'Hola mundo');
1625
-
1626
- await new Promise((resolve) => setTimeout(resolve, 150));
1627
- const log = await readFile(path.join(cwd, 'hook.log'), 'utf8').catch(() => '');
1628
- assert.match(log, /tool\.after:plugin__demo__saludar/);
1629
- } finally {
1630
- await instance.close();
1631
- }
1632
- });
1633
-
1634
- test('skills: modo eager inyecta el body en el system prompt', async () => {
1635
- let captured = '';
1636
- const captureProvider = {
1637
- id: 'capture',
1638
- async streamChat(_config, { messages }) {
1639
- captured = messages?.[0]?.content ?? '';
1640
- return { content: 'ok', reasoning: '', toolCalls: [] };
1641
- },
1642
- };
1643
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-srv-'));
1644
- await writeFile(path.join(cwd, 'tamnora.json'), JSON.stringify({ skills: { mode: 'eager' } }));
1645
- const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider: captureProvider, config: {} }) });
1646
- const json = { 'Content-Type': 'application/json' };
1647
- try {
1648
- await fetch(`${base}/api/skills/demo`, {
1649
- method: 'PUT',
1650
- headers: json,
1651
- body: JSON.stringify({ name: 'Demo', body: 'Regla secreta de la skill.' }),
1652
- });
1653
- const created = await (
1654
- await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm' }) })
1655
- ).json();
1656
- await fetch(`${base}/api/chat`, {
1657
- method: 'POST',
1658
- headers: json,
1659
- body: JSON.stringify({ sessionId: created.session.id, model: 'm', input: 'hola' }),
1660
- }).then((r) => r.text());
1661
- assert.match(captured, /Regla secreta de la skill/);
1662
- } finally {
1663
- await instance.close();
1664
- }
1665
- });
1666
-
1667
- test('auto-compact resume el historial cuando el contexto se pasa', async () => {
1668
- let captured = '';
1669
- const provider = {
1670
- id: 'capture',
1671
- async streamChat(_config, { messages }) {
1672
- captured = (messages ?? [])
1673
- .map((m) => (typeof m.content === 'string' ? m.content : ''))
1674
- .join('\n');
1675
- return { content: 'RESUMEN OK', reasoning: '', toolCalls: [] };
1676
- },
1677
- };
1678
- const json = { 'Content-Type': 'application/json' };
1679
- const { instance, base } = await startServer({ resolveProvider: () => ({ provider, config: {} }) });
1680
- try {
1681
- const created = await (
1682
- await fetch(`${base}/api/sessions`, {
1683
- method: 'POST',
1684
- headers: json,
1685
- body: JSON.stringify({ title: 't', model: 'm' }),
1686
- })
1687
- ).json();
1688
- const id = created.session.id;
1689
- const big = 'x'.repeat(4000);
1690
- instance.store.addMessages(
1691
- id,
1692
- Array.from({ length: 12 }, (_, i) => ({ role: i % 2 ? 'assistant' : 'user', content: `m${i} ${big}` }))
1693
- );
1694
-
1695
- await fetch(`${base}/api/chat`, {
1696
- method: 'POST',
1697
- headers: json,
1698
- body: JSON.stringify({ sessionId: id, model: 'm', input: 'hola', contextTokens: 1000 }),
1699
- }).then((r) => r.text());
1700
-
1701
- const meta = instance.store.getSession(id).meta;
1702
- assert.ok(meta.summaryCursor, 'debe guardar el cursor de compactación');
1703
- assert.match(captured, /Resumen de la conversación anterior/);
1704
- } finally {
1705
- await instance.close();
1706
- }
1707
- });
1708
-
1709
- test('detects, runs and stops dev processes', async () => {
1710
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));
1711
- await writeFile(path.join(cwd, 'package.json'), JSON.stringify({ scripts: { dev: 'node server.js' } }));
1712
- const { instance, base } = await startServer({ cwd });
1713
- const json = { 'Content-Type': 'application/json' };
1714
- try {
1715
- const detected = await (await fetch(`${base}/api/procs/detect?cwd=${encodeURIComponent(cwd)}`)).json();
1716
- assert.ok(detected.chips.some((c) => c.command === 'npm' && c.args.includes('dev')));
1717
-
1718
- const blocked = await fetch(`${base}/api/procs/start`, {
1719
- method: 'POST',
1720
- headers: json,
1721
- body: JSON.stringify({ cwd, command: 'curl' }),
1722
- });
1723
- assert.equal(blocked.status, 400);
1724
-
1725
- const started = await (
1726
- await fetch(`${base}/api/procs/start`, {
1727
- method: 'POST',
1728
- headers: json,
1729
- body: JSON.stringify({ cwd, command: 'node', args: ['-e', 'console.log(42)'] }),
1730
- })
1731
- ).json();
1732
- assert.ok(started.proc.id);
1733
- await new Promise((resolve) => setTimeout(resolve, 500));
1734
-
1735
- const logs = await (await fetch(`${base}/api/procs/${started.proc.id}/logs`)).json();
1736
- assert.match(logs.logs.map((l) => l.text).join(''), /42/);
1737
-
1738
- const stopped = await fetch(`${base}/api/procs/${started.proc.id}/stop`, { method: 'POST' });
1739
- assert.equal(stopped.status, 200);
1740
-
1741
- const badTunnel = await fetch(`${base}/api/preview/tunnel`, {
1742
- method: 'POST',
1743
- headers: json,
1744
- body: JSON.stringify({ port: 0 }),
1745
- });
1746
- assert.equal(badTunnel.status, 400);
1747
- } finally {
1748
- await instance.close();
1749
- }
1750
- });
1751
-
1752
- test('runs a console shell command and kills a port', async () => {
1753
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-shell-'));
1754
- const { instance, base } = await startServer({ cwd });
1755
- const json = { 'Content-Type': 'application/json' };
1756
- try {
1757
- const shell = await fetch(`${base}/api/shell`, {
1758
- method: 'POST',
1759
- headers: json,
1760
- body: JSON.stringify({ command: 'echo hola-shell' }),
1761
- });
1762
- assert.equal(shell.status, 200);
1763
- const out = await shell.json();
1764
- assert.match(out.output, /hola-shell/);
1765
-
1766
- const missing = await fetch(`${base}/api/shell`, {
1767
- method: 'POST',
1768
- headers: json,
1769
- body: JSON.stringify({ command: ' ' }),
1770
- });
1771
- assert.equal(missing.status, 400);
1772
-
1773
- const badPort = await fetch(`${base}/api/procs/kill`, {
1774
- method: 'POST',
1775
- headers: json,
1776
- body: JSON.stringify({ port: 0 }),
1777
- });
1778
- assert.equal(badPort.status, 400);
1779
-
1780
- const none = await fetch(`${base}/api/procs/kill`, {
1781
- method: 'POST',
1782
- headers: json,
1783
- body: JSON.stringify({ port: 47555 }),
1784
- });
1785
- assert.equal(none.status, 200);
1786
- assert.deepEqual((await none.json()).pids, []);
1787
-
1788
- const status = await fetch(`${base}/api/ports/47555`);
1789
- assert.equal(status.status, 200);
1790
- const info = await status.json();
1791
- assert.equal(info.port, 47555);
1792
- assert.equal(info.listening, false);
1793
- assert.deepEqual(info.pids, []);
1794
-
1795
- const aliasKill = await fetch(`${base}/api/ports/kill`, {
1796
- method: 'POST',
1797
- headers: json,
1798
- body: JSON.stringify({ port: 47555 }),
1799
- });
1800
- assert.equal(aliasKill.status, 200);
1801
- assert.deepEqual((await aliasKill.json()).pids, []);
1802
-
1803
- const badStatus = await fetch(`${base}/api/ports/0`);
1804
- assert.equal(badStatus.status, 400);
1805
- } finally {
1806
- await instance.close();
1807
- }
1808
- });
1809
-
1810
- test('the agent can install a skill from chat (with approval)', async () => {
1811
- const calls = { n: 0 };
1812
- const toolProvider = {
1813
- id: 'tooly',
1814
- async streamChat() {
1815
- calls.n += 1;
1816
- if (calls.n === 1) {
1817
- return {
1818
- content: '',
1819
- reasoning: '',
1820
- toolCalls: [
1821
- {
1822
- id: 't1',
1823
- function: {
1824
- name: 'install_skill',
1825
- arguments: JSON.stringify({ id: 'demo-skill', content: '---\nname: Demo\n---\nCuerpo de prueba.' }),
1826
- },
1827
- },
1828
- ],
1829
- };
1830
- }
1831
- return { content: 'listo', reasoning: '', toolCalls: [] };
1832
- },
1833
- };
1834
- const { instance, base } = await startServer({ resolveProvider: () => ({ provider: toolProvider, config: {} }) });
1835
- const json = { 'Content-Type': 'application/json' };
1836
- try {
1837
- const created = await (
1838
- await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm' }) })
1839
- ).json();
1840
- const res = await fetch(`${base}/api/chat`, {
1841
- method: 'POST',
1842
- headers: json,
1843
- body: JSON.stringify({ sessionId: created.session.id, model: 'm', input: 'instalá una skill', yes: true }),
1844
- });
1845
- await res.text();
1846
-
1847
- const skills = await (await fetch(`${base}/api/skills`)).json();
1848
- assert.ok(skills.skills.some((s) => s.id === 'demo-skill'));
1849
- } finally {
1850
- await instance.close();
1851
- }
1852
- });
1853
-
1854
- test('rejects chat without input or model', async () => {
1855
- const { instance, base } = await startServer();
1856
- try {
1857
- const noInput = await fetch(`${base}/api/chat`, {
1858
- method: 'POST',
1859
- headers: { 'Content-Type': 'application/json' },
1860
- body: JSON.stringify({ model: 'm' }),
1861
- });
1862
- assert.equal(noInput.status, 400);
1863
-
1864
- const noModel = await fetch(`${base}/api/chat`, {
1865
- method: 'POST',
1866
- headers: { 'Content-Type': 'application/json' },
1867
- body: JSON.stringify({ input: 'hola' }),
1868
- });
1869
- assert.equal(noModel.status, 400);
1870
- } finally {
1871
- await instance.close();
1872
- }
1873
- });
1874
-
1875
- test('manages providers and active settings', async () => {
1876
- const { instance, base } = await startServer();
1877
- const json = { 'Content-Type': 'application/json' };
1878
- try {
1879
- const put = await fetch(`${base}/api/providers/deepseek`, {
1880
- method: 'PUT',
1881
- headers: json,
1882
- body: JSON.stringify({
1883
- label: 'DeepSeek',
1884
- baseUrl: 'https://api.deepseek.com/v1',
1885
- apiKey: 'sk-secret-1234',
1886
- models: ['deepseek-chat'],
1887
- }),
1888
- });
1889
- const data = await put.json();
1890
- const ds = data.providers.find((p) => p.id === 'deepseek');
1891
- assert.equal(ds.hasKey, true);
1892
- assert.equal(ds.apiKeyHint.endsWith('1234'), true);
1893
- assert.deepEqual(ds.models, ['deepseek-chat']);
1894
-
1895
- await fetch(`${base}/api/settings`, {
1896
- method: 'PUT',
1897
- headers: json,
1898
- body: JSON.stringify({ provider: 'deepseek', model: 'deepseek-chat' }),
1899
- });
1900
- const settings = await (await fetch(`${base}/api/settings`)).json();
1901
- assert.equal(settings.settings.provider, 'deepseek');
1902
- assert.equal(settings.settings.model, 'deepseek-chat');
1903
-
1904
- const list = await (await fetch(`${base}/api/providers`)).json();
1905
- assert.equal(list.active.provider, 'deepseek');
1906
-
1907
- const deleted = await fetch(`${base}/api/providers/deepseek`, { method: 'DELETE' });
1908
- assert.equal(deleted.status, 200);
1909
- const after = await (await fetch(`${base}/api/providers`)).json();
1910
- assert.equal(after.providers.find((p) => p.id === 'deepseek').hasKey, false);
1911
- } finally {
1912
- await instance.close();
1913
- }
1914
- });
1915
-
1916
- test('runs an evaluation over HTTP and persists the run', async () => {
1917
- const model = http.createServer((req, res) => {
1918
- let body = '';
1919
- req.on('data', (c) => (body += c));
1920
- req.on('end', () => {
1921
- res.setHeader('Content-Type', 'text/event-stream');
1922
- res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: 'hola' } }] })}\n\n`);
1923
- res.write('data: [DONE]\n\n');
1924
- res.end();
1925
- });
1926
- });
1927
- await new Promise((r) => model.listen(0, '127.0.0.1', r));
1928
- const modelBase = `http://127.0.0.1:${model.address().port}/v1`;
1929
-
1930
- const { instance, base } = await startServer();
1931
- try {
1932
- const res = await fetch(`${base}/api/eval`, {
1933
- method: 'POST',
1934
- headers: { 'Content-Type': 'application/json' },
1935
- body: JSON.stringify({
1936
- provider: 'openai-compatible',
1937
- baseUrl: modelBase,
1938
- models: ['mock'],
1939
- concurrency: 1,
1940
- tasks: [{ id: 't1', input: 'saluda', expect: { contains: 'hola' } }],
1941
- }),
1942
- });
1943
- const text = await res.text();
1944
- assert.match(text, /event: result/);
1945
- assert.match(text, /event: summary/);
1946
- assert.match(text, /"passed":1/);
1947
-
1948
- const runs = await (await fetch(`${base}/api/runs`)).json();
1949
- assert.equal(runs.runs.length, 1);
1950
- const detail = await (await fetch(`${base}/api/runs/${runs.runs[0].id}`)).json();
1951
- assert.equal(detail.results.length, 1);
1952
- assert.equal(detail.results[0].passed, 1);
1953
- } finally {
1954
- await instance.close();
1955
- model.close();
1956
- }
1957
- });
1958
-
1959
- test('creates a folder over HTTP and rejects bad input', async () => {
1960
- const root = await mkdtemp(path.join(tmpdir(), 'tamnora-newdir-'));
1961
- const { instance, base } = await startServer();
1962
- const json = { 'Content-Type': 'application/json' };
1963
- const create = (body) =>
1964
- fetch(`${base}/api/folders`, { method: 'POST', headers: json, body: JSON.stringify(body) });
1965
- try {
1966
- const created = await create({ parent: root, name: 'mi-proyecto' });
1967
- assert.equal(created.status, 201);
1968
- const data = await created.json();
1969
- assert.equal(data.path, path.join(root, 'mi-proyecto'));
1970
- assert.ok((await stat(data.path)).isDirectory());
1971
-
1972
- assert.equal((await create({ parent: root, name: 'mi-proyecto' })).status, 409);
1973
- assert.equal((await create({ parent: root, name: '../escape' })).status, 400);
1974
- assert.equal((await create({ parent: root, name: '' })).status, 400);
1975
- assert.equal((await create({ name: 'sin-parent' })).status, 400);
1976
- } finally {
1977
- await instance.close();
1978
- }
1979
- });
1980
-
1981
- test('lists and previews workspace files, confining to the root', async () => {
1982
- const root = await mkdtemp(path.join(tmpdir(), 'tamnora-files-'));
1983
- await mkdir(path.join(root, 'src'));
1984
- await mkdir(path.join(root, 'node_modules'));
1985
- await writeFile(path.join(root, 'README.md'), '# hola\n');
1986
- await writeFile(path.join(root, 'src', 'index.js'), 'export const x = 1;\n');
1987
- await writeFile(path.join(root, 'zeta.bin'), Buffer.from([1, 2, 0, 3]));
1988
-
1989
- const { instance, base } = await startServer();
1990
- const list = (params) => fetch(`${base}/api/files?${new URLSearchParams(params)}`);
1991
- const content = (params) => fetch(`${base}/api/files/content?${new URLSearchParams(params)}`);
1992
- try {
1993
- const top = await (await list({ root, path: '' })).json();
1994
- assert.equal(top.path, '');
1995
- assert.deepEqual(
1996
- top.entries.map((e) => `${e.type}:${e.name}`),
1997
- ['dir:src', 'file:README.md', 'file:zeta.bin']
1998
- );
1999
-
2000
- const sub = await (await list({ root, path: 'src' })).json();
2001
- assert.deepEqual(sub.entries.map((e) => e.name), ['index.js']);
2002
- assert.equal(sub.entries[0].size > 0, true);
2003
-
2004
- const preview = await (await content({ root, path: 'src/index.js' })).json();
2005
- assert.equal(preview.content, 'export const x = 1;\n');
2006
-
2007
- assert.equal((await list({ root, path: '../../' })).status, 400);
2008
- assert.equal((await list({ root: path.join(root, 'nope') })).status, 400);
2009
- assert.equal((await content({ root, path: 'zeta.bin' })).status, 415);
2010
- } finally {
2011
- await instance.close();
2012
- }
2013
- });
2014
-
2015
- test('reports git status and per-file diffs', { skip: !HAS_GIT }, async () => {
2016
- const root = await mkdtemp(path.join(tmpdir(), 'tamnora-git-'));
2017
- gitInit(root);
2018
- await writeFile(path.join(root, 'app.js'), 'const a = 1;\n');
2019
- execFileSync('git', ['add', 'app.js'], { cwd: root });
2020
- execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: root });
2021
- await writeFile(path.join(root, 'app.js'), 'const a = 2;\n');
2022
- await writeFile(path.join(root, 'nuevo.txt'), 'hola\n');
2023
-
2024
- const { instance, base } = await startServer();
2025
- try {
2026
- const status = await (
2027
- await fetch(`${base}/api/git/status?${new URLSearchParams({ cwd: root })}`)
2028
- ).json();
2029
- assert.equal(status.isRepo, true);
2030
- assert.ok(status.branch);
2031
- assert.deepEqual(
2032
- status.entries.map((e) => e.path).sort(),
2033
- ['app.js', 'nuevo.txt']
2034
- );
2035
- assert.equal(status.entries.find((e) => e.path === 'app.js').status, 'modified');
2036
- assert.equal(status.entries.find((e) => e.path === 'nuevo.txt').status, 'untracked');
2037
-
2038
- const diff = await (
2039
- await fetch(`${base}/api/git/diff?${new URLSearchParams({ cwd: root, path: 'app.js' })}`)
2040
- ).json();
2041
- assert.match(diff.diff, /-const a = 1;/);
2042
- assert.match(diff.diff, /\+const a = 2;/);
2043
-
2044
- const untracked = await (
2045
- await fetch(`${base}/api/git/diff?${new URLSearchParams({ cwd: root, path: 'nuevo.txt' })}`)
2046
- ).json();
2047
- assert.equal(untracked.untracked, true);
2048
- assert.match(untracked.diff, /\+hola/);
2049
-
2050
- const outside = await fetch(
2051
- `${base}/api/git/diff?${new URLSearchParams({ cwd: root, path: '../fuera' })}`
2052
- );
2053
- assert.equal(outside.status, 400);
2054
-
2055
- const plain = await mkdtemp(path.join(tmpdir(), 'tamnora-nogit-'));
2056
- const none = await (
2057
- await fetch(`${base}/api/git/status?${new URLSearchParams({ cwd: plain })}`)
2058
- ).json();
2059
- assert.equal(none.isRepo, false);
2060
- } finally {
2061
- await instance.close();
2062
- }
2063
- });
2064
-
2065
- test('previews an HTML file through a static server', async () => {
2066
- const root = await mkdtemp(path.join(tmpdir(), 'tamnora-html-'));
2067
- await writeFile(path.join(root, 'index.html'), '<h1>hola</h1>');
2068
- const { instance, base } = await startServer();
2069
- const json = { 'Content-Type': 'application/json' };
2070
- try {
2071
- const res = await fetch(`${base}/api/files/preview`, {
2072
- method: 'POST',
2073
- headers: json,
2074
- body: JSON.stringify({ root, path: 'index.html' }),
2075
- });
2076
- assert.equal(res.status, 200);
2077
- const data = await res.json();
2078
- assert.match(data.url, /^http:\/\/127\.0\.0\.1:\d+\/index\.html$/);
2079
-
2080
- const outside = await fetch(`${base}/api/files/preview`, {
2081
- method: 'POST',
2082
- headers: json,
2083
- body: JSON.stringify({ root, path: '../secret.txt' }),
2084
- });
2085
- assert.equal(outside.status, 400);
2086
- } finally {
2087
- await instance.close();
2088
- }
2089
- });
2090
-
2091
- test('lists workspace folders and rejects invalid paths', async () => {
2092
- const root = await mkdtemp(path.join(tmpdir(), 'tamnora-folders-'));
2093
- await mkdir(path.join(root, 'beta'));
2094
- await mkdir(path.join(root, 'alpha'));
2095
- await writeFile(path.join(root, 'solo-archivo.txt'), 'x');
2096
-
2097
- const { instance, base } = await startServer();
2098
- try {
2099
- const roots = await (await fetch(`${base}/api/folders`)).json();
2100
- assert.ok(roots.dirs.length >= 1);
2101
- assert.equal(roots.path, null);
2102
-
2103
- const listed = await (await fetch(`${base}/api/folders?path=${encodeURIComponent(root)}`)).json();
2104
- assert.equal(listed.path, path.resolve(root));
2105
- assert.deepEqual(
2106
- listed.dirs.map((d) => d.name),
2107
- ['alpha', 'beta']
2108
- );
2109
- assert.ok(listed.parent);
2110
-
2111
- const missing = await fetch(`${base}/api/folders?path=${encodeURIComponent(path.join(root, 'nope'))}`);
2112
- assert.equal(missing.status, 400);
2113
-
2114
- const file = await fetch(`${base}/api/folders?path=${encodeURIComponent(path.join(root, 'solo-archivo.txt'))}`);
2115
- assert.equal(file.status, 400);
2116
- } finally {
2117
- await instance.close();
2118
- }
2119
- });
2120
-
2121
- test('the system prompt follows the session workspace, not the server cwd', async () => {
2122
- const serverCwd = await mkdtemp(path.join(tmpdir(), 'tamnora-srv-cwd-'));
2123
- const projectCwd = await mkdtemp(path.join(tmpdir(), 'tamnora-proj-cwd-'));
2124
- await writeFile(path.join(serverCwd, 'AGENTS.md'), 'REGLAS_DEL_SERVER');
2125
- await writeFile(path.join(projectCwd, 'AGENTS.md'), 'REGLAS_DEL_PROYECTO');
2126
-
2127
- let captured = null;
2128
- const provider = {
2129
- id: 'capture',
2130
- async streamChat(_config, opts) {
2131
- captured = opts.messages.find((m) => m.role === 'system')?.content ?? '';
2132
- return { content: 'ok', reasoning: '', toolCalls: [] };
2133
- },
2134
- };
2135
-
2136
- const { instance, base } = await startServer({
2137
- cwd: serverCwd,
2138
- resolveProvider: () => ({ provider, config: {} }),
2139
- });
2140
- try {
2141
- const res = await fetch(`${base}/api/chat`, {
2142
- method: 'POST',
2143
- headers: { 'Content-Type': 'application/json' },
2144
- body: JSON.stringify({ input: 'hola', model: 'm', cwd: projectCwd }),
2145
- });
2146
- await res.text();
2147
- assert.ok(captured.includes(path.resolve(projectCwd)), 'nombra la carpeta de la sesión');
2148
- assert.match(captured, /REGLAS_DEL_PROYECTO/);
2149
- assert.doesNotMatch(captured, /REGLAS_DEL_SERVER/);
2150
- } finally {
2151
- await instance.close();
2152
- }
2153
- });
2154
-
2155
- test('asks for approval before running a gated tool and runs it once approved', async () => {
2156
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-ask-'));
2157
- let calls = 0;
2158
- const provider = {
2159
- id: 'tooly',
2160
- async streamChat() {
2161
- calls += 1;
2162
- if (calls === 1) {
2163
- return {
2164
- content: '',
2165
- reasoning: '',
2166
- toolCalls: [
2167
- { id: 'call_1', function: { name: 'write', arguments: JSON.stringify({ path: 'nota.txt', content: 'hi' }) } },
2168
- ],
2169
- };
2170
- }
2171
- return { content: 'listo', reasoning: '', toolCalls: [] };
2172
- },
2173
- };
2174
-
2175
- const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
2176
- try {
2177
- const res = await fetch(`${base}/api/chat`, {
2178
- method: 'POST',
2179
- headers: { 'Content-Type': 'application/json' },
2180
- body: JSON.stringify({ input: 'creá nota.txt con hi', model: 'm' }),
2181
- });
2182
- const reader = res.body.getReader();
2183
- const decoder = new TextDecoder();
2184
- let buffer = '';
2185
- let askId = null;
2186
- let done = false;
2187
- while (!done) {
2188
- const { value } = await reader.read();
2189
- if (!value) break;
2190
- buffer += decoder.decode(value, { stream: true });
2191
- if (!askId) {
2192
- const match = buffer.match(/"askId":"([0-9a-f-]+)"/);
2193
- if (match && buffer.includes('event: permission')) {
2194
- askId = match[1];
2195
- const decision = await fetch(`${base}/api/permissions/${askId}`, {
2196
- method: 'POST',
2197
- headers: { 'Content-Type': 'application/json' },
2198
- body: JSON.stringify({ allow: true }),
2199
- });
2200
- assert.equal(decision.status, 200);
2201
- }
2202
- }
2203
- if (buffer.includes('event: done')) done = true;
2204
- }
2205
- assert.ok(askId, 'emitió el evento permission');
2206
- assert.equal(await readFile(path.join(cwd, 'nota.txt'), 'utf8'), 'hi');
2207
- } finally {
2208
- await instance.close();
2209
- }
2210
- });
2211
-
2212
- test('rejects unknown permission requests', async () => {
2213
- const { instance, base } = await startServer();
2214
- try {
2215
- const res = await fetch(`${base}/api/permissions/does-not-exist`, {
2216
- method: 'POST',
2217
- headers: { 'Content-Type': 'application/json' },
2218
- body: JSON.stringify({ allow: true }),
2219
- });
2220
- assert.equal(res.status, 404);
2221
- } finally {
2222
- await instance.close();
2223
- }
2224
- });
2225
-
2226
- test('ask_user muestra un formulario y recibe las respuestas', async () => {
2227
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-askuser-'));
2228
- let calls = 0;
2229
- let captured = null;
2230
- const provider = {
2231
- id: 'formy',
2232
- async streamChat(_config, opts) {
2233
- calls += 1;
2234
- if (calls === 1) {
2235
- return {
2236
- content: '',
2237
- reasoning: '',
2238
- toolCalls: [
2239
- {
2240
- id: 'q1',
2241
- function: {
2242
- name: 'ask_user',
2243
- arguments: JSON.stringify({
2244
- questions: [
2245
- { id: 'nombre', label: '¿Tu nombre?' },
2246
- { id: 'color', label: 'Color', type: 'select', options: ['rojo', 'verde'] },
2247
- ],
2248
- }),
2249
- },
2250
- },
2251
- ],
2252
- };
2253
- }
2254
- captured = opts.messages;
2255
- return { content: 'Gracias', reasoning: '', toolCalls: [] };
2256
- },
2257
- };
2258
- const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
2259
- try {
2260
- const res = await fetch(`${base}/api/chat`, {
2261
- method: 'POST',
2262
- headers: { 'Content-Type': 'application/json' },
2263
- body: JSON.stringify({ input: 'preguntame', model: 'm' }),
2264
- });
2265
- const reader = res.body.getReader();
2266
- const decoder = new TextDecoder();
2267
- let buffer = '';
2268
- let askId = null;
2269
- let done = false;
2270
- while (!done) {
2271
- const { value } = await reader.read();
2272
- if (!value) break;
2273
- buffer += decoder.decode(value, { stream: true });
2274
- if (!askId && buffer.includes('event: question')) {
2275
- const match = buffer.match(/"askId":"([0-9a-f-]+)"/);
2276
- if (match) {
2277
- askId = match[1];
2278
- const answer = await fetch(`${base}/api/questions/${askId}`, {
2279
- method: 'POST',
2280
- headers: { 'Content-Type': 'application/json' },
2281
- body: JSON.stringify({ answers: { nombre: 'Ana', color: 'verde' } }),
2282
- });
2283
- assert.equal(answer.status, 200);
2284
- }
2285
- }
2286
- if (buffer.includes('event: done')) done = true;
2287
- }
2288
- assert.ok(askId, 'emitió el evento question');
2289
- const toolMsg = (captured ?? []).find((m) => m.role === 'tool');
2290
- assert.match(String(toolMsg?.content ?? ''), /Ana/);
2291
- assert.match(String(toolMsg?.content ?? ''), /verde/);
2292
- } finally {
2293
- await instance.close();
2294
- }
2295
- });
2296
-
2297
- async function readEvents(res, onFrame) {
2298
- const reader = res.body.getReader();
2299
- const decoder = new TextDecoder();
2300
- let buffer = '';
2301
- let done = false;
2302
- while (!done) {
2303
- const { value } = await reader.read();
2304
- if (!value) break;
2305
- buffer += decoder.decode(value, { stream: true });
2306
- if (onFrame) await onFrame(buffer);
2307
- if (buffer.includes('event: done')) done = true;
2308
- }
2309
- return buffer;
2310
- }
2311
-
2312
- test('forwards attached images as multimodal content', async () => {
2313
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-img-'));
2314
- let captured = null;
2315
- const provider = {
2316
- id: 'vision',
2317
- async streamChat(_config, opts) {
2318
- captured = opts.messages.find((m) => m.role === 'user');
2319
- return { content: 'ok', reasoning: '', toolCalls: [] };
2320
- },
2321
- };
2322
- const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
2323
- try {
2324
- const image = 'data:image/png;base64,iVBORw0KGgo=';
2325
- const res = await fetch(`${base}/api/chat`, {
2326
- method: 'POST',
2327
- headers: { 'Content-Type': 'application/json' },
2328
- body: JSON.stringify({ input: 'mirá', model: 'm', images: [image] }),
2329
- });
2330
- await res.text();
2331
- assert.ok(Array.isArray(captured.content));
2332
- assert.deepEqual(captured.content[0], { type: 'text', text: 'mirá' });
2333
- assert.equal(captured.content[1].type, 'image_url');
2334
- assert.equal(captured.content[1].image_url.url, image);
2335
- } finally {
2336
- await instance.close();
2337
- }
2338
- });
2339
-
2340
- test('scope=session approves later asks without prompting again', async () => {
2341
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-scope-'));
2342
- let round = 0;
2343
- const provider = {
2344
- id: 'twice',
2345
- async streamChat() {
2346
- round += 1;
2347
- if (round <= 2) {
2348
- return {
2349
- content: '',
2350
- reasoning: '',
2351
- toolCalls: [
2352
- {
2353
- id: `call_${round}`,
2354
- function: { name: 'write', arguments: JSON.stringify({ path: `f${round}.txt`, content: 'x' }) },
2355
- },
2356
- ],
2357
- };
2358
- }
2359
- return { content: 'fin', reasoning: '', toolCalls: [] };
2360
- },
2361
- };
2362
- const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
2363
- try {
2364
- const res = await fetch(`${base}/api/chat`, {
2365
- method: 'POST',
2366
- headers: { 'Content-Type': 'application/json' },
2367
- body: JSON.stringify({ input: 'creá dos archivos', model: 'm' }),
2368
- });
2369
- let decisions = 0;
2370
- const text = await readEvents(res, async (buffer) => {
2371
- if (decisions === 0 && buffer.includes('event: permission')) {
2372
- decisions += 1;
2373
- const askId = buffer.match(/"askId":"([0-9a-f-]+)"/)[1];
2374
- await fetch(`${base}/api/permissions/${askId}`, {
2375
- method: 'POST',
2376
- headers: { 'Content-Type': 'application/json' },
2377
- body: JSON.stringify({ allow: true, scope: 'session' }),
2378
- });
2379
- }
2380
- });
2381
- assert.equal(decisions, 1, 'solo pidió permiso una vez');
2382
- assert.equal((text.match(/event: permission\n/g) ?? []).length, 1);
2383
- assert.equal(await readFile(path.join(cwd, 'f1.txt'), 'utf8'), 'x');
2384
- assert.equal(await readFile(path.join(cwd, 'f2.txt'), 'utf8'), 'x');
2385
- } finally {
2386
- await instance.close();
2387
- }
2388
- });
2389
-
2390
- test('stores and injects the session goal', async () => {
2391
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-goal-'));
2392
- let captured = null;
2393
- const provider = {
2394
- id: 'goal',
2395
- async streamChat(_config, opts) {
2396
- captured = opts.messages;
2397
- return { content: 'ok', reasoning: '', toolCalls: [] };
2398
- },
2399
- };
2400
- const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
2401
- const json = { 'Content-Type': 'application/json' };
2402
- try {
2403
- const created = await (
2404
- await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 'G' }) })
2405
- ).json();
2406
- const id = created.session.id;
2407
- const updated = await (
2408
- await fetch(`${base}/api/sessions/${id}`, {
2409
- method: 'PUT',
2410
- headers: json,
2411
- body: JSON.stringify({ goal: 'migrar a ESM' }),
2412
- })
2413
- ).json();
2414
- assert.equal(updated.session.goal, 'migrar a ESM');
2415
-
2416
- await (await fetch(`${base}/api/chat`, {
2417
- method: 'POST',
2418
- headers: json,
2419
- body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
2420
- })).text();
2421
- assert.ok(captured.some((m) => m.content === 'Objetivo de la sesión: migrar a ESM'));
2422
- } finally {
2423
- await instance.close();
2424
- }
2425
- });
2426
-
2427
- test('compact summarizes and keeps the history with a marker', async () => {
2428
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-compact-'));
2429
- let compacting = false;
2430
- const provider = {
2431
- id: 'sum',
2432
- async streamChat(_config, opts) {
2433
- const system = opts.messages.find((m) => m.role === 'system')?.content ?? '';
2434
- if (system.includes('Resumí la conversación')) compacting = true;
2435
- return { content: compacting ? 'RESUMEN FINAL' : 'hola', reasoning: '', toolCalls: [] };
2436
- },
2437
- };
2438
- const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
2439
- const json = { 'Content-Type': 'application/json' };
2440
- try {
2441
- const created = await (
2442
- await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 'C' }) })
2443
- ).json();
2444
- const id = created.session.id;
2445
- await (await fetch(`${base}/api/chat`, {
2446
- method: 'POST',
2447
- headers: json,
2448
- body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
2449
- })).text();
2450
-
2451
- const compact = await (
2452
- await fetch(`${base}/api/sessions/${id}/compact`, { method: 'POST', headers: json })
2453
- ).json();
2454
- assert.equal(compact.summary, 'RESUMEN FINAL');
2455
- assert.ok(compact.cursor, 'debe devolver el cursor de compactación');
2456
-
2457
- const detail = await (await fetch(`${base}/api/sessions/${id}`)).json();
2458
- const marker = detail.messages.find((m) => m.name === 'compact');
2459
- assert.ok(marker, 'debe dejar una marca de compactación');
2460
- assert.equal(marker.content, 'RESUMEN FINAL');
2461
- assert.ok(detail.messages.length > 1, 'no borra el historial');
2462
- assert.equal(detail.session.hasSummary, true);
2463
- } finally {
2464
- await instance.close();
2465
- }
2466
- });
2467
-
2468
- test('the summary endpoint returns a summary without clearing the messages', async () => {
2469
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-summary-'));
2470
- const provider = {
2471
- id: 'sum',
2472
- async streamChat(_config, opts) {
2473
- const system = opts.messages.find((m) => m.role === 'system')?.content ?? '';
2474
- return { content: system.includes('Resumí la conversación') ? 'RESUMEN FINAL' : 'hola', reasoning: '', toolCalls: [] };
2475
- },
2476
- };
2477
- const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
2478
- const json = { 'Content-Type': 'application/json' };
2479
- try {
2480
- const created = await (
2481
- await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 'S' }) })
2482
- ).json();
2483
- const id = created.session.id;
2484
- await (await fetch(`${base}/api/chat`, {
2485
- method: 'POST',
2486
- headers: json,
2487
- body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
2488
- })).text();
2489
-
2490
- const summary = await (
2491
- await fetch(`${base}/api/sessions/${id}/summary`, { method: 'POST', headers: json })
2492
- ).json();
2493
- assert.equal(summary.summary, 'RESUMEN FINAL');
2494
-
2495
- const detail = await (await fetch(`${base}/api/sessions/${id}`)).json();
2496
- assert.ok(detail.messages.length > 0, 'no borra el historial');
2497
- } finally {
2498
- await instance.close();
2499
- }
2500
- });
2501
-
2502
- test('exposes modes and persists the active mode', async () => {
2503
- const { instance, base } = await startServer();
2504
- const json = { 'Content-Type': 'application/json' };
2505
- try {
2506
- const modes = await (await fetch(`${base}/api/modes`)).json();
2507
- assert.deepEqual(
2508
- modes.modes.map((m) => m.id),
2509
- ['agent', 'plan', 'voice']
2510
- );
2511
- await fetch(`${base}/api/settings`, { method: 'PUT', headers: json, body: JSON.stringify({ mode: 'plan' }) });
2512
- const settings = await (await fetch(`${base}/api/settings`)).json();
2513
- assert.equal(settings.settings.mode, 'plan');
2514
- } finally {
2515
- await instance.close();
2516
- }
2517
- });
2518
-
2519
- test('plan mode exposes only read-only tools and adds guidance', async () => {
2520
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-plan-'));
2521
- let captured = null;
2522
- const provider = {
2523
- id: 'plan',
2524
- async streamChat(_config, opts) {
2525
- captured = {
2526
- tools: (opts.tools ?? []).map((t) => t.function.name).sort(),
2527
- system: opts.messages.find((m) => m.role === 'system').content,
2528
- };
2529
- return { content: 'ok', reasoning: '', toolCalls: [] };
2530
- },
2531
- };
2532
- const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
2533
- try {
2534
- await (
2535
- await fetch(`${base}/api/chat`, {
2536
- method: 'POST',
2537
- headers: { 'Content-Type': 'application/json' },
2538
- body: JSON.stringify({ input: 'planificá', model: 'm', mode: 'plan' }),
2539
- })
2540
- ).text();
2541
- assert.deepEqual(captured.tools, [
2542
- 'ask_user',
2543
- 'console_errors',
2544
- 'fetch_url',
2545
- 'git_diff',
2546
- 'git_log',
2547
- 'git_status',
2548
- 'glob',
2549
- 'grep',
2550
- 'list',
2551
- 'list_mcp',
2552
- 'list_skills',
2553
- 'offer_choices',
2554
- 'preview_url',
2555
- 'read',
2556
- 'screenshot',
2557
- 'search_mcp',
2558
- 'search_skills',
2559
- 'show_plan',
2560
- 'skill',
2561
- 'speak',
2562
- 'task',
2563
- ]);
2564
- assert.match(captured.system, /modo PLAN/);
2565
- } finally {
2566
- await instance.close();
2567
- }
2568
- });
2569
-
2570
- test('voice mode expone write/edit pero solo para .md y .json', async () => {
2571
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-voice-'));
2572
- let seenTools = null;
2573
- let seenSystem = null;
2574
- let toolResult = null;
2575
- let round = 0;
2576
- const provider = {
2577
- id: 'voice',
2578
- async streamChat(_config, opts) {
2579
- round += 1;
2580
- seenTools = (opts.tools ?? []).map((t) => t.function.name).sort();
2581
- seenSystem = opts.messages.find((m) => m.role === 'system').content;
2582
- if (round === 1) {
2583
- return {
2584
- content: '',
2585
- reasoning: '',
2586
- toolCalls: [
2587
- {
2588
- id: 'w1',
2589
- type: 'function',
2590
- function: { name: 'write', arguments: JSON.stringify({ path: 'nota.txt', content: 'x' }) },
2591
- },
2592
- ],
2593
- };
2594
- }
2595
- toolResult = opts.messages.find((m) => m.role === 'tool')?.content ?? '';
2596
- return { content: 'ok', reasoning: '', toolCalls: [] };
2597
- },
2598
- };
2599
- const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
2600
- try {
2601
- await (
2602
- await fetch(`${base}/api/chat`, {
2603
- method: 'POST',
2604
- headers: { 'Content-Type': 'application/json' },
2605
- body: JSON.stringify({ input: 'tomá nota', model: 'm', mode: 'voice' }),
2606
- })
2607
- ).text();
2608
- assert.ok(seenTools.includes('write'));
2609
- assert.ok(seenTools.includes('edit'));
2610
- assert.match(seenSystem, /modo VOZ/);
2611
- assert.match(toolResult, /solo se permite crear o editar/);
2612
- } finally {
2613
- await instance.close();
2614
- }
2615
- });
2616
-
2617
- test('the agent can delegate to a subagent with the task tool', async () => {
2618
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-task-'));
2619
- const provider = {
2620
- id: 'multi',
2621
- async streamChat(_config, { messages, onEvent }) {
2622
- const isSub = messages.some((m) => m.role === 'system' && /subagente/i.test(String(m.content)));
2623
- if (isSub) {
2624
- onEvent?.({ type: 'text', delta: 'resumen', full: 'resumen subagente' });
2625
- return { content: 'resumen subagente', reasoning: '', toolCalls: [] };
2626
- }
2627
- const done = messages.some((m) => m.role === 'tool' && m.name === 'task');
2628
- if (done) return { content: 'listo', reasoning: '', toolCalls: [] };
2629
- return {
2630
- content: '',
2631
- reasoning: '',
2632
- toolCalls: [
2633
- { id: 't1', function: { name: 'task', arguments: JSON.stringify({ description: 'explorar', prompt: 'leé el proyecto' }) } },
2634
- ],
2635
- };
2636
- },
2637
- };
2638
- const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
2639
- const json = { 'Content-Type': 'application/json' };
2640
- try {
2641
- const stream = await (
2642
- await fetch(`${base}/api/chat`, {
2643
- method: 'POST',
2644
- headers: json,
2645
- body: JSON.stringify({ input: 'delegá', model: 'm' }),
2646
- })
2647
- ).text();
2648
- assert.match(stream, /event: agent_start/);
2649
- assert.match(stream, /event: agent_end/);
2650
-
2651
- const list = await (await fetch(`${base}/api/sessions`)).json();
2652
- const id = list.sessions[0].id;
2653
- const agents = await (await fetch(`${base}/api/sessions/${id}/agents`)).json();
2654
- assert.equal(agents.agents.length, 1);
2655
- assert.equal(agents.agents[0].status, 'done');
2656
- assert.match(agents.agents[0].summary, /resumen subagente/);
2657
- } finally {
2658
- await instance.close();
2659
- }
2660
- });
2661
-
2662
- test('aggregates the token usage of a session', async () => {
2663
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tokens-'));
2664
- const provider = {
2665
- id: 'tok',
2666
- async streamChat() {
2667
- return {
2668
- content: 'ok',
2669
- reasoning: '',
2670
- toolCalls: [],
2671
- usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
2672
- };
2673
- },
2674
- };
2675
- const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
2676
- const json = { 'Content-Type': 'application/json' };
2677
- try {
2678
- const created = await (
2679
- await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 'T' }) })
2680
- ).json();
2681
- const id = created.session.id;
2682
- await (await fetch(`${base}/api/chat`, {
2683
- method: 'POST',
2684
- headers: json,
2685
- body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
2686
- })).text();
2687
- const detail = await (await fetch(`${base}/api/sessions/${id}`)).json();
2688
- assert.deepEqual(detail.tokens, { in: 10, out: 5 });
2689
- } finally {
2690
- await instance.close();
2691
- }
2692
- });
2693
-
2694
- test('sessions and chat accept a workspace cwd', async () => {
2695
- const root = await mkdtemp(path.join(tmpdir(), 'tamnora-cwd-'));
2696
- const { instance, base } = await startServer();
2697
- try {
2698
- const created = await (
2699
- await fetch(`${base}/api/sessions`, {
2700
- method: 'POST',
2701
- headers: { 'Content-Type': 'application/json' },
2702
- body: JSON.stringify({ title: 'Con carpeta', model: 'm', cwd: root }),
2703
- })
2704
- ).json();
2705
- assert.equal(created.session.cwd, path.resolve(root));
2706
-
2707
- const bad = await fetch(`${base}/api/sessions`, {
2708
- method: 'POST',
2709
- headers: { 'Content-Type': 'application/json' },
2710
- body: JSON.stringify({ title: 'Mala', cwd: path.join(root, 'nope') }),
2711
- });
2712
- assert.equal(bad.status, 400);
2713
-
2714
- const res = await fetch(`${base}/api/chat`, {
2715
- method: 'POST',
2716
- headers: { 'Content-Type': 'application/json' },
2717
- body: JSON.stringify({ input: 'hola', model: 'm', cwd: root }),
2718
- });
2719
- const text = await res.text();
2720
- assert.match(text, /event: start/);
2721
- assert.ok(text.includes(`"cwd":${JSON.stringify(path.resolve(root))}`));
2722
-
2723
- const list = await (await fetch(`${base}/api/sessions`)).json();
2724
- const fromChat = list.sessions.find((s) => s.cwd === path.resolve(root) && s.title !== 'Con carpeta');
2725
- assert.ok(fromChat);
2726
- } finally {
2727
- await instance.close();
2728
- }
2729
- });
2730
-
2731
- test('requires login and CSRF when a user is configured', async () => {
2732
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-auth-'));
2733
- setHome(cwd);
2734
- const app = ensureApp();
2735
- app.user = makeUser('admin', 'secreto123');
2736
- writeApp(app);
2737
- assert.equal(readApp().user.name, 'admin');
2738
-
2739
- const instance = await createTamnoraServer({
2740
- cwd,
2741
- dbPath: path.join(cwd, 'tamnora.db'),
2742
- loadMcp: false,
2743
- resolveProvider: () => ({ provider: fakeProvider, config: {} }),
2744
- });
2745
- const info = await instance.listen({ port: 0 });
2746
- const base = info.url;
2747
- try {
2748
- const unauth = await fetch(`${base}/api/providers`);
2749
- assert.equal(unauth.status, 401);
2750
-
2751
- const bad = await fetch(`${base}/api/auth/login`, {
2752
- method: 'POST',
2753
- headers: { 'Content-Type': 'application/json' },
2754
- body: JSON.stringify({ username: 'admin', password: 'nope' }),
2755
- });
2756
- assert.equal(bad.status, 401);
2757
-
2758
- const login = await fetch(`${base}/api/auth/login`, {
2759
- method: 'POST',
2760
- headers: { 'Content-Type': 'application/json' },
2761
- body: JSON.stringify({ username: 'admin', password: 'secreto123' }),
2762
- });
2763
- assert.equal(login.status, 200);
2764
- const cookie = login.headers.get('set-cookie');
2765
- assert.ok(cookie);
2766
- const csrf = (await login.json()).csrf;
2767
- assert.ok(csrf);
2768
-
2769
- const authed = await fetch(`${base}/api/providers`, { headers: { cookie } });
2770
- assert.equal(authed.status, 200);
2771
-
2772
- const noCsrf = await fetch(`${base}/api/settings`, {
2773
- method: 'PUT',
2774
- headers: { 'Content-Type': 'application/json', cookie },
2775
- body: JSON.stringify({ provider: 'openrouter' }),
2776
- });
2777
- assert.equal(noCsrf.status, 403);
2778
-
2779
- const withCsrf = await fetch(`${base}/api/settings`, {
2780
- method: 'PUT',
2781
- headers: { 'Content-Type': 'application/json', cookie, 'x-csrf': csrf },
2782
- body: JSON.stringify({ provider: 'openrouter' }),
2783
- });
2784
- assert.equal(withCsrf.status, 200);
2785
- } finally {
2786
- await instance.close();
2787
- }
2788
- });
2789
-
2790
- test('searches sessions and messages', async () => {
2791
- const { instance, base } = await startServer();
2792
- try {
2793
- const created = await (
2794
- await fetch(`${base}/api/sessions`, {
2795
- method: 'POST',
2796
- headers: { 'Content-Type': 'application/json' },
2797
- body: JSON.stringify({ title: 'Busqueda pepita' }),
2798
- })
2799
- ).json();
2800
- const sessionId = created.session.id;
2801
-
2802
- await fetch(`${base}/api/chat`, {
2803
- method: 'POST',
2804
- headers: { 'Content-Type': 'application/json' },
2805
- body: JSON.stringify({ input: 'contenido único zanahoria', model: 'm', sessionId }),
2806
- });
2807
-
2808
- const bySession = await (await fetch(`${base}/api/search?q=pepita`)).json();
2809
- assert.ok(bySession.sessions.some((s) => s.id === sessionId));
2810
-
2811
- const byMessage = await (await fetch(`${base}/api/search?q=zanahoria`)).json();
2812
- assert.ok(byMessage.messages.some((m) => m.sessionId === sessionId));
2813
-
2814
- const tooShort = await (await fetch(`${base}/api/search?q=p`)).json();
2815
- assert.deepEqual(tooShort, { sessions: [], messages: [] });
2816
- } finally {
2817
- await instance.close();
2818
- }
2819
- });
2820
-
2821
- test('truncates a session from a message id', async () => {
2822
- const { instance, base } = await startServer();
2823
- try {
2824
- const created = await (
2825
- await fetch(`${base}/api/sessions`, {
2826
- method: 'POST',
2827
- headers: { 'Content-Type': 'application/json' },
2828
- body: JSON.stringify({ title: 'T' }),
2829
- })
2830
- ).json();
2831
- const sessionId = created.session.id;
2832
-
2833
- await fetch(`${base}/api/chat`, {
2834
- method: 'POST',
2835
- headers: { 'Content-Type': 'application/json' },
2836
- body: JSON.stringify({ input: 'hola', model: 'm', sessionId }),
2837
- });
2838
-
2839
- let detail = await (await fetch(`${base}/api/sessions/${sessionId}`)).json();
2840
- assert.ok(detail.messages.length >= 2);
2841
- const firstId = detail.messages[0].id;
2842
- assert.ok(firstId);
2843
-
2844
- const res = await fetch(`${base}/api/sessions/${sessionId}/truncate`, {
2845
- method: 'POST',
2846
- headers: { 'Content-Type': 'application/json' },
2847
- body: JSON.stringify({ fromId: firstId }),
2848
- });
2849
- assert.equal(res.status, 200);
2850
-
2851
- detail = await (await fetch(`${base}/api/sessions/${sessionId}`)).json();
2852
- assert.equal(detail.messages.length, 0);
2853
- } finally {
2854
- await instance.close();
2855
- }
2856
- });
2857
-
2858
- test('serves browser screenshots from the home dir, confined to png names', async () => {
2859
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-shots-'));
2860
- const { instance, base } = await startServer({ cwd });
2861
- try {
2862
- const dir = screenshotsDir();
2863
- await mkdir(dir, { recursive: true });
2864
- const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
2865
- await writeFile(path.join(dir, 'abc-123.png'), png);
2866
-
2867
- const ok = await fetch(`${base}/api/screenshots/abc-123.png`);
2868
- assert.equal(ok.status, 200);
2869
- assert.equal(ok.headers.get('content-type'), 'image/png');
2870
- assert.equal((await ok.arrayBuffer()).byteLength, png.length);
2871
-
2872
- assert.equal((await fetch(`${base}/api/screenshots/bad.txt`)).status, 400);
2873
- assert.equal((await fetch(`${base}/api/screenshots/missing.png`)).status, 404);
2874
- } finally {
2875
- await instance.close();
2876
- }
2877
- });
2878
-