@danieltmn/tamnora 0.21.2 → 0.23.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 (77) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +3 -1
  3. package/SECURITY.md +1 -1
  4. package/harness/packages/cli/bin/tamnora.mjs +7 -1
  5. package/harness/packages/cli/src/commands/doctor.js +42 -4
  6. package/harness/packages/cli/src/commands/repl.js +1 -0
  7. package/harness/packages/cli/src/commands/run.js +1 -0
  8. package/harness/packages/cli/src/commands/tui.js +1 -0
  9. package/harness/packages/cli/src/lib/runtime.js +2 -0
  10. package/harness/packages/cli/src/tui/app.js +2 -0
  11. package/harness/packages/core/src/agent/runAgent.js +9 -5
  12. package/harness/packages/core/src/browser/index.js +27 -13
  13. package/harness/packages/core/src/index.js +1 -0
  14. package/harness/packages/core/src/mcp/stdio.js +15 -0
  15. package/harness/packages/core/src/net/hosts.js +70 -0
  16. package/harness/packages/core/src/redact.js +2 -0
  17. package/harness/packages/core/src/tools/executors.js +19 -24
  18. package/harness/packages/core/src/tools/index.js +1 -1
  19. package/harness/packages/core/src/tools/paths.js +35 -5
  20. package/harness/packages/core/src/tools/permissions.js +41 -2
  21. package/harness/packages/core/src/tools/registry.js +8 -3
  22. package/harness/packages/core/src/tts/edge.js +34 -4
  23. package/harness/packages/core/src/tunnel.js +15 -5
  24. package/harness/packages/core/test/agent.test.js +18 -0
  25. package/harness/packages/core/test/servicios-vivos.test.js +71 -0
  26. package/harness/packages/core/test/tools.test.js +44 -1
  27. package/harness/packages/core/test/tts.test.js +81 -0
  28. package/harness/packages/server/bin/tamnora-server.mjs +7 -1
  29. package/harness/packages/server/public/app.js +347 -6757
  30. package/harness/packages/server/public/cards.js +470 -0
  31. package/harness/packages/server/public/chat.js +1343 -0
  32. package/harness/packages/server/public/chatFormat.js +156 -0
  33. package/harness/packages/server/public/chrome.js +813 -0
  34. package/harness/packages/server/public/commands.js +403 -0
  35. package/harness/packages/server/public/core.js +209 -0
  36. package/harness/packages/server/public/explorer.js +625 -0
  37. package/harness/packages/server/public/icons/logo_dark.png +0 -0
  38. package/harness/packages/server/public/icons/logo_light.png +0 -0
  39. package/harness/packages/server/public/index.html +40 -41
  40. package/harness/packages/server/public/sessions.js +274 -0
  41. package/harness/packages/server/public/settings.js +1080 -0
  42. package/harness/packages/server/public/styles.css +45 -52
  43. package/harness/packages/server/public/system.js +495 -0
  44. package/harness/packages/server/public/voice.js +1058 -0
  45. package/harness/packages/server/src/auth.js +12 -10
  46. package/harness/packages/server/src/lib/agentRun.js +6 -1
  47. package/harness/packages/server/src/lib/channels/telegram/api.js +102 -0
  48. package/harness/packages/server/src/lib/channels/telegram/commands.js +309 -0
  49. package/harness/packages/server/src/lib/channels/telegram/media.js +64 -0
  50. package/harness/packages/server/src/lib/channels/telegram/permissions.js +0 -0
  51. package/harness/packages/server/src/lib/channels/telegram/projects.js +102 -0
  52. package/harness/packages/server/src/lib/channels/telegram/run.js +206 -0
  53. package/harness/packages/server/src/lib/channels/telegram/text.js +57 -0
  54. package/harness/packages/server/src/lib/channels/telegram.js +163 -932
  55. package/harness/packages/server/src/lib/presence.js +12 -1
  56. package/harness/packages/server/src/lib/scheduler.js +12 -2
  57. package/harness/packages/server/src/lib/tasks.js +21 -3
  58. package/harness/packages/server/src/lib/workspace.js +90 -2
  59. package/harness/packages/server/src/routes/auth.js +8 -4
  60. package/harness/packages/server/src/routes/chat.js +16 -5
  61. package/harness/packages/server/src/routes/preview.js +10 -2
  62. package/harness/packages/server/src/routes/providers.js +14 -9
  63. package/harness/packages/server/src/server.js +25 -0
  64. package/harness/packages/server/test/chat.test.js +586 -0
  65. package/harness/packages/server/test/chatFormat.test.js +119 -0
  66. package/harness/packages/server/test/consoleModules.test.js +301 -0
  67. package/harness/packages/server/test/consoleSmoke.test.js +34 -0
  68. package/harness/packages/server/test/helpers.js +121 -0
  69. package/harness/packages/server/test/ops.test.js +515 -0
  70. package/harness/packages/server/test/runs.test.js +816 -0
  71. package/harness/packages/server/test/security.test.js +112 -9
  72. package/harness/packages/server/test/sessions.test.js +437 -0
  73. package/harness/packages/server/test/settings.test.js +482 -0
  74. package/harness/scripts/canary.mjs +24 -0
  75. package/harness/tamnora.example.json +1 -1
  76. package/package.json +3 -2
  77. package/harness/packages/server/test/server.test.js +0 -2885
