@danieltmn/tamnora 0.5.0 → 0.6.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.
@@ -0,0 +1,126 @@
1
+ // Tool "task": el agente principal delega una subtarea a un subagente con
2
+ // contexto propio y un toolset acotado (solo lectura por defecto). Los eventos
3
+ // del subagente se emiten con `agentId` para que la consola los anide, y su
4
+ // resumen final se devuelve como resultado de la tool.
5
+
6
+ import { runAgent, createTools, resolveMode, summarizeToolCall, previewToolResult } from './core.js';
7
+
8
+ const SUBAGENT_PROMPT = [
9
+ 'Sos un subagente de Tamnora. Ejecutá de forma autónoma la tarea encomendada',
10
+ 'usando las herramientas disponibles y devolvé un resumen conciso y concreto de',
11
+ 'lo que encontraste o hiciste (archivos, hallazgos, decisiones). No pidas',
12
+ 'confirmaciones: si algo no es posible, explicalo en el resumen.',
13
+ ].join(' ');
14
+
15
+ function definition(name, description, parameters, required = []) {
16
+ return {
17
+ type: 'function',
18
+ function: { name, description, parameters: { type: 'object', properties: parameters, required } },
19
+ };
20
+ }
21
+
22
+ /**
23
+ * Crea la tool `task` para una sesión/run y la función `execute`.
24
+ * `emit(type, data, agentId)` persiste y difunde el evento (via RunManager).
25
+ */
26
+ export function createSubagentTool(ctx, options) {
27
+ const {
28
+ sessionId,
29
+ cwd,
30
+ providerId,
31
+ model,
32
+ permissions = {},
33
+ mcp = null,
34
+ signal,
35
+ exclude = [],
36
+ emit,
37
+ } = options;
38
+
39
+ const definitions = [
40
+ definition(
41
+ 'task',
42
+ 'Delega una subtarea a un subagente con su propio contexto (solo lectura por defecto). Devolvé un prompt claro y autocontenido; el subagente responde con un resumen.',
43
+ {
44
+ description: { type: 'string', description: 'Título corto de la subtarea' },
45
+ prompt: { type: 'string', description: 'Instrucción completa y autocontenida para el subagente' },
46
+ mode: { type: 'string', enum: ['plan', 'agent'], description: 'plan (solo lectura, default) o agent' },
47
+ tools: { type: 'array', items: { type: 'string' }, description: 'Limitar las tools del subagente' },
48
+ },
49
+ ['prompt']
50
+ ),
51
+ ];
52
+
53
+ const handles = (name) => name === 'task';
54
+
55
+ async function execute(name, args = {}) {
56
+ if (!handles(name)) return `Herramienta desconocida: ${name}`;
57
+ const prompt = String(args.prompt ?? args.task ?? '').trim();
58
+ const description = String(args.description ?? prompt).slice(0, 80).trim() || 'subtarea';
59
+ if (!prompt) return 'La subtarea necesita "prompt".';
60
+
61
+ const agentId = ctx.store.createAgent({ sessionId, kind: 'task', title: description, model });
62
+ const mode = resolveMode(args.mode ?? 'plan');
63
+ emit('agent_start', { agentId, description, mode: mode.id, model }, agentId);
64
+
65
+ const enabled =
66
+ Array.isArray(args.tools) && args.tools.length
67
+ ? args.tools.filter((tool) => !exclude.includes(tool))
68
+ : mode.tools ?? undefined;
69
+ const local = createTools({ root: cwd, enabled, permissions, yes: false, mcp });
70
+ const basePrompt = await ctx.systemPromptFor(cwd);
71
+ const messages = [
72
+ { role: 'system', content: `${basePrompt}\n\n${SUBAGENT_PROMPT}\n\nTarea: ${description}` },
73
+ { role: 'user', content: prompt },
74
+ ];
75
+ const { provider, config } = ctx.resolveProvider({
76
+ ...ctx.providerSettings(providerId),
77
+ apiKey: ctx.apiKeyFor(providerId),
78
+ });
79
+
80
+ try {
81
+ const result = await runAgent({
82
+ provider,
83
+ config,
84
+ model,
85
+ messages,
86
+ tools: local.definitions,
87
+ execute: local.execute,
88
+ signal,
89
+ maxRounds: ctx.config.agents?.maxRounds ?? 6,
90
+ onEvent(event) {
91
+ if (event.type === 'text') emit('text', { full: event.full, agentId }, agentId);
92
+ else if (event.type === 'reasoning') emit('reasoning', { full: event.full, agentId }, agentId);
93
+ else if (event.type === 'tool_call') {
94
+ emit(
95
+ 'tool_call',
96
+ { id: event.id, name: event.name, args: event.args, summary: summarizeToolCall(event.name, event.args), agentId },
97
+ agentId
98
+ );
99
+ } else if (event.type === 'tool_result') {
100
+ const { preview, truncated } = previewToolResult(event.name, event.result);
101
+ const failed = /^Error|^Permiso denegado/i.test(String(event.result));
102
+ emit('tool_result', { id: event.id, name: event.name, preview, truncated, failed, agentId }, agentId);
103
+ }
104
+ },
105
+ });
106
+
107
+ ctx.store.addMessages(
108
+ sessionId,
109
+ result.messages.slice(messages.length).map((message) => ({ ...message, agent_id: agentId }))
110
+ );
111
+ const summary = (result.content ?? '').trim() || '(sin resumen)';
112
+ const status = result.stopReason === 'aborted' ? 'aborted' : 'done';
113
+ ctx.store.finishAgent(agentId, { status, summary });
114
+ emit('agent_end', { agentId, status, content: summary, rounds: result.rounds, tokens: result.usage ?? null }, agentId);
115
+ return `Subagente "${description}" (${status}):\n\n${summary}`;
116
+ } catch (error) {
117
+ const status = error?.name === 'AbortError' ? 'aborted' : 'error';
118
+ const message = String(error?.message ?? error);
119
+ ctx.store.finishAgent(agentId, { status, summary: message });
120
+ emit('agent_end', { agentId, status, content: message }, agentId);
121
+ return `Subagente "${description}" falló (${status}): ${message}`;
122
+ }
123
+ }
124
+
125
+ return { definitions, handles, execute };
126
+ }
@@ -1,7 +1,15 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { runAgent, createTools, resolveMode, MAX_TOOL_ROUNDS } from '../lib/core.js';
2
+ import {
3
+ runAgent,
4
+ createTools,
5
+ resolveMode,
6
+ MAX_TOOL_ROUNDS,
7
+ summarizeToolCall,
8
+ previewToolResult,
9
+ } from '../lib/core.js';
3
10
  import { openSse, readJson, sendJson, sendError } from '../lib/http.js';
