@danieltmn/tamnora 0.18.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +6 -3
  3. package/harness/packages/cli/src/commands/tui.js +2 -2
  4. package/harness/packages/cli/src/tui/app.js +2 -1
  5. package/harness/packages/core/src/agent/compact.js +6 -1
  6. package/harness/packages/core/src/agent/modes.js +29 -0
  7. package/harness/packages/core/src/providers/openaiCompatible.js +30 -17
  8. package/harness/packages/core/src/providers/stream.js +81 -7
  9. package/harness/packages/core/src/tools/executors.js +2 -2
  10. package/harness/packages/core/src/tools/registry.js +21 -1
  11. package/harness/packages/core/src/tools/schema.js +1 -0
  12. package/harness/packages/core/test/compact.test.js +4 -2
  13. package/harness/packages/core/test/providers.test.js +29 -0
  14. package/harness/packages/core/test/stream.test.js +46 -0
  15. package/harness/packages/core/test/tools.test.js +30 -0
  16. package/harness/packages/server/public/app.js +433 -53
  17. package/harness/packages/server/public/index.html +27 -8
  18. package/harness/packages/server/public/styles.css +51 -1
  19. package/harness/packages/server/src/lib/advisor.js +14 -13
  20. package/harness/packages/server/src/lib/channels/telegram.js +8 -3
  21. package/harness/packages/server/src/lib/compact.js +26 -1
  22. package/harness/packages/server/src/lib/scheduler.js +1 -0
  23. package/harness/packages/server/src/lib/sessionView.js +1 -0
  24. package/harness/packages/server/src/lib/tasks.js +11 -1
  25. package/harness/packages/server/src/lib/ttsSettings.js +11 -0
  26. package/harness/packages/server/src/routes/chat.js +20 -26
  27. package/harness/packages/server/src/routes/sessions.js +20 -11
  28. package/harness/packages/server/src/routes/tts.js +4 -2
  29. package/harness/packages/server/test/advisor.test.js +17 -13
  30. package/harness/packages/server/test/server.test.js +154 -51
  31. package/harness/packages/server/test/tts.test.js +5 -1
  32. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,37 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.20.0] - 2026-10-14
9
+
10
+ ### Agregado
11
+
12
+ - **Modo Voz** (`voice`): como Plan (solo lectura) pero con `write`/`edit`
13
+ **limitados a `.md` y `.json`**. La restricción por extensión se aplica en el
14
+ motor de tools (`writeExtensions`), aunque el permiso del usuario sea `allow`, y
15
+ se propaga a la consola, Telegram, subagentes, scheduler y la CLI/TUI.
16
+ - **Manos libres** en la consola: loop conversacional
17
+ (escuchar → enviar → esperar la voz → volver a escuchar), con corte por silencio,
18
+ lectura forzada de la respuesta final y **auto-activación en modo Voz**.
19
+ - **Asesor de un solo mensaje** (estilo Jarvis): el resumen breve y la
20
+ alerta/consejo viajan en un mismo texto, con **una** tarjeta y **una** locución.
21
+ - **Tipo de agente por sesión**: cada sesión guarda su proveedor/modelo/modo y la
22
+ consola aplica y persiste ese tipo al abrirla.
23
+
24
+ ### Corregido
25
+
26
+ - **Razonamiento y tokens con MiniMax/DeepSeek**: el pensamiento en línea
27
+ (`<think>`) y `reasoning_content` se separan del texto, y el streaming pide
28
+ `usage` (`stream_options`/`include_usage`) para que la consola muestre tokens y
29
+ contexto.
30
+ - **Compactar** ya no pierde el historial previo al resumen.
31
+
32
+ ## [0.19.0] - 2026-10-12
33
+
34
+ ### Cambios
35
+
36
+ - fix(consola): permiso y URL de preview legibles en movil
37
+ - feat(voz): velocidad y tono como rangos numericos
38
+
8
39
  ## [0.18.0] - 2026-10-12
