@danieltmn/tamnora 0.1.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 (123) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/LICENSE +21 -0
  3. package/README.md +130 -0
  4. package/SECURITY.md +31 -0
  5. package/harness/packages/cli/bin/tamnora.mjs +11 -0
  6. package/harness/packages/cli/package.json +10 -0
  7. package/harness/packages/cli/src/cli.js +90 -0
  8. package/harness/packages/cli/src/commands/autostart.js +121 -0
  9. package/harness/packages/cli/src/commands/bridge.js +274 -0
  10. package/harness/packages/cli/src/commands/doctor.js +73 -0
  11. package/harness/packages/cli/src/commands/eval.js +183 -0
  12. package/harness/packages/cli/src/commands/init.js +31 -0
  13. package/harness/packages/cli/src/commands/mcp.js +101 -0
  14. package/harness/packages/cli/src/commands/repl.js +172 -0
  15. package/harness/packages/cli/src/commands/run.js +165 -0
  16. package/harness/packages/cli/src/commands/serve.js +1 -0
  17. package/harness/packages/cli/src/commands/server.js +345 -0
  18. package/harness/packages/cli/src/commands/tui.js +110 -0
  19. package/harness/packages/cli/src/commands/update.js +76 -0
  20. package/harness/packages/cli/src/commands/user.js +135 -0
  21. package/harness/packages/cli/src/lib/core.js +2 -0
  22. package/harness/packages/cli/src/lib/daemon.js +153 -0
  23. package/harness/packages/cli/src/lib/qr.js +203 -0
  24. package/harness/packages/cli/src/lib/runtime.js +110 -0
  25. package/harness/packages/cli/src/lib/tunnel.js +113 -0
  26. package/harness/packages/cli/src/lib/util.js +24 -0
  27. package/harness/packages/cli/src/tui/app.js +302 -0
  28. package/harness/packages/cli/src/tui/keys.js +78 -0
  29. package/harness/packages/cli/src/tui/layout.js +130 -0
  30. package/harness/packages/cli/src/tui/screen.js +13 -0
  31. package/harness/packages/cli/src/ui/color.js +15 -0
  32. package/harness/packages/cli/test/release.test.js +34 -0
  33. package/harness/packages/cli/test/tui.test.js +99 -0
  34. package/harness/packages/cli/test/tunnel.test.js +20 -0
  35. package/harness/packages/core/package.json +14 -0
  36. package/harness/packages/core/src/agent/context.js +66 -0
  37. package/harness/packages/core/src/agent/modes.js +26 -0
  38. package/harness/packages/core/src/agent/runAgent.js +257 -0
  39. package/harness/packages/core/src/config/app.js +49 -0
  40. package/harness/packages/core/src/config/home.js +98 -0
  41. package/harness/packages/core/src/config/identity.js +41 -0
  42. package/harness/packages/core/src/config/index.js +69 -0
  43. package/harness/packages/core/src/config/providers.js +24 -0
  44. package/harness/packages/core/src/env.js +19 -0
  45. package/harness/packages/core/src/eval/dataset.js +47 -0
  46. package/harness/packages/core/src/eval/judge.js +39 -0
  47. package/harness/packages/core/src/eval/report.js +48 -0
  48. package/harness/packages/core/src/eval/runTask.js +71 -0
  49. package/harness/packages/core/src/eval/runner.js +150 -0
  50. package/harness/packages/core/src/eval/scorers.js +217 -0
  51. package/harness/packages/core/src/eval/task.js +59 -0
  52. package/harness/packages/core/src/index.js +72 -0
  53. package/harness/packages/core/src/mcp/common.js +82 -0
  54. package/harness/packages/core/src/mcp/http.js +149 -0
  55. package/harness/packages/core/src/mcp/index.js +167 -0
  56. package/harness/packages/core/src/mcp/stdio.js +218 -0
  57. package/harness/packages/core/src/providers/index.js +119 -0
  58. package/harness/packages/core/src/providers/ollama.js +76 -0
  59. package/harness/packages/core/src/providers/openaiCompatible.js +57 -0
  60. package/harness/packages/core/src/providers/openrouter.js +57 -0
  61. package/harness/packages/core/src/providers/presets.js +19 -0
  62. package/harness/packages/core/src/providers/resilience.js +74 -0
  63. package/harness/packages/core/src/providers/stream.js +200 -0
  64. package/harness/packages/core/src/providers/utils.js +66 -0
  65. package/harness/packages/core/src/storage/sqlite.js +453 -0
  66. package/harness/packages/core/src/tools/diff.js +90 -0
  67. package/harness/packages/core/src/tools/executors.js +277 -0
  68. package/harness/packages/core/src/tools/glob.js +67 -0
  69. package/harness/packages/core/src/tools/index.js +28 -0
  70. package/harness/packages/core/src/tools/paths.js +29 -0
  71. package/harness/packages/core/src/tools/permissions.js +69 -0
  72. package/harness/packages/core/src/tools/registry.js +44 -0
  73. package/harness/packages/core/src/tools/schema.js +177 -0
  74. package/harness/packages/core/test/agent.test.js +237 -0
  75. package/harness/packages/core/test/config.test.js +33 -0
  76. package/harness/packages/core/test/context.test.js +44 -0
  77. package/harness/packages/core/test/diff.test.js +20 -0
  78. package/harness/packages/core/test/eval.test.js +144 -0
  79. package/harness/packages/core/test/git-tools.test.js +44 -0
  80. package/harness/packages/core/test/home.test.js +62 -0
  81. package/harness/packages/core/test/mcp.test.js +158 -0
  82. package/harness/packages/core/test/providers.test.js +80 -0
  83. package/harness/packages/core/test/resilience.test.js +61 -0
  84. package/harness/packages/core/test/storage.test.js +55 -0
  85. package/harness/packages/core/test/stream.test.js +91 -0
  86. package/harness/packages/core/test/tools.test.js +96 -0
  87. package/harness/packages/server/bin/tamnora-server.mjs +11 -0
  88. package/harness/packages/server/package.json +10 -0
  89. package/harness/packages/server/public/app.js +2188 -0
  90. package/harness/packages/server/public/img/fondo-agentes.webp +0 -0
  91. package/harness/packages/server/public/index.html +368 -0
  92. package/harness/packages/server/public/landing/index.html +523 -0
  93. package/harness/packages/server/public/landing/landing.js +89 -0
  94. package/harness/packages/server/public/markdown.js +174 -0
  95. package/harness/packages/server/public/styles.css +923 -0
  96. package/harness/packages/server/src/auth.js +146 -0
  97. package/harness/packages/server/src/cli.js +61 -0
  98. package/harness/packages/server/src/index.js +1 -0
  99. package/harness/packages/server/src/lib/approvals.js +51 -0
  100. package/harness/packages/server/src/lib/core.js +2 -0
  101. package/harness/packages/server/src/lib/http.js +74 -0
  102. package/harness/packages/server/src/lib/modelCache.js +32 -0
  103. package/harness/packages/server/src/lib/prompt.js +48 -0
  104. package/harness/packages/server/src/lib/workspace.js +74 -0
  105. package/harness/packages/server/src/routes/auth.js +56 -0
  106. package/harness/packages/server/src/routes/chat.js +222 -0
  107. package/harness/packages/server/src/routes/eval.js +100 -0
  108. package/harness/packages/server/src/routes/files.js +117 -0
  109. package/harness/packages/server/src/routes/folders.js +80 -0
  110. package/harness/packages/server/src/routes/mcp.js +50 -0
  111. package/harness/packages/server/src/routes/models.js +26 -0
  112. package/harness/packages/server/src/routes/providers.js +135 -0
  113. package/harness/packages/server/src/routes/sessions.js +157 -0
  114. package/harness/packages/server/src/server.js +249 -0
  115. package/harness/packages/server/src/static.js +67 -0
  116. package/harness/packages/server/test/markdown.test.js +36 -0
  117. package/harness/packages/server/test/modelCache.test.js +72 -0
  118. package/harness/packages/server/test/server.test.js +809 -0
  119. package/harness/scripts/example-dataset.jsonl +4 -0
  120. package/harness/scripts/mcp-echo-server.mjs +108 -0
  121. package/harness/scripts/mock-provider.mjs +134 -0
  122. package/harness/tamnora.example.json +18 -0
  123. package/package.json +70 -0
