@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.
- package/CHANGELOG.md +15 -0
- package/README.md +10 -6
- package/SECURITY.md +13 -0
- package/harness/packages/core/src/agent/runAgent.js +9 -5
- package/harness/packages/core/src/browser/index.js +56 -2
- package/harness/packages/core/src/hooks/index.js +9 -5
- package/harness/packages/core/src/index.js +1 -0
- package/harness/packages/core/src/mcp/stdio.js +15 -0
- package/harness/packages/core/src/net/hosts.js +181 -0
- package/harness/packages/core/src/plugins/index.js +6 -4
- package/harness/packages/core/src/redact.js +2 -0
- package/harness/packages/core/src/tools/executors.js +54 -53
- package/harness/packages/core/src/tools/index.js +1 -1
- package/harness/packages/core/src/tools/paths.js +35 -5
- package/harness/packages/core/src/tools/permissions.js +41 -2
- package/harness/packages/core/src/tools/registry.js +8 -3
- package/harness/packages/core/src/tunnel.js +15 -5
- package/harness/packages/core/test/hooks.test.js +6 -3
- package/harness/packages/core/test/hosts.test.js +50 -0
- package/harness/packages/core/test/plugins.test.js +5 -3
- package/harness/packages/core/test/tools.test.js +98 -2
- package/harness/packages/server/public/app.js +343 -6729
- package/harness/packages/server/public/chat.js +1893 -0
- package/harness/packages/server/public/chrome.js +812 -0
- package/harness/packages/server/public/commands.js +403 -0
- package/harness/packages/server/public/core.js +209 -0
- package/harness/packages/server/public/explorer.js +625 -0
- package/harness/packages/server/public/index.html +19 -19
- package/harness/packages/server/public/sessions.js +274 -0
- package/harness/packages/server/public/settings.js +1076 -0
- package/harness/packages/server/public/styles.css +29 -2
- package/harness/packages/server/public/system.js +495 -0
- package/harness/packages/server/public/voice.js +1058 -0
- package/harness/packages/server/src/auth.js +12 -10
- package/harness/packages/server/src/lib/agentRun.js +6 -1
- package/harness/packages/server/src/lib/channels/telegram.js +1 -0
- package/harness/packages/server/src/lib/presence.js +12 -1
- package/harness/packages/server/src/lib/procs.js +30 -3
- package/harness/packages/server/src/lib/scheduler.js +11 -2
- package/harness/packages/server/src/lib/tasks.js +20 -3
- package/harness/packages/server/src/lib/workspace.js +90 -2
- package/harness/packages/server/src/routes/auth.js +8 -4
- package/harness/packages/server/src/routes/chat.js +15 -4
- package/harness/packages/server/src/routes/preview.js +10 -2
- package/harness/packages/server/src/routes/procs.js +2 -6
- package/harness/packages/server/src/routes/providers.js +12 -9
- package/harness/packages/server/src/server.js +28 -2
- package/harness/packages/server/test/chat.test.js +586 -0
- package/harness/packages/server/test/helpers.js +107 -0
- package/harness/packages/server/test/ops.test.js +511 -0
- package/harness/packages/server/test/procs-manager.test.js +43 -0
- package/harness/packages/server/test/runs.test.js +816 -0
- package/harness/packages/server/test/security.test.js +112 -9
- package/harness/packages/server/test/sessions.test.js +437 -0
- package/harness/packages/server/test/settings.test.js +465 -0
- package/harness/tamnora.example.json +1 -1
- package/package.json +1 -1
- package/harness/packages/server/test/server.test.js +0 -2878
|
@@ -0,0 +1,586 @@
|
|
|
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('project memory is injected into the system prompt', async () => {
|
|
9
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-mem-prompt-'));
|
|
10
|
+
await mkdir(path.join(cwd, '.tamnora', 'memory'), { recursive: true });
|
|
11
|
+
await writeFile(path.join(cwd, '.tamnora', 'memory', 'MEMORY.md'), '# Memoria\n\n- [Dato](dato.md) — clave\n');
|
|
12
|
+
let captured = '';
|
|
13
|
+
const provider = {
|
|
14
|
+
id: 'capture',
|
|
15
|
+
async streamChat(_config, { messages }) {
|
|
16
|
+
captured = messages?.[0]?.content ?? '';
|
|
17
|
+
return { content: 'ok', reasoning: '', toolCalls: [] };
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
|
|
21
|
+
const json = { 'Content-Type': 'application/json' };
|
|
22
|
+
try {
|
|
23
|
+
const created = await (
|
|
24
|
+
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm', cwd }) })
|
|
25
|
+
).json();
|
|
26
|
+
await (
|
|
27
|
+
await fetch(`${base}/api/chat`, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: json,
|
|
30
|
+
body: JSON.stringify({ sessionId: created.session.id, model: 'm', input: 'hola' }),
|
|
31
|
+
})
|
|
32
|
+
).text();
|
|
33
|
+
assert.match(captured, /Memoria del proyecto/);
|
|
34
|
+
assert.match(captured, /dato\.md/);
|
|
35
|
+
} finally {
|
|
36
|
+
await instance.close();
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('the memory tool writes project memory', async () => {
|
|
41
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-mem-tool-'));
|
|
42
|
+
await writeFile(path.join(cwd, 'tamnora.json'), JSON.stringify({ permissions: { memory: 'allow' } }));
|
|
43
|
+
const provider = {
|
|
44
|
+
id: 'memtool',
|
|
45
|
+
async streamChat(_config, { messages }) {
|
|
46
|
+
if (messages.some((m) => m.role === 'tool' && m.name === 'memory')) {
|
|
47
|
+
return { content: 'listo', reasoning: '', toolCalls: [] };
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
content: '',
|
|
51
|
+
reasoning: '',
|
|
52
|
+
toolCalls: [
|
|
53
|
+
{
|
|
54
|
+
id: '1',
|
|
55
|
+
type: 'function',
|
|
56
|
+
function: { name: 'memory', arguments: JSON.stringify({ action: 'write', topic: 'nota', content: 'Hola memoria.' }) },
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
|
|
63
|
+
const json = { 'Content-Type': 'application/json' };
|
|
64
|
+
try {
|
|
65
|
+
const created = await (
|
|
66
|
+
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm', cwd }) })
|
|
67
|
+
).json();
|
|
68
|
+
await (
|
|
69
|
+
await fetch(`${base}/api/chat`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: json,
|
|
72
|
+
body: JSON.stringify({ sessionId: created.session.id, model: 'm', input: 'recordá esto' }),
|
|
73
|
+
})
|
|
74
|
+
).text();
|
|
75
|
+
const file = await readFile(path.join(cwd, '.tamnora', 'memory', 'nota.md'), 'utf8');
|
|
76
|
+
assert.match(file, /Hola memoria/);
|
|
77
|
+
} finally {
|
|
78
|
+
await instance.close();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('rules by path are injected (transiently) when a tool touches a matching file', async () => {
|
|
83
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-rules-inject-'));
|
|
84
|
+
await mkdir(path.join(cwd, '.tamnora', 'rules'), { recursive: true });
|
|
85
|
+
await writeFile(path.join(cwd, '.tamnora', 'rules', 'estilo.md'), '---\npaths: src/**\n---\nMARCADOR_ESTILO: usá 2 espacios.');
|
|
86
|
+
await mkdir(path.join(cwd, 'src'), { recursive: true });
|
|
87
|
+
await writeFile(path.join(cwd, 'src', 'index.js'), 'export const x = 1;\n');
|
|
88
|
+
let captured = null;
|
|
89
|
+
const provider = {
|
|
90
|
+
id: 'rules',
|
|
91
|
+
async streamChat(_config, { messages }) {
|
|
92
|
+
if (messages.some((m) => m.role === 'tool' && m.name === 'read')) {
|
|
93
|
+
captured = messages;
|
|
94
|
+
return { content: 'ok', reasoning: '', toolCalls: [] };
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
content: '',
|
|
98
|
+
reasoning: '',
|
|
99
|
+
toolCalls: [
|
|
100
|
+
{ id: 'r1', type: 'function', function: { name: 'read', arguments: JSON.stringify({ path: 'src/index.js' }) } },
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
|
|
106
|
+
const json = { 'Content-Type': 'application/json' };
|
|
107
|
+
try {
|
|
108
|
+
const created = await (
|
|
109
|
+
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm', cwd }) })
|
|
110
|
+
).json();
|
|
111
|
+
await (
|
|
112
|
+
await fetch(`${base}/api/chat`, {
|
|
113
|
+
method: 'POST',
|
|
114
|
+
headers: json,
|
|
115
|
+
body: JSON.stringify({ sessionId: created.session.id, model: 'm', input: 'mirá src/index.js' }),
|
|
116
|
+
})
|
|
117
|
+
).text();
|
|
118
|
+
assert.ok(captured, 'el provider no recibió una segunda ronda');
|
|
119
|
+
assert.ok(
|
|
120
|
+
captured.some((m) => m.role === 'system' && /MARCADOR_ESTILO/.test(m.content)),
|
|
121
|
+
'no se inyectó la regla por path'
|
|
122
|
+
);
|
|
123
|
+
// No se persiste: el historial guardado no incluye la regla efímera.
|
|
124
|
+
const session = await (await fetch(`${base}/api/sessions/${created.session.id}`)).json();
|
|
125
|
+
assert.ok(
|
|
126
|
+
session.messages.every((m) => !(m.role === 'system' && /MARCADOR_ESTILO/.test(String(m.content ?? '')))),
|
|
127
|
+
'la regla efímera se persistió en el historial'
|
|
128
|
+
);
|
|
129
|
+
} finally {
|
|
130
|
+
await instance.close();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('hooks observe tool events and plugins add custom tools', async () => {
|
|
135
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-hooks-plugins-'));
|
|
136
|
+
await mkdir(path.join(cwd, '.tamnora', 'hooks'), { recursive: true });
|
|
137
|
+
await mkdir(path.join(cwd, '.tamnora', 'plugins'), { recursive: true });
|
|
138
|
+
await writeFile(
|
|
139
|
+
path.join(cwd, '.tamnora', 'hooks', 'audit.mjs'),
|
|
140
|
+
"import { appendFileSync } from 'node:fs';\nimport path from 'node:path';\n" +
|
|
141
|
+
"export default { name: 'audit', events: { 'tool.after': (event, ctx) => { appendFileSync(path.join(ctx.cwd, 'hook.log'), `${event.type}:${event.name}\\n`); } } };\n"
|
|
142
|
+
);
|
|
143
|
+
await writeFile(
|
|
144
|
+
path.join(cwd, '.tamnora', 'plugins', 'demo.mjs'),
|
|
145
|
+
"export default { name: 'demo', tools: [{ name: 'saludar', description: 'saluda', parameters: { quien: { type: 'string' } }, handler: (args) => `Hola ${args.quien}` }] };\n"
|
|
146
|
+
);
|
|
147
|
+
await writeFile(
|
|
148
|
+
path.join(cwd, 'tamnora.json'),
|
|
149
|
+
JSON.stringify({
|
|
150
|
+
hooks: ['.tamnora/hooks/*.mjs'],
|
|
151
|
+
plugins: ['.tamnora/plugins/*.mjs'],
|
|
152
|
+
permissions: { plugin__demo__saludar: 'allow' },
|
|
153
|
+
})
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
let captured = null;
|
|
157
|
+
const provider = {
|
|
158
|
+
id: 'hp',
|
|
159
|
+
async streamChat(_config, { messages }) {
|
|
160
|
+
const toolMsg = messages.find((m) => m.role === 'tool' && String(m.name).startsWith('plugin__'));
|
|
161
|
+
if (toolMsg) {
|
|
162
|
+
captured = toolMsg.content;
|
|
163
|
+
return { content: 'ok', reasoning: '', toolCalls: [] };
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
content: '',
|
|
167
|
+
reasoning: '',
|
|
168
|
+
toolCalls: [
|
|
169
|
+
{
|
|
170
|
+
id: 't1',
|
|
171
|
+
type: 'function',
|
|
172
|
+
function: { name: 'plugin__demo__saludar', arguments: JSON.stringify({ quien: 'mundo' }) },
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
|
|
179
|
+
const json = { 'Content-Type': 'application/json' };
|
|
180
|
+
try {
|
|
181
|
+
const hooks = await (await fetch(`${base}/api/hooks`)).json();
|
|
182
|
+
assert.ok(hooks.hooks.some((h) => h.id === 'audit'));
|
|
183
|
+
|
|
184
|
+
const plugins = await (await fetch(`${base}/api/plugins`)).json();
|
|
185
|
+
assert.ok(plugins.plugins.some((p) => p.name === 'plugin__demo__saludar'));
|
|
186
|
+
|
|
187
|
+
const created = await (
|
|
188
|
+
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm', cwd }) })
|
|
189
|
+
).json();
|
|
190
|
+
await (
|
|
191
|
+
await fetch(`${base}/api/chat`, {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers: json,
|
|
194
|
+
body: JSON.stringify({ sessionId: created.session.id, model: 'm', input: 'saludá' }),
|
|
195
|
+
})
|
|
196
|
+
).text();
|
|
197
|
+
assert.equal(captured, 'Hola mundo');
|
|
198
|
+
|
|
199
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
200
|
+
const log = await readFile(path.join(cwd, 'hook.log'), 'utf8').catch(() => '');
|
|
201
|
+
assert.match(log, /tool\.after:plugin__demo__saludar/);
|
|
202
|
+
} finally {
|
|
203
|
+
await instance.close();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('the agent can install a skill from chat (with approval)', async () => {
|
|
208
|
+
const calls = { n: 0 };
|
|
209
|
+
const toolProvider = {
|
|
210
|
+
id: 'tooly',
|
|
211
|
+
async streamChat() {
|
|
212
|
+
calls.n += 1;
|
|
213
|
+
if (calls.n === 1) {
|
|
214
|
+
return {
|
|
215
|
+
content: '',
|
|
216
|
+
reasoning: '',
|
|
217
|
+
toolCalls: [
|
|
218
|
+
{
|
|
219
|
+
id: 't1',
|
|
220
|
+
function: {
|
|
221
|
+
name: 'install_skill',
|
|
222
|
+
arguments: JSON.stringify({ id: 'demo-skill', content: '---\nname: Demo\n---\nCuerpo de prueba.' }),
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return { content: 'listo', reasoning: '', toolCalls: [] };
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
const { instance, base } = await startServer({ resolveProvider: () => ({ provider: toolProvider, config: {} }) });
|
|
232
|
+
const json = { 'Content-Type': 'application/json' };
|
|
233
|
+
try {
|
|
234
|
+
const created = await (
|
|
235
|
+
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm' }) })
|
|
236
|
+
).json();
|
|
237
|
+
const res = await fetch(`${base}/api/chat`, {
|
|
238
|
+
method: 'POST',
|
|
239
|
+
headers: json,
|
|
240
|
+
body: JSON.stringify({ sessionId: created.session.id, model: 'm', input: 'instalá una skill', yes: true }),
|
|
241
|
+
});
|
|
242
|
+
await res.text();
|
|
243
|
+
|
|
244
|
+
const skills = await (await fetch(`${base}/api/skills`)).json();
|
|
245
|
+
assert.ok(skills.skills.some((s) => s.id === 'demo-skill'));
|
|
246
|
+
} finally {
|
|
247
|
+
await instance.close();
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('the system prompt follows the session workspace, not the server cwd', async () => {
|
|
252
|
+
const serverCwd = await mkdtemp(path.join(tmpdir(), 'tamnora-srv-cwd-'));
|
|
253
|
+
const projectCwd = await mkdtemp(path.join(tmpdir(), 'tamnora-proj-cwd-'));
|
|
254
|
+
await writeFile(path.join(serverCwd, 'AGENTS.md'), 'REGLAS_DEL_SERVER');
|
|
255
|
+
await writeFile(path.join(projectCwd, 'AGENTS.md'), 'REGLAS_DEL_PROYECTO');
|
|
256
|
+
|
|
257
|
+
let captured = null;
|
|
258
|
+
const provider = {
|
|
259
|
+
id: 'capture',
|
|
260
|
+
async streamChat(_config, opts) {
|
|
261
|
+
captured = opts.messages.find((m) => m.role === 'system')?.content ?? '';
|
|
262
|
+
return { content: 'ok', reasoning: '', toolCalls: [] };
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const { instance, base } = await startServer({
|
|
267
|
+
cwd: serverCwd,
|
|
268
|
+
resolveProvider: () => ({ provider, config: {} }),
|
|
269
|
+
});
|
|
270
|
+
try {
|
|
271
|
+
const res = await fetch(`${base}/api/chat`, {
|
|
272
|
+
method: 'POST',
|
|
273
|
+
headers: { 'Content-Type': 'application/json' },
|
|
274
|
+
body: JSON.stringify({ input: 'hola', model: 'm', cwd: projectCwd }),
|
|
275
|
+
});
|
|
276
|
+
await res.text();
|
|
277
|
+
assert.ok(captured.includes(path.resolve(projectCwd)), 'nombra la carpeta de la sesión');
|
|
278
|
+
assert.match(captured, /REGLAS_DEL_PROYECTO/);
|
|
279
|
+
assert.doesNotMatch(captured, /REGLAS_DEL_SERVER/);
|
|
280
|
+
} finally {
|
|
281
|
+
await instance.close();
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('asks for approval before running a gated tool and runs it once approved', async () => {
|
|
286
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-ask-'));
|
|
287
|
+
let calls = 0;
|
|
288
|
+
const provider = {
|
|
289
|
+
id: 'tooly',
|
|
290
|
+
async streamChat() {
|
|
291
|
+
calls += 1;
|
|
292
|
+
if (calls === 1) {
|
|
293
|
+
return {
|
|
294
|
+
content: '',
|
|
295
|
+
reasoning: '',
|
|
296
|
+
toolCalls: [
|
|
297
|
+
{ id: 'call_1', function: { name: 'write', arguments: JSON.stringify({ path: 'nota.txt', content: 'hi' }) } },
|
|
298
|
+
],
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
return { content: 'listo', reasoning: '', toolCalls: [] };
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
|
|
306
|
+
try {
|
|
307
|
+
const res = await fetch(`${base}/api/chat`, {
|
|
308
|
+
method: 'POST',
|
|
309
|
+
headers: { 'Content-Type': 'application/json' },
|
|
310
|
+
body: JSON.stringify({ input: 'creá nota.txt con hi', model: 'm' }),
|
|
311
|
+
});
|
|
312
|
+
const reader = res.body.getReader();
|
|
313
|
+
const decoder = new TextDecoder();
|
|
314
|
+
let buffer = '';
|
|
315
|
+
let askId = null;
|
|
316
|
+
let done = false;
|
|
317
|
+
while (!done) {
|
|
318
|
+
const { value } = await reader.read();
|
|
319
|
+
if (!value) break;
|
|
320
|
+
buffer += decoder.decode(value, { stream: true });
|
|
321
|
+
if (!askId) {
|
|
322
|
+
const match = buffer.match(/"askId":"([0-9a-f-]+)"/);
|
|
323
|
+
if (match && buffer.includes('event: permission')) {
|
|
324
|
+
askId = match[1];
|
|
325
|
+
const decision = await fetch(`${base}/api/permissions/${askId}`, {
|
|
326
|
+
method: 'POST',
|
|
327
|
+
headers: { 'Content-Type': 'application/json' },
|
|
328
|
+
body: JSON.stringify({ allow: true }),
|
|
329
|
+
});
|
|
330
|
+
assert.equal(decision.status, 200);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (buffer.includes('event: done')) done = true;
|
|
334
|
+
}
|
|
335
|
+
assert.ok(askId, 'emitió el evento permission');
|
|
336
|
+
assert.equal(await readFile(path.join(cwd, 'nota.txt'), 'utf8'), 'hi');
|
|
337
|
+
} finally {
|
|
338
|
+
await instance.close();
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('rejects unknown permission requests', async () => {
|
|
343
|
+
const { instance, base } = await startServer();
|
|
344
|
+
try {
|
|
345
|
+
const res = await fetch(`${base}/api/permissions/does-not-exist`, {
|
|
346
|
+
method: 'POST',
|
|
347
|
+
headers: { 'Content-Type': 'application/json' },
|
|
348
|
+
body: JSON.stringify({ allow: true }),
|
|
349
|
+
});
|
|
350
|
+
assert.equal(res.status, 404);
|
|
351
|
+
} finally {
|
|
352
|
+
await instance.close();
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('ask_user muestra un formulario y recibe las respuestas', async () => {
|
|
357
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-askuser-'));
|
|
358
|
+
let calls = 0;
|
|
359
|
+
let captured = null;
|
|
360
|
+
const provider = {
|
|
361
|
+
id: 'formy',
|
|
362
|
+
async streamChat(_config, opts) {
|
|
363
|
+
calls += 1;
|
|
364
|
+
if (calls === 1) {
|
|
365
|
+
return {
|
|
366
|
+
content: '',
|
|
367
|
+
reasoning: '',
|
|
368
|
+
toolCalls: [
|
|
369
|
+
{
|
|
370
|
+
id: 'q1',
|
|
371
|
+
function: {
|
|
372
|
+
name: 'ask_user',
|
|
373
|
+
arguments: JSON.stringify({
|
|
374
|
+
questions: [
|
|
375
|
+
{ id: 'nombre', label: '¿Tu nombre?' },
|
|
376
|
+
{ id: 'color', label: 'Color', type: 'select', options: ['rojo', 'verde'] },
|
|
377
|
+
],
|
|
378
|
+
}),
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
],
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
captured = opts.messages;
|
|
385
|
+
return { content: 'Gracias', reasoning: '', toolCalls: [] };
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
|
|
389
|
+
try {
|
|
390
|
+
const res = await fetch(`${base}/api/chat`, {
|
|
391
|
+
method: 'POST',
|
|
392
|
+
headers: { 'Content-Type': 'application/json' },
|
|
393
|
+
body: JSON.stringify({ input: 'preguntame', model: 'm' }),
|
|
394
|
+
});
|
|
395
|
+
const reader = res.body.getReader();
|
|
396
|
+
const decoder = new TextDecoder();
|
|
397
|
+
let buffer = '';
|
|
398
|
+
let askId = null;
|
|
399
|
+
let done = false;
|
|
400
|
+
while (!done) {
|
|
401
|
+
const { value } = await reader.read();
|
|
402
|
+
if (!value) break;
|
|
403
|
+
buffer += decoder.decode(value, { stream: true });
|
|
404
|
+
if (!askId && buffer.includes('event: question')) {
|
|
405
|
+
const match = buffer.match(/"askId":"([0-9a-f-]+)"/);
|
|
406
|
+
if (match) {
|
|
407
|
+
askId = match[1];
|
|
408
|
+
const answer = await fetch(`${base}/api/questions/${askId}`, {
|
|
409
|
+
method: 'POST',
|
|
410
|
+
headers: { 'Content-Type': 'application/json' },
|
|
411
|
+
body: JSON.stringify({ answers: { nombre: 'Ana', color: 'verde' } }),
|
|
412
|
+
});
|
|
413
|
+
assert.equal(answer.status, 200);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (buffer.includes('event: done')) done = true;
|
|
417
|
+
}
|
|
418
|
+
assert.ok(askId, 'emitió el evento question');
|
|
419
|
+
const toolMsg = (captured ?? []).find((m) => m.role === 'tool');
|
|
420
|
+
assert.match(String(toolMsg?.content ?? ''), /Ana/);
|
|
421
|
+
assert.match(String(toolMsg?.content ?? ''), /verde/);
|
|
422
|
+
} finally {
|
|
423
|
+
await instance.close();
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test('exposes modes and persists the active mode', async () => {
|
|
428
|
+
const { instance, base } = await startServer();
|
|
429
|
+
const json = { 'Content-Type': 'application/json' };
|
|
430
|
+
try {
|
|
431
|
+
const modes = await (await fetch(`${base}/api/modes`)).json();
|
|
432
|
+
assert.deepEqual(
|
|
433
|
+
modes.modes.map((m) => m.id),
|
|
434
|
+
['agent', 'plan', 'voice']
|
|
435
|
+
);
|
|
436
|
+
await fetch(`${base}/api/settings`, { method: 'PUT', headers: json, body: JSON.stringify({ mode: 'plan' }) });
|
|
437
|
+
const settings = await (await fetch(`${base}/api/settings`)).json();
|
|
438
|
+
assert.equal(settings.settings.mode, 'plan');
|
|
439
|
+
} finally {
|
|
440
|
+
await instance.close();
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test('plan mode exposes only read-only tools and adds guidance', async () => {
|
|
445
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-plan-'));
|
|
446
|
+
let captured = null;
|
|
447
|
+
const provider = {
|
|
448
|
+
id: 'plan',
|
|
449
|
+
async streamChat(_config, opts) {
|
|
450
|
+
captured = {
|
|
451
|
+
tools: (opts.tools ?? []).map((t) => t.function.name).sort(),
|
|
452
|
+
system: opts.messages.find((m) => m.role === 'system').content,
|
|
453
|
+
};
|
|
454
|
+
return { content: 'ok', reasoning: '', toolCalls: [] };
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
|
|
458
|
+
try {
|
|
459
|
+
await (
|
|
460
|
+
await fetch(`${base}/api/chat`, {
|
|
461
|
+
method: 'POST',
|
|
462
|
+
headers: { 'Content-Type': 'application/json' },
|
|
463
|
+
body: JSON.stringify({ input: 'planificá', model: 'm', mode: 'plan' }),
|
|
464
|
+
})
|
|
465
|
+
).text();
|
|
466
|
+
assert.deepEqual(captured.tools, [
|
|
467
|
+
'ask_user',
|
|
468
|
+
'console_errors',
|
|
469
|
+
'fetch_url',
|
|
470
|
+
'git_diff',
|
|
471
|
+
'git_log',
|
|
472
|
+
'git_status',
|
|
473
|
+
'glob',
|
|
474
|
+
'grep',
|
|
475
|
+
'list',
|
|
476
|
+
'list_mcp',
|
|
477
|
+
'list_skills',
|
|
478
|
+
'offer_choices',
|
|
479
|
+
'preview_url',
|
|
480
|
+
'read',
|
|
481
|
+
'screenshot',
|
|
482
|
+
'search_mcp',
|
|
483
|
+
'search_skills',
|
|
484
|
+
'show_plan',
|
|
485
|
+
'skill',
|
|
486
|
+
'speak',
|
|
487
|
+
'task',
|
|
488
|
+
]);
|
|
489
|
+
assert.match(captured.system, /modo PLAN/);
|
|
490
|
+
} finally {
|
|
491
|
+
await instance.close();
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test('voice mode expone write/edit pero solo para .md y .json', async () => {
|
|
496
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-voice-'));
|
|
497
|
+
let seenTools = null;
|
|
498
|
+
let seenSystem = null;
|
|
499
|
+
let toolResult = null;
|
|
500
|
+
let round = 0;
|
|
501
|
+
const provider = {
|
|
502
|
+
id: 'voice',
|
|
503
|
+
async streamChat(_config, opts) {
|
|
504
|
+
round += 1;
|
|
505
|
+
seenTools = (opts.tools ?? []).map((t) => t.function.name).sort();
|
|
506
|
+
seenSystem = opts.messages.find((m) => m.role === 'system').content;
|
|
507
|
+
if (round === 1) {
|
|
508
|
+
return {
|
|
509
|
+
content: '',
|
|
510
|
+
reasoning: '',
|
|
511
|
+
toolCalls: [
|
|
512
|
+
{
|
|
513
|
+
id: 'w1',
|
|
514
|
+
type: 'function',
|
|
515
|
+
function: { name: 'write', arguments: JSON.stringify({ path: 'nota.txt', content: 'x' }) },
|
|
516
|
+
},
|
|
517
|
+
],
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
toolResult = opts.messages.find((m) => m.role === 'tool')?.content ?? '';
|
|
521
|
+
return { content: 'ok', reasoning: '', toolCalls: [] };
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
|
|
525
|
+
try {
|
|
526
|
+
await (
|
|
527
|
+
await fetch(`${base}/api/chat`, {
|
|
528
|
+
method: 'POST',
|
|
529
|
+
headers: { 'Content-Type': 'application/json' },
|
|
530
|
+
body: JSON.stringify({ input: 'tomá nota', model: 'm', mode: 'voice' }),
|
|
531
|
+
})
|
|
532
|
+
).text();
|
|
533
|
+
assert.ok(seenTools.includes('write'));
|
|
534
|
+
assert.ok(seenTools.includes('edit'));
|
|
535
|
+
assert.match(seenSystem, /modo VOZ/);
|
|
536
|
+
assert.match(toolResult, /solo se permite crear o editar/);
|
|
537
|
+
} finally {
|
|
538
|
+
await instance.close();
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test('the agent can delegate to a subagent with the task tool', async () => {
|
|
543
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-task-'));
|
|
544
|
+
const provider = {
|
|
545
|
+
id: 'multi',
|
|
546
|
+
async streamChat(_config, { messages, onEvent }) {
|
|
547
|
+
const isSub = messages.some((m) => m.role === 'system' && /subagente/i.test(String(m.content)));
|
|
548
|
+
if (isSub) {
|
|
549
|
+
onEvent?.({ type: 'text', delta: 'resumen', full: 'resumen subagente' });
|
|
550
|
+
return { content: 'resumen subagente', reasoning: '', toolCalls: [] };
|
|
551
|
+
}
|
|
552
|
+
const done = messages.some((m) => m.role === 'tool' && m.name === 'task');
|
|
553
|
+
if (done) return { content: 'listo', reasoning: '', toolCalls: [] };
|
|
554
|
+
return {
|
|
555
|
+
content: '',
|
|
556
|
+
reasoning: '',
|
|
557
|
+
toolCalls: [
|
|
558
|
+
{ id: 't1', function: { name: 'task', arguments: JSON.stringify({ description: 'explorar', prompt: 'leé el proyecto' }) } },
|
|
559
|
+
],
|
|
560
|
+
};
|
|
561
|
+
},
|
|
562
|
+
};
|
|
563
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
|
|
564
|
+
const json = { 'Content-Type': 'application/json' };
|
|
565
|
+
try {
|
|
566
|
+
const stream = await (
|
|
567
|
+
await fetch(`${base}/api/chat`, {
|
|
568
|
+
method: 'POST',
|
|
569
|
+
headers: json,
|
|
570
|
+
body: JSON.stringify({ input: 'delegá', model: 'm' }),
|
|
571
|
+
})
|
|
572
|
+
).text();
|
|
573
|
+
assert.match(stream, /event: agent_start/);
|
|
574
|
+
assert.match(stream, /event: agent_end/);
|
|
575
|
+
|
|
576
|
+
const list = await (await fetch(`${base}/api/sessions`)).json();
|
|
577
|
+
const id = list.sessions[0].id;
|
|
578
|
+
const agents = await (await fetch(`${base}/api/sessions/${id}/agents`)).json();
|
|
579
|
+
assert.equal(agents.agents.length, 1);
|
|
580
|
+
assert.equal(agents.agents[0].status, 'done');
|
|
581
|
+
assert.match(agents.agents[0].summary, /resumen subagente/);
|
|
582
|
+
} finally {
|
|
583
|
+
await instance.close();
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { createTamnoraServer } from '../src/index.js';
|
|
6
|
+
import { setHome } from '../../core/src/index.js';
|
|
7
|
+
|
|
8
|
+
const fakeProvider = {
|
|
9
|
+
id: 'fake',
|
|
10
|
+
async streamChat(_config, { onEvent }) {
|
|
11
|
+
onEvent?.({ type: 'text', delta: 'hola', full: 'hola' });
|
|
12
|
+
return { content: 'hola', reasoning: '', toolCalls: [] };
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
async function startServer(overrides = {}) {
|
|
17
|
+
const cwd = overrides.cwd ?? (await mkdtemp(path.join(tmpdir(), 'tamnora-srv-')));
|
|
18
|
+
setHome(cwd);
|
|
19
|
+
const instance = await createTamnoraServer({
|
|
20
|
+
cwd,
|
|
21
|
+
dbPath: path.join(cwd, 'tamnora.db'),
|
|
22
|
+
loadMcp: false,
|
|
23
|
+
resolveProvider: () => ({ provider: fakeProvider, config: {} }),
|
|
24
|
+
...overrides,
|
|
25
|
+
});
|
|
26
|
+
const info = await instance.listen({ port: 0 });
|
|
27
|
+
return { instance, base: info.url };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const HAS_GIT = (() => {
|
|
31
|
+
try {
|
|
32
|
+
execFileSync('git', ['--version'], { stdio: 'ignore' });
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
})();
|
|
38
|
+
|
|
39
|
+
function gitInit(root) {
|
|
40
|
+
execFileSync('git', ['init', '-q'], { cwd: root });
|
|
41
|
+
execFileSync('git', ['config', 'user.email', 'test@tamnora.dev'], { cwd: root });
|
|
42
|
+
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: root });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function readSse(url, options, until, timeoutMs = 5000) {
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
48
|
+
const frames = [];
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(url, { ...options, signal: controller.signal });
|
|
51
|
+
const reader = res.body.getReader();
|
|
52
|
+
const decoder = new TextDecoder();
|
|
53
|
+
let buffer = '';
|
|
54
|
+
for (;;) {
|
|
55
|
+
const { value, done } = await reader.read();
|
|
56
|
+
if (done) break;
|
|
57
|
+
buffer += decoder.decode(value, { stream: true });
|
|
58
|
+
let index;
|
|
59
|
+
while ((index = buffer.indexOf('\n\n')) !== -1) {
|
|
60
|
+
const frame = buffer.slice(0, index);
|
|
61
|
+
buffer = buffer.slice(index + 2);
|
|
62
|
+
let event = null;
|
|
63
|
+
let data = '';
|
|
64
|
+
for (const line of frame.split('\n')) {
|
|
65
|
+
if (line.startsWith('event:')) event = line.slice(6).trim();
|
|
66
|
+
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
|
67
|
+
}
|
|
68
|
+
if (!event) continue;
|
|
69
|
+
const parsed = data ? JSON.parse(data) : {};
|
|
70
|
+
frames.push({ event, data: parsed });
|
|
71
|
+
if (until(parsed, event)) return frames;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error?.name !== 'AbortError') throw error;
|
|
76
|
+
} finally {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
}
|
|
79
|
+
return frames;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function waitIdle(base, id, attempts = 60) {
|
|
83
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
84
|
+
const detail = await (await fetch(`${base}/api/sessions/${id}`)).json();
|
|
85
|
+
if (!detail.running) return true;
|
|
86
|
+
await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' }).catch(() => {});
|
|
87
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function readEvents(res, onFrame) {
|
|
93
|
+
const reader = res.body.getReader();
|
|
94
|
+
const decoder = new TextDecoder();
|
|
95
|
+
let buffer = '';
|
|
96
|
+
let done = false;
|
|
97
|
+
while (!done) {
|
|
98
|
+
const { value } = await reader.read();
|
|
99
|
+
if (!value) break;
|
|
100
|
+
buffer += decoder.decode(value, { stream: true });
|
|
101
|
+
if (onFrame) await onFrame(buffer);
|
|
102
|
+
if (buffer.includes('event: done')) done = true;
|
|
103
|
+
}
|
|
104
|
+
return buffer;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export { fakeProvider, startServer, HAS_GIT, gitInit, readSse, waitIdle, readEvents };
|