@danieltmn/tamnora 0.19.0 → 0.20.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.
@@ -147,12 +147,64 @@ test('lists and deletes sessions grouped by project (cwd)', async () => {
147
147
  assert.equal(list.sessions.length, 1);
148
148
  assert.equal(list.sessions[0].cwd, dirB);
149
149
 
150
- const missing = await fetch(`${base}/api/projects`, { method: 'DELETE' });
151
- assert.equal(missing.status, 400);
152
- } finally {
153
- await instance.close();
154
- }
155
- });
150
+ const missing = await fetch(`${base}/api/projects`, { method: 'DELETE' });
151
+ assert.equal(missing.status, 400);
152
+ } finally {
153
+ await instance.close();
154
+ }
155
+ });
156
+
157
+ test('cada sesión recuerda su propio tipo de agente (proveedor/modelo/modo)', async () => {
158
+ const calls = [];
159
+ const provider = {
160
+ id: 'recorder',
161
+ async streamChat(_config, { model }) {
162
+ calls.push(model);
163
+ return { content: 'ok', reasoning: '', toolCalls: [] };
164
+ },
165
+ };
166
+ const { instance, base } = await startServer({ resolveProvider: () => ({ provider, config: {} }) });
167
+ const json = { 'Content-Type': 'application/json' };
168
+ const create = (body) =>
169
+ fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify(body) }).then((r) => r.json());
170
+ const chat = (sessionId, body = {}) =>
171
+ fetch(`${base}/api/chat`, {
172
+ method: 'POST',
173
+ headers: json,
174
+ body: JSON.stringify({ input: 'hola', sessionId, ...body }),
175
+ }).then((r) => r.text());
176
+ try {
177
+ const a = await create({ title: 'A', provider: 'openrouter', model: 'modelo-a', mode: 'plan' });
178
+ const b = await create({ title: 'B', provider: 'openai', model: 'modelo-b' });
179
+ assert.equal(a.session.mode, 'plan');
180
+
181
+ // Aunque el cuerpo no manda modelo, cada sesión corre con el suyo.
182
+ await chat(a.session.id);
183
+ await chat(b.session.id);
184
+ assert.deepEqual(calls, ['modelo-a', 'modelo-b']);
185
+
186
+ // El tipo de agente se puede fijar sin correr (PUT) y se persiste.
187
+ const updated = await (
188
+ await fetch(`${base}/api/sessions/${b.session.id}`, {
189
+ method: 'PUT',
190
+ headers: json,
191
+ body: JSON.stringify({ provider: 'openrouter', model: 'modelo-c', mode: 'agent' }),
192
+ })
193
+ ).json();
194
+ assert.equal(updated.session.model, 'modelo-c');
195
+ assert.equal(updated.session.mode, 'agent');
196
+
197
+ await chat(b.session.id);
198
+ assert.equal(calls.at(-1), 'modelo-c');
199
+
200
+ // El default global no se toca por cambiar el de una sesión.
201
+ const settings = await (await fetch(`${base}/api/settings`)).json();
202
+ assert.notEqual(settings.settings.model, 'modelo-c');
203
+ } finally {
204
+ await instance.close();
205
+ }
206
+ });
207
+
156
208
 
157
209
  test('streams a chat turn over SSE and persists the messages', async () => {
158
210
  const { instance, base } = await startServer();
@@ -2372,41 +2424,45 @@ test('stores and injects the session goal', async () => {
2372
2424
  }
2373
2425
  });
2374
2426
 
2375
- test('compact summarizes the session and clears its messages', async () => {
2376
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-compact-'));
2377
- let compacting = false;
2378
- const provider = {
2379
- id: 'sum',
2380
- async streamChat(_config, opts) {
2381
- const system = opts.messages.find((m) => m.role === 'system')?.content ?? '';
2382
- if (system.includes('Resumí la conversación')) compacting = true;
2383
- return { content: compacting ? 'RESUMEN FINAL' : 'hola', reasoning: '', toolCalls: [] };
2384
- },
2385
- };
2386
- const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
2387
- const json = { 'Content-Type': 'application/json' };
2388
- try {
2389
- const created = await (
2390
- await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 'C' }) })
2391
- ).json();
2392
- const id = created.session.id;
2393
- await (await fetch(`${base}/api/chat`, {
2394
- method: 'POST',
2395
- headers: json,
2396
- body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
2397
- })).text();
2398
-
2399
- const compact = await (
2400
- await fetch(`${base}/api/sessions/${id}/compact`, { method: 'POST', headers: json })
2401
- ).json();
2402
- assert.equal(compact.summary, 'RESUMEN FINAL');
2403
-
2404
- const detail = await (await fetch(`${base}/api/sessions/${id}`)).json();
2405
- assert.deepEqual(detail.messages, []);
2406
- assert.equal(detail.session.hasSummary, true);
2407
- } finally {
2408
- await instance.close();
2409
- }
2427
+ test('compact summarizes and keeps the history with a marker', async () => {
2428
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-compact-'));
2429
+ let compacting = false;
2430
+ const provider = {
2431
+ id: 'sum',
2432
+ async streamChat(_config, opts) {
2433
+ const system = opts.messages.find((m) => m.role === 'system')?.content ?? '';
2434
+ if (system.includes('Resumí la conversación')) compacting = true;
2435
+ return { content: compacting ? 'RESUMEN FINAL' : 'hola', reasoning: '', toolCalls: [] };
2436
+ },
2437
+ };
2438
+ const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
2439
+ const json = { 'Content-Type': 'application/json' };
2440
+ try {
2441
+ const created = await (
2442
+ await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 'C' }) })
2443
+ ).json();
2444
+ const id = created.session.id;
2445
+ await (await fetch(`${base}/api/chat`, {
2446
+ method: 'POST',
2447
+ headers: json,
2448
+ body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
2449
+ })).text();
2450
+
2451
+ const compact = await (
2452
+ await fetch(`${base}/api/sessions/${id}/compact`, { method: 'POST', headers: json })
2453
+ ).json();
2454
+ assert.equal(compact.summary, 'RESUMEN FINAL');
2455
+ assert.ok(compact.cursor, 'debe devolver el cursor de compactación');
2456
+
2457
+ const detail = await (await fetch(`${base}/api/sessions/${id}`)).json();
2458
+ const marker = detail.messages.find((m) => m.name === 'compact');
2459
+ assert.ok(marker, 'debe dejar una marca de compactación');
2460
+ assert.equal(marker.content, 'RESUMEN FINAL');
2461
+ assert.ok(detail.messages.length > 1, 'no borra el historial');
2462
+ assert.equal(detail.session.hasSummary, true);
2463
+ } finally {
2464
+ await instance.close();
2465
+ }
2410
2466
  });