@@ -28,7 +28,7 @@ async function startServer(overrides = {}) {
28
28
  ...overrides,
29
29
  });
30
30
  const info = await instance.listen({ port: 0 });
31
- return { instance, base: info.url, cwd, home: cwd };
31
+ return { instance, base: info.url, port: info.port, cwd, home: cwd };
32
32
  }
33
33
 
34
34
  /** Deja un bridge.json apuntando a un supervisor vivo (este proceso). */
@@ -64,8 +64,6 @@ test('localOk exige loopback en remoteAddress y sin tunel activo', async () => {
64
64
  try {
65
65
  // Con tunel activo, hasta una conexion loopback deja de ser local.
66
66
  await fakeTunnel();
67
- // El cache de tunnelState vive 1 s.
68
- await new Promise((r) => setTimeout(r, 1100));
69
67
  assert.ok(!localOk({ socket: { remoteAddress: '127.0.0.1' } }));
70
68
  assert.equal(home, path.dirname(bridgeStatePath()));
71
69
  } finally {
@@ -81,7 +79,6 @@ test('un bridge de tailscale no degrada localOk (red privada por loopback)', asy
81
79
  bridgeStatePath(),
82
80
  `${JSON.stringify({ pid: process.pid, url: 'https://pc.tailnet.ts.net', provider: 'tailscale', port: 4517 })}\n`
83
81
  );
84
- await new Promise((r) => setTimeout(r, 1100));
85
82
  assert.ok(localOk({ socket: { remoteAddress: '127.0.0.1' } }), 'loopback sigue siendo local con tailscale');
86
83
  } finally {
87
84
  await clearTunnel();
@@ -105,7 +102,6 @@ test('POST /api/shell responde 403 mientras el tunel esta activo', async () => {
105
102
  assert.match(String(body.output), /tamnora-ok/);
106
103
 
107
104
  await fakeTunnel();
108
- await new Promise((r) => setTimeout(r, 1100));
109
105
  const blocked = await shell();
110
106
  assert.equal(blocked.status, 403);
111
107
 
@@ -131,7 +127,6 @@ test('GET /api/files: sin tunel acepta ?root; con tunel y sin auth responde 403'
131
127
  assert.equal(escape.status, 400);
132
128
 
133
129
  await fakeTunnel();
134
- await new Promise((r) => setTimeout(r, 1100));
135
130
  const viaTunnel = await fetch(`${base}/api/files`);
136
131
  assert.equal(viaTunnel.status, 403);
137
132
  } finally {
@@ -166,7 +161,6 @@ test('con auth, git/files quedan usables por el tunel y confinados al workspace'
166
161
  const { csrf } = await login.json();
167
162
 
168
163
  await fakeTunnel();
169
- await new Promise((r) => setTimeout(r, 1100));
170
164
 
171
165
  const files = await fetch(`${base}/api/files`, { headers: { cookie } });
172
166
  assert.equal(files.status, 200);
@@ -217,7 +211,7 @@ test('el lockout de login no se esquiva falsificando X-Forwarded-For', async ()
217
211
  const lockedSpoof = await attempt('198.51.100.7');
218
212
  assert.equal(lockedSpoof.status, 429, 'XFF falsificado no resetea el contador');
219
213
  } finally {
220
- loginClear({ socket: { remoteAddress: '127.0.0.1' } }, 'locktest');
214
+ loginClear({ socket: { remoteAddress: '127.0.0.1' } });
221
215
  await instance.close();
222
216
  }
223
217
  });
@@ -238,7 +232,6 @@ test('la cookie va con Secure cuando hay tunel y el Host es publico', async () =
238
232
  const info = await instance.listen({ port: 0 });
239
233
  try {
240
234
  await fakeTunnel();
241
- await new Promise((r) => setTimeout(r, 1100));
242
235
 
243
236
  const rawLogin = (host) =>
244
237
  new Promise((resolve, reject) => {
@@ -265,3 +258,113 @@ test('la cookie va con Secure cuando hay tunel y el Host es publico', async () =
265
258
  await instance.close();
266
259
  }
267
260
  });
261
+
262
+ /** Request crudo para poder fijar headers que `fetch` no deja (Host, Origin). */
263
+ function rawRequest({ port, path: reqPath, method = 'GET', headers = {}, body = '' }) {
264
+ return new Promise((resolve, reject) => {
265
+ const req = http.request(
266
+ {
267
+ host: '127.0.0.1',
268
+ port,
269
+ path: reqPath,
270
+ method,
271
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), ...headers },
272
+ },
273
+ (res) => {
274
+ let out = '';
275
+ res.on('data', (c) => (out += c));
276
+ res.on('end', () => resolve({ status: res.statusCode, body: out }));
277
+ }
278
+ );
279
+ req.on('error', reject);
280
+ req.end(body);
281
+ });
282
+ }
283
+
284
+ test('las mutaciones rechazan un Origin ajeno, con o sin auth (CSRF drive-by)', async () => {
285
+ const { instance, port } = await startServer();
286
+ try {
287
+ const shell = (headers) =>
288
+ rawRequest({
289
+ port,
290
+ path: '/api/shell',
291
+ method: 'POST',
292
+ headers,
293
+ body: JSON.stringify({ command: 'echo pwned' }),
294
+ });
295
+
296
+ // Una web maliciosa: simple request con Origin ajeno no ejecuta nada.
297
+ const driveBy = await shell({ Origin: 'https://evil.example' });
298
+ assert.equal(driveBy.status, 403, 'Origin ajeno bloqueado');
299
+
300
+ const nullOrigin = await shell({ Origin: 'null' });
301
+ assert.equal(nullOrigin.status, 403, 'Origin: null bloqueado');
302
+
303
+ // Mismo origen (la consola) y clientes sin navegador (CLI) siguen pasando.
304
+ const sameOrigin = await shell({ Origin: `http://127.0.0.1:${port}` });
305
+ assert.equal(sameOrigin.status, 200, 'mismo origen permitido');
306
+ assert.match(sameOrigin.body, /pwned/);
307
+
308
+ const noOrigin = await shell({});
309
+ assert.equal(noOrigin.status, 200, 'cliente sin Origin (no navegador) permitido');
310
+ } finally {
311
+ await instance.close();
312
+ }
313
+ });
314
+
315
+ test('el Host de un dominio ajeno se rechaza (DNS rebinding)', async () => {
316
+ const { instance, port } = await startServer();
317
+ try {
318
+ const health = (host) => rawRequest({ port, path: '/api/health', headers: { Host: host } });
319
+
320
+ const rebinding = await health('evil.example:8080');
321
+ assert.equal(rebinding.status, 403, 'Host de un dominio ajeno bloqueado');
322
+
323
+ const local = await health('127.0.0.1');
324
+ assert.equal(local.status, 200, 'IP literal permitida');
325
+
326
+ const named = await health('mi-pc.tailnet.ts.net');
327
+ assert.equal(named.status, 200, 'dominio de tailscale permitido');
328
+
329
+ const viaTunnelHost = await health('abc.tunnelmole.net');
330
+ assert.equal(viaTunnelHost.status, 200, 'dominio del tunel permitido');
331
+ } finally {
332
+ await instance.close();
333
+ }
334
+ });
335
+
336
+ test('el lockout de login no se esquiva rotando el username', async () => {
337
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-sec-lock2-'));
338
+ setHome(cwd);
339
+ const app = ensureApp();
340
+ app.user = makeUser('locktest2', 'secreto123');
341
+ writeApp(app);
342
+
343
+ const instance = await createTamnoraServer({
344
+ cwd,
345
+ dbPath: path.join(cwd, 'tamnora.db'),
346
+ loadMcp: false,
347
+ resolveProvider: () => ({ provider: fakeProvider, config: {} }),
348
+ });
349
+ const info = await instance.listen({ port: 0 });
350
+ const base = info.url;
351
+ try {
352
+ const attempt = (username) =>
353
+ fetch(`${base}/api/auth/login`, {
354
+ method: 'POST',
355
+ headers: { 'Content-Type': 'application/json' },
356
+ body: JSON.stringify({ username, password: 'mala' }),
357
+ });
358
+
359
+ // Cada intento con un nombre distinto: el contador es por IP, no por usuario.
360
+ for (let i = 0; i < 5; i++) {
361
+ const res = await attempt(`intruso${i}`);
362
+ assert.equal(res.status, 401, `intento ${i} sin bloquear todavia`);
363
+ }
364
+ const locked = await attempt('locktest2');
365
+ assert.equal(locked.status, 429, 'tras 5 fallos el bloqueo aplica a toda la IP');
366
+ } finally {
367
+ loginClear({ socket: { remoteAddress: '127.0.0.1' } });
368
+ await instance.close();
369
+ }
370
+ });
@@ -0,0 +1,437 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, writeFile, readFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { startServer } from './helpers.js';
7
+
8
+ test('creates, lists, reads and deletes sessions', async () => {
9
+ const { instance, base } = await startServer();
10
+ try {
11
+ const created = await (
12
+ await fetch(`${base}/api/sessions`, {
13
+ method: 'POST',
14
+ headers: { 'Content-Type': 'application/json' },
15
+ body: JSON.stringify({ title: 'Prueba', model: 'm' }),
16
+ })
17
+ ).json();
18
+ assert.ok(created.session.id);
19
+
20
+ const list = await (await fetch(`${base}/api/sessions`)).json();
21
+ assert.equal(list.sessions.length, 1);
22
+ assert.equal(list.sessions[0].title, 'Prueba');
23
+
24
+ const detail = await (await fetch(`${base}/api/sessions/${created.session.id}`)).json();
25
+ assert.deepEqual(detail.messages, []);
26
+
27
+ const deleted = await fetch(`${base}/api/sessions/${created.session.id}`, { method: 'DELETE' });
28
+ assert.equal(deleted.status, 200);
29
+ const afterList = await (await fetch(`${base}/api/sessions`)).json();
30
+ assert.equal(afterList.sessions.length, 0);
31
+ } finally {
32
+ await instance.close();
33
+ }
34
+ });
35
+
36
+ test('lists and deletes sessions grouped by project (cwd)', async () => {
37
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-proj-'));
38
+ const dirA = path.join(cwd, 'a');
39
+ const dirB = path.join(cwd, 'b');
40
+ await mkdir(dirA);
41
+ await mkdir(dirB);
42
+ const { instance, base } = await startServer({ cwd });
43
+ const create = (body) =>
44
+ fetch(`${base}/api/sessions`, {
45
+ method: 'POST',
46
+ headers: { 'Content-Type': 'application/json' },
47
+ body: JSON.stringify(body),
48
+ });
49
+ try {
50
+ await create({ title: 'a1', model: 'm', cwd: dirA });
51
+ await create({ title: 'a2', model: 'm', cwd: dirA });
52
+ await create({ title: 'b1', model: 'm', cwd: dirB });
53
+
54
+ const projects = await (await fetch(`${base}/api/projects`)).json();
55
+ assert.equal(projects.projects.find((p) => p.cwd === dirA).sessions, 2);
56
+ assert.equal(projects.projects.find((p) => p.cwd === dirB).sessions, 1);
57
+
58
+ const removed = await (
59
+ await fetch(`${base}/api/projects?cwd=${encodeURIComponent(dirA)}`, { method: 'DELETE' })
60
+ ).json();
61
+ assert.equal(removed.sessions, 2);
62
+
63
+ const list = await (await fetch(`${base}/api/sessions`)).json();
64
+ assert.equal(list.sessions.length, 1);
65
+ assert.equal(list.sessions[0].cwd, dirB);
66
+
67
+ const missing = await fetch(`${base}/api/projects`, { method: 'DELETE' });
68
+ assert.equal(missing.status, 400);
69
+ } finally {
70
+ await instance.close();
71
+ }
72
+ });
73
+
74
+ test('cada sesión recuerda su propio tipo de agente (proveedor/modelo/modo)', async () => {
75
+ const calls = [];
76
+ const provider = {
77
+ id: 'recorder',
78
+ async streamChat(_config, { model }) {
79
+ calls.push(model);
80
+ return { content: 'ok', reasoning: '', toolCalls: [] };
81
+ },
82
+ };
83
+ const { instance, base } = await startServer({ resolveProvider: () => ({ provider, config: {} }) });
84
+ const json = { 'Content-Type': 'application/json' };
85
+ const create = (body) =>
86
+ fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify(body) }).then((r) => r.json());
87
+ const chat = (sessionId, body = {}) =>
88
+ fetch(`${base}/api/chat`, {
89
+ method: 'POST',
90
+ headers: json,
91
+ body: JSON.stringify({ input: 'hola', sessionId, ...body }),
92
+ }).then((r) => r.text());
93
+ try {
94
+ const a = await create({ title: 'A', provider: 'openrouter', model: 'modelo-a', mode: 'plan' });
95
+ const b = await create({ title: 'B', provider: 'openai', model: 'modelo-b' });
96
+ assert.equal(a.session.mode, 'plan');
97
+
98
+ // Aunque el cuerpo no manda modelo, cada sesión corre con el suyo.
99
+ await chat(a.session.id);
100
+ await chat(b.session.id);
101
+ assert.deepEqual(calls, ['modelo-a', 'modelo-b']);
102
+
103
+ // El tipo de agente se puede fijar sin correr (PUT) y se persiste.
104
+ const updated = await (
105
+ await fetch(`${base}/api/sessions/${b.session.id}`, {
106
+ method: 'PUT',
107
+ headers: json,
108
+ body: JSON.stringify({ provider: 'openrouter', model: 'modelo-c', mode: 'agent' }),
109
+ })
110
+ ).json();
111
+ assert.equal(updated.session.model, 'modelo-c');
112
+ assert.equal(updated.session.mode, 'agent');
113
+
114
+ await chat(b.session.id);
115
+ assert.equal(calls.at(-1), 'modelo-c');
116
+
117
+ // El default global no se toca por cambiar el de una sesión.
118
+ const settings = await (await fetch(`${base}/api/settings`)).json();
119
+ assert.notEqual(settings.settings.model, 'modelo-c');
120
+ } finally {
121
+ await instance.close();
122
+ }
123
+ });
124
+
125
+
126
+ test('rewind restores files and truncates the turn', async () => {
127
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-rewind-'));
128
+ await writeFile(path.join(cwd, 'tamnora.json'), JSON.stringify({ permissions: { write: 'allow' } }));
129
+ await writeFile(path.join(cwd, 'nota.txt'), 'original');
130
+ const provider = {
131
+ id: 'rw',
132
+ async streamChat(_config, { messages }) {
133
+ if (messages.some((m) => m.role === 'tool' && m.name === 'write')) {
134
+ return { content: 'listo', reasoning: '', toolCalls: [] };
135
+ }
136
+ return {
137
+ content: '',
138
+ reasoning: '',
139
+ toolCalls: [
140
+ {
141
+ id: 'w1',
142
+ type: 'function',
143
+ function: { name: 'write', arguments: JSON.stringify({ path: 'nota.txt', content: 'cambiado' }) },
144
+ },
145
+ ],
146
+ };
147
+ },
148
+ };
149
+ const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
150
+ const json = { 'Content-Type': 'application/json' };
151
+ try {
152
+ const created = await (
153
+ await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm', cwd }) })
154
+ ).json();
155
+ const sid = created.session.id;
156
+ await (
157
+ await fetch(`${base}/api/chat`, {
158
+ method: 'POST',
159
+ headers: json,
160
+ body: JSON.stringify({ sessionId: sid, model: 'm', input: 'cambiá la nota' }),
161
+ })
162
+ ).text();
163
+ assert.equal(await readFile(path.join(cwd, 'nota.txt'), 'utf8'), 'cambiado');
164
+
165
+ const detail = await (await fetch(`${base}/api/sessions/${sid}`)).json();
166
+ const userMsg = detail.messages.find((m) => m.role === 'user');
167
+ assert.ok(userMsg, 'no se persistió el mensaje del usuario');
168
+
169
+ const cps = await (await fetch(`${base}/api/sessions/${sid}/checkpoints`)).json();
170
+ assert.ok(cps.turns.includes(userMsg.id), 'no se registró el checkpoint del turno');
171
+
172
+ const res = await fetch(`${base}/api/sessions/${sid}/rewind`, {
173
+ method: 'POST',
174
+ headers: json,
175
+ body: JSON.stringify({ turn: userMsg.id }),
176
+ });
177
+ assert.equal(res.status, 200);
178
+ const out = await res.json();
179
+ assert.equal(out.restored, 1);
180
+ assert.equal(await readFile(path.join(cwd, 'nota.txt'), 'utf8'), 'original');
181
+
182
+ const after = await (await fetch(`${base}/api/sessions/${sid}`)).json();
183
+ assert.equal(after.messages.length, 0);
184
+ } finally {
185
+ await instance.close();
186
+ }
187
+ });
188
+
189
+ test('stores and injects the session goal', async () => {
190
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-goal-'));
191
+ let captured = null;
192
+ const provider = {
193
+ id: 'goal',
194
+ async streamChat(_config, opts) {
195
+ captured = opts.messages;
196
+ return { content: 'ok', reasoning: '', toolCalls: [] };
197
+ },
198
+ };
199
+ const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
200
+ const json = { 'Content-Type': 'application/json' };
201
+ try {
202
+ const created = await (
203
+ await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 'G' }) })
204
+ ).json();
205
+ const id = created.session.id;
206
+ const updated = await (
207
+ await fetch(`${base}/api/sessions/${id}`, {
208
+ method: 'PUT',
209
+ headers: json,
210
+ body: JSON.stringify({ goal: 'migrar a ESM' }),
211
+ })
212
+ ).json();
213
+ assert.equal(updated.session.goal, 'migrar a ESM');
214
+
215
+ await (await fetch(`${base}/api/chat`, {
216
+ method: 'POST',
217
+ headers: json,
218
+ body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
219
+ })).text();
220
+ assert.ok(captured.some((m) => m.content === 'Objetivo de la sesión: migrar a ESM'));
221
+ } finally {
222
+ await instance.close();
223
+ }
224
+ });
225
+
226
+ test('compact summarizes and keeps the history with a marker', async () => {
227
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-compact-'));
228
+ let compacting = false;
229
+ const provider = {
230
+ id: 'sum',
231
+ async streamChat(_config, opts) {
232
+ const system = opts.messages.find((m) => m.role === 'system')?.content ?? '';
233
+ if (system.includes('Resumí la conversación')) compacting = true;
234
+ return { content: compacting ? 'RESUMEN FINAL' : 'hola', reasoning: '', toolCalls: [] };
235
+ },
236
+ };
237
+ const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
238
+ const json = { 'Content-Type': 'application/json' };
239
+ try {
240
+ const created = await (
241
+ await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 'C' }) })
242
+ ).json();
243
+ const id = created.session.id;
244
+ await (await fetch(`${base}/api/chat`, {
245
+ method: 'POST',
246
+ headers: json,
247
+ body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
248
+ })).text();
249
+
250
+ const compact = await (
251
+ await fetch(`${base}/api/sessions/${id}/compact`, { method: 'POST', headers: json })
252
+ ).json();
253
+ assert.equal(compact.summary, 'RESUMEN FINAL');
254
+ assert.ok(compact.cursor, 'debe devolver el cursor de compactación');
255
+
256
+ const detail = await (await fetch(`${base}/api/sessions/${id}`)).json();
257
+ const marker = detail.messages.find((m) => m.name === 'compact');
258
+ assert.ok(marker, 'debe dejar una marca de compactación');
259
+ assert.equal(marker.content, 'RESUMEN FINAL');
260
+ assert.ok(detail.messages.length > 1, 'no borra el historial');
261
+ assert.equal(detail.session.hasSummary, true);
262
+ } finally {
263
+ await instance.close();
264
+ }
265
+ });
266
+
267
+ test('the summary endpoint returns a summary without clearing the messages', async () => {
268
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-summary-'));
269
+ const provider = {
270
+ id: 'sum',
271
+ async streamChat(_config, opts) {
272
+ const system = opts.messages.find((m) => m.role === 'system')?.content ?? '';
273
+ return { content: system.includes('Resumí la conversación') ? 'RESUMEN FINAL' : 'hola', reasoning: '', toolCalls: [] };
274
+ },
275
+ };
276
+ const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
277
+ const json = { 'Content-Type': 'application/json' };
278
+ try {
279
+ const created = await (
280
+ await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 'S' }) })
281
+ ).json();
282
+ const id = created.session.id;
283
+ await (await fetch(`${base}/api/chat`, {
284
+ method: 'POST',
285
+ headers: json,
286
+ body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
287
+ })).text();
288
+
289
+ const summary = await (
290
+ await fetch(`${base}/api/sessions/${id}/summary`, { method: 'POST', headers: json })
291
+ ).json();
292
+ assert.equal(summary.summary, 'RESUMEN FINAL');
293
+
294
+ const detail = await (await fetch(`${base}/api/sessions/${id}`)).json();
295
+ assert.ok(detail.messages.length > 0, 'no borra el historial');
296
+ } finally {
297
+ await instance.close();
298
+ }
299
+ });
300
+
301
+ test('aggregates the token usage of a session', async () => {
302
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tokens-'));
303
+ const provider = {
304
+ id: 'tok',
305
+ async streamChat() {
306
+ return {
307
+ content: 'ok',
308
+ reasoning: '',
309
+ toolCalls: [],
310
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
311
+ };
312
+ },
313
+ };
314
+ const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
315
+ const json = { 'Content-Type': 'application/json' };
316
+ try {
317
+ const created = await (
318
+ await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 'T' }) })
319
+ ).json();
320
+ const id = created.session.id;
321
+ await (await fetch(`${base}/api/chat`, {
322
+ method: 'POST',
323
+ headers: json,
324
+ body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
325
+ })).text();
326
+ const detail = await (await fetch(`${base}/api/sessions/${id}`)).json();
327
+ assert.deepEqual(detail.tokens, { in: 10, out: 5 });
328
+ } finally {
329
+ await instance.close();
330
+ }
331
+ });
332
+
333
+ test('sessions and chat accept a workspace cwd', async () => {
334
+ const root = await mkdtemp(path.join(tmpdir(), 'tamnora-cwd-'));
335
+ const { instance, base } = await startServer();
336
+ try {
337
+ const created = await (
338
+ await fetch(`${base}/api/sessions`, {
339
+ method: 'POST',
340
+ headers: { 'Content-Type': 'application/json' },
341
+ body: JSON.stringify({ title: 'Con carpeta', model: 'm', cwd: root }),
342
+ })
343
+ ).json();
344
+ assert.equal(created.session.cwd, path.resolve(root));
345
+
346
+ const bad = await fetch(`${base}/api/sessions`, {
347
+ method: 'POST',
348
+ headers: { 'Content-Type': 'application/json' },
349
+ body: JSON.stringify({ title: 'Mala', cwd: path.join(root, 'nope') }),
350
+ });
351
+ assert.equal(bad.status, 400);
352
+
353
+ const res = await fetch(`${base}/api/chat`, {
354
+ method: 'POST',
355
+ headers: { 'Content-Type': 'application/json' },
356
+ body: JSON.stringify({ input: 'hola', model: 'm', cwd: root }),
357
+ });
358
+ const text = await res.text();
359
+ assert.match(text, /event: start/);
360
+ assert.ok(text.includes(`"cwd":${JSON.stringify(path.resolve(root))}`));
361
+
362
+ const list = await (await fetch(`${base}/api/sessions`)).json();
363
+ const fromChat = list.sessions.find((s) => s.cwd === path.resolve(root) && s.title !== 'Con carpeta');
364
+ assert.ok(fromChat);
365
+ } finally {
366
+ await instance.close();
367
+ }
368
+ });
369
+
370
+ test('searches sessions and messages', async () => {
371
+ const { instance, base } = await startServer();
372
+ try {
373
+ const created = await (
374
+ await fetch(`${base}/api/sessions`, {
375
+ method: 'POST',
376
+ headers: { 'Content-Type': 'application/json' },
377
+ body: JSON.stringify({ title: 'Busqueda pepita' }),
378
+ })
379
+ ).json();
380
+ const sessionId = created.session.id;
381
+
382
+ await fetch(`${base}/api/chat`, {
383
+ method: 'POST',
384
+ headers: { 'Content-Type': 'application/json' },
385
+ body: JSON.stringify({ input: 'contenido único zanahoria', model: 'm', sessionId }),
386
+ });
387
+
388
+ const bySession = await (await fetch(`${base}/api/search?q=pepita`)).json();
389
+ assert.ok(bySession.sessions.some((s) => s.id === sessionId));
390
+
391
+ const byMessage = await (await fetch(`${base}/api/search?q=zanahoria`)).json();
392
+ assert.ok(byMessage.messages.some((m) => m.sessionId === sessionId));
393
+
394
+ const tooShort = await (await fetch(`${base}/api/search?q=p`)).json();
395
+ assert.deepEqual(tooShort, { sessions: [], messages: [] });
396
+ } finally {
397
+ await instance.close();
398
+ }
399
+ });
400
+
401
+ test('truncates a session from a message id', async () => {
402
+ const { instance, base } = await startServer();
403
+ try {
404
+ const created = await (
405
+ await fetch(`${base}/api/sessions`, {
406
+ method: 'POST',
407
+ headers: { 'Content-Type': 'application/json' },
408
+ body: JSON.stringify({ title: 'T' }),
409
+ })
410
+ ).json();
411
+ const sessionId = created.session.id;
412
+
413
+ await fetch(`${base}/api/chat`, {
414
+ method: 'POST',
415
+ headers: { 'Content-Type': 'application/json' },
416
+ body: JSON.stringify({ input: 'hola', model: 'm', sessionId }),
417
+ });
418
+
419
+ let detail = await (await fetch(`${base}/api/sessions/${sessionId}`)).json();
420
+ assert.ok(detail.messages.length >= 2);
421
+ const firstId = detail.messages[0].id;
422
+ assert.ok(firstId);
423
+
424
+ const res = await fetch(`${base}/api/sessions/${sessionId}/truncate`, {
425
+ method: 'POST',
426
+ headers: { 'Content-Type': 'application/json' },
427
+ body: JSON.stringify({ fromId: firstId }),
428
+ });
429
+ assert.equal(res.status, 200);
430
+
431
+ detail = await (await fetch(`${base}/api/sessions/${sessionId}`)).json();
432
+ assert.equal(detail.messages.length, 0);
433
+ } finally {
434
+ await instance.close();
435
+ }
436
+ });
437
+