@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.
- package/CHANGELOG.md +28 -0
- package/README.md +3 -1
- package/SECURITY.md +1 -1
- package/harness/packages/cli/bin/tamnora.mjs +7 -1
- package/harness/packages/cli/src/commands/doctor.js +42 -4
- package/harness/packages/cli/src/commands/repl.js +1 -0
- package/harness/packages/cli/src/commands/run.js +1 -0
- package/harness/packages/cli/src/commands/tui.js +1 -0
- package/harness/packages/cli/src/lib/runtime.js +2 -0
- package/harness/packages/cli/src/tui/app.js +2 -0
- package/harness/packages/core/src/agent/runAgent.js +9 -5
- package/harness/packages/core/src/browser/index.js +27 -13
- 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 +70 -0
- package/harness/packages/core/src/redact.js +2 -0
- package/harness/packages/core/src/tools/executors.js +19 -24
- 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/tts/edge.js +34 -4
- package/harness/packages/core/src/tunnel.js +15 -5
- package/harness/packages/core/test/agent.test.js +18 -0
- package/harness/packages/core/test/servicios-vivos.test.js +71 -0
- package/harness/packages/core/test/tools.test.js +44 -1
- package/harness/packages/core/test/tts.test.js +81 -0
- package/harness/packages/server/bin/tamnora-server.mjs +7 -1
- package/harness/packages/server/public/app.js +347 -6757
- package/harness/packages/server/public/cards.js +470 -0
- package/harness/packages/server/public/chat.js +1343 -0
- package/harness/packages/server/public/chatFormat.js +156 -0
- package/harness/packages/server/public/chrome.js +813 -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/icons/logo_dark.png +0 -0
- package/harness/packages/server/public/icons/logo_light.png +0 -0
- package/harness/packages/server/public/index.html +40 -41
- package/harness/packages/server/public/sessions.js +274 -0
- package/harness/packages/server/public/settings.js +1080 -0
- package/harness/packages/server/public/styles.css +45 -52
- 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/api.js +102 -0
- package/harness/packages/server/src/lib/channels/telegram/commands.js +309 -0
- package/harness/packages/server/src/lib/channels/telegram/media.js +64 -0
- package/harness/packages/server/src/lib/channels/telegram/permissions.js +0 -0
- package/harness/packages/server/src/lib/channels/telegram/projects.js +102 -0
- package/harness/packages/server/src/lib/channels/telegram/run.js +206 -0
- package/harness/packages/server/src/lib/channels/telegram/text.js +57 -0
- package/harness/packages/server/src/lib/channels/telegram.js +163 -932
- package/harness/packages/server/src/lib/presence.js +12 -1
- package/harness/packages/server/src/lib/scheduler.js +12 -2
- package/harness/packages/server/src/lib/tasks.js +21 -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 +16 -5
- package/harness/packages/server/src/routes/preview.js +10 -2
- package/harness/packages/server/src/routes/providers.js +14 -9
- package/harness/packages/server/src/server.js +25 -0
- package/harness/packages/server/test/chat.test.js +586 -0
- package/harness/packages/server/test/chatFormat.test.js +119 -0
- package/harness/packages/server/test/consoleModules.test.js +301 -0
- package/harness/packages/server/test/consoleSmoke.test.js +34 -0
- package/harness/packages/server/test/helpers.js +121 -0
- package/harness/packages/server/test/ops.test.js +515 -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 +482 -0
- package/harness/scripts/canary.mjs +24 -0
- package/harness/tamnora.example.json +1 -1
- package/package.json +3 -2
- package/harness/packages/server/test/server.test.js +0 -2885
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, writeFile, readFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { startServer, readSse, waitIdle, readEvents } from './helpers.js';
|
|
7
|
+
|
|
8
|
+
test('streams a chat turn over SSE and persists the messages', async () => {
|
|
9
|
+
const { instance, base } = await startServer();
|
|
10
|
+
try {
|
|
11
|
+
const res = await fetch(`${base}/api/chat`, {
|
|
12
|
+
method: 'POST',
|
|
13
|
+
headers: { 'Content-Type': 'application/json' },
|
|
14
|
+
body: JSON.stringify({ input: 'hola', model: 'm' }),
|
|
15
|
+
});
|
|
16
|
+
assert.equal(res.status, 200);
|
|
17
|
+
assert.match(res.headers.get('content-type'), /text\/event-stream/);
|
|
18
|
+
const text = await res.text();
|
|
19
|
+
assert.match(text, /event: start/);
|
|
20
|
+
assert.match(text, /event: text/);
|
|
21
|
+
assert.match(text, /event: done/);
|
|
22
|
+
|
|
23
|
+
const list = await (await fetch(`${base}/api/sessions`)).json();
|
|
24
|
+
assert.equal(list.sessions.length, 1);
|
|
25
|
+
const detail = await (await fetch(`${base}/api/sessions/${list.sessions[0].id}`)).json();
|
|
26
|
+
const assistant = detail.messages.find((m) => m.role === 'assistant');
|
|
27
|
+
assert.equal(assistant.content, 'hola');
|
|
28
|
+
} finally {
|
|
29
|
+
await instance.close();
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('persists the run log and replays it on the stream endpoint', async () => {
|
|
34
|
+
const { instance, base } = await startServer();
|
|
35
|
+
const json = { 'Content-Type': 'application/json' };
|
|
36
|
+
try {
|
|
37
|
+
await (
|
|
38
|
+
await fetch(`${base}/api/chat`, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: json,
|
|
41
|
+
body: JSON.stringify({ input: 'hola', model: 'm' }),
|
|
42
|
+
})
|
|
43
|
+
).text();
|
|
44
|
+
const list = await (await fetch(`${base}/api/sessions`)).json();
|
|
45
|
+
const id = list.sessions[0].id;
|
|
46
|
+
|
|
47
|
+
const stream = await (await fetch(`${base}/api/sessions/${id}/stream?from=0`)).text();
|
|
48
|
+
assert.match(stream, /event: start/);
|
|
49
|
+
assert.match(stream, /event: text/);
|
|
50
|
+
assert.match(stream, /event: done/);
|
|
51
|
+
assert.match(stream, /event: idle/);
|
|
52
|
+
assert.match(stream, /^id: \d+$/m);
|
|
53
|
+
} finally {
|
|
54
|
+
await instance.close();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('keeps a run alive after the client disconnects and can stop it', async () => {
|
|
59
|
+
let release;
|
|
60
|
+
const gate = new Promise((resolve) => (release = resolve));
|
|
61
|
+
const slowProvider = {
|
|
62
|
+
id: 'slow',
|
|
63
|
+
async streamChat(_config, { onEvent, signal }) {
|
|
64
|
+
onEvent?.({ type: 'text', delta: '…', full: '…' });
|
|
65
|
+
await new Promise((resolve, reject) => {
|
|
66
|
+
if (signal?.aborted) return reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
|
|
67
|
+
signal?.addEventListener('abort', () =>
|
|
68
|
+
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))
|
|
69
|
+
);
|
|
70
|
+
gate.then(resolve);
|
|
71
|
+
});
|
|
72
|
+
return { content: 'fin', reasoning: '', toolCalls: [] };
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
const { instance, base } = await startServer({ resolveProvider: () => ({ provider: slowProvider, config: {} }) });
|
|
76
|
+
const json = { 'Content-Type': 'application/json' };
|
|
77
|
+
try {
|
|
78
|
+
const created = await (
|
|
79
|
+
await fetch(`${base}/api/sessions`, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: json,
|
|
82
|
+
body: JSON.stringify({ model: 'm' }),
|
|
83
|
+
})
|
|
84
|
+
).json();
|
|
85
|
+
const id = created.session.id;
|
|
86
|
+
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
const chat = fetch(`${base}/api/chat`, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: json,
|
|
91
|
+
body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
|
|
92
|
+
signal: controller.signal,
|
|
93
|
+
}).catch(() => null);
|
|
94
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
95
|
+
controller.abort();
|
|
96
|
+
await chat;
|
|
97
|
+
|
|
98
|
+
const during = await (await fetch(`${base}/api/sessions/${id}`)).json();
|
|
99
|
+
assert.equal(during.running, true);
|
|
100
|
+
|
|
101
|
+
const stopped = await (
|
|
102
|
+
await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST', headers: json })
|
|
103
|
+
).json();
|
|
104
|
+
assert.equal(stopped.stopped, true);
|
|
105
|
+
|
|
106
|
+
release();
|
|
107
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
108
|
+
const after = await (await fetch(`${base}/api/sessions/${id}`)).json();
|
|
109
|
+
assert.equal(after.running, false);
|
|
110
|
+
} finally {
|
|
111
|
+
release();
|
|
112
|
+
await instance.close();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('interrupt cancels the running turn and starts the new message right away', async () => {
|
|
117
|
+
const calls = { n: 0 };
|
|
118
|
+
let abortedFirst = false;
|
|
119
|
+
const provider = {
|
|
120
|
+
id: 'interrupt',
|
|
121
|
+
async streamChat(_config, { onEvent, signal }) {
|
|
122
|
+
calls.n += 1;
|
|
123
|
+
if (calls.n === 1) {
|
|
124
|
+
onEvent?.({ type: 'text', delta: 'trabajando', full: 'trabajando' });
|
|
125
|
+
await new Promise((resolve, reject) => {
|
|
126
|
+
const timer = setTimeout(resolve, 5000);
|
|
127
|
+
const onAbort = () => {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
abortedFirst = true;
|
|
130
|
+
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
|
|
131
|
+
};
|
|
132
|
+
if (signal?.aborted) return onAbort();
|
|
133
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
onEvent?.({ type: 'text', delta: 'listo', full: 'listo' });
|
|
137
|
+
return { content: 'listo', reasoning: '', toolCalls: [] };
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
const { instance, base } = await startServer({ resolveProvider: () => ({ provider, config: {} }) });
|
|
141
|
+
const json = { 'Content-Type': 'application/json' };
|
|
142
|
+
try {
|
|
143
|
+
const created = await (
|
|
144
|
+
await fetch(`${base}/api/sessions`, {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
headers: json,
|
|
147
|
+
body: JSON.stringify({ model: 'm' }),
|
|
148
|
+
})
|
|
149
|
+
).json();
|
|
150
|
+
const id = created.session.id;
|
|
151
|
+
|
|
152
|
+
const first = fetch(`${base}/api/chat`, {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: json,
|
|
155
|
+
body: JSON.stringify({ input: 'uno', model: 'm', sessionId: id }),
|
|
156
|
+
})
|
|
157
|
+
.then((res) => res.text())
|
|
158
|
+
.catch(() => null);
|
|
159
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
160
|
+
|
|
161
|
+
const second = await (
|
|
162
|
+
await fetch(`${base}/api/chat`, {
|
|
163
|
+
method: 'POST',
|
|
164
|
+
headers: json,
|
|
165
|
+
body: JSON.stringify({ input: 'dos', model: 'm', sessionId: id, interrupt: true }),
|
|
166
|
+
})
|
|
167
|
+
).text();
|
|
168
|
+
|
|
169
|
+
assert.match(second, /event: text/);
|
|
170
|
+
assert.match(second, /listo/);
|
|
171
|
+
assert.equal(abortedFirst, true);
|
|
172
|
+
await first;
|
|
173
|
+
|
|
174
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
175
|
+
const after = await (await fetch(`${base}/api/sessions/${id}`)).json();
|
|
176
|
+
assert.equal(after.running, false);
|
|
177
|
+
} finally {
|
|
178
|
+
await instance.close();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
/** Lee un SSE hasta que `until(data)` se cumpla, y devuelve lo acumulado. */
|
|
183
|
+
test('the global channel announces run_start and run_end to another client', async () => {
|
|
184
|
+
const { instance, base } = await startServer();
|
|
185
|
+
const json = { 'Content-Type': 'application/json' };
|
|
186
|
+
try {
|
|
187
|
+
const created = await (
|
|
188
|
+
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ model: 'm' }) })
|
|
189
|
+
).json();
|
|
190
|
+
const id = created.session.id;
|
|
191
|
+
|
|
192
|
+
// Cliente B: abre el canal global antes de que arranque nada.
|
|
193
|
+
const events = readSse(`${base}/api/events`, {}, (data, event) => event === 'run_end');
|
|
194
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
195
|
+
|
|
196
|
+
// Cliente A: pide una tarea.
|
|
197
|
+
await (
|
|
198
|
+
await fetch(`${base}/api/chat`, {
|
|
199
|
+
method: 'POST',
|
|
200
|
+
headers: json,
|
|
201
|
+
body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
|
|
202
|
+
})
|
|
203
|
+
).text();
|
|
204
|
+
|
|
205
|
+
const frames = await events;
|
|
206
|
+
const names = frames.map((frame) => frame.event);
|
|
207
|
+
assert.deepEqual(names[0], 'snapshot');
|
|
208
|
+
assert.ok(names.includes('run_start'), 'esperaba run_start');
|
|
209
|
+
assert.ok(names.includes('run_end'), 'esperaba run_end');
|
|
210
|
+
|
|
211
|
+
const start = frames.find((frame) => frame.event === 'run_start');
|
|
212
|
+
const end = frames.find((frame) => frame.event === 'run_end');
|
|
213
|
+
assert.equal(start.data.sessionId, id);
|
|
214
|
+
assert.equal(end.data.sessionId, id);
|
|
215
|
+
assert.ok(typeof end.data.durationMs === 'number');
|
|
216
|
+
|
|
217
|
+
// El canal global no debe filtrar el detalle del turno: eso va por el
|
|
218
|
+
// stream de la sesion, o seria duplicarlo.
|
|
219
|
+
for (const detail of ['start', 'user', 'text', 'tool_call', 'done']) {
|
|
220
|
+
assert.ok(!names.includes(detail), `el canal global no debe emitir ${detail}`);
|
|
221
|
+
}
|
|
222
|
+
} finally {
|
|
223
|
+
await instance.close();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('connecting to the global channel mid-run returns it in the snapshot', async () => {
|
|
228
|
+
let release;
|
|
229
|
+
const gate = new Promise((resolve) => (release = resolve));
|
|
230
|
+
const slowProvider = {
|
|
231
|
+
id: 'slow',
|
|
232
|
+
async streamChat() {
|
|
233
|
+
await gate;
|
|
234
|
+
return { content: 'fin', reasoning: '', toolCalls: [] };
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
const { instance, base } = await startServer({ resolveProvider: () => ({ provider: slowProvider, 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({ model: 'm' }) })
|
|
242
|
+
).json();
|
|
243
|
+
const id = created.session.id;
|
|
244
|
+
|
|
245
|
+
const chat = fetch(`${base}/api/chat`, {
|
|
246
|
+
method: 'POST',
|
|
247
|
+
headers: json,
|
|
248
|
+
body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
|
|
249
|
+
}).catch(() => null);
|
|
250
|
+
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
251
|
+
|
|
252
|
+
// Cliente nuevo: el snapshot tiene que informarle que hay un run vivo, y
|
|
253
|
+
// por eso el cliente se engancha sin que nadie le avise.
|
|
254
|
+
const frames = await readSse(`${base}/api/events`, {}, (data, event) => event === 'snapshot');
|
|
255
|
+
assert.equal(frames[0].event, 'snapshot');
|
|
256
|
+
assert.ok(frames[0].data.active.includes(id), 'el snapshot deberia listar la sesion activa');
|
|
257
|
+
|
|
258
|
+
release();
|
|
259
|
+
await chat;
|
|
260
|
+
} finally {
|
|
261
|
+
release();
|
|
262
|
+
await instance.close();
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('the session list reports which sessions are running', async () => {
|
|
267
|
+
let release;
|
|
268
|
+
const gate = new Promise((resolve) => (release = resolve));
|
|
269
|
+
const slowProvider = {
|
|
270
|
+
id: 'slow',
|
|
271
|
+
async streamChat() {
|
|
272
|
+
await gate;
|
|
273
|
+
return { content: 'fin', reasoning: '', toolCalls: [] };
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
const { instance, base } = await startServer({ resolveProvider: () => ({ provider: slowProvider, config: {} }) });
|
|
277
|
+
const json = { 'Content-Type': 'application/json' };
|
|
278
|
+
try {
|
|
279
|
+
const created = await (
|
|
280
|
+
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ model: 'm' }) })
|
|
281
|
+
).json();
|
|
282
|
+
const id = created.session.id;
|
|
283
|
+
|
|
284
|
+
const idle = await (await fetch(`${base}/api/sessions`)).json();
|
|
285
|
+
assert.equal(idle.sessions.find((session) => session.id === id).running, false);
|
|
286
|
+
|
|
287
|
+
const chat = fetch(`${base}/api/chat`, {
|
|
288
|
+
method: 'POST',
|
|
289
|
+
headers: json,
|
|
290
|
+
body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
|
|
291
|
+
}).catch(() => null);
|
|
292
|
+
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
293
|
+
|
|
294
|
+
const busy = await (await fetch(`${base}/api/sessions`)).json();
|
|
295
|
+
assert.equal(busy.sessions.find((session) => session.id === id).running, true);
|
|
296
|
+
|
|
297
|
+
release();
|
|
298
|
+
await chat;
|
|
299
|
+
} finally {
|
|
300
|
+
release();
|
|
301
|
+
await instance.close();
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('resuming a run from a cursor replays only what was missed', async () => {
|
|
306
|
+
const { instance, base } = await startServer();
|
|
307
|
+
const json = { 'Content-Type': 'application/json' };
|
|
308
|
+
try {
|
|
309
|
+
const created = await (
|
|
310
|
+
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ model: 'm' }) })
|
|
311
|
+
).json();
|
|
312
|
+
const id = created.session.id;
|
|
313
|
+
await (
|
|
314
|
+
await fetch(`${base}/api/chat`, {
|
|
315
|
+
method: 'POST',
|
|
316
|
+
headers: json,
|
|
317
|
+
body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
|
|
318
|
+
})
|
|
319
|
+
).text();
|
|
320
|
+
|
|
321
|
+
const full = await (await fetch(`${base}/api/sessions/${id}/stream?from=0`)).text();
|
|
322
|
+
const ids = [...full.matchAll(/^id: (\d+)$/gm)].map((match) => Number(match[1]));
|
|
323
|
+
assert.ok(ids.length > 2, 'esperaba varios eventos con id');
|
|
324
|
+
const last = ids[ids.length - 1];
|
|
325
|
+
|
|
326
|
+
// Cliente que se cortó justo antes del ultimo evento: al volver no debe
|
|
327
|
+
// recibir lo que ya tenia.
|
|
328
|
+
const tail = await (await fetch(`${base}/api/sessions/${id}/stream?from=${last - 1}`)).text();
|
|
329
|
+
const tailIds = [...tail.matchAll(/^id: (\d+)$/gm)].map((match) => Number(match[1]));
|
|
330
|
+
assert.deepEqual(tailIds, [last]);
|
|
331
|
+
|
|
332
|
+
// Y el log del run no secontamination con el canal global.
|
|
333
|
+
assert.ok(!/event: run_start/.test(full));
|
|
334
|
+
assert.ok(!/event: run_end/.test(full));
|
|
335
|
+
} finally {
|
|
336
|
+
await instance.close();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('a pending permission is visible to any client, not only the one streaming', async () => {
|
|
341
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-ask-'));
|
|
342
|
+
let calls = 0;
|
|
343
|
+
const provider = {
|
|
344
|
+
id: 'tooly',
|
|
345
|
+
async streamChat() {
|
|
346
|
+
calls += 1;
|
|
347
|
+
if (calls === 1) {
|
|
348
|
+
return {
|
|
349
|
+
content: '',
|
|
350
|
+
reasoning: '',
|
|
351
|
+
toolCalls: [{ id: 'call_1', function: { name: 'write', arguments: JSON.stringify({ path: 'nota.txt', content: 'hi' }) } }],
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
return { content: 'listo', reasoning: '', toolCalls: [] };
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
|
|
358
|
+
const json = { 'Content-Type': 'application/json' };
|
|
359
|
+
try {
|
|
360
|
+
const created = await (
|
|
361
|
+
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ model: 'm' }) })
|
|
362
|
+
).json();
|
|
363
|
+
const id = created.session.id;
|
|
364
|
+
|
|
365
|
+
// Cliente B: canal global abierto, sin ningun stream de la sesion.
|
|
366
|
+
const seen = [];
|
|
367
|
+
const watching = readSse(`${base}/api/events`, {}, (data, event) => event === 'permission');
|
|
368
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
369
|
+
|
|
370
|
+
// Cliente A: pide la tarea y se queda esperando el permiso.
|
|
371
|
+
const chat = fetch(`${base}/api/chat`, {
|
|
372
|
+
method: 'POST',
|
|
373
|
+
headers: json,
|
|
374
|
+
body: JSON.stringify({ input: 'crea nota.txt', model: 'm', sessionId: id }),
|
|
375
|
+
}).catch(() => null);
|
|
376
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
377
|
+
|
|
378
|
+
const frames = await watching;
|
|
379
|
+
const permission = frames.find((frame) => frame.event === 'permission');
|
|
380
|
+
assert.ok(permission, 'el canal global deberia avisar del permiso');
|
|
381
|
+
assert.equal(permission.data.sessionId, id);
|
|
382
|
+
assert.equal(permission.data.name, 'write');
|
|
383
|
+
seen.push(permission.data.askId);
|
|
384
|
+
|
|
385
|
+
// Y tambien se puede consultar, que es lo que cubre una recarga de pagina.
|
|
386
|
+
const list = await (await fetch(`${base}/api/permissions`)).json();
|
|
387
|
+
assert.equal(list.pending.length, 1);
|
|
388
|
+
assert.equal(list.pending[0].askId, permission.data.askId);
|
|
389
|
+
assert.equal(list.pending[0].sessionId, id);
|
|
390
|
+
assert.deepEqual(list.pending[0].args, { path: 'nota.txt', content: 'hi' });
|
|
391
|
+
|
|
392
|
+
const scoped = await (await fetch(`${base}/api/permissions?sessionId=${id}`)).json();
|
|
393
|
+
assert.equal(scoped.pending.length, 1);
|
|
394
|
+
const other = await (await fetch(`${base}/api/permissions?sessionId=otra`)).json();
|
|
395
|
+
assert.equal(other.pending.length, 0);
|
|
396
|
+
|
|
397
|
+
const detail = await (await fetch(`${base}/api/sessions/${id}`)).json();
|
|
398
|
+
assert.equal(detail.permissions.length, 1, 'el detalle debe traer los pendientes para reengancharse');
|
|
399
|
+
const listSessions = await (await fetch(`${base}/api/sessions`)).json();
|
|
400
|
+
assert.equal(listSessions.sessions.find((s) => s.id === id).awaiting, 1);
|
|
401
|
+
|
|
402
|
+
const decision = await (
|
|
403
|
+
await fetch(`${base}/api/permissions/${permission.data.askId}`, {
|
|
404
|
+
method: 'POST',
|
|
405
|
+
headers: json,
|
|
406
|
+
body: JSON.stringify({ allow: true }),
|
|
407
|
+
})
|
|
408
|
+
).json();
|
|
409
|
+
assert.equal(decision.ok, true);
|
|
410
|
+
|
|
411
|
+
const after = await (await fetch(`${base}/api/permissions`)).json();
|
|
412
|
+
assert.equal(after.pending.length, 0);
|
|
413
|
+
await chat;
|
|
414
|
+
} finally {
|
|
415
|
+
await instance.close();
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Espera a que la sesion se quede sin run.
|
|
421
|
+
*
|
|
422
|
+
* Hace falta antes de `instance.close()`: un run que sigue vivo emite `aborted`
|
|
423
|
+
* en otro tick y escribe sobre la base ya cerrada.
|
|
424
|
+
*/
|
|
425
|
+
test('/api/state carries liveness and pending permissions in one request', async () => {
|
|
426
|
+
let release;
|
|
427
|
+
const gate = new Promise((resolve) => (release = resolve));
|
|
428
|
+
let calls = 0;
|
|
429
|
+
const provider = {
|
|
430
|
+
id: 'tooly',
|
|
431
|
+
async streamChat() {
|
|
432
|
+
calls += 1;
|
|
433
|
+
if (calls === 1) {
|
|
434
|
+
return {
|
|
435
|
+
content: '',
|
|
436
|
+
reasoning: '',
|
|
437
|
+
toolCalls: [{ id: 'call_1', function: { name: 'write', arguments: JSON.stringify({ path: 'nota.txt', content: 'hi' }) } }],
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
await gate;
|
|
441
|
+
return { content: 'listo', reasoning: '', toolCalls: [] };
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
const { instance, base } = await startServer({ resolveProvider: () => ({ provider, config: {} }) });
|
|
445
|
+
const json = { 'Content-Type': 'application/json' };
|
|
446
|
+
try {
|
|
447
|
+
const created = await (
|
|
448
|
+
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ model: 'm' }) })
|
|
449
|
+
).json();
|
|
450
|
+
const id = created.session.id;
|
|
451
|
+
|
|
452
|
+
const idle = await (await fetch(`${base}/api/state`)).json();
|
|
453
|
+
assert.equal(idle.sessions.find((s) => s.id === id).running, false);
|
|
454
|
+
assert.equal(idle.sessions.find((s) => s.id === id).awaiting, 0);
|
|
455
|
+
assert.deepEqual(idle.permissions, []);
|
|
456
|
+
assert.equal(typeof idle.now, 'number');
|
|
457
|
+
// El snapshot no lleva mensajes: es lo que se puede pedir seguido.
|
|
458
|
+
assert.equal(idle.sessions[0].messages, undefined);
|
|
459
|
+
|
|
460
|
+
const chat = fetch(`${base}/api/chat`, {
|
|
461
|
+
method: 'POST',
|
|
462
|
+
headers: json,
|
|
463
|
+
body: JSON.stringify({ input: 'crea nota.txt', model: 'm', sessionId: id }),
|
|
464
|
+
}).catch(() => null);
|
|
465
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
466
|
+
|
|
467
|
+
// Un permiso creado despues aparece en el siguiente read: esto es lo que
|
|
468
|
+
// hace que la consola se recupere sin ningun SSE.
|
|
469
|
+
const busy = await (await fetch(`${base}/api/state`)).json();
|
|
470
|
+
const session = busy.sessions.find((s) => s.id === id);
|
|
471
|
+
assert.equal(session.running, true);
|
|
472
|
+
assert.equal(session.awaiting, 1);
|
|
473
|
+
assert.equal(busy.permissions.length, 1);
|
|
474
|
+
assert.equal(busy.permissions[0].name, 'write');
|
|
475
|
+
assert.deepEqual(busy.permissions[0].args, { path: 'nota.txt', content: 'hi' });
|
|
476
|
+
assert.equal(busy.permissions[0].sessionId, id);
|
|
477
|
+
|
|
478
|
+
const askId = busy.permissions[0].askId;
|
|
479
|
+
await fetch(`${base}/api/permissions/${askId}`, {
|
|
480
|
+
method: 'POST',
|
|
481
|
+
headers: json,
|
|
482
|
+
body: JSON.stringify({ allow: true }),
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const after = await (await fetch(`${base}/api/state`)).json();
|
|
486
|
+
assert.deepEqual(after.permissions, []);
|
|
487
|
+
assert.equal(after.sessions.find((s) => s.id === id).awaiting, 0);
|
|
488
|
+
|
|
489
|
+
release();
|
|
490
|
+
await chat;
|
|
491
|
+
await waitIdle(base, id);
|
|
492
|
+
} finally {
|
|
493
|
+
release();
|
|
494
|
+
await instance.close();
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test('a client with no streams at all can find and resolve a permission', async () => {
|
|
499
|
+
// El escenario que reporto el celular: sin stream de sesion y sin canal global.
|
|
500
|
+
let release;
|
|
501
|
+
const gate = new Promise((resolve) => (release = resolve));
|
|
502
|
+
let calls = 0;
|
|
503
|
+
const provider = {
|
|
504
|
+
id: 'tooly',
|
|
505
|
+
async streamChat() {
|
|
506
|
+
calls += 1;
|
|
507
|
+
if (calls === 1) {
|
|
508
|
+
return {
|
|
509
|
+
content: '',
|
|
510
|
+
reasoning: '',
|
|
511
|
+
toolCalls: [{ id: 'call_1', function: { name: 'bash', arguments: JSON.stringify({ command: 'ls' }) } }],
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
await gate;
|
|
515
|
+
return { content: 'listo', reasoning: '', toolCalls: [] };
|
|
516
|
+
},
|
|
517
|
+
};
|
|
518
|
+
const { instance, base } = await startServer({ resolveProvider: () => ({ provider, config: {} }) });
|
|
519
|
+
const json = { 'Content-Type': 'application/json' };
|
|
520
|
+
try {
|
|
521
|
+
const created = await (
|
|
522
|
+
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ model: 'm' }) })
|
|
523
|
+
).json();
|
|
524
|
+
const id = created.session.id;
|
|
525
|
+
|
|
526
|
+
const chat = fetch(`${base}/api/chat`, {
|
|
527
|
+
method: 'POST',
|
|
528
|
+
headers: json,
|
|
529
|
+
body: JSON.stringify({ input: 'lista los archivos', model: 'm', sessionId: id }),
|
|
530
|
+
}).catch(() => null);
|
|
531
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
532
|
+
|
|
533
|
+
// Solo requests cortos: es lo que sobrevive en un enlace movil.
|
|
534
|
+
const state = await (await fetch(`${base}/api/state`)).json();
|
|
535
|
+
assert.equal(state.permissions.length, 1);
|
|
536
|
+
assert.equal(state.permissions[0].name, 'bash');
|
|
537
|
+
|
|
538
|
+
const decision = await (
|
|
539
|
+
await fetch(`${base}/api/permissions/${state.permissions[0].askId}`, {
|
|
540
|
+
method: 'POST',
|
|
541
|
+
headers: json,
|
|
542
|
+
body: JSON.stringify({ allow: true, scope: 'once' }),
|
|
543
|
+
})
|
|
544
|
+
).json();
|
|
545
|
+
assert.equal(decision.ok, true);
|
|
546
|
+
assert.deepEqual((await (await fetch(`${base}/api/state`)).json()).permissions, []);
|
|
547
|
+
|
|
548
|
+
release();
|
|
549
|
+
await chat;
|
|
550
|
+
await waitIdle(base, id);
|
|
551
|
+
} finally {
|
|
552
|
+
release();
|
|
553
|
+
await instance.close();
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test('approving clears the permission instead of bringing it back', async () => {
|
|
558
|
+
// El sintoma era "le doy que si y me vuelve a pedir lo mismo". Dos causas
|
|
559
|
+
// distintas y esta las separa: el server vacia el estado al resolver, y una
|
|
560
|
+
// peticion nueva del agente es un `askId` nuevo (no el mismo permiso
|
|
561
|
+
// revivido).
|
|
562
|
+
let round = 0;
|
|
563
|
+
const provider = {
|
|
564
|
+
id: 'loopish',
|
|
565
|
+
async streamChat() {
|
|
566
|
+
round += 1;
|
|
567
|
+
if (round <= 3) {
|
|
568
|
+
return {
|
|
569
|
+
content: '',
|
|
570
|
+
reasoning: '',
|
|
571
|
+
toolCalls: [{ id: `c${round}`, function: { name: 'bash', arguments: JSON.stringify({ command: `echo paso-${round}` }) } }],
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
return { content: 'listo', reasoning: '', toolCalls: [] };
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
const { instance, base } = await startServer({ resolveProvider: () => ({ provider, config: {} }) });
|
|
578
|
+
const json = { 'Content-Type': 'application/json' };
|
|
579
|
+
try {
|
|
580
|
+
const created = await (
|
|
581
|
+
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ model: 'm' }) })
|
|
582
|
+
).json();
|
|
583
|
+
const id = created.session.id;
|
|
584
|
+
const chat = fetch(`${base}/api/chat`, {
|
|
585
|
+
method: 'POST',
|
|
586
|
+
headers: json,
|
|
587
|
+
body: JSON.stringify({ input: 'hacé tres pasos', model: 'm', sessionId: id }),
|
|
588
|
+
}).catch(() => null);
|
|
589
|
+
|
|
590
|
+
const approved = [];
|
|
591
|
+
for (let i = 0; i < 40 && approved.length < 3; i += 1) {
|
|
592
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
593
|
+
const state = await (await fetch(`${base}/api/state`)).json();
|
|
594
|
+
for (const ask of state.permissions) {
|
|
595
|
+
const decision = await fetch(`${base}/api/permissions/${ask.askId}`, {
|
|
596
|
+
method: 'POST',
|
|
597
|
+
headers: json,
|
|
598
|
+
body: JSON.stringify({ allow: true }),
|
|
599
|
+
});
|
|
600
|
+
assert.equal(decision.status, 200);
|
|
601
|
+
approved.push(ask.askId);
|
|
602
|
+
// Inmediatamente despues de resolver, ese permiso no queda pendiente: si
|
|
603
|
+
// volviera, el cliente lo re-pintaria en bucle. (Puede haber otro askId
|
|
604
|
+
// nuevo del siguiente paso, pero el resuelto no puede reaparecer.)
|
|
605
|
+
const after = await (await fetch(`${base}/api/state`)).json();
|
|
606
|
+
assert.ok(
|
|
607
|
+
!after.permissions.some((p) => p.askId === ask.askId),
|
|
608
|
+
'el permiso resuelto no debe reaparecer'
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
// Tres peticiones del agente -> tres askId distintos, ninguno revivido.
|
|
613
|
+
assert.equal(approved.length, 3);
|
|
614
|
+
assert.equal(new Set(approved).size, 3);
|
|
615
|
+
|
|
616
|
+
await waitIdle(base, id);
|
|
617
|
+
await chat;
|
|
618
|
+
} finally {
|
|
619
|
+
await instance.close();
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
test('resolving a permission that no longer exists reports 404', async () => {
|
|
624
|
+
const { instance, base } = await startServer();
|
|
625
|
+
const json = { 'Content-Type': 'application/json' };
|
|
626
|
+
try {
|
|
627
|
+
// Un askId inventado: la tarjeta era vieja. Con esto el cliente puede
|
|
628
|
+
// decir "ya no aplica" en vez de dejar el permiso esperando.
|
|
629
|
+
const res = await fetch(`${base}/api/permissions/inexistente`, {
|
|
630
|
+
method: 'POST',
|
|
631
|
+
headers: json,
|
|
632
|
+
body: JSON.stringify({ allow: true }),
|
|
633
|
+
});
|
|
634
|
+
assert.equal(res.status, 404);
|
|
635
|
+
} finally {
|
|
636
|
+
await instance.close();
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test('done reports max_rounds and the tool count when the model never answers', async () => {
|
|
641
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-loop-'));
|
|
642
|
+
await writeFile(path.join(cwd, 'a.txt'), 'hola');
|
|
643
|
+
let seq = 0;
|
|
644
|
+
const provider = {
|
|
645
|
+
id: 'loop',
|
|
646
|
+
async streamChat() {
|
|
647
|
+
seq += 1;
|
|
648
|
+
return {
|
|
649
|
+
content: '',
|
|
650
|
+
reasoning: '',
|
|
651
|
+
toolCalls: [
|
|
652
|
+
{ id: `call_${seq}`, function: { name: 'read', arguments: JSON.stringify({ path: 'a.txt' }) } },
|
|
653
|
+
],
|
|
654
|
+
};
|
|
655
|
+
},
|
|
656
|
+
};
|
|
657
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
|
|
658
|
+
try {
|
|
659
|
+
const sse = await (
|
|
660
|
+
await fetch(`${base}/api/chat`, {
|
|
661
|
+
method: 'POST',
|
|
662
|
+
headers: { 'Content-Type': 'application/json' },
|
|
663
|
+
body: JSON.stringify({ input: 'leé', model: 'm', maxRounds: 2 }),
|
|
664
|
+
})
|
|
665
|
+
).text();
|
|
666
|
+
assert.match(sse, /event: done/);
|
|
667
|
+
assert.match(sse, /"stopReason":"max_rounds"/);
|
|
668
|
+
const match = sse.match(/"toolCalls":(\d+)/);
|
|
669
|
+
assert.ok(match && Number(match[1]) >= 2, 'el done debe reportar las tools usadas');
|
|
670
|
+
assert.match(sse, /"durationMs":\d+/);
|
|
671
|
+
} finally {
|
|
672
|
+
await instance.close();
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test('auto-compact resume el historial cuando el contexto se pasa', async () => {
|
|
677
|
+
let captured = '';
|
|
678
|
+
const provider = {
|
|
679
|
+
id: 'capture',
|
|
680
|
+
async streamChat(_config, { messages }) {
|
|
681
|
+
captured = (messages ?? [])
|
|
682
|
+
.map((m) => (typeof m.content === 'string' ? m.content : ''))
|
|
683
|
+
.join('\n');
|
|
684
|
+
return { content: 'RESUMEN OK', reasoning: '', toolCalls: [] };
|
|
685
|
+
},
|
|
686
|
+
};
|
|
687
|
+
const json = { 'Content-Type': 'application/json' };
|
|
688
|
+
const { instance, base } = await startServer({ resolveProvider: () => ({ provider, config: {} }) });
|
|
689
|
+
try {
|
|
690
|
+
const created = await (
|
|
691
|
+
await fetch(`${base}/api/sessions`, {
|
|
692
|
+
method: 'POST',
|
|
693
|
+
headers: json,
|
|
694
|
+
body: JSON.stringify({ title: 't', model: 'm' }),
|
|
695
|
+
})
|
|
696
|
+
).json();
|
|
697
|
+
const id = created.session.id;
|
|
698
|
+
const big = 'x'.repeat(4000);
|
|
699
|
+
instance.store.addMessages(
|
|
700
|
+
id,
|
|
701
|
+
Array.from({ length: 12 }, (_, i) => ({ role: i % 2 ? 'assistant' : 'user', content: `m${i} ${big}` }))
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
await fetch(`${base}/api/chat`, {
|
|
705
|
+
method: 'POST',
|
|
706
|
+
headers: json,
|
|
707
|
+
body: JSON.stringify({ sessionId: id, model: 'm', input: 'hola', contextTokens: 1000 }),
|
|
708
|
+
}).then((r) => r.text());
|
|
709
|
+
|
|
710
|
+
const meta = instance.store.getSession(id).meta;
|
|
711
|
+
assert.ok(meta.summaryCursor, 'debe guardar el cursor de compactación');
|
|
712
|
+
assert.match(captured, /Resumen de la conversación anterior/);
|
|
713
|
+
} finally {
|
|
714
|
+
await instance.close();
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
test('rejects chat without input or model', async () => {
|
|
719
|
+
const { instance, base } = await startServer();
|
|
720
|
+
try {
|
|
721
|
+
const noInput = await fetch(`${base}/api/chat`, {
|
|
722
|
+
method: 'POST',
|
|
723
|
+
headers: { 'Content-Type': 'application/json' },
|
|
724
|
+
body: JSON.stringify({ model: 'm' }),
|
|
725
|
+
});
|
|
726
|
+
assert.equal(noInput.status, 400);
|
|
727
|
+
|
|
728
|
+
const noModel = await fetch(`${base}/api/chat`, {
|
|
729
|
+
method: 'POST',
|
|
730
|
+
headers: { 'Content-Type': 'application/json' },
|
|
731
|
+
body: JSON.stringify({ input: 'hola' }),
|
|
732
|
+
});
|
|
733
|
+
assert.equal(noModel.status, 400);
|
|
734
|
+
} finally {
|
|
735
|
+
await instance.close();
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test('forwards attached images as multimodal content', async () => {
|
|
740
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-img-'));
|
|
741
|
+
let captured = null;
|
|
742
|
+
const provider = {
|
|
743
|
+
id: 'vision',
|
|
744
|
+
async streamChat(_config, opts) {
|
|
745
|
+
captured = opts.messages.find((m) => m.role === 'user');
|
|
746
|
+
return { content: 'ok', reasoning: '', toolCalls: [] };
|
|
747
|
+
},
|
|
748
|
+
};
|
|
749
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
|
|
750
|
+
try {
|
|
751
|
+
const image = 'data:image/png;base64,iVBORw0KGgo=';
|
|
752
|
+
const res = await fetch(`${base}/api/chat`, {
|
|
753
|
+
method: 'POST',
|
|
754
|
+
headers: { 'Content-Type': 'application/json' },
|
|
755
|
+
body: JSON.stringify({ input: 'mirá', model: 'm', images: [image] }),
|
|
756
|
+
});
|
|
757
|
+
await res.text();
|
|
758
|
+
assert.ok(Array.isArray(captured.content));
|
|
759
|
+
assert.deepEqual(captured.content[0], { type: 'text', text: 'mirá' });
|
|
760
|
+
assert.equal(captured.content[1].type, 'image_url');
|
|
761
|
+
assert.equal(captured.content[1].image_url.url, image);
|
|
762
|
+
} finally {
|
|
763
|
+
await instance.close();
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
test('scope=session approves later asks without prompting again', async () => {
|
|
768
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-scope-'));
|
|
769
|
+
let round = 0;
|
|
770
|
+
const provider = {
|
|
771
|
+
id: 'twice',
|
|
772
|
+
async streamChat() {
|
|
773
|
+
round += 1;
|
|
774
|
+
if (round <= 2) {
|
|
775
|
+
return {
|
|
776
|
+
content: '',
|
|
777
|
+
reasoning: '',
|
|
778
|
+
toolCalls: [
|
|
779
|
+
{
|
|
780
|
+
id: `call_${round}`,
|
|
781
|
+
function: { name: 'write', arguments: JSON.stringify({ path: `f${round}.txt`, content: 'x' }) },
|
|
782
|
+
},
|
|
783
|
+
],
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
return { content: 'fin', reasoning: '', toolCalls: [] };
|
|
787
|
+
},
|
|
788
|
+
};
|
|
789
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
|
|
790
|
+
try {
|
|
791
|
+
const res = await fetch(`${base}/api/chat`, {
|
|
792
|
+
method: 'POST',
|
|
793
|
+
headers: { 'Content-Type': 'application/json' },
|
|
794
|
+
body: JSON.stringify({ input: 'creá dos archivos', model: 'm' }),
|
|
795
|
+
});
|
|
796
|
+
let decisions = 0;
|
|
797
|
+
const text = await readEvents(res, async (buffer) => {
|
|
798
|
+
if (decisions === 0 && buffer.includes('event: permission')) {
|
|
799
|
+
decisions += 1;
|
|
800
|
+
const askId = buffer.match(/"askId":"([0-9a-f-]+)"/)[1];
|
|
801
|
+
await fetch(`${base}/api/permissions/${askId}`, {
|
|
802
|
+
method: 'POST',
|
|
803
|
+
headers: { 'Content-Type': 'application/json' },
|
|
804
|
+
body: JSON.stringify({ allow: true, scope: 'session' }),
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
assert.equal(decisions, 1, 'solo pidió permiso una vez');
|
|
809
|
+
assert.equal((text.match(/event: permission\n/g) ?? []).length, 1);
|
|
810
|
+
assert.equal(await readFile(path.join(cwd, 'f1.txt'), 'utf8'), 'x');
|
|
811
|
+
assert.equal(await readFile(path.join(cwd, 'f2.txt'), 'utf8'), 'x');
|
|
812
|
+
} finally {
|
|
813
|
+
await instance.close();
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
|