2411
2467
 
2412
2468
  test('the summary endpoint returns a summary without clearing the messages', async () => {
@@ -2447,10 +2503,10 @@ test('exposes modes and persists the active mode', async () => {
2447
2503
  const { instance, base } = await startServer();
2448
2504
  const json = { 'Content-Type': 'application/json' };
2449
2505
  try {
2450
- const modes = await (await fetch(`${base}/api/modes`)).json();
2451
- assert.deepEqual(
2452
- modes.modes.map((m) => m.id),
2453
- ['agent', 'plan']
2506
+ const modes = await (await fetch(`${base}/api/modes`)).json();
2507
+ assert.deepEqual(
2508
+ modes.modes.map((m) => m.id),
2509
+ ['agent', 'plan', 'voice']
2454
2510
  );
2455
2511
  await fetch(`${base}/api/settings`, { method: 'PUT', headers: json, body: JSON.stringify({ mode: 'plan' }) });
2456
2512
  const settings = await (await fetch(`${base}/api/settings`)).json();
@@ -2505,12 +2561,59 @@ test('plan mode exposes only read-only tools and adds guidance', async () => {
2505
2561
  'speak',
2506
2562
  'task',
2507
2563
  ]);
2508
- assert.match(captured.system, /modo PLAN/);
2509
- } finally {
2510
- await instance.close();
2511
- }
2512
- });
2513
-
2564
+ assert.match(captured.system, /modo PLAN/);
2565
+ } finally {
2566
+ await instance.close();
2567
+ }
2568
+ });
2569
+
2570
+ test('voice mode expone write/edit pero solo para .md y .json', async () => {
2571
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-voice-'));
2572
+ let seenTools = null;
2573
+ let seenSystem = null;
2574
+ let toolResult = null;
2575
+ let round = 0;
2576
+ const provider = {
2577
+ id: 'voice',
2578
+ async streamChat(_config, opts) {
2579
+ round += 1;
2580
+ seenTools = (opts.tools ?? []).map((t) => t.function.name).sort();
2581
+ seenSystem = opts.messages.find((m) => m.role === 'system').content;
2582
+ if (round === 1) {
2583
+ return {
2584
+ content: '',
2585
+ reasoning: '',
2586
+ toolCalls: [
2587
+ {
2588
+ id: 'w1',
2589
+ type: 'function',
2590
+ function: { name: 'write', arguments: JSON.stringify({ path: 'nota.txt', content: 'x' }) },
2591
+ },
2592
+ ],
2593
+ };
2594
+ }
2595
+ toolResult = opts.messages.find((m) => m.role === 'tool')?.content ?? '';
2596
+ return { content: 'ok', reasoning: '', toolCalls: [] };
2597
+ },
2598
+ };
2599
+ const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
2600
+ try {
2601
+ await (
2602
+ await fetch(`${base}/api/chat`, {
2603
+ method: 'POST',
2604
+ headers: { 'Content-Type': 'application/json' },
2605
+ body: JSON.stringify({ input: 'tomá nota', model: 'm', mode: 'voice' }),
2606
+ })
2607
+ ).text();
2608
+ assert.ok(seenTools.includes('write'));
2609
+ assert.ok(seenTools.includes('edit'));
2610
+ assert.match(seenSystem, /modo VOZ/);
2611
+ assert.match(toolResult, /solo se permite crear o editar/);
2612
+ } finally {
2613
+ await instance.close();
2614
+ }
2615
+ });
2616
+
2514
2617
  test('the agent can delegate to a subagent with the task tool', async () => {
2515
2618
  const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-task-'));
2516
2619
  const provider = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",