diff --git a/backend/src/pi/pi-process-manager.ts b/backend/src/pi/pi-process-manager.ts index d0b0a5d7..e06c98ad 100644 --- a/backend/src/pi/pi-process-manager.ts +++ b/backend/src/pi/pi-process-manager.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import type { AppConfig } from "../config.js"; import { RpcClient } from "../rpc/rpc-client.js"; import { SessionBridge } from "../bridge/session-bridge.js"; +import type { ThtRunner } from "../tht/tht-runner.js"; export interface SessionRuntime { rpc: RpcClient; @@ -74,6 +75,15 @@ export class PiProcessManager { return rt; } + async resume(sessionId: string, tht: ThtRunner): Promise { + const manifest = await tht.sessionShow(sessionId) as { provider?: string; model?: string; thinking?: string } | null; + return this.spawnFor(sessionId, { + provider: manifest?.provider, + model: manifest?.model, + thinking: manifest?.thinking, + }); + } + teardown(id: string): void { const rt = this.runtimes.get(id); if (rt) { diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index d78292c3..ef011ba2 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -31,6 +31,12 @@ export function sessionRoutes(app: FastifyInstance, d: { mgr: PiProcessManager; rt.bridge.steer((req.body as any).text); return reply.code(204).send(); }); + app.post("/sessions/:id/resume", async (req, reply) => { + const id = (req.params as any).id; + const rt = await d.mgr.resume(id, d.tht); + rt.bridge.onClientEvent((e) => d.hub.publish(id, e.type, e)); + return reply.code(200).send({ id }); + }); app.post("/sessions/:id/close", async (req) => { d.mgr.teardown((req.params as any).id); return { closed: true }; diff --git a/backend/test/e2e-f1.test.ts b/backend/test/e2e-f1.test.ts new file mode 100644 index 00000000..8c3f1fb1 --- /dev/null +++ b/backend/test/e2e-f1.test.ts @@ -0,0 +1,49 @@ +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"); + +test("loop F1: crea sessione → SSE riceve il widget → risponde → 204", async () => { + const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + thtRunner: { + sessionNew: async () => ({ id: "s1" }), + sessionShow: async (_id: string) => ({ id: "s1", 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 + await fetch(`${base}/sessions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ workspace: "w", question: "q" }), + }); + + // 2. Small delay to let fake-pi process the prompt and fill pendingWidget + await new Promise((r) => setTimeout(r, 50)); + + // 3. SSE: read the first event (pendingWidget re-emit via subscribe) + const es = await fetch(`${base}/sessions/s1/events`); + const reader = es.body!.getReader(); + const chunk = await reader.read(); + const text = new TextDecoder().decode(chunk.value); + expect(text).toContain("ui_request"); + await reader.cancel(); + + // 4. POST response — widget id is "u1" from f1_disambiguation.json + 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); + + await app.close(); +}, 10000);