4
11
  import { workspaceForRequest, resolveExistingDir } from '../lib/workspace.js';
12
+ import { createSubagentTool } from '../lib/tasks.js';
5
13
 
6
14
  /**
7
15
  * Emit streaming partials at most once per `ms` window, keeping only the
@@ -49,6 +57,43 @@ export function registerChat(route, ctx) {
49
57
  sendJson(res, 200, { ok: true, allow, scope });
50
58
  });
51
59
 
60
+ route('POST', '/api/sessions/:id/stop', ({ res, params }) => {
61
+ if (!ctx.runs.isActive(params.id)) return sendJson(res, 200, { ok: true, stopped: false });
62
+ ctx.runs.cancel(params.id);
63
+ sendJson(res, 200, { ok: true, stopped: true });
64
+ });
65
+
66
+ // Reanuda un turno en curso: reproduce los eventos guardados y sigue en vivo.
67
+ route('GET', '/api/sessions/:id/stream', ({ req, res, params, query }) => {
68
+ if (!ctx.store.getSession(params.id)) return sendError(res, 404, 'Sesión no encontrada.');
69
+ const from = Number(query.get('from') ?? req.headers['last-event-id'] ?? 0) || 0;
70
+ const sse = openSse(req, res);
71
+
72
+ const buffered = [];
73
+ let replaying = true;
74
+ const unsubscribe = ctx.runs.subscribe(params.id, (event) => {
75
+ if (replaying) buffered.push(event);
76
+ else sse.event(event.type, event.data, event.id);
77
+ });
78
+ const stored = ctx.store.getSessionEvents(params.id, { from });
79
+ let cursor = from;
80
+ for (const event of stored) {
81
+ sse.event(event.type, event.data, event.id);
82
+ cursor = event.id;
83
+ }
84
+ replaying = false;
85
+ for (const event of buffered) {
86
+ if (event.id > cursor) sse.event(event.type, event.data, event.id);
87
+ }
88
+ const active = ctx.runs.isActive(params.id);
89
+ sse.event('resumed', { active, cursor });
90
+ req.on('close', unsubscribe);
91
+ if (!active) {
92
+ sse.event('idle', {});
93
+ sse.close();
94
+ }
95
+ });
96
+
52
97
  route('POST', '/api/chat', async ({ req, res }) => {
53
98
  const body = await readJson(req);
54
99
  const input = String(body.input ?? '').trim();
@@ -87,25 +132,28 @@ export function registerChat(route, ctx) {
87
132
  });
88
133
  }
89
134
 
90
- const mode = resolveMode(body.mode ?? ctx.store.getSetting('activeMode'));
135
+ if (ctx.runs.isActive(sessionId)) {
136
+ return sendError(res, 409, 'La sesión ya tiene una respuesta en curso.');
137
+ }
91
138
 
139
+ const mode = resolveMode(body.mode ?? ctx.store.getSetting('activeMode'));
92
140
  const sse = openSse(req, res);
93
- const controller = new AbortController();
141
+ ctx.runs.start(sessionId);
142
+ const unsubscribe = ctx.runs.subscribe(sessionId, (event) => sse.event(event.type, event.data, event.id));
143
+ // Desconectar no cancela: el run sigue y los eventos quedan persistidos.
144
+ req.on('close', () => unsubscribe());
145
+
94
146
  const autoApprove = body.yes ?? ctx.yes;
95
147
  const asked = new Set();
96
- req.on('close', () => {
97
- controller.abort();
98
- ctx.approvals.cancel(asked);
99
- });
100
148
 
101
- sse.event('start', { sessionId, provider: providerId, model, mode: mode.id, cwd: sessionCwd });
102
- sse.event('user', { content: input });
149
+ ctx.runs.emit(sessionId, 'start', { sessionId, provider: providerId, model, mode: mode.id, cwd: sessionCwd });
150
+ ctx.runs.emit(sessionId, 'user', { content: input });
103
151
 
104
152
  let mcp = null;
105
153
  try {
106
154
  mcp = await ctx.getMcp();
107
155
  } catch (error) {
108
- sse.event('warning', { message: `MCP: ${error?.message ?? error}` });
156
+ ctx.runs.emit(sessionId, 'warning', { message: `MCP: ${error?.message ?? error}` });
109
157
  }
110
158
 
111
159
  const enabled = Array.isArray(body.tools) && body.tools.length ? body.tools : mode.tools ?? undefined;
@@ -116,11 +164,11 @@ export function registerChat(route, ctx) {
116
164
  const askId = randomUUID();
117
165
  asked.add(askId);
118
166
  askMeta.set(askId, { sessionId });
119
- sse.event('permission', { askId, sessionId, name, args });
167
+ ctx.runs.emit(sessionId, 'permission', { askId, sessionId, name, args });
120
168
  const allow = await ctx.approvals.ask(askId);
121
169
  asked.delete(askId);
122
170
  askMeta.delete(askId);
123
- sse.event('permission_resolved', { askId, allow });
171
+ ctx.runs.emit(sessionId, 'permission_resolved', { askId, allow });
124
172
  return allow;
125
173
  };
126
174
  const local = createTools({
@@ -134,11 +182,29 @@ export function registerChat(route, ctx) {
134
182
  // En modo plan (solo lectura) no exponemos las tools de administración.
135
183
  const exposeAdmin = !mode.tools;
136
184
  const admin = ctx.agentTools;
137
- const definitions = exposeAdmin ? [...local.definitions, ...admin.definitions] : local.definitions;
138
- const execute = (name, args, context) =>
139
- exposeAdmin && admin.handles(name)
140
- ? admin.execute(name, args, { approve: autoApprove ? async () => true : onAsk })
141
- : local.execute(name, args, context);
185
+ const emitAgent = (type, data, agentId = null) => ctx.runs.emit(sessionId, type, data, { agentId });
186
+ const tasks = createSubagentTool(ctx, {
187
+ sessionId,
188
+ cwd: sessionCwd,
189
+ providerId,
190
+ model,
191
+ permissions: ctx.config.permissions,
192
+ mcp,
193
+ signal: ctx.runs.signal(sessionId),
194
+ exclude: admin.definitions.map((d) => d.function.name),
195
+ emit: emitAgent,
196
+ });
197
+ const definitions = [
198
+ ...(exposeAdmin ? [...local.definitions, ...admin.definitions] : local.definitions),
199
+ ...tasks.definitions,
200
+ ];
201
+ const execute = (name, args, context) => {
202
+ if (tasks.handles(name)) return tasks.execute(name, args);
203
+ if (exposeAdmin && admin.handles(name)) {
204
+ return admin.execute(name, args, { approve: autoApprove ? async () => true : onAsk });
205
+ }
206
+ return local.execute(name, args, context);
207
+ };
142
208
 
143
209
  const { provider, config } = ctx.resolveProvider({
144
210
  ...ctx.providerSettings(providerId),
@@ -167,66 +233,84 @@ export function registerChat(route, ctx) {
167
233
  ...history,
168
234
  userMessage,
169
235
  ];
236
+ // Persistimos el mensaje del usuario ya: asi sobrevive a un corte y la
237
+ // reconexion puede reconstruir el turno desde el historial.
238
+ ctx.store.addMessage(sessionId, userMessage);
239
+ const prefixLength = messages.length;
170
240
 
171
- try {
172
- const partials = createPartialThrottle(300, (name, payload) => sse.event(name, payload));
173
- const result = await runAgent({
174
- provider,
175
- config,
176
- model,
177
- messages,
178
- tools: definitions,
179
- execute,
180
- signal: controller.signal,
181
- maxRounds: Number(body.maxRounds) || ctx.config.maxRounds || MAX_TOOL_ROUNDS,
182
- stallWarnMs: body.stallWarnMs ?? ctx.config.stallWarnMs,
183
- contextTokens: Number(body.contextTokens) || ctx.config.contextTokens || 0,
184
- onEvent(event) {
185
- if (event.type === 'text') partials.push('text', { delta: event.delta, full: event.full });
186
- else if (event.type === 'reasoning') partials.push('reasoning', { delta: event.delta, full: event.full });
187
- else if (event.type === 'stall') {
188
- partials.flush();
189
- sse.event('stall', { seconds: event.seconds });
190
- } else if (event.type === 'retry') {
191
- partials.flush();
192
- sse.event('retry', { attempt: event.attempt, delayMs: event.delayMs, message: event.message });
193
- } else if (event.type === 'trim') {
194
- partials.flush();
195
- sse.event('trim', { trimmed: event.trimmed, dropped: event.dropped, tokens: event.tokens });
196
- } else if (event.type === 'tool_round') {
197
- partials.flush();
198
- sse.event('tools', { toolCalls: event.toolCalls });
199
- } else if (event.type === 'tool_call') {
200
- partials.flush();
201
- sse.event('tool_call', { name: event.name, args: event.args });
202
- } else if (event.type === 'tool_result') {
203
- partials.flush();
204
- sse.event('tool_result', { name: event.name, result: String(event.result).slice(0, 8000) });
205
- }
206
- },
207
- });
208
- partials.flush();
209
-
210
- const newMessages = result.messages
211
- .slice(1 + history.length)
212
- .map((message) => (message.role === 'assistant' ? { ...message, mode: mode.id } : message));
213
- ctx.store.addMessages(sessionId, newMessages);
214
- const current = ctx.store.getSession(sessionId);
215
- const title =
216
- current && (!current.title || current.title === 'Nueva sesión') ? input.slice(0, 60) : undefined;
217
- ctx.store.touchSession(sessionId, { provider: providerId, model, title });
218
- sse.event('done', {
219
- sessionId,
220
- content: result.content,
221
- rounds: result.rounds,
222
- stopReason: result.stopReason,
223
- tokens: result.usage ?? null,
224
- });
225
- } catch (error) {
226
- if (error?.name === 'AbortError') sse.event('aborted', {});
227
- else sse.event('error', { message: error?.message ?? String(error) });
228
- } finally {
229
- sse.close();
230
- }
241
+ (async () => {
242
+ const partials = createPartialThrottle(300, (name, payload) => ctx.runs.emit(sessionId, name, payload));
243
+ try {
244
+ const result = await runAgent({
245
+ provider,
246
+ config,
247
+ model,
248
+ messages,
249
+ tools: definitions,
250
+ execute,
251
+ signal: ctx.runs.signal(sessionId),
252
+ maxRounds: Number(body.maxRounds) || ctx.config.maxRounds || MAX_TOOL_ROUNDS,
253
+ stallWarnMs: body.stallWarnMs ?? ctx.config.stallWarnMs,
254
+ contextTokens: Number(body.contextTokens) || ctx.config.contextTokens || 0,
255
+ onEvent(event) {
256
+ if (event.type === 'text') partials.push('text', { delta: event.delta, full: event.full });
257
+ else if (event.type === 'reasoning') partials.push('reasoning', { delta: event.delta, full: event.full });
258
+ else if (event.type === 'stall') {
259
+ partials.flush();
260
+ ctx.runs.emit(sessionId, 'stall', { seconds: event.seconds });
261
+ } else if (event.type === 'retry') {
262
+ partials.flush();
263
+ ctx.runs.emit(sessionId, 'retry', { attempt: event.attempt, delayMs: event.delayMs, message: event.message });
264
+ } else if (event.type === 'trim') {
265
+ partials.flush();
266
+ ctx.runs.emit(sessionId, 'trim', { trimmed: event.trimmed, dropped: event.dropped, tokens: event.tokens });
267
+ } else if (event.type === 'tool_round') {
268
+ partials.flush();
269
+ ctx.runs.emit(sessionId, 'tools', { toolCalls: event.toolCalls });
270
+ } else if (event.type === 'tool_call') {
271
+ partials.flush();
272
+ ctx.runs.emit(sessionId, 'tool_call', {
273
+ id: event.id,
274
+ name: event.name,
275
+ args: event.args,
276
+ summary: summarizeToolCall(event.name, event.args),
277
+ });
278
+ } else if (event.type === 'tool_result') {
279
+ partials.flush();
280
+ const { preview, truncated } = previewToolResult(event.name, event.result);
281
+ const failed = /^Error|^Permiso denegado/i.test(String(event.result));
282
+ ctx.runs.emit(sessionId, 'tool_result', { id: event.id, name: event.name, preview, truncated, failed });
283
+ }
284
+ },
285
+ });
286
+ partials.flush();
287
+
288
+ const newMessages = result.messages
289
+ .slice(prefixLength)
290
+ .map((message) => (message.role === 'assistant' ? { ...message, mode: mode.id } : message));
291
+ ctx.store.addMessages(sessionId, newMessages);
292
+ const current = ctx.store.getSession(sessionId);
293
+ const title =
294
+ current && (!current.title || current.title === 'Nueva sesión') ? input.slice(0, 60) : undefined;
295
+ ctx.store.touchSession(sessionId, { provider: providerId, model, title });
296
+ if (result.stopReason === 'aborted') ctx.runs.emit(sessionId, 'aborted', {});
297
+ else
298
+ ctx.runs.emit(sessionId, 'done', {
299
+ sessionId,
300
+ content: result.content,
301
+ rounds: result.rounds,
302
+ stopReason: result.stopReason,
303
+ tokens: result.usage ?? null,
304
+ });
305
+ } catch (error) {
306
+ partials.flush();
307
+ if (error?.name === 'AbortError') ctx.runs.emit(sessionId, 'aborted', {});
308
+ else ctx.runs.emit(sessionId, 'error', { message: error?.message ?? String(error) });
309
+ } finally {
310
+ unsubscribe();
311
+ sse.close();
312
+ ctx.runs.finish(sessionId);
313
+ }
314
+ })();
231
315
  });
232
316
  }
@@ -76,9 +76,26 @@ export function registerSessions(route, ctx) {
76
76
  session: view(session),
77
77
  tokens: ctx.store.sessionTokens(params.id),
78
78
  messages: ctx.store.getMessages(params.id),
79
+ running: ctx.runs?.isActive(params.id) ?? false,
79
80
  });
80
81
  });
81
82
 
83
+ route('GET', '/api/sessions/:id/agents', ({ res, params }) => {
84
+ if (!ctx.store.getSession(params.id)) return sendError(res, 404, 'Sesión no encontrada.');
85
+ sendJson(res, 200, { agents: ctx.store.listAgents(params.id) });
86
+ });
87
+
88
+ route('GET', '/api/sessions/:id/tool-result', ({ res, params, query }) => {
89
+ if (!ctx.store.getSession(params.id)) return sendError(res, 404, 'Sesión no encontrada.');
90
+ const callId = String(query.get('callId') || '');
91
+ if (!callId) return sendError(res, 400, 'Falta "callId".');
92
+ const message = ctx.store.getMessages(params.id).find((m) => m.tool_call_id === callId);
93
+ if (!message) return sendError(res, 404, 'Resultado no encontrado.');
94
+ const content =
95
+ typeof message.content === 'string' ? message.content : JSON.stringify(message.content, null, 2);
96
+ sendJson(res, 200, { callId, name: message.name ?? null, content });
97
+ });
98
+
82
99
  route('PUT', '/api/sessions/:id', async ({ req, res, params }) => {
83
100
  const session = ctx.store.getSession(params.id);
84
101
  if (!session) return sendError(res, 404, 'Sesión no encontrada.');
@@ -18,8 +18,9 @@ import { sendJson, sendError } from './lib/http.js';
18
18
  import { createPromptBuilder, DEFAULT_SYSTEM_PROMPT } from './lib/prompt.js';
19
19
  import { skillsPrompt } from './lib/skills.js';
20
20
  import { createApprovalBroker } from './lib/approvals.js';
21
+ import { createRunManager } from './lib/runManager.js';
21
22
  import { createProcManager } from './lib/procs.js';
22
- import { startTelegramBot } from './lib/telegram.js';
23
+ import { startChannels } from './lib/channels/index.js';
23
24
  import { createAgentTools } from './lib/agentTools.js';
24
25
  import { createStaticHandler } from './static.js';
25
26
  import { requireLogin, requireCsrf } from './auth.js';
@@ -71,6 +72,7 @@ export async function createTamnoraServer({
71
72
  const authEnabled = Boolean(app.user && app.user.password);
72
73
  const store = new Store(dbPath ?? databasePath(cwd));
73
74
  const approvals = createApprovalBroker({ timeoutMs: config.approvalTimeoutMs });
75
+ const runs = createRunManager({ store });
74
76
  const procs = createProcManager({ allow: config.processes?.allow });
75
77
  const procsEnabled = config.processes?.enabled !== false;
76
78
  const autoApproved = new Set();
@@ -142,6 +144,7 @@ export async function createTamnoraServer({
142
144
  authEnabled,
143
145
  store,
144
146
  approvals,
147
+ runs,
145
148
  isAutoApprove,
146
149
  setAutoApprove,
147
150
  yes,
@@ -167,20 +170,12 @@ export async function createTamnoraServer({
167
170
  ctx.systemPrompt = (await ctx.systemPromptFor(cwd)) || DEFAULT_SYSTEM_PROMPT;
168
171
  ctx.agentTools = createAgentTools(ctx);
169
172
 
170
- // Bot de Telegram (long polling) si hay token: config, app.json o entorno.
171
- const tgConfig = { ...(app.telegram ?? {}), ...(config.telegram ?? {}) };
172
- const tgToken = tgConfig.token || process.env.TAMNORA_TELEGRAM_TOKEN || '';
173
- const rawAllow = tgConfig.allowedChatIds ?? process.env.TAMNORA_TELEGRAM_CHAT_IDS ?? [];
174
- const tgAllow = Array.isArray(rawAllow) ? rawAllow : String(rawAllow).split(/[,\s]+/).filter(Boolean);
175
- const telegramBot =
176
- telegram && tgToken
177
- ? startTelegramBot(ctx, {
178
- token: tgToken,
179
- allowedChatIds: tgAllow,
180
- log: (message) => process.stderr.write(`${message}\n`),
181
- })
182
- : null;
183
- ctx.telegram = Boolean(telegramBot);
173
+ // Canales de mensajeria (Telegram via long polling) si hay token.
174
+ const channels = telegram
175
+ ? startChannels(ctx, { log: (message) => process.stderr.write(`${message}\n`) })
176
+ : { channels: [], enabled: false, stop() {} };
177
+ ctx.telegram = channels.enabled;
178
+ ctx.channels = channels;
184
179
 
185
180
  const routes = [];
186
181
  const route = (method, pattern, handler) =>
@@ -290,7 +285,8 @@ export async function createTamnoraServer({
290
285
 
291
286
  const close = async () => {
292
287
  approvals.cancelAll();
293
- telegramBot?.stop();
288
+ runs.cancelAll();
289
+ channels.stop();
294
290
  preview.close();
295
291
  procs.close();
296
292
  try {
@@ -141,6 +141,89 @@ test('streams a chat turn over SSE and persists the messages', async () => {
141
141
  }
142
142
  });
143
143
 
144
+ test('persists the run log and replays it on the stream endpoint', async () => {
145
+ const { instance, base } = await startServer();
146
+ const json = { 'Content-Type': 'application/json' };
147
+ try {
148
+ await (
149
+ await fetch(`${base}/api/chat`, {
150
+ method: 'POST',
151
+ headers: json,
152
+ body: JSON.stringify({ input: 'hola', model: 'm' }),
153
+ })
154
+ ).text();
155
+ const list = await (await fetch(`${base}/api/sessions`)).json();
156
+ const id = list.sessions[0].id;
157
+
158
+ const stream = await (await fetch(`${base}/api/sessions/${id}/stream?from=0`)).text();
159
+ assert.match(stream, /event: start/);
160
+ assert.match(stream, /event: text/);
161
+ assert.match(stream, /event: done/);
162
+ assert.match(stream, /event: idle/);
163
+ assert.match(stream, /^id: \d+$/m);
164
+ } finally {
165
+ await instance.close();
166
+ }
167
+ });
168
+
169
+ test('keeps a run alive after the client disconnects and can stop it', async () => {
170
+ let release;
171
+ const gate = new Promise((resolve) => (release = resolve));
172
+ const slowProvider = {
173
+ id: 'slow',
174
+ async streamChat(_config, { onEvent, signal }) {
175
+ onEvent?.({ type: 'text', delta: '…', full: '…' });
176
+ await new Promise((resolve, reject) => {
177
+ if (signal?.aborted) return reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
178
+ signal?.addEventListener('abort', () =>
179
+ reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))
180
+ );
181
+ gate.then(resolve);
182
+ });
183
+ return { content: 'fin', reasoning: '', toolCalls: [] };
184
+ },
185
+ };
186
+ const { instance, base } = await startServer({ resolveProvider: () => ({ provider: slowProvider, config: {} }) });
187
+ const json = { 'Content-Type': 'application/json' };
188
+ try {
189
+ const created = await (
190
+ await fetch(`${base}/api/sessions`, {
191
+ method: 'POST',
192
+ headers: json,
193
+ body: JSON.stringify({ model: 'm' }),
194
+ })
195
+ ).json();
196
+ const id = created.session.id;
197
+
198
+ const controller = new AbortController();
199
+ const chat = fetch(`${base}/api/chat`, {
200
+ method: 'POST',
201
+ headers: json,
202
+ body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
203
+ signal: controller.signal,
204
+ }).catch(() => null);
205
+ await new Promise((resolve) => setTimeout(resolve, 80));
206
+ controller.abort();
207
+ await chat;
208
+
209
+ const during = await (await fetch(`${base}/api/sessions/${id}`)).json();
210
+ assert.equal(during.running, true);
211
+
212
+ const stopped = await (
213
+ await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST', headers: json })
214
+ ).json();
215
+ assert.equal(stopped.stopped, true);
216
+
217
+ release();
218
+ await new Promise((resolve) => setTimeout(resolve, 80));
219
+ const after = await (await fetch(`${base}/api/sessions/${id}`)).json();
220
+ assert.equal(after.running, false);
221
+ } finally {
222
+ release();
223
+ await instance.close();
224
+ }
225
+ });
226
+
144
227
  test('manages MCP servers from the console', async () => {
145
228
  const { instance, base } = await startServer();
146
229
  const json = { 'Content-Type': 'application/json' };
@@ -898,13 +981,58 @@ test('plan mode exposes only read-only tools and adds guidance', async () => {
898
981
  body: JSON.stringify({ input: 'planificá', model: 'm', mode: 'plan' }),
899
982
  })
900
983
  ).text();
901
- assert.deepEqual(captured.tools, ['fetch_url', 'git_diff', 'git_log', 'git_status', 'glob', 'grep', 'list', 'read']);
984
+ assert.deepEqual(captured.tools, ['fetch_url', 'git_diff', 'git_log', 'git_status', 'glob', 'grep', 'list', 'read', 'task']);
902
985
  assert.match(captured.system, /modo PLAN/);
903
986
  } finally {
904
987
  await instance.close();
905
988
  }
906
989
  });
907
990
 
991
+ test('the agent can delegate to a subagent with the task tool', async () => {
992
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-task-'));
993
+ const provider = {
994
+ id: 'multi',
995
+ async streamChat(_config, { messages, onEvent }) {
996
+ const isSub = messages.some((m) => m.role === 'system' && /subagente/i.test(String(m.content)));
997
+ if (isSub) {
998
+ onEvent?.({ type: 'text', delta: 'resumen', full: 'resumen subagente' });
999
+ return { content: 'resumen subagente', reasoning: '', toolCalls: [] };
1000
+ }
1001
+ const done = messages.some((m) => m.role === 'tool' && m.name === 'task');
1002
+ if (done) return { content: 'listo', reasoning: '', toolCalls: [] };
1003
+ return {
1004
+ content: '',
1005
+ reasoning: '',
1006
+ toolCalls: [
1007
+ { id: 't1', function: { name: 'task', arguments: JSON.stringify({ description: 'explorar', prompt: 'leé el proyecto' }) } },
1008
+ ],
1009
+ };
1010
+ },
1011
+ };
1012
+ const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
1013
+ const json = { 'Content-Type': 'application/json' };
1014
+ try {
1015
+ const stream = await (
1016
+ await fetch(`${base}/api/chat`, {
1017
+ method: 'POST',
1018
+ headers: json,
1019
+ body: JSON.stringify({ input: 'delegá', model: 'm' }),
1020
+ })
1021
+ ).text();
1022
+ assert.match(stream, /event: agent_start/);
1023
+ assert.match(stream, /event: agent_end/);
1024
+
1025
+ const list = await (await fetch(`${base}/api/sessions`)).json();
1026
+ const id = list.sessions[0].id;
1027
+ const agents = await (await fetch(`${base}/api/sessions/${id}/agents`)).json();
1028
+ assert.equal(agents.agents.length, 1);
1029
+ assert.equal(agents.agents[0].status, 'done');
1030
+ assert.match(agents.agents[0].summary, /resumen subagente/);
1031
+ } finally {
1032
+ await instance.close();
1033
+ }
1034
+ });
1035
+
908
1036
  test('aggregates the token usage of a session', async () => {
909
1037
  const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tokens-'));
910
1038
  const provider = {