9
40
 
10
41
  ### Agregado
package/README.md CHANGED
@@ -12,10 +12,12 @@ con TUI, consola web, tools locales, MCP y evaluacion de modelos.
12
12
  saca una captura PNG o lista los errores de consola) y `preview_url`.
13
13
  - **Voz**: el agente narra su progreso, puede **leer la respuesta final** (sin
14
14
  bloques de codigo ni tablas) y mandar audio bajo pedido (`speak`,
15
- `/audioresumen`).
15
+ `/audioresumen`). El **modo Voz** habilita **manos libres**: hablás y responde en
16
+ audio, como una conversacion (y solo escribe notas `.md`/`.json`).
16
17
  - **Asesor**: una capa de comentario sobre el run que interpreta lo que hizo el
17
- agente y, si aporta algo, avisa de un problema en el codigo, explica un proceso
18
- o da un consejo (con tarjeta en el chat y voz opcional). No lee la respuesta.
18
+ agente y, en **un solo mensaje** estilo Jarvis, resume lo hecho y —si aporta—
19
+ avisa de un problema en el codigo, explica un proceso o da un consejo (tarjeta en
20
+ el chat y voz opcional). No lee la respuesta.
19
21
  - **Actualizar desde la consola**: la consola avisa cuando hay una version nueva
20
22
  y la instala con un click (el server se reinicia solo).
21
23
  - **Memoria y reglas**: memoria durable por proyecto (`memory`) y reglas por path
@@ -55,6 +57,7 @@ tamnora serve # consola web
55
57
  ```bash
56
58
  tamnora # TUI full-screen (REPL si no hay TTY)
57
59
  tamnora tui --mode plan # modo plan: solo lectura, sin escritura ni bash
60
+ tamnora tui --mode voice # modo voz: solo lectura + notas .md/.json
58
61
  ```
59
62
 
60
63
  Atajos: `enter` enviar · `↑/↓` historial · `←/→` cursor · `home/end` inicio/fin ·
@@ -77,7 +77,7 @@ export async function tuiCommand(argv) {
77
77
  );
78
78
  const modeContext = (id) => {
79
79
  const m = resolveMode(id);
80
- return { label: m.label, enabled: enabledFor(m), systemPrompt: promptFor(m) };
80
+ return { label: m.label, enabled: enabledFor(m), systemPrompt: promptFor(m), writeExtensions: m.writeExtensions };
81
81
  };
82
82
 
83
83
  const sessionId = store.createSession({ title: 'Consola', provider: providerId, model, cwd });
@@ -127,7 +127,7 @@ Consola full-screen (estilo opencode/codex). Atajos:
127
127
  Opciones:
128
128
  --provider <id> Proveedor (por defecto: el activo)
129
129
  --model <id> Modelo (por defecto: el activo)
130
- --mode <id> Modo de agente: agent | plan
130
+ --mode <id> Modo de agente: agent | plan | voice
131
131
  --dir <path> Directorio del proyecto
132
132
  --max-steps <n> Techo de rondas de herramientas (0 = sin límite, default)
133
133
  --tools <lista> Tools habilitadas (coma) --no-tools
@@ -86,7 +86,8 @@ export function runTui({
86
86
  draw();
87
87
  });
88
88
 
89
- const buildTools = () => createTools({ ...toolOptions, enabled: modeEnabled, onAsk });
89
+ const buildTools = () =>
90
+ createTools({ ...toolOptions, enabled: modeEnabled, writeExtensions: activeMode.writeExtensions, onAsk });
90
91
  let toolset = buildTools();
91
92
 
