@johpaz/hive-sdk 0.1.5 → 0.1.6
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 +32 -0
- package/README.md +1 -1
- package/bun.lock +55 -29
- package/package.json +9 -9
- package/packages/cli/package.json +1 -1
- package/packages/core/package.json +9 -9
- package/packages/core/src/agent/acceptance-checks.ts +7 -1
- package/packages/core/src/config/loader.ts +5 -0
- package/packages/core/src/tools/web/browser-backend.ts +129 -0
- package/packages/core/src/tools/web/browser-service.ts +75 -35
- package/packages/core/src/tools/web/webview-backend.ts +412 -0
- package/test/acceptance-checks.test.ts +403 -0
- package/test/browser-backend.test.ts +308 -0
- package/test/tool-selector-runtime-tools.test.ts +117 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrowserBackend — selección de backend y el WebViewBackend real.
|
|
3
|
+
*
|
|
4
|
+
* Los tests marcados como "vivos" abren un navegador de verdad y se saltan solos
|
|
5
|
+
* donde no hay motor (sin Chrome y sin macOS). Los de selección son puros y
|
|
6
|
+
* corren siempre: son los que evitan que un cambio de default mande el gateway
|
|
7
|
+
* headless a un backend que necesita display.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
process.env.HIVE_DB_PATH = ":memory:";
|
|
11
|
+
|
|
12
|
+
import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test";
|
|
13
|
+
import {
|
|
14
|
+
resolveBackendKind,
|
|
15
|
+
isWebViewSupported,
|
|
16
|
+
resolveWebViewEngine,
|
|
17
|
+
findChrome,
|
|
18
|
+
} from "../packages/core/src/tools/web/browser-backend.ts";
|
|
19
|
+
import { WebViewBackend } from "../packages/core/src/tools/web/webview-backend.ts";
|
|
20
|
+
|
|
21
|
+
const ORIGINAL_ENV = process.env.HIVE_BROWSER_BACKEND;
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
if (ORIGINAL_ENV === undefined) delete process.env.HIVE_BROWSER_BACKEND;
|
|
25
|
+
else process.env.HIVE_BROWSER_BACKEND = ORIGINAL_ENV;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Una sola vez, al final del archivo: `closeAll()` mata TODOS los subprocesos de
|
|
29
|
+
// navegador del proceso, así que dentro de un `afterAll` por bloque le arranca
|
|
30
|
+
// Chrome de abajo al bloque siguiente ("Chrome killed by signal 9").
|
|
31
|
+
afterAll(() => {
|
|
32
|
+
(globalThis as { Bun?: { WebView?: { closeAll?: () => void } } }).Bun?.WebView?.closeAll?.();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// ─── selección de backend ─────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
describe("resolveBackendKind", () => {
|
|
38
|
+
test("el default es agent-browser — es el único que corre headless sin Chrome", () => {
|
|
39
|
+
delete process.env.HIVE_BROWSER_BACKEND;
|
|
40
|
+
expect(resolveBackendKind(undefined)).toBe("agent-browser");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("respeta la preferencia de config", () => {
|
|
44
|
+
delete process.env.HIVE_BROWSER_BACKEND;
|
|
45
|
+
expect(resolveBackendKind("webview")).toBe("webview");
|
|
46
|
+
expect(resolveBackendKind("agent-browser")).toBe("agent-browser");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("HIVE_BROWSER_BACKEND pisa la config", () => {
|
|
50
|
+
process.env.HIVE_BROWSER_BACKEND = "webview";
|
|
51
|
+
expect(resolveBackendKind("agent-browser")).toBe("webview");
|
|
52
|
+
|
|
53
|
+
process.env.HIVE_BROWSER_BACKEND = "agent-browser";
|
|
54
|
+
expect(resolveBackendKind("webview")).toBe("agent-browser");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("'auto' nunca elige webview si no hay motor disponible", () => {
|
|
58
|
+
delete process.env.HIVE_BROWSER_BACKEND;
|
|
59
|
+
const kind = resolveBackendKind("auto");
|
|
60
|
+
expect(kind).toBe(isWebViewSupported() ? "webview" : "agent-browser");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("un valor desconocido cae al default en vez de romper", () => {
|
|
64
|
+
process.env.HIVE_BROWSER_BACKEND = "netscape";
|
|
65
|
+
expect(resolveBackendKind("webview")).toBe("agent-browser");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("detección de motor", () => {
|
|
70
|
+
test("resolveWebViewEngine devuelve webkit en macOS y chrome sólo si hay binario", () => {
|
|
71
|
+
const engine = resolveWebViewEngine();
|
|
72
|
+
if (process.platform === "darwin") {
|
|
73
|
+
expect(engine).toBe("webkit");
|
|
74
|
+
} else {
|
|
75
|
+
expect(engine).toBe(findChrome() ? "chrome" : null);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("BUN_CHROME_PATH gana sobre la búsqueda en rutas estándar", () => {
|
|
80
|
+
const previous = process.env.BUN_CHROME_PATH;
|
|
81
|
+
process.env.BUN_CHROME_PATH = "/ruta/propia/chrome";
|
|
82
|
+
try {
|
|
83
|
+
expect(findChrome()).toBe("/ruta/propia/chrome");
|
|
84
|
+
} finally {
|
|
85
|
+
if (previous === undefined) delete process.env.BUN_CHROME_PATH;
|
|
86
|
+
else process.env.BUN_CHROME_PATH = previous;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ─── WebViewBackend vivo ──────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
const LIVE = isWebViewSupported();
|
|
94
|
+
// El charset va explícito: sin él el motor asume latin-1 y "botón" llega como
|
|
95
|
+
// "botón". Una página real lo declara; una data: URL no.
|
|
96
|
+
const page = (html: string) => `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
|
|
97
|
+
|
|
98
|
+
describe.skipIf(!LIVE)("WebViewBackend (navegador real)", () => {
|
|
99
|
+
let backend: WebViewBackend;
|
|
100
|
+
|
|
101
|
+
beforeEach(() => {
|
|
102
|
+
backend = new WebViewBackend();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
backend.close();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("navega y evalúa en la página", async () => {
|
|
110
|
+
await backend.navigate(page("<h1>hola mundo</h1>"));
|
|
111
|
+
expect(await backend.evaluate<string>("document.querySelector('h1').textContent")).toBe("hola mundo");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("serializa operaciones concurrentes — WebView rechaza las solapadas", async () => {
|
|
115
|
+
// Sin la cola interna esto falla con
|
|
116
|
+
// "ERR_INVALID_STATE: a simple operation is already pending".
|
|
117
|
+
await backend.navigate(page("<p id=x>1</p>"));
|
|
118
|
+
|
|
119
|
+
const results = await Promise.all([
|
|
120
|
+
backend.evaluate("1 + 1"),
|
|
121
|
+
backend.evaluate("2 + 2"),
|
|
122
|
+
backend.evaluate("3 + 3"),
|
|
123
|
+
backend.evaluate("document.getElementById('x').textContent"),
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
expect(results).toEqual([2, 4, 6, "1"]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("una operación fallida no rompe la cola para las siguientes", async () => {
|
|
130
|
+
await backend.navigate(page("<h1>sigo viva</h1>"));
|
|
131
|
+
|
|
132
|
+
await expect(backend.evaluate("esto.no.existe()")).rejects.toThrow();
|
|
133
|
+
expect(await backend.evaluate<string>("document.querySelector('h1').textContent")).toBe("sigo viva");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("click y fill operan por selector CSS", async () => {
|
|
137
|
+
await backend.navigate(
|
|
138
|
+
page(`<input id="i"><button id="b" onclick="document.getElementById('i').dataset.hit='1'">Go</button>`),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
await backend.fill("#i", "texto nuevo");
|
|
142
|
+
expect(await backend.evaluate<string>("document.getElementById('i').value")).toBe("texto nuevo");
|
|
143
|
+
|
|
144
|
+
await backend.click("#b");
|
|
145
|
+
expect(await backend.evaluate<string>("document.getElementById('i').dataset.hit")).toBe("1");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("fill reemplaza el contenido en vez de agregarlo", async () => {
|
|
149
|
+
await backend.navigate(page(`<input id="i" value="viejo">`));
|
|
150
|
+
await backend.fill("#i", "nuevo");
|
|
151
|
+
expect(await backend.evaluate<string>("document.getElementById('i').value")).toBe("nuevo");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("typeIn y fill fallan claro cuando el selector no existe", async () => {
|
|
155
|
+
await backend.navigate(page("<h1>x</h1>"));
|
|
156
|
+
await expect(backend.typeIn("#no-existe", "x")).rejects.toThrow(/no encontrado/);
|
|
157
|
+
await expect(backend.fill("#no-existe", "x")).rejects.toThrow(/no encontrado/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("la navegación hacia atrás usa goBack — los tipos de bun-types dicen back()", async () => {
|
|
161
|
+
await backend.navigate(page("<h1>primera</h1>"));
|
|
162
|
+
await backend.navigate(page("<h1>segunda</h1>"));
|
|
163
|
+
expect(await backend.evaluate<string>("document.querySelector('h1').textContent")).toBe("segunda");
|
|
164
|
+
|
|
165
|
+
await backend.back();
|
|
166
|
+
expect(await backend.evaluate<string>("document.querySelector('h1').textContent")).toBe("primera");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("screenshot devuelve un PNG en base64", async () => {
|
|
170
|
+
await backend.navigate(page("<h1>foto</h1>"));
|
|
171
|
+
const shot = await backend.screenshot();
|
|
172
|
+
expect(shot.length).toBeGreaterThan(100);
|
|
173
|
+
// Cabecera PNG (\x89PNG) en base64.
|
|
174
|
+
expect(shot.startsWith("iVBORw0KGgo")).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("screenshotElement recorta al elemento pedido", async () => {
|
|
178
|
+
await backend.navigate(
|
|
179
|
+
page(`<div id="chico" style="width:80px;height:40px;background:red"></div>`),
|
|
180
|
+
);
|
|
181
|
+
const shot = await backend.screenshotElement("#chico");
|
|
182
|
+
expect(shot.startsWith("iVBORw0KGgo")).toBe(true);
|
|
183
|
+
// El recorte tiene que pesar menos que la captura del viewport entero.
|
|
184
|
+
expect(shot.length).toBeLessThan((await backend.screenshot()).length);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("screenshotElement falla claro si el elemento no existe", async () => {
|
|
188
|
+
await backend.navigate(page("<h1>x</h1>"));
|
|
189
|
+
await expect(backend.screenshotElement("#fantasma")).rejects.toThrow(/no visible o inexistente/);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ─── snapshot sintetizado ─────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
describe.skipIf(!LIVE)("WebViewBackend.snapshot", () => {
|
|
196
|
+
let backend: WebViewBackend;
|
|
197
|
+
|
|
198
|
+
beforeEach(() => {
|
|
199
|
+
backend = new WebViewBackend();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
afterEach(() => {
|
|
203
|
+
backend.close();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("reproduce el formato de agent-browser: rol, nombre y ref", async () => {
|
|
207
|
+
await backend.navigate(page(`<h1>Example Domain</h1><p><a href="/x">Learn more</a></p>`));
|
|
208
|
+
const snapshot = await backend.snapshot({ compact: true, depth: 3 });
|
|
209
|
+
|
|
210
|
+
expect(snapshot).toContain('- heading "Example Domain" [level=1, ref=e1]');
|
|
211
|
+
expect(snapshot).toContain('link "Learn more"');
|
|
212
|
+
expect(snapshot).toMatch(/ref=e\d+/);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("anida los hijos bajo el padre que emitió línea", async () => {
|
|
216
|
+
await backend.navigate(page(`<p>Intro <a href="/x">un link</a></p>`));
|
|
217
|
+
const snapshot = await backend.snapshot();
|
|
218
|
+
|
|
219
|
+
const linea = snapshot.split("\n").find((l) => l.includes("link"));
|
|
220
|
+
expect(linea?.startsWith(" ")).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("los divs de maquetado no generan sangría — el árbol no se hunde", async () => {
|
|
224
|
+
await backend.navigate(
|
|
225
|
+
page(`<div><div><div><div><button>Enviar</button></div></div></div></div>`),
|
|
226
|
+
);
|
|
227
|
+
const snapshot = await backend.snapshot({ depth: 3 });
|
|
228
|
+
|
|
229
|
+
// Cuatro divs de por medio y el botón sigue en el nivel raíz.
|
|
230
|
+
expect(snapshot).toBe('- button "Enviar" [ref=e1]');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("ignora script, style y lo oculto", async () => {
|
|
234
|
+
await backend.navigate(
|
|
235
|
+
page(`<script>var secreto=1</script><style>.x{}</style>
|
|
236
|
+
<div hidden><a href="/h">oculto</a></div>
|
|
237
|
+
<div style="display:none"><a href="/d">tampoco</a></div>
|
|
238
|
+
<div aria-hidden="true"><a href="/a">ni este</a></div>
|
|
239
|
+
<a href="/v">visible</a>`),
|
|
240
|
+
);
|
|
241
|
+
const snapshot = await backend.snapshot();
|
|
242
|
+
|
|
243
|
+
expect(snapshot).toContain("visible");
|
|
244
|
+
expect(snapshot).not.toContain("secreto");
|
|
245
|
+
expect(snapshot).not.toContain("oculto");
|
|
246
|
+
expect(snapshot).not.toContain("tampoco");
|
|
247
|
+
expect(snapshot).not.toContain("ni este");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("toma el nombre accesible de aria-label, alt y placeholder", async () => {
|
|
251
|
+
await backend.navigate(
|
|
252
|
+
page(`<button aria-label="Cerrar ventana"></button>
|
|
253
|
+
<img src="x.png" alt="Un gato">
|
|
254
|
+
<input placeholder="Tu correo">`),
|
|
255
|
+
);
|
|
256
|
+
const snapshot = await backend.snapshot();
|
|
257
|
+
|
|
258
|
+
expect(snapshot).toContain("Cerrar ventana");
|
|
259
|
+
expect(snapshot).toContain("Un gato");
|
|
260
|
+
expect(snapshot).toContain("Tu correo");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("interactiveOnly deja sólo lo accionable", async () => {
|
|
264
|
+
await backend.navigate(
|
|
265
|
+
page(`<h1>Un título</h1><p>Un párrafo largo</p><a href="/x">Un link</a><button>Un botón</button>`),
|
|
266
|
+
);
|
|
267
|
+
const snapshot = await backend.snapshot({ interactiveOnly: true });
|
|
268
|
+
|
|
269
|
+
expect(snapshot).toContain("Un link");
|
|
270
|
+
expect(snapshot).toContain("Un botón");
|
|
271
|
+
expect(snapshot).not.toContain("Un título");
|
|
272
|
+
expect(snapshot).not.toContain("Un párrafo largo");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("depth corta la profundidad de lo que sí anida", async () => {
|
|
276
|
+
// La profundidad cuenta niveles *emitidos*, no nodos del DOM: por eso hace
|
|
277
|
+
// falta un ancestro con nombre propio (el nav) para que el link baje un nivel.
|
|
278
|
+
await backend.navigate(page(`<nav aria-label="Menú"><a href="/x">hondo</a></nav>`));
|
|
279
|
+
|
|
280
|
+
expect(await backend.snapshot({ depth: 1 })).not.toContain("hondo");
|
|
281
|
+
expect(await backend.snapshot({ depth: 5 })).toContain("hondo");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("marca los atributos de estado del control", async () => {
|
|
285
|
+
await backend.navigate(
|
|
286
|
+
page(`<input type="checkbox" aria-label="Acepto" checked>
|
|
287
|
+
<button disabled aria-label="Enviar">x</button>`),
|
|
288
|
+
);
|
|
289
|
+
const snapshot = await backend.snapshot();
|
|
290
|
+
|
|
291
|
+
expect(snapshot).toContain("checked");
|
|
292
|
+
expect(snapshot).toContain("disabled");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("un DOM enorme se trunca en vez de comerse el contexto", async () => {
|
|
296
|
+
const filas = Array.from({ length: 4000 }, (_, i) => `<p>fila número ${i} con texto de relleno</p>`).join("");
|
|
297
|
+
await backend.navigate(page(`<div>${filas}</div>`));
|
|
298
|
+
|
|
299
|
+
const snapshot = await backend.snapshot();
|
|
300
|
+
expect(snapshot.length).toBeLessThan(21_000);
|
|
301
|
+
expect(snapshot).toContain("snapshot truncado");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("una página vacía devuelve string vacío, no una excepción", async () => {
|
|
305
|
+
await backend.navigate(page("<body></body>"));
|
|
306
|
+
expect(await backend.snapshot()).toBe("");
|
|
307
|
+
});
|
|
308
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-selector — las tools que sólo viven en la colección `tools`.
|
|
3
|
+
*
|
|
4
|
+
* `syncToolCatalogToIndex` construye el índice BM25 con CORE_TOOL_CATALOG **más**
|
|
5
|
+
* las filas de la colección. `selectTools`, en cambio, resolvía los hits contra
|
|
6
|
+
* `fullToolList`, que por defecto es sólo CORE_TOOL_CATALOG. Una tool registrada
|
|
7
|
+
* en runtime podía salir primera en la búsqueda y aun así no llegar nunca al
|
|
8
|
+
* modelo: el hit se descartaba en silencio, sin log ni error.
|
|
9
|
+
*
|
|
10
|
+
* Acá no se notaba porque todas las tools de hive están en el catálogo estático.
|
|
11
|
+
* Se detectó en el SDK, donde las apps declaran tools con `defineTool`.
|
|
12
|
+
*
|
|
13
|
+
* Usa HIVE_DB_PATH=":memory:".
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
process.env.HIVE_DB_PATH = ":memory:";
|
|
17
|
+
|
|
18
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
19
|
+
import { closeHiveDb } from "../packages/core/src/storage/hivedb";
|
|
20
|
+
import { ensureHiveDb } from "../packages/core/src/storage/bootstrap";
|
|
21
|
+
import { col } from "../packages/core/src/storage/hive";
|
|
22
|
+
import type { ToolDoc } from "../packages/core/src/storage/collections";
|
|
23
|
+
import { selectTools, syncToolCatalogToIndex } from "../packages/core/src/agent/tool-selector";
|
|
24
|
+
|
|
25
|
+
/** Nombre y vocabulario deliberadamente ajenos al catálogo estático. */
|
|
26
|
+
const RUNTIME_TOOL = "consultar_inventario_ferreteria";
|
|
27
|
+
const QUERY = "necesito consultar el inventario de la ferretería";
|
|
28
|
+
|
|
29
|
+
async function registerRuntimeTool(overrides: Partial<ToolDoc> = {}): Promise<void> {
|
|
30
|
+
const tools = await col<ToolDoc>("tools");
|
|
31
|
+
await tools.put(RUNTIME_TOOL, {
|
|
32
|
+
id: RUNTIME_TOOL,
|
|
33
|
+
name: RUNTIME_TOOL,
|
|
34
|
+
description: "Consulta el inventario de la ferretería: stock, existencias y precios de artículos",
|
|
35
|
+
category: "core",
|
|
36
|
+
enabled: true,
|
|
37
|
+
active: true,
|
|
38
|
+
created_at: Date.now(),
|
|
39
|
+
updated_at: Date.now(),
|
|
40
|
+
...overrides,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
beforeEach(async () => {
|
|
45
|
+
closeHiveDb();
|
|
46
|
+
await ensureHiveDb();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
closeHiveDb();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("selectTools con tools registradas en runtime", () => {
|
|
54
|
+
test("una tool que sólo está en la colección llega al modelo", async () => {
|
|
55
|
+
await registerRuntimeTool();
|
|
56
|
+
await syncToolCatalogToIndex();
|
|
57
|
+
|
|
58
|
+
const selected = await selectTools(QUERY);
|
|
59
|
+
|
|
60
|
+
expect(selected.map((t) => t.name)).toContain(RUNTIME_TOOL);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("conserva la descripción de la fila — es lo que el modelo lee para decidir", async () => {
|
|
64
|
+
await registerRuntimeTool();
|
|
65
|
+
await syncToolCatalogToIndex();
|
|
66
|
+
|
|
67
|
+
const tool = (await selectTools(QUERY)).find((t) => t.name === RUNTIME_TOOL);
|
|
68
|
+
|
|
69
|
+
expect(tool?.description).toContain("inventario de la ferretería");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("una tool deshabilitada no se ofrece aunque esté indexada", async () => {
|
|
73
|
+
// El índice se construye desde la colección sin mirar `enabled`, así que la
|
|
74
|
+
// única defensa es el filtro al resolver el hit.
|
|
75
|
+
await registerRuntimeTool({ enabled: false });
|
|
76
|
+
await syncToolCatalogToIndex();
|
|
77
|
+
|
|
78
|
+
const selected = await selectTools(QUERY);
|
|
79
|
+
|
|
80
|
+
expect(selected.map((t) => t.name)).not.toContain(RUNTIME_TOOL);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("una tool inactiva tampoco se ofrece", async () => {
|
|
84
|
+
await registerRuntimeTool({ active: false });
|
|
85
|
+
await syncToolCatalogToIndex();
|
|
86
|
+
|
|
87
|
+
const selected = await selectTools(QUERY);
|
|
88
|
+
|
|
89
|
+
expect(selected.map((t) => t.name)).not.toContain(RUNTIME_TOOL);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("un hit sin fila en la colección se descarta sin romper la selección", async () => {
|
|
93
|
+
// El índice puede quedar con basura de una tool borrada; el resto del
|
|
94
|
+
// loadout tiene que seguir funcionando.
|
|
95
|
+
await registerRuntimeTool();
|
|
96
|
+
await syncToolCatalogToIndex();
|
|
97
|
+
await (await col<ToolDoc>("tools")).delete(RUNTIME_TOOL);
|
|
98
|
+
|
|
99
|
+
const selected = await selectTools(QUERY);
|
|
100
|
+
|
|
101
|
+
expect(selected.map((t) => t.name)).not.toContain(RUNTIME_TOOL);
|
|
102
|
+
expect(Array.isArray(selected)).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("no rompe la resolución de las tools del catálogo estático", async () => {
|
|
106
|
+
await registerRuntimeTool();
|
|
107
|
+
await syncToolCatalogToIndex();
|
|
108
|
+
|
|
109
|
+
const selected = await selectTools("buscá en la web información sobre tarifas de envío");
|
|
110
|
+
|
|
111
|
+
expect(selected.length).toBeGreaterThan(0);
|
|
112
|
+
for (const tool of selected) {
|
|
113
|
+
expect(tool.name).toBeString();
|
|
114
|
+
expect(tool.description).toBeString();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|