import { test, expect } from "vitest"; import { spawn } from "node:child_process"; import path from "node:path"; import { buildApp } from "../src/app.js"; import { loadConfig } from "../src/config.js"; const FAKE = path.resolve("../harness/tests/fake_pi/fake_pi_rpc.mjs"); const SCRIPT = path.resolve("../harness/tests/fake_pi/scripts/f1_disambiguation.json"); // Legge dallo stream SSE finche' `predicate(acc)` e' vero o scade il timeout. async function readUntil( reader: ReadableStreamDefaultReader, predicate: (acc: string) => boolean, timeoutMs: number, ): Promise { const dec = new TextDecoder(); let acc = ""; const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const res: any = await Promise.race([ reader.read(), new Promise((r) => setTimeout(() => r({ timeout: true }), deadline - Date.now())), ]); if (res.timeout || res.done) break; acc += dec.decode(res.value); if (predicate(acc)) return acc; } return acc; } test("loop F1: crea sessione → SSE riceve il widget → risponde → il modello riparte (follow-up)", async () => { const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness", THT_LEGACY_WORKSPACE_MODE: "local", }), { thtRunner: { ollamaEnsure: async () => ({ ok: true }), searchPack: async () => {}, sessionNew: async () => ({ id: "s1" }), sessionShow: async (_id: string) => ({ id: "s1", interaction_language: "en", provider: undefined, model: undefined, thinking: undefined }), sessionList: async () => [], } as any, spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any, }); await app.listen({ port: 0, host: "127.0.0.1" }); const base = `http://127.0.0.1:${(app.server.address() as any).port}`; // 1. Create session — spawns fake-pi, sends prompt, fake-pi emits widget const created = await fetch(`${base}/sessions`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ workspace: "w", question: "q", interactionLanguage: "en" }), }); expect(created.status).toBe(200); // 2. Small delay to let fake-pi process the prompt and fill pendingWidget await new Promise((r) => setTimeout(r, 50)); // 3. SSE: the pending widget is re-emitted on subscribe const es = await fetch(`${base}/sessions/s1/events`); const reader = es.body!.getReader(); const first = await readUntil(reader, (t) => t.includes("ui_request"), 2000); expect(first).toContain("ui_request"); // 4. POST response — widget id is "u1" from f1_disambiguation.json (id INTERNO del descriptor) const resp = await fetch(`${base}/sessions/s1/response`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ui_response: { id: "u1", choices: ["a"] } }), }); expect(resp.status).toBe(204); // 5. Prova del fix: la risposta deve essere correlata sull'id RPC di Pi, cosi' ctx.ui.input // si risolve e il modello produce il follow-up. Col bug, la risposta veniva scartata e // nessun follow-up arrivava ("stuck senza output"). const after = await readUntil(reader, (t) => t.includes("Procedo."), 2000); expect(after).toContain("Procedo."); await reader.cancel(); await app.close(); }, 10000);