92
93
  function setMode(id) {
@@ -9,13 +9,18 @@ export const COMPACT_INSTRUCTION =
9
9
  export const SUMMARY_INSTRUCTION =
10
10
  'Resumí la conversación para la persona: qué se pidió, qué se hizo, decisiones tomadas, archivos o comandos relevantes y el estado actual. Sé claro y conciso, en español, sin markdown pesado.';
11
11
 
12
+ // Mensajes que existen para el usuario pero nunca entran al contexto del modelo:
13
+ // el asesor (capa de comentario) y la marca de compactación (el resumen ya viaja
14
+ // aparte como `meta.summary`).
15
+ const NON_CONTEXT_NAMES = new Set(['advisor', 'compact']);
16
+
12
17
  /**
13
18
  * Historial vigente: sin los mensajes del asesor ni lo ya resumido (anterior al
14
19
  * cursor de compactación). Si el cursor no está (historial truncado), devuelve
15
20
  * todo para no perder contexto.
16
21
  */
17
22
  export function liveHistory(history = [], cursor = null) {
18
- const filtered = history.filter((message) => message.name !== 'advisor');
23
+ const filtered = history.filter((message) => !NON_CONTEXT_NAMES.has(message.name));
19
24
  if (!cursor) return filtered;
20
25
  const idx = filtered.findIndex((message) => message.id === cursor);
21
26
  return idx === -1 ? filtered : filtered.slice(idx + 1);
@@ -33,6 +33,35 @@ export const MODES = {
33
33
  systemPrompt:
34
34
  'Estás en modo PLAN: no modifiques archivos ni ejecutes comandos. Investigá el proyecto con las herramientas de solo lectura y respondé con un plan concreto y accionable.',
35
35
  },
36
+ voice: {
37
+ id: 'voice',
38
+ label: 'Voz',
39
+ description: 'Conversación por voz y notas: como Plan, pero solo crea/edita .md y .json.',
40
+ tools: [
41
+ 'read',
42
+ 'list',
43
+ 'glob',
44
+ 'grep',
45
+ 'fetch_url',
46
+ 'speak',
47
+ 'screenshot',
48
+ 'console_errors',
49
+ 'preview_url',
50
+ 'ask_user',
51
+ 'offer_choices',
52
+ 'show_plan',
53
+ 'git_status',
54
+ 'git_diff',
55
+ 'git_log',
56
+ 'write',
57
+ 'edit',
58
+ ],
59
+ writeExtensions: ['.md', '.json'],
60
+ systemPrompt:
61
+ 'Estás en modo VOZ: una conversación hablada con el usuario, breve y natural (tus respuestas se leen en voz alta). ' +
62
+ 'No ejecutes comandos. Podés investigar el proyecto con las herramientas de solo lectura y SOLO podés crear o editar archivos .md y .json ' +
63
+ '(notas, ideas, listas, datos). Nunca toques otro tipo de archivo; si el usuario pide modificar código, decíselo y ofrecele hacerlo en modo Build.',
64
+ },
36
65
  };
37
66
 
38
67
  export const MODE_IDS = Object.keys(MODES);
@@ -23,24 +23,37 @@ export const openaiCompatibleProvider = {
23
23
  },
24
24
 
25
25
  async streamChat({ baseUrl, apiKey, headers }, { model, messages, tools, signal, onEvent, maxOutputTokens, promptCaching }) {
26
- const body = { model, messages, stream: true };
27
- if (tools?.length) body.tools = tools;
28
- if (maxOutputTokens > 0) {
29
- // OpenAI o-series exige `max_completion_tokens`; el resto usa `max_tokens`.
30
- const field = this?.maxTokensField ?? 'max_tokens';
31
- body[field] = maxOutputTokens;
32
- }
33
- if (promptCaching) applyPromptCache(body, { mode: this?.caching ?? 'none' });
26
+ const buildBody = () => {
27
+ const body = { model, messages, stream: true };
28
+ if (tools?.length) body.tools = tools;
29
+ if (maxOutputTokens > 0) {
30
+ // OpenAI o-series exige `max_completion_tokens`; el resto usa `max_tokens`.
31
+ const field = this?.maxTokensField ?? 'max_tokens';
32
+ body[field] = maxOutputTokens;
33
+ }
34
+ if (promptCaching) applyPromptCache(body, { mode: this?.caching ?? 'none' });
35
+ return body;
36
+ };
37
+ const run = (body) =>
38
+ requestStream({
39
+ url: joinUrl(baseUrl, '/chat/completions'),
40
+ headers: authHeaders({ apiKey, headers }),
41
+ providerLabel: 'OpenAI-compatible',
42
+ model,
43
+ body,
44
+ signal,
45
+ onEvent,
46
+ });
34
47
 
35
- return requestStream({
36
- url: joinUrl(baseUrl, '/chat/completions'),
37
- headers: authHeaders({ apiKey, headers }),
38
- providerLabel: 'OpenAI-compatible',
39
- model,
40
- body,
41
- signal,
42
- onEvent,
43
- });
48
+ // La mayoria de los proveedores (OpenAI, MiniMax, DeepSeek, Groq...) solo
49
+ // reportan `usage` en streaming si se pide explicitamente.
50
+ try {
51
+ return await run({ ...buildBody(), stream_options: { include_usage: true } });
52
+ } catch (error) {
53
+ // Servidores OpenAI-compatibles que no conocen `stream_options`.
54
+ if (!/stream_options/i.test(error?.message ?? '')) throw error;
55
+ return run(buildBody());
56
+ }
44
57
  },
45
58
  };
