feat(backend): resume + venv PATH + end-to-end F1 smoke (fake-pi-rpc)
- Add PiProcessManager.resume(sessionId, tht): reads provider/model/thinking from tht.sessionShow() and calls spawnFor with those values - Add POST /sessions/:id/resume route: calls mgr.resume then re-wires bridge.onClientEvent → hub.publish - Confirm venv PATH already present in real spawn (no change needed) - Add e2e test: POST /sessions → SSE receives ui_request via pendingWidget re-emit → POST /sessions/s1/response → 204 (all over real HTTP against fake-pi-rpc) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|||||||
import type { AppConfig } from "../config.js";
|
import type { AppConfig } from "../config.js";
|
||||||
import { RpcClient } from "../rpc/rpc-client.js";
|
import { RpcClient } from "../rpc/rpc-client.js";
|
||||||
import { SessionBridge } from "../bridge/session-bridge.js";
|
import { SessionBridge } from "../bridge/session-bridge.js";
|
||||||
|
import type { ThtRunner } from "../tht/tht-runner.js";
|
||||||
|
|
||||||
export interface SessionRuntime {
|
export interface SessionRuntime {
|
||||||
rpc: RpcClient;
|
rpc: RpcClient;
|
||||||
@@ -74,6 +75,15 @@ export class PiProcessManager {
|
|||||||
return rt;
|
return rt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async resume(sessionId: string, tht: ThtRunner): Promise<SessionRuntime> {
|
||||||
|
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 {
|
teardown(id: string): void {
|
||||||
const rt = this.runtimes.get(id);
|
const rt = this.runtimes.get(id);
|
||||||
if (rt) {
|
if (rt) {
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ export function sessionRoutes(app: FastifyInstance, d: { mgr: PiProcessManager;
|
|||||||
rt.bridge.steer((req.body as any).text);
|
rt.bridge.steer((req.body as any).text);
|
||||||
return reply.code(204).send();
|
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) => {
|
app.post("/sessions/:id/close", async (req) => {
|
||||||
d.mgr.teardown((req.params as any).id);
|
d.mgr.teardown((req.params as any).id);
|
||||||
return { closed: true };
|
return { closed: true };
|
||||||
|
|||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user