@danieltmn/tamnora 0.9.6 → 0.11.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.
@@ -1,219 +0,0 @@
1
- import { test } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { spawn } from 'node:child_process';
4
- import { mkdtemp } from 'node:fs/promises';
5
- import { execFileSync } from 'node:child_process';
6
- import { tmpdir } from 'node:os';
7
- import path from 'node:path';
8
-
9
-
10
- import { createTamnoraServer } from '../src/index.js';
11
- import { setHome, startRelay, randomToken } from '../../core/src/index.js';
12
-
13
- const HAS_PHP = (() => {
14
- try {
15
- execFileSync('php', ['-v'], { stdio: 'ignore' });
16
- return true;
17
- } catch {
18
- return false;
19
- }
20
- })();
21
-
22
- const fakeProvider = {
23
- id: 'fake',
24
- async streamChat(_config, { onEvent }) {
25
- onEvent?.({ type: 'text', delta: 'hola', full: 'hola' });
26
- return { content: 'hola', reasoning: '', toolCalls: [] };
27
- },
28
- };
29
-
30
- /** SSE crudo por GET crudo (fetch con streaming del lado Node). */
31
- async function sseGet(url, { expectFrames = 1, timeoutMs = 30000 } = {}) {
32
- const frames = [];
33
- const controller = new AbortController();
34
- const timer = setTimeout(() => controller.abort(), timeoutMs);
35
- try {
36
- const res = await fetch(url, { signal: controller.signal });
37
- const reader = res.body.getReader();
38
- const decoder = new TextDecoder();
39
- let buf = '';
40
- while (frames.length < expectFrames) {
41
- const { value, done } = await reader.read();
42
- if (done) break;
43
- buf += decoder.decode(value, { stream: true });
44
- let index;
45
- while ((index = buf.indexOf('\n\n')) !== -1) {
46
- const frame = buf.slice(0, index);
47
- buf = buf.slice(index + 2);
48
- let event = null;
49
- let data = '';
50
- let id = null;
51
- for (const line of frame.split('\n')) {
52
- if (line.startsWith('event:')) event = line.slice(6).trim();
53
- else if (line.startsWith('data:')) data += line.slice(5).trim();
54
- else if (line.startsWith('id:')) id = line.slice(3).trim();
55
- }
56
- if (!event && !data) continue;
57
- let parsed = {};
58
- try {
59
- parsed = JSON.parse(data);
60
- } catch {
61
- parsed = { raw: data };
62
- }
63
- frames.push({ event, data: parsed, id });
64
- if (frames.length >= expectFrames) {
65
- controller.abort();
66
- break;
67
- }
68
- }
69
- }
70
- } catch (error) {
71
- if (error?.name !== 'AbortError' && frames.length < expectFrames) throw error;
72
- } finally {
73
- clearTimeout(timer);
74
- }
75
- return frames;
76
- }
77
-
78
- async function phpServer(docroot) {
79
- const port = 44000 + Math.floor(Math.random() * 4000);
80
- const args = ['-S', `127.0.0.1:${port}`, '-t', docroot];
81
- const child = spawn('php', args, {
82
- env: { ...process.env },
83
- stdio: ['ignore', 'pipe', 'pipe'],
84
- windowsHide: true,
85
- });
86
- let stderr = '';
87
- child.stderr.on('data', (d) => (stderr += String(d)));
88
- let exited = null;
89
- child.on('exit', (code) => (exited = code));
90
- const base = `http://127.0.0.1:${port}/index.php`;
91
- // esperar a que escuche
92
- let lastError = '';
93
- for (let i = 0; i < 60; i++) {
94
- try {
95
- await fetch(`${base}?a=status`, { signal: AbortSignal.timeout(1000) });
96
- return { child, base };
97
- } catch (error) {
98
- lastError = error?.message ?? String(error);
99
- if (exited !== null) break;
100
- await new Promise((r) => setTimeout(r, 250));
101
- }
102
- }
103
- throw new Error(
104
- `php server no arranco: ${JSON.stringify({ docroot, exited, lastError, stderr: stderr.slice(0, 400) })}`
105
- );
106
- }
107
-
108
- /**
109
- * El PHP built-in en Windows es monohilo (PHP_CLI_SERVER_WORKERS no existe
110
- * alla); send/poll largos se bloquearian mutuamente. Para el test corremos
111
- * DOS servers sobre la misma SQLite (WAL es multi-proceso): el agente usa
112
- * uno, el browser el otro. En cPanel con FPM esto no hace falta.
113
- */
114
- async function relayStack(docroot) {
115
- const agent = await phpServer(docroot);
116
- const browser = await phpServer(docroot);
117
- return { children: [agent.child, browser.child], relayBase: agent.base, browserBase: browser.base };
118
- }
119
-
120
- test('el relay PHP mailbox carga y hace GC', async (t) => {
121
- if (!HAS_PHP) return t.skip('sin PHP disponible');
122
- const docroot = path.join(import.meta.dirname, '..', '..', '..', '..', 'relay');
123
- const { child, base } = await phpServer(docroot);
124
- try {
125
- const status = await (await fetch(`${base}?a=status`)).json();
126
- assert.equal(status.ok, true);
127
-
128
- const token = randomToken(24);
129
- const reg = await (
130
- await fetch(`${base}?a=register`, {
131
- method: 'POST',
132
- headers: { 'Content-Type': 'application/json' },
133
- body: JSON.stringify({ token }),
134
- })
135
- ).json();
136
- assert.equal(reg.ok, true);
137
- assert.equal(reg.cursor, 0);
138
-
139
- const bad = await fetch(`${base}?a=send&t=${token}`, {
140
- method: 'POST',
141
- body: JSON.stringify({ path: 'nope' }),
142
- });
143
- assert.equal(bad.status, 400);
144
- } finally {
145
- child.kill('SIGTERM');
146
- }
147
- });
148
-
149
- test('end-to-end: request no-stream y stream de chat via relay', async (t) => {
150
- if (!HAS_PHP) return t.skip('sin PHP disponible');
151
-
152
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-relay-'));
153
- setHome(cwd);
154
- const instance = await createTamnoraServer({
155
- cwd,
156
- dbPath: path.join(cwd, 'tamnora.db'),
157
- loadMcp: false,
158
- resolveProvider: () => ({ provider: fakeProvider, config: {} }),
159
- });
160
- const local = await instance.listen({ port: 0 });
161
-
162
- const docroot = path.join(import.meta.dirname, '..', '..', '..', '..', 'relay');
163
- const { children, relayBase, browserBase } = await relayStack(docroot);
164
- const token = randomToken(24);
165
- const logs = [];
166
- const pump = startRelay(local.port, { relayUrl: relayBase, token, log: (line) => logs.push(line) });
167
-
168
- try {
169
- // 1) request sincrono: /api/health pasa entero
170
- const health = await (
171
- await fetch(`${browserBase}?a=send&t=${token}`, {
172
- method: 'POST',
173
- headers: { 'Content-Type': 'application/json' },
174
- body: JSON.stringify({ method: 'GET', path: '/api/health' }),
175
- })
176
- ).json();
177
- assert.equal(health.status, 200);
178
- assert.equal(JSON.parse(Buffer.from(health.body, 'base64').toString('utf8')).ok, true);
179
-
180
- // 2) crear sesion y correr un turno SSE via mailbox
181
- const session = await (
182
- await fetch(`${browserBase}?a=send&t=${token}`, {
183
- method: 'POST',
184
- headers: { 'Content-Type': 'application/json' },
185
- body: JSON.stringify({ method: 'POST', path: '/api/sessions', body: JSON.stringify({ title: 'relay' }) }),
186
- })
187
- ).json();
188
- assert.equal(session.status, 201, `sesion: ${session.body && Buffer.from(session.body, 'base64').toString('utf8')}`);
189
- const sessionId = JSON.parse(Buffer.from(session.body, 'base64').toString('utf8')).session.id;
190
-
191
- const opened = await (
192
- await fetch(`${browserBase}?a=send&t=${token}`, {
193
- method: 'POST',
194
- headers: { 'Content-Type': 'application/json' },
195
- body: JSON.stringify({
196
- method: 'POST',
197
- path: '/api/chat',
198
- body: JSON.stringify({ input: 'hola', sessionId, model: 'm' }),
199
- stream: true,
200
- }),
201
- })
202
- ).json();
203
- assert.ok(opened.rid, 'a=send stream devuelve rid');
204
-
205
- const frames = await sseGet(`${browserBase}?a=stream&t=${token}&rid=${opened.rid}&from=0`, { expectFrames: 20 });
206
- const events = frames.filter((f) => f.event === 'chunk').map((f) => f.data.payload?.ev);
207
- assert.ok(events.includes('start'), `start en ${JSON.stringify(events)}`);
208
- assert.ok(events.includes('text'), `text en ${JSON.stringify(events)}`);
209
- const doneRow = frames.some((f) => f.event === 'meta' && f.data.payload?.done === true);
210
- assert.ok(doneRow, 'la fila final done cierra el stream');
211
- } catch (error) {
212
- error.logs = logs.slice();
213
- throw error;
214
- } finally {
215
- pump.stop();
216
- for (const child of children) child.kill('SIGTERM');
217
- await instance.close();
218
- }
219
- });