46
59
 
@@ -89,6 +89,18 @@ export async function readOpenAISSE(response, { onEvent, signal } = {}) {
89
89
  const toolCalls = [];
90
90
  let serverError = null;
91
91
  let usage = null;
92
+ // Algunos modelos abren y cierran el stream antes de mandar `usage`; guardamos
93
+ // el ultimo que hayamos visto (puede llegar en un frame sin `choices`).
94
+ const think = createThinkSplitter({
95
+ onText(piece) {
96
+ content += piece;
97
+ onEvent?.({ type: 'text', delta: piece, full: content });
98
+ },
99
+ onReasoning(piece) {
100
+ reasoning += piece;
101
+ onEvent?.({ type: 'reasoning', delta: piece, full: reasoning });
102
+ },
103
+ });
92
104
 
93
105
  while (true) {
94
106
  const { done, value } = await readChunk(reader, signal);
@@ -114,14 +126,14 @@ export async function readOpenAISSE(response, { onEvent, signal } = {}) {
114
126
  const delta = parsed.choices?.[0]?.delta;
115
127
  if (!delta) continue;
116
128
 
117
- if (delta.content) {
118
- content += delta.content;
119
- onEvent?.({ type: 'text', delta: delta.content, full: content });
120
- }
121
- if (delta.reasoning) {
122
- reasoning += delta.reasoning;
123
- onEvent?.({ type: 'reasoning', delta: delta.reasoning, full: reasoning });
129
+ // DeepSeek y MiniMax (con `reasoning_split`) mandan el pensamiento en
130
+ // `reasoning_content`; OpenRouter/OpenAI usan `reasoning`.
131
+ const reasoningDelta = delta.reasoning ?? delta.reasoning_content;
132
+ if (reasoningDelta) {
133
+ reasoning += reasoningDelta;
134
+ onEvent?.({ type: 'reasoning', delta: reasoningDelta, full: reasoning });
124
135
  }
136
+ if (delta.content) think.feed(delta.content);
125
137
  if (delta.tool_calls) {
126
138
  for (const tc of delta.tool_calls) {
127
139
  const idx = tc.index ?? 0;
@@ -142,9 +154,71 @@ export async function readOpenAISSE(response, { onEvent, signal } = {}) {
142
154
  }
143
155
 
144
156
  if (serverError) throw new Error(serverError);
157
+ think.flush();
145
158
  return finalize({ content, reasoning, toolCalls, usage });
146
159
  }
147
160
 
161
+ const THINK_OPEN = '<think>';
162
+ const THINK_CLOSE = '</think>';
163
+
164
+ /**
165
+ * MiniMax (sin `reasoning_split`) y otros modelos sin un campo de razonamiento
166
+ * mandan el pensamiento dentro del propio `content`, envuelto en
167
+ * `<think>…</think>`. Este separador incremental lo mueve a `reasoning`,
168
+ * tolerando que la etiqueta venga partida entre fragmentos del stream.
169
+ */
170
+ function createThinkSplitter({ onText, onReasoning }) {
171
+ let mode = 'start';
172
+ let pending = '';
173
+ const emitText = (text) => text && onText(text);
174
+ const emitReasoning = (text) => text && onReasoning(text);
175
+
176
+ return {
177
+ feed(chunk) {
178
+ if (!chunk) return;
179
+ pending += chunk;
180
+ for (;;) {
181
+ if (mode === 'content') {
182
+ emitText(pending);
183
+ pending = '';
184
+ return;
185
+ }
186
+ if (mode === 'start') {
187
+ if (pending.startsWith(THINK_OPEN)) {
188
+ pending = pending.slice(THINK_OPEN.length);
189
+ mode = 'thinking';
190
+ continue;
191
+ }
192
+ // Mientras sea un prefijo de la etiqueta, puede ser el inicio: esperar.
193
+ if (THINK_OPEN.startsWith(pending)) return;
194
+ mode = 'content';
195
+ continue;
196
+ }
197
+ const idx = pending.indexOf(THINK_CLOSE);
198
+ if (idx !== -1) {
199
+ emitReasoning(pending.slice(0, idx));
200
+ pending = pending.slice(idx + THINK_CLOSE.length);
201
+ mode = 'content';
202
+ continue;
203
+ }
204
+ // Conservamos el posible cierre partido al final.
205
+ const keep = THINK_CLOSE.length - 1;
206
+ if (pending.length > keep) {
207
+ emitReasoning(pending.slice(0, pending.length - keep));
208
+ pending = pending.slice(-keep);
209
+ }
210
+ return;
211
+ }
212
+ },
213
+ flush() {
214
+ if (!pending) return;
215
+ if (mode === 'thinking') emitReasoning(pending);
216
+ else emitText(pending);
217
+ pending = '';
218
+ },
219
+ };
220
+ }
221
+
148
222
  /**
149
223
  * Parse Ollama's native NDJSON stream (`/api/chat`).
150
224
  * @returns {Promise<{content:string, reasoning:string, toolCalls:object[], usage:object|null}>}
@@ -422,11 +422,11 @@ const MAX_SPEAK_TEXT = 1200;
422
422
  * son del cliente (la consola lo reproduce y Telegram lo manda como nota de
423
423
  * voz), asi que aca solo se normaliza el pedido y se acota el largo.
424
424
  */
425
- export function speakTool(_root, { texto, voice, rate } = {}) {
425
+ export function speakTool(_root, { texto, voice, rate, pitch } = {}) {
426
426
  const value = String(texto ?? '').replace(/\s+/g, ' ').trim();
427
427
  if (!value) return 'Error: falta "texto".';
428
428
  const recortado = value.length > MAX_SPEAK_TEXT ? `${value.slice(0, MAX_SPEAK_TEXT - 1).trimEnd()}…` : value;
429
- const opciones = [voice, rate].filter(Boolean).join(', ');
429
+ const opciones = [voice, rate, pitch].filter(Boolean).join(', ');
430
430
  return `Audio pedido${opciones ? ` (${opciones})` : ''}: "${recortado}". El cliente lo reproduce o lo envía como nota de voz.`;
431
431
  }
432
432
 
@@ -1,7 +1,15 @@
1
+ import path from 'node:path';
1
2
  import { TOOL_DEFINITIONS, TOOL_NAMES } from './schema.js';
2
3
  import { EXECUTORS } from './executors.js';
3
4
  import { evaluatePermission } from './permissions.js';
4
5
 
6
+ const WRITE_TOOLS = new Set(['write', 'edit']);
7
+
8
+ /** Extension (en minusculas) de un path, o '' si no tiene. */
9
+ function extensionOf(p) {
10
+ return path.extname(String(p ?? '')).toLowerCase();
11
+ }
12
+
5
13
  /**
6
14
  * Build the local tool registry.
7
15
  *
@@ -9,16 +17,25 @@ import { evaluatePermission } from './permissions.js';
9
17
  * @param {string} opts.root - workspace root (all paths are confined here)
10
18
  * @param {string[]} [opts.enabled] - tool names to expose (default: all)
11
19
  * @param {object} [opts.permissions] - policy per tool ('allow'|'ask'|'deny' or pattern map)
20
+ * @param {string[]} [opts.writeExtensions] - si se define, `write`/`edit` solo aceptan estas extensiones (p.ej. ['.md', '.json'])
12
21
  * @param {(name:string, args:object)=>Promise<boolean>} [opts.onAsk] - interactive approval
13
22
  * @param {(payload:{title:string, questions:object[]})=>Promise<{answers:object,cancelled?:boolean}>} [opts.onQuestion] - interactive form
14
23
  * @param {boolean} [opts.yes] - auto-approve 'ask' (non-interactive)
15
24
  * @param {AbortSignal} [opts.signal] - cancela las tools de larga duracion (bash, git)
16
25
  */
17
- export function createToolRegistry({ root, enabled, permissions = {}, onAsk, onQuestion, yes = false, signal } = {}) {
26
+ export function createToolRegistry({ root, enabled, permissions = {}, onAsk, onQuestion, yes = false, signal, writeExtensions } = {}) {
18
27
  const names = new Set(enabled?.length ? enabled : TOOL_NAMES);
19
28
  const definitions = TOOL_DEFINITIONS.filter((d) => names.has(d.function.name));
29
+ const allowedWrite = Array.isArray(writeExtensions) && writeExtensions.length
30
+ ? new Set(writeExtensions.map((ext) => String(ext).toLowerCase()))
31
+ : null;
32
+
33
+ // Restriccion por extension (modo Voz): solo write/edit, y solo si el modo la define.
34
+ const writeAllowed = (name, args = {}) =>
35
+ !allowedWrite || !WRITE_TOOLS.has(name) || allowedWrite.has(extensionOf(args?.path));
20
36
 
21
37
  async function authorize(name, args = {}) {
38
+ if (!writeAllowed(name, args)) return 'deny';
22
39
  let decision = evaluatePermission(name, args, permissions);
23
40
  if (decision === 'ask') {
24
41
  if (onAsk) decision = (await onAsk(name, args)) ? 'allow' : 'deny';
@@ -31,6 +48,9 @@ export function createToolRegistry({ root, enabled, permissions = {}, onAsk, onQ
31
48
  if (!names.has(name) || !(name in EXECUTORS)) {
32
49
  return `Error: herramienta desconocida "${name}".`;
33
50
  }
51
+ if (!writeAllowed(name, args)) {
52
+ return `Error: en este modo solo se permite crear o editar ${[...allowedWrite].join(' / ')}.`;
53
+ }
34
54
 
35
55
  const decision = await authorize(name, args);
36
56
  if (decision === 'deny') {
@@ -145,6 +145,7 @@ export const TOOL_DEFINITIONS = [
145
145
  texto: { type: 'string', description: 'Text to speak (long text is trimmed)' },
146
146
  voice: { type: 'string', description: 'Optional voice id, e.g. "es-AR-ElenaNeural"' },
147
147
  rate: { type: 'string', description: 'Optional speed, e.g. "+10%"' },
148
+ pitch: { type: 'string', description: 'Optional tone, e.g. "-10Hz"' },
148
149
  },
149
150
  required: ['texto'],
150
151
  },
@@ -2,15 +2,17 @@ import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { liveHistory, planCompaction } from '../src/agent/compact.js';
4
4
 
5
- test('liveHistory saca al asesor y recorta hasta el cursor', () => {
5
+ test('liveHistory saca al asesor y la marca de compactación, y recorta hasta el cursor', () => {
6
6
  const history = [
7
7
  { id: 'a', role: 'user', content: '1' },
8
8
  { id: 'b', role: 'assistant', name: 'advisor', content: 'charla' },
9
9
  { id: 'c', role: 'assistant', content: '2' },
10
10
  { id: 'd', role: 'user', content: '3' },
11
11
  { id: 'e', role: 'assistant', name: 'advisor', mode: 'summary', content: 'resumen de lo hecho' },
12
+ { id: 'f', role: 'assistant', name: 'compact', mode: 'compact', content: 'resumen de contexto' },
12
13
  ];
13
- // El consejo y el resumen del asesor nunca entran al contexto del modelo.
14
+ // El consejo, el resumen del asesor y la marca de compactación nunca entran
15
+ // al contexto del modelo.
14
16
  assert.deepEqual(liveHistory(history).map((m) => m.id), ['a', 'c', 'd']);
15
17
  assert.deepEqual(liveHistory(history, 'a').map((m) => m.id), ['c', 'd']);
16
18
  // Cursor inexistente: no perdemos contexto, devolvemos todo (sin asesor).
@@ -132,6 +132,35 @@ test('openaiCompatible manda max_tokens y respeta maxTokensField', async () => {
132
132
  assert.equal(bodies[2].max_tokens, undefined);
133
133
  });
134
134
 
135
+ test('openaiCompatible pide usage en streaming y cae si el server no lo soporta', async () => {
136
+ const bodies = [];
137
+ const original = globalThis.fetch;
138
+ const sse = 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: [DONE]\n\n';
139
+ globalThis.fetch = async (_url, init) => {
140
+ const body = JSON.parse(init.body);
141
+ bodies.push(body);
142
+ if (body.stream_options) {
143
+ return new Response(
144
+ JSON.stringify({ error: { message: 'Unrecognized request argument supplied: stream_options' } }),
145
+ { status: 400, headers: { 'content-type': 'application/json' } }
146
+ );
147
+ }
148
+ return new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream' } });
149
+ };
150
+ const cfg = { baseUrl: 'https://x/v1', apiKey: 'k' };
151
+ try {
152
+ const res = await openaiCompatibleProvider.streamChat(cfg, {
153
+ model: 'm',
154
+ messages: [{ role: 'user', content: 'u' }],
155
+ });
156
+ assert.equal(res.content, 'hi');
157
+ } finally {
158
+ globalThis.fetch = original;
159
+ }
160
+ assert.deepEqual(bodies[0].stream_options, { include_usage: true });
161
+ assert.equal(bodies[1].stream_options, undefined);
162
+ });
163
+
135
164
  test('applyPromptCache: anthropic marca el system, openai agrega la clave', () => {
136
165
  const anthropic = { messages: [{ role: 'system', content: 'sys' }, { role: 'user', content: 'u' }] };
137
166
  applyPromptCache(anthropic, { mode: 'anthropic' });
@@ -21,6 +21,52 @@ test('readOpenAISSE parses content, reasoning and tool calls', async () => {
21
21
  assert.equal(res.toolCalls[0].function.arguments, '{"a":1}');
22
22
  });
23
23
 
24
+ test('readOpenAISSE separa el pensamiento en línea (<think>) del contenido', async () => {
25
+ const sse = [
26
+ 'data: {"choices":[{"delta":{"content":"<thi"}}]}',
27
+ 'data: {"choices":[{"delta":{"content":"nk>pienso"}}]}',
28
+ 'data: {"choices":[{"delta":{"content":" mucho</thi"}}]}',
29
+ 'data: {"choices":[{"delta":{"content":"nk>Hola"}}]}',
30
+ 'data: [DONE]',
31
+ '',
32
+ ].join('\n');
33
+
34
+ const events = [];
35
+ const res = await readOpenAISSE(new Response(sse), { onEvent: (event) => events.push(event) });
36
+ assert.equal(res.reasoning, 'pienso mucho');
37
+ assert.equal(res.content, 'Hola');
38
+ assert.ok(events.some((event) => event.type === 'reasoning' && event.full.includes('pienso')));
39
+ assert.ok(events.some((event) => event.type === 'text' && event.full === 'Hola'));
40
+ });
41
+
42
+ test('readOpenAISSE parsea reasoning_content (DeepSeek/MiniMax)', async () => {
43
+ const sse = [
44
+ 'data: {"choices":[{"delta":{"reasoning_content":"pienso "}}]}',
45
+ 'data: {"choices":[{"delta":{"reasoning_content":"esto"}}]}',
46
+ 'data: {"choices":[{"delta":{"content":"Hola"}}]}',
47
+ 'data: {"choices":[],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}',
48
+ 'data: [DONE]',
49
+ '',
50
+ ].join('\n');
51
+
52
+ const res = await readOpenAISSE(new Response(sse));
53
+ assert.equal(res.reasoning, 'pienso esto');
54
+ assert.equal(res.content, 'Hola');
55
+ assert.deepEqual(res.usage, { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 });
56
+ });
57
+
58
+ test('readOpenAISSE no toca el contenido sin etiquetas <think>', async () => {
59
+ const sse = [
60
+ 'data: {"choices":[{"delta":{"content":"<p>hola</p>"}}]}',
61
+ 'data: {"choices":[{"delta":{"content":" mundo"}}]}',
62
+ 'data: [DONE]',
63
+ '',
64
+ ].join('\n');
65
+ const res = await readOpenAISSE(new Response(sse));
66
+ assert.equal(res.content, '<p>hola</p> mundo');
67
+ assert.equal(res.reasoning, '');
68
+ });
69
+
24
70
  test('readNDJSON parses Ollama messages and tool calls', async () => {
25
71
  const ndjson = [
26
72
  '{"message":{"content":"Hola "},"done":false}',
@@ -91,6 +91,36 @@ test('edit refuses ambiguous replacements without replaceAll', async () => {
91
91
  );
92
92
  });
93
93
 
94
+ test('writeExtensions limita write/edit por extension', async () => {
95
+ const root = await makeWorkspace();
96
+ const reg = createToolRegistry({
97
+ root,
98
+ permissions: { write: 'allow', edit: 'allow' },
99
+ writeExtensions: ['.md', '.json'],
100
+ });
101
+
102
+ assert.match(await reg.execute('write', { path: 'nota.md', content: '# hola' }), /Creado|Escrito/);
103
+ assert.match(await reg.execute('write', { path: 'datos.json', content: '{}' }), /Creado|Escrito/);
104
+ assert.match(await reg.execute('write', { path: 'src/x.txt', content: 'nope' }), /solo se permite crear o editar/);
105
+ assert.match(await reg.execute('write', { path: 'sin_extension', content: 'nope' }), /solo se permite crear o editar/);
106
+
107
+ assert.match(
108
+ await reg.execute('edit', { path: 'datos.json', oldString: '{}', newString: '{"a":1}' }),
109
+ /Editado/
110
+ );
111
+ assert.match(
112
+ await reg.execute('edit', { path: 'nota.txt', oldString: 'x', newString: 'y' }),
113
+ /solo se permite crear o editar/
114
+ );
115
+
116
+ // Leer sigue permitido aunque la extension no este en la lista.
117
+ assert.match(await reg.execute('read', { path: 'src/a.js' }), /const a = 1;/);
118
+
119
+ // Sin writeExtensions no hay restriccion (modo agent intacto).
120
+ const open = createToolRegistry({ root, permissions: { write: 'allow' } });
121
+ assert.match(await open.execute('write', { path: 'src/libre.txt', content: 'ok' }), /Creado|Escrito/);
122
+ });
123
+
94
124
  test('permission "ask" is denied by default and allowed with yes', async () => {
95
125
  const root = await makeWorkspace();
96
126
  const denied = createToolRegistry({ root });