@danieltmn/tamnora 0.19.0 → 0.20.1
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 +38 -0
- package/README.md +6 -3
- package/harness/packages/cli/src/commands/tui.js +2 -2
- package/harness/packages/cli/src/tui/app.js +2 -1
- package/harness/packages/core/src/agent/compact.js +12 -2
- package/harness/packages/core/src/agent/modes.js +29 -0
- package/harness/packages/core/src/providers/openaiCompatible.js +30 -17
- package/harness/packages/core/src/providers/stream.js +81 -7
- package/harness/packages/core/src/tools/registry.js +21 -1
- package/harness/packages/core/test/compact.test.js +4 -2
- package/harness/packages/core/test/providers.test.js +29 -0
- package/harness/packages/core/test/stream.test.js +46 -0
- package/harness/packages/core/test/tools.test.js +30 -0
- package/harness/packages/server/public/app.js +376 -47
- package/harness/packages/server/public/index.html +17 -3
- package/harness/packages/server/public/styles.css +25 -0
- package/harness/packages/server/src/lib/advisor.js +14 -13
- package/harness/packages/server/src/lib/compact.js +26 -1
- package/harness/packages/server/src/lib/scheduler.js +1 -0
- package/harness/packages/server/src/lib/sessionView.js +1 -0
- package/harness/packages/server/src/lib/tasks.js +11 -1
- package/harness/packages/server/src/routes/chat.js +20 -26
- package/harness/packages/server/src/routes/sessions.js +20 -11
- package/harness/packages/server/test/advisor.test.js +17 -13
- package/harness/packages/server/test/server.test.js +154 -51
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
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