@@ -0,0 +1,809 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import http from 'node:http';
4
+ import { mkdtemp, mkdir, writeFile, readFile, stat } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import path from 'node:path';
7
+ import { createTamnoraServer } from '../src/index.js';
8
+ import { setHome, ensureApp, readApp, writeApp, makeUser } from '../../core/src/index.js';
9
+
10
+ const fakeProvider = {
11
+ id: 'fake',
12
+ async streamChat(_config, { onEvent }) {
13
+ onEvent?.({ type: 'text', delta: 'hola', full: 'hola' });
14
+ return { content: 'hola', reasoning: '', toolCalls: [] };
15
+ },
16
+ };
17
+
18
+ async function startServer(overrides = {}) {
19
+ const cwd = overrides.cwd ?? (await mkdtemp(path.join(tmpdir(), 'tamnora-srv-')));
20
+ setHome(cwd);
21
+ const instance = await createTamnoraServer({
22
+ cwd,
23
+ dbPath: path.join(cwd, 'tamnora.db'),
24
+ loadMcp: false,
25
+ resolveProvider: () => ({ provider: fakeProvider, config: {} }),
26
+ ...overrides,
27
+ });
28
+ const info = await instance.listen({ port: 0 });
29
+ return { instance, base: info.url };
30
+ }
31
+
32
+ test('serves health, providers and the console', async () => {
33
+ const { instance, base } = await startServer();
34
+ try {
35
+ const health = await (await fetch(`${base}/api/health`)).json();
36
+ assert.equal(health.ok, true);
37
+ assert.equal(health.name, 'tamnora');
38
+
39
+ const providers = await (await fetch(`${base}/api/providers`)).json();
40
+ assert.ok(providers.providers.some((p) => p.id === 'openrouter'));
41
+ assert.equal(providers.active.provider, 'openrouter');
42
+
43
+ const html = await (await fetch(`${base}/`)).text();
44
+ assert.match(html, /Tamnora Console/);
45
+
46
+ const missing = await fetch(`${base}/api/nope`);
47
+ assert.equal(missing.status, 404);
48
+ } finally {
49
+ await instance.close();
50
+ }
51
+ });
52
+
53
+ test('creates, lists, reads and deletes sessions', async () => {
54
+ const { instance, base } = await startServer();
55
+ try {
56
+ const created = await (
57
+ await fetch(`${base}/api/sessions`, {
58
+ method: 'POST',
59
+ headers: { 'Content-Type': 'application/json' },
60
+ body: JSON.stringify({ title: 'Prueba', model: 'm' }),
61
+ })
62
+ ).json();
63
+ assert.ok(created.session.id);
64
+
65
+ const list = await (await fetch(`${base}/api/sessions`)).json();
66
+ assert.equal(list.sessions.length, 1);
67
+ assert.equal(list.sessions[0].title, 'Prueba');
68
+
69
+ const detail = await (await fetch(`${base}/api/sessions/${created.session.id}`)).json();
70
+ assert.deepEqual(detail.messages, []);
71
+
72
+ const deleted = await fetch(`${base}/api/sessions/${created.session.id}`, { method: 'DELETE' });
73
+ assert.equal(deleted.status, 200);
74
+ const afterList = await (await fetch(`${base}/api/sessions`)).json();
75
+ assert.equal(afterList.sessions.length, 0);
76
+ } finally {
77
+ await instance.close();
78
+ }
79
+ });
80
+
81
+ test('streams a chat turn over SSE and persists the messages', async () => {
82
+ const { instance, base } = await startServer();
83
+ try {
84
+ const res = await fetch(`${base}/api/chat`, {
85
+ method: 'POST',
86
+ headers: { 'Content-Type': 'application/json' },
87
+ body: JSON.stringify({ input: 'hola', model: 'm' }),
88
+ });
89
+ assert.equal(res.status, 200);
90
+ assert.match(res.headers.get('content-type'), /text\/event-stream/);
91
+ const text = await res.text();
92
+ assert.match(text, /event: start/);
93
+ assert.match(text, /event: text/);
94
+ assert.match(text, /event: done/);
95
+
96
+ const list = await (await fetch(`${base}/api/sessions`)).json();
97
+ assert.equal(list.sessions.length, 1);
98
+ const detail = await (await fetch(`${base}/api/sessions/${list.sessions[0].id}`)).json();
99
+ const assistant = detail.messages.find((m) => m.role === 'assistant');
100
+ assert.equal(assistant.content, 'hola');
101
+ } finally {
102
+ await instance.close();
103
+ }
104
+ });
105
+
106
+ test('rejects chat without input or model', async () => {
107
+ const { instance, base } = await startServer();
108
+ try {
109
+ const noInput = await fetch(`${base}/api/chat`, {
110
+ method: 'POST',
111
+ headers: { 'Content-Type': 'application/json' },
112
+ body: JSON.stringify({ model: 'm' }),
113
+ });
114
+ assert.equal(noInput.status, 400);
115
+
116
+ const noModel = await fetch(`${base}/api/chat`, {
117
+ method: 'POST',
118
+ headers: { 'Content-Type': 'application/json' },
119
+ body: JSON.stringify({ input: 'hola' }),
120
+ });
121
+ assert.equal(noModel.status, 400);
122
+ } finally {
123
+ await instance.close();
124
+ }
125
+ });
126
+
127
+ test('manages providers and active settings', async () => {
128
+ const { instance, base } = await startServer();
129
+ const json = { 'Content-Type': 'application/json' };
130
+ try {
131
+ const put = await fetch(`${base}/api/providers/deepseek`, {
132
+ method: 'PUT',
133
+ headers: json,
134
+ body: JSON.stringify({
135
+ label: 'DeepSeek',
136
+ baseUrl: 'https://api.deepseek.com/v1',
137
+ apiKey: 'sk-secret-1234',
138
+ models: ['deepseek-chat'],
139
+ }),
140
+ });
141
+ const data = await put.json();
142
+ const ds = data.providers.find((p) => p.id === 'deepseek');
143
+ assert.equal(ds.hasKey, true);
144
+ assert.equal(ds.apiKeyHint.endsWith('1234'), true);
145
+ assert.deepEqual(ds.models, ['deepseek-chat']);
146
+
147
+ await fetch(`${base}/api/settings`, {
148
+ method: 'PUT',
149
+ headers: json,
150
+ body: JSON.stringify({ provider: 'deepseek', model: 'deepseek-chat' }),
151
+ });
152
+ const settings = await (await fetch(`${base}/api/settings`)).json();
153
+ assert.equal(settings.settings.provider, 'deepseek');
154
+ assert.equal(settings.settings.model, 'deepseek-chat');
155
+
156
+ const list = await (await fetch(`${base}/api/providers`)).json();
157
+ assert.equal(list.active.provider, 'deepseek');
158
+
159
+ const deleted = await fetch(`${base}/api/providers/deepseek`, { method: 'DELETE' });
160
+ assert.equal(deleted.status, 200);
161
+ const after = await (await fetch(`${base}/api/providers`)).json();
162
+ assert.equal(after.providers.find((p) => p.id === 'deepseek').hasKey, false);
163
+ } finally {
164
+ await instance.close();
165
+ }
166
+ });
167
+
168
+ test('runs an evaluation over HTTP and persists the run', async () => {
169
+ const model = http.createServer((req, res) => {
170
+ let body = '';
171
+ req.on('data', (c) => (body += c));
172
+ req.on('end', () => {
173
+ res.setHeader('Content-Type', 'text/event-stream');
174
+ res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: 'hola' } }] })}\n\n`);
175
+ res.write('data: [DONE]\n\n');
176
+ res.end();
177
+ });
178
+ });
179
+ await new Promise((r) => model.listen(0, '127.0.0.1', r));
180
+ const modelBase = `http://127.0.0.1:${model.address().port}/v1`;
181
+
182
+ const { instance, base } = await startServer();
183
+ try {
184
+ const res = await fetch(`${base}/api/eval`, {
185
+ method: 'POST',
186
+ headers: { 'Content-Type': 'application/json' },
187
+ body: JSON.stringify({
188
+ provider: 'openai-compatible',
189
+ baseUrl: modelBase,
190
+ models: ['mock'],
191
+ concurrency: 1,
192
+ tasks: [{ id: 't1', input: 'saluda', expect: { contains: 'hola' } }],
193
+ }),
194
+ });
195
+ const text = await res.text();
196
+ assert.match(text, /event: result/);
197
+ assert.match(text, /event: summary/);
198
+ assert.match(text, /"passed":1/);
199
+
200
+ const runs = await (await fetch(`${base}/api/runs`)).json();
201
+ assert.equal(runs.runs.length, 1);
202
+ const detail = await (await fetch(`${base}/api/runs/${runs.runs[0].id}`)).json();
203
+ assert.equal(detail.results.length, 1);
204
+ assert.equal(detail.results[0].passed, 1);
205
+ } finally {
206
+ await instance.close();
207
+ model.close();
208
+ }
209
+ });
210
+
211
+ test('creates a folder over HTTP and rejects bad input', async () => {
212
+ const root = await mkdtemp(path.join(tmpdir(), 'tamnora-newdir-'));
213
+ const { instance, base } = await startServer();
214
+ const json = { 'Content-Type': 'application/json' };
215
+ const create = (body) =>
216
+ fetch(`${base}/api/folders`, { method: 'POST', headers: json, body: JSON.stringify(body) });
217
+ try {
218
+ const created = await create({ parent: root, name: 'mi-proyecto' });
219
+ assert.equal(created.status, 201);
220
+ const data = await created.json();
221
+ assert.equal(data.path, path.join(root, 'mi-proyecto'));
222
+ assert.ok((await stat(data.path)).isDirectory());
223
+
224
+ assert.equal((await create({ parent: root, name: 'mi-proyecto' })).status, 409);
225
+ assert.equal((await create({ parent: root, name: '../escape' })).status, 400);
226
+ assert.equal((await create({ parent: root, name: '' })).status, 400);
227
+ assert.equal((await create({ name: 'sin-parent' })).status, 400);
228
+ } finally {
229
+ await instance.close();
230
+ }
231
+ });
232
+
233
+ test('lists and previews workspace files, confining to the root', async () => {
234
+ const root = await mkdtemp(path.join(tmpdir(), 'tamnora-files-'));
235
+ await mkdir(path.join(root, 'src'));
236
+ await mkdir(path.join(root, 'node_modules'));
237
+ await writeFile(path.join(root, 'README.md'), '# hola\n');
238
+ await writeFile(path.join(root, 'src', 'index.js'), 'export const x = 1;\n');
239
+ await writeFile(path.join(root, 'zeta.bin'), Buffer.from([1, 2, 0, 3]));
240
+
241
+ const { instance, base } = await startServer();
242
+ const list = (params) => fetch(`${base}/api/files?${new URLSearchParams(params)}`);
243
+ const content = (params) => fetch(`${base}/api/files/content?${new URLSearchParams(params)}`);
244
+ try {
245
+ const top = await (await list({ root, path: '' })).json();
246
+ assert.equal(top.path, '');
247
+ assert.deepEqual(
248
+ top.entries.map((e) => `${e.type}:${e.name}`),
249
+ ['dir:src', 'file:README.md', 'file:zeta.bin']
250
+ );
251
+
252
+ const sub = await (await list({ root, path: 'src' })).json();
253
+ assert.deepEqual(sub.entries.map((e) => e.name), ['index.js']);
254
+ assert.equal(sub.entries[0].size > 0, true);
255
+
256
+ const preview = await (await content({ root, path: 'src/index.js' })).json();
257
+ assert.equal(preview.content, 'export const x = 1;\n');
258
+
259
+ assert.equal((await list({ root, path: '../../' })).status, 400);
260
+ assert.equal((await list({ root: path.join(root, 'nope') })).status, 400);
261
+ assert.equal((await content({ root, path: 'zeta.bin' })).status, 415);
262
+ } finally {
263
+ await instance.close();
264
+ }
265
+ });
266
+
267
+ test('lists workspace folders and rejects invalid paths', async () => {
268
+ const root = await mkdtemp(path.join(tmpdir(), 'tamnora-folders-'));
269
+ await mkdir(path.join(root, 'beta'));
270
+ await mkdir(path.join(root, 'alpha'));
271
+ await writeFile(path.join(root, 'solo-archivo.txt'), 'x');
272
+
273
+ const { instance, base } = await startServer();
274
+ try {
275
+ const roots = await (await fetch(`${base}/api/folders`)).json();
276
+ assert.ok(roots.dirs.length >= 1);
277
+ assert.equal(roots.path, null);
278
+
279
+ const listed = await (await fetch(`${base}/api/folders?path=${encodeURIComponent(root)}`)).json();
280
+ assert.equal(listed.path, path.resolve(root));
281
+ assert.deepEqual(
282
+ listed.dirs.map((d) => d.name),
283
+ ['alpha', 'beta']
284
+ );
285
+ assert.ok(listed.parent);
286
+
287
+ const missing = await fetch(`${base}/api/folders?path=${encodeURIComponent(path.join(root, 'nope'))}`);
288
+ assert.equal(missing.status, 400);
289
+
290
+ const file = await fetch(`${base}/api/folders?path=${encodeURIComponent(path.join(root, 'solo-archivo.txt'))}`);
291
+ assert.equal(file.status, 400);
292
+ } finally {
293
+ await instance.close();
294
+ }
295
+ });
296
+
297
+ test('the system prompt follows the session workspace, not the server cwd', async () => {
298
+ const serverCwd = await mkdtemp(path.join(tmpdir(), 'tamnora-srv-cwd-'));
299
+ const projectCwd = await mkdtemp(path.join(tmpdir(), 'tamnora-proj-cwd-'));
300
+ await writeFile(path.join(serverCwd, 'AGENTS.md'), 'REGLAS_DEL_SERVER');
301
+ await writeFile(path.join(projectCwd, 'AGENTS.md'), 'REGLAS_DEL_PROYECTO');
302
+
303
+ let captured = null;
304
+ const provider = {
305
+ id: 'capture',
306
+ async streamChat(_config, opts) {
307
+ captured = opts.messages.find((m) => m.role === 'system')?.content ?? '';
308
+ return { content: 'ok', reasoning: '', toolCalls: [] };
309
+ },
310
+ };
311
+
312
+ const { instance, base } = await startServer({
313
+ cwd: serverCwd,
314
+ resolveProvider: () => ({ provider, config: {} }),
315
+ });
316
+ try {
317
+ const res = await fetch(`${base}/api/chat`, {
318
+ method: 'POST',
319
+ headers: { 'Content-Type': 'application/json' },
320
+ body: JSON.stringify({ input: 'hola', model: 'm', cwd: projectCwd }),
321
+ });
322
+ await res.text();
323
+ assert.ok(captured.includes(path.resolve(projectCwd)), 'nombra la carpeta de la sesión');
324
+ assert.match(captured, /REGLAS_DEL_PROYECTO/);
325
+ assert.doesNotMatch(captured, /REGLAS_DEL_SERVER/);
326
+ } finally {
327
+ await instance.close();
328
+ }
329
+ });
330
+
331
+ test('asks for approval before running a gated tool and runs it once approved', async () => {
332
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-ask-'));
333
+ let calls = 0;
334
+ const provider = {
335
+ id: 'tooly',
336
+ async streamChat() {
337
+ calls += 1;
338
+ if (calls === 1) {
339
+ return {
340
+ content: '',
341
+ reasoning: '',
342
+ toolCalls: [
343
+ { id: 'call_1', function: { name: 'write', arguments: JSON.stringify({ path: 'nota.txt', content: 'hi' }) } },
344
+ ],
345
+ };
346
+ }
347
+ return { content: 'listo', reasoning: '', toolCalls: [] };
348
+ },
349
+ };
350
+
351
+ const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
352
+ try {
353
+ const res = await fetch(`${base}/api/chat`, {
354
+ method: 'POST',
355
+ headers: { 'Content-Type': 'application/json' },
356
+ body: JSON.stringify({ input: 'creá nota.txt con hi', model: 'm' }),
357
+ });
358
+ const reader = res.body.getReader();
359
+ const decoder = new TextDecoder();
360
+ let buffer = '';
361
+ let askId = null;
362
+ let done = false;
363
+ while (!done) {
364
+ const { value } = await reader.read();
365
+ if (!value) break;
366
+ buffer += decoder.decode(value, { stream: true });
367
+ if (!askId) {
368
+ const match = buffer.match(/"askId":"([0-9a-f-]+)"/);
369
+ if (match && buffer.includes('event: permission')) {
370
+ askId = match[1];
371
+ const decision = await fetch(`${base}/api/permissions/${askId}`, {
372
+ method: 'POST',
373
+ headers: { 'Content-Type': 'application/json' },
374
+ body: JSON.stringify({ allow: true }),
375
+ });
376
+ assert.equal(decision.status, 200);
377
+ }
378
+ }
379
+ if (buffer.includes('event: done')) done = true;
380
+ }
381
+ assert.ok(askId, 'emitió el evento permission');
382
+ assert.equal(await readFile(path.join(cwd, 'nota.txt'), 'utf8'), 'hi');
383
+ } finally {
384
+ await instance.close();
385
+ }
386
+ });
387
+
388
+ test('rejects unknown permission requests', async () => {
389
+ const { instance, base } = await startServer();
390
+ try {
391
+ const res = await fetch(`${base}/api/permissions/does-not-exist`, {
392
+ method: 'POST',
393
+ headers: { 'Content-Type': 'application/json' },
394
+ body: JSON.stringify({ allow: true }),
395
+ });
396
+ assert.equal(res.status, 404);
397
+ } finally {
398
+ await instance.close();
399
+ }
400
+ });
401
+
402
+ async function readEvents(res, onFrame) {
403
+ const reader = res.body.getReader();
404
+ const decoder = new TextDecoder();
405
+ let buffer = '';
406
+ let done = false;
407
+ while (!done) {
408
+ const { value } = await reader.read();
409
+ if (!value) break;
410
+ buffer += decoder.decode(value, { stream: true });
411
+ if (onFrame) await onFrame(buffer);
412
+ if (buffer.includes('event: done')) done = true;
413
+ }
414
+ return buffer;
415
+ }
416
+
417
+ test('forwards attached images as multimodal content', async () => {
418
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-img-'));
419
+ let captured = null;
420
+ const provider = {
421
+ id: 'vision',
422
+ async streamChat(_config, opts) {
423
+ captured = opts.messages.find((m) => m.role === 'user');
424
+ return { content: 'ok', reasoning: '', toolCalls: [] };
425
+ },
426
+ };
427
+ const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
428
+ try {
429
+ const image = 'data:image/png;base64,iVBORw0KGgo=';
430
+ const res = await fetch(`${base}/api/chat`, {
431
+ method: 'POST',
432
+ headers: { 'Content-Type': 'application/json' },
433
+ body: JSON.stringify({ input: 'mirá', model: 'm', images: [image] }),
434
+ });
435
+ await res.text();
436
+ assert.ok(Array.isArray(captured.content));
437
+ assert.deepEqual(captured.content[0], { type: 'text', text: 'mirá' });
438
+ assert.equal(captured.content[1].type, 'image_url');
439
+ assert.equal(captured.content[1].image_url.url, image);
440
+ } finally {
441
+ await instance.close();
442
+ }
443
+ });
444
+
445
+ test('scope=session approves later asks without prompting again', async () => {
446
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-scope-'));
447
+ let round = 0;
448
+ const provider = {
449
+ id: 'twice',
450
+ async streamChat() {
451
+ round += 1;
452
+ if (round <= 2) {
453
+ return {
454
+ content: '',
455
+ reasoning: '',
456
+ toolCalls: [
457
+ {
458
+ id: `call_${round}`,
459
+ function: { name: 'write', arguments: JSON.stringify({ path: `f${round}.txt`, content: 'x' }) },
460
+ },
461
+ ],
462
+ };
463
+ }
464
+ return { content: 'fin', reasoning: '', toolCalls: [] };
465
+ },
466
+ };
467
+ const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
468
+ try {
469
+ const res = await fetch(`${base}/api/chat`, {
470
+ method: 'POST',
471
+ headers: { 'Content-Type': 'application/json' },
472
+ body: JSON.stringify({ input: 'creá dos archivos', model: 'm' }),
473
+ });
474
+ let decisions = 0;
475
+ const text = await readEvents(res, async (buffer) => {
476
+ if (decisions === 0 && buffer.includes('event: permission')) {
477
+ decisions += 1;
478
+ const askId = buffer.match(/"askId":"([0-9a-f-]+)"/)[1];
479
+ await fetch(`${base}/api/permissions/${askId}`, {
480
+ method: 'POST',
481
+ headers: { 'Content-Type': 'application/json' },
482
+ body: JSON.stringify({ allow: true, scope: 'session' }),
483
+ });
484
+ }
485
+ });
486
+ assert.equal(decisions, 1, 'solo pidió permiso una vez');
487
+ assert.equal((text.match(/event: permission\n/g) ?? []).length, 1);
488
+ assert.equal(await readFile(path.join(cwd, 'f1.txt'), 'utf8'), 'x');
489
+ assert.equal(await readFile(path.join(cwd, 'f2.txt'), 'utf8'), 'x');
490
+ } finally {
491
+ await instance.close();
492
+ }
493
+ });
494
+
495
+ test('stores and injects the session goal', async () => {
496
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-goal-'));
497
+ let captured = null;
498
+ const provider = {
499
+ id: 'goal',
500
+ async streamChat(_config, opts) {
501
+ captured = opts.messages;
502
+ return { content: 'ok', reasoning: '', toolCalls: [] };
503
+ },
504
+ };
505
+ const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
506
+ const json = { 'Content-Type': 'application/json' };
507
+ try {
508
+ const created = await (
509
+ await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 'G' }) })
510
+ ).json();
511
+ const id = created.session.id;
512
+ const updated = await (
513
+ await fetch(`${base}/api/sessions/${id}`, {
514
+ method: 'PUT',
515
+ headers: json,
516
+ body: JSON.stringify({ goal: 'migrar a ESM' }),
517
+ })
518
+ ).json();
519
+ assert.equal(updated.session.goal, 'migrar a ESM');
520
+
521
+ await (await fetch(`${base}/api/chat`, {
522
+ method: 'POST',
523
+ headers: json,
524
+ body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
525
+ })).text();
526
+ assert.ok(captured.some((m) => m.content === 'Objetivo de la sesión: migrar a ESM'));
527
+ } finally {
528
+ await instance.close();
529
+ }
530
+ });
531
+
532
+ test('compact summarizes the session and clears its messages', async () => {
533
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-compact-'));
534
+ let compacting = false;
535
+ const provider = {
536
+ id: 'sum',
537
+ async streamChat(_config, opts) {
538
+ const system = opts.messages.find((m) => m.role === 'system')?.content ?? '';
539
+ if (system.includes('Resumí la conversación')) compacting = true;
540
+ return { content: compacting ? 'RESUMEN FINAL' : 'hola', reasoning: '', toolCalls: [] };
541
+ },
542
+ };
543
+ const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
544
+ const json = { 'Content-Type': 'application/json' };
545
+ try {
546
+ const created = await (
547
+ await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 'C' }) })
548
+ ).json();
549
+ const id = created.session.id;
550
+ await (await fetch(`${base}/api/chat`, {
551
+ method: 'POST',
552
+ headers: json,
553
+ body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
554
+ })).text();
555
+
556
+ const compact = await (
557
+ await fetch(`${base}/api/sessions/${id}/compact`, { method: 'POST', headers: json })
558
+ ).json();
559
+ assert.equal(compact.summary, 'RESUMEN FINAL');
560
+
561
+ const detail = await (await fetch(`${base}/api/sessions/${id}`)).json();
562
+ assert.deepEqual(detail.messages, []);
563
+ assert.equal(detail.session.hasSummary, true);
564
+ } finally {
565
+ await instance.close();
566
+ }
567
+ });
568
+
569
+ test('exposes modes and persists the active mode', async () => {
570
+ const { instance, base } = await startServer();
571
+ const json = { 'Content-Type': 'application/json' };
572
+ try {
573
+ const modes = await (await fetch(`${base}/api/modes`)).json();
574
+ assert.deepEqual(
575
+ modes.modes.map((m) => m.id),
576
+ ['agent', 'plan']
577
+ );
578
+ await fetch(`${base}/api/settings`, { method: 'PUT', headers: json, body: JSON.stringify({ mode: 'plan' }) });
579
+ const settings = await (await fetch(`${base}/api/settings`)).json();
580
+ assert.equal(settings.settings.mode, 'plan');
581
+ } finally {
582
+ await instance.close();
583
+ }
584
+ });
585
+
586
+ test('plan mode exposes only read-only tools and adds guidance', async () => {
587
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-plan-'));
588
+ let captured = null;
589
+ const provider = {
590
+ id: 'plan',
591
+ async streamChat(_config, opts) {
592
+ captured = {
593
+ tools: (opts.tools ?? []).map((t) => t.function.name).sort(),
594
+ system: opts.messages.find((m) => m.role === 'system').content,
595
+ };
596
+ return { content: 'ok', reasoning: '', toolCalls: [] };
597
+ },
598
+ };
599
+ const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
600
+ try {
601
+ await (
602
+ await fetch(`${base}/api/chat`, {
603
+ method: 'POST',
604
+ headers: { 'Content-Type': 'application/json' },
605
+ body: JSON.stringify({ input: 'planificá', model: 'm', mode: 'plan' }),
606
+ })
607
+ ).text();
608
+ assert.deepEqual(captured.tools, ['git_diff', 'git_log', 'git_status', 'glob', 'grep', 'list', 'read']);
609
+ assert.match(captured.system, /modo PLAN/);
610
+ } finally {
611
+ await instance.close();
612
+ }
613
+ });
614
+
615
+ test('aggregates the token usage of a session', async () => {
616
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tokens-'));
617
+ const provider = {
618
+ id: 'tok',
619
+ async streamChat() {
620
+ return {
621
+ content: 'ok',
622
+ reasoning: '',
623
+ toolCalls: [],
624
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
625
+ };
626
+ },
627
+ };
628
+ const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
629
+ const json = { 'Content-Type': 'application/json' };
630
+ try {
631
+ const created = await (
632
+ await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 'T' }) })
633
+ ).json();
634
+ const id = created.session.id;
635
+ await (await fetch(`${base}/api/chat`, {
636
+ method: 'POST',
637
+ headers: json,
638
+ body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
639
+ })).text();
640
+ const detail = await (await fetch(`${base}/api/sessions/${id}`)).json();
641
+ assert.deepEqual(detail.tokens, { in: 10, out: 5 });
642
+ } finally {
643
+ await instance.close();
644
+ }
645
+ });
646
+
647
+ test('sessions and chat accept a workspace cwd', async () => {
648
+ const root = await mkdtemp(path.join(tmpdir(), 'tamnora-cwd-'));
649
+ const { instance, base } = await startServer();
650
+ try {
651
+ const created = await (
652
+ await fetch(`${base}/api/sessions`, {
653
+ method: 'POST',
654
+ headers: { 'Content-Type': 'application/json' },
655
+ body: JSON.stringify({ title: 'Con carpeta', model: 'm', cwd: root }),
656
+ })
657
+ ).json();
658
+ assert.equal(created.session.cwd, path.resolve(root));
659
+
660
+ const bad = await fetch(`${base}/api/sessions`, {
661
+ method: 'POST',
662
+ headers: { 'Content-Type': 'application/json' },
663
+ body: JSON.stringify({ title: 'Mala', cwd: path.join(root, 'nope') }),
664
+ });
665
+ assert.equal(bad.status, 400);
666
+
667
+ const res = await fetch(`${base}/api/chat`, {
668
+ method: 'POST',
669
+ headers: { 'Content-Type': 'application/json' },
670
+ body: JSON.stringify({ input: 'hola', model: 'm', cwd: root }),
671
+ });
672
+ const text = await res.text();
673
+ assert.match(text, /event: start/);
674
+ assert.ok(text.includes(`"cwd":${JSON.stringify(path.resolve(root))}`));
675
+
676
+ const list = await (await fetch(`${base}/api/sessions`)).json();
677
+ const fromChat = list.sessions.find((s) => s.cwd === path.resolve(root) && s.title !== 'Con carpeta');
678
+ assert.ok(fromChat);
679
+ } finally {
680
+ await instance.close();
681
+ }
682
+ });
683
+
684
+ test('requires login and CSRF when a user is configured', async () => {
685
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-auth-'));
686
+ setHome(cwd);
687
+ const app = ensureApp();
688
+ app.user = makeUser('admin', 'secreto123');
689
+ writeApp(app);
690
+ assert.equal(readApp().user.name, 'admin');
691
+
692
+ const instance = await createTamnoraServer({
693
+ cwd,
694
+ dbPath: path.join(cwd, 'tamnora.db'),
695
+ loadMcp: false,
696
+ resolveProvider: () => ({ provider: fakeProvider, config: {} }),
697
+ });
698
+ const info = await instance.listen({ port: 0 });
699
+ const base = info.url;
700
+ try {
701
+ const unauth = await fetch(`${base}/api/providers`);
702
+ assert.equal(unauth.status, 401);
703
+
704
+ const bad = await fetch(`${base}/api/auth/login`, {
705
+ method: 'POST',
706
+ headers: { 'Content-Type': 'application/json' },
707
+ body: JSON.stringify({ username: 'admin', password: 'nope' }),
708
+ });
709
+ assert.equal(bad.status, 401);
710
+
711
+ const login = await fetch(`${base}/api/auth/login`, {
712
+ method: 'POST',
713
+ headers: { 'Content-Type': 'application/json' },
714
+ body: JSON.stringify({ username: 'admin', password: 'secreto123' }),
715
+ });
716
+ assert.equal(login.status, 200);
717
+ const cookie = login.headers.get('set-cookie');
718
+ assert.ok(cookie);
719
+ const csrf = (await login.json()).csrf;
720
+ assert.ok(csrf);
721
+
722
+ const authed = await fetch(`${base}/api/providers`, { headers: { cookie } });
723
+ assert.equal(authed.status, 200);
724
+
725
+ const noCsrf = await fetch(`${base}/api/settings`, {
726
+ method: 'PUT',
727
+ headers: { 'Content-Type': 'application/json', cookie },
728
+ body: JSON.stringify({ provider: 'openrouter' }),
729
+ });
730
+ assert.equal(noCsrf.status, 403);
731
+
732
+ const withCsrf = await fetch(`${base}/api/settings`, {
733
+ method: 'PUT',
734
+ headers: { 'Content-Type': 'application/json', cookie, 'x-csrf': csrf },
735
+ body: JSON.stringify({ provider: 'openrouter' }),
736
+ });
737
+ assert.equal(withCsrf.status, 200);
738
+ } finally {
739
+ await instance.close();
740
+ }
741
+ });
742
+
743
+ test('searches sessions and messages', async () => {
744
+ const { instance, base } = await startServer();
745
+ try {
746
+ const created = await (
747
+ await fetch(`${base}/api/sessions`, {
748
+ method: 'POST',
749
+ headers: { 'Content-Type': 'application/json' },
750
+ body: JSON.stringify({ title: 'Busqueda pepita' }),
751
+ })
752
+ ).json();
753
+ const sessionId = created.session.id;
754
+
755
+ await fetch(`${base}/api/chat`, {
756
+ method: 'POST',
757
+ headers: { 'Content-Type': 'application/json' },
758
+ body: JSON.stringify({ input: 'contenido único zanahoria', model: 'm', sessionId }),
759
+ });
760
+
761
+ const bySession = await (await fetch(`${base}/api/search?q=pepita`)).json();
762
+ assert.ok(bySession.sessions.some((s) => s.id === sessionId));
763
+
764
+ const byMessage = await (await fetch(`${base}/api/search?q=zanahoria`)).json();
765
+ assert.ok(byMessage.messages.some((m) => m.sessionId === sessionId));
766
+
767
+ const tooShort = await (await fetch(`${base}/api/search?q=p`)).json();
768
+ assert.deepEqual(tooShort, { sessions: [], messages: [] });
769
+ } finally {
770
+ await instance.close();
771
+ }
772
+ });
773
+
774
+ test('truncates a session from a message id', async () => {
775
+ const { instance, base } = await startServer();
776
+ try {
777
+ const created = await (
778
+ await fetch(`${base}/api/sessions`, {
779
+ method: 'POST',
780
+ headers: { 'Content-Type': 'application/json' },
781
+ body: JSON.stringify({ title: 'T' }),
782
+ })
783
+ ).json();
784
+ const sessionId = created.session.id;
785
+
786
+ await fetch(`${base}/api/chat`, {
787
+ method: 'POST',
788
+ headers: { 'Content-Type': 'application/json' },
789
+ body: JSON.stringify({ input: 'hola', model: 'm', sessionId }),
790
+ });
791
+
792
+ let detail = await (await fetch(`${base}/api/sessions/${sessionId}`)).json();
793
+ assert.ok(detail.messages.length >= 2);
794
+ const firstId = detail.messages[0].id;
795
+ assert.ok(firstId);
796
+
797
+ const res = await fetch(`${base}/api/sessions/${sessionId}/truncate`, {
798
+ method: 'POST',
799
+ headers: { 'Content-Type': 'application/json' },
800
+ body: JSON.stringify({ fromId: firstId }),
801
+ });
802
+ assert.equal(res.status, 200);
803
+
804
+ detail = await (await fetch(`${base}/api/sessions/${sessionId}`)).json();
805
+ assert.equal(detail.messages.length, 0);
806
+ } finally {
807
+ await instance.close();
808
+ }
809
+ });