From 2d680c958cca1f2dcbbbdeb62b62a2ad40e12966 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sat, 27 Jun 2026 20:56:26 +0200 Subject: [PATCH] feat(backend): SessionBridge widget-descriptor <-> RPC + pending widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decodes native extension_ui_request (method:input, title=JSON) into ui_request ClientEvent; encodes respond() as extension_ui_response with value payload; tracks pending widget; handles notify→info and steer. Co-Authored-By: Claude Sonnet 4.6 --- backend/src/bridge/session-bridge.ts | 43 ++++++++++++++++++++++++++++ backend/test/session-bridge.test.ts | 42 +++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 backend/src/bridge/session-bridge.ts create mode 100644 backend/test/session-bridge.test.ts diff --git a/backend/src/bridge/session-bridge.ts b/backend/src/bridge/session-bridge.ts new file mode 100644 index 00000000..80a13e70 --- /dev/null +++ b/backend/src/bridge/session-bridge.ts @@ -0,0 +1,43 @@ +import type { RpcClient } from "../rpc/rpc-client.js"; + +export type ClientEvent = + | { type: "ui_request"; ui_request: any } + | { type: "text_delta"; text: string } + | { type: "info"; [k: string]: any } + | { type: "system_event"; [k: string]: any }; + +export class SessionBridge { + private pending: any = null; + private cbs = new Set<(e: ClientEvent) => void>(); + + constructor(private rpc: RpcClient) { + rpc.on("event", (m) => { + if (m.type === "extension_ui_request" && m.method === "input") { + let descriptor: any; + try { descriptor = JSON.parse(m.title); } catch { return; } + this.pending = descriptor; + this.fan({ type: "ui_request", ui_request: descriptor }); + } else if (m.type === "extension_ui_request" && m.method === "notify") { + this.fan({ type: "info", level: m.notifyType ?? "info", text: m.message ?? "" }); + } else if (m.type === "text_delta") { + this.fan({ type: "text_delta", text: m.text ?? "" }); + } else if (m.type === "system_event") { + this.fan(m as ClientEvent); + } + // altri method nativi (setStatus/setWidget) e altri eventi Pi ignorati in MVP + }); + } + + private fan(e: ClientEvent) { for (const cb of this.cbs) cb(e); } + + onClientEvent(cb: (e: ClientEvent) => void): void { this.cbs.add(cb); } + + respond(uiResponse: object & { id: string }): void { + this.rpc.send({ type: "extension_ui_response", id: uiResponse.id, value: JSON.stringify(uiResponse) }); + if (this.pending && uiResponse.id === this.pending.id) this.pending = null; + } + + steer(text: string): void { this.rpc.send({ type: "steer", message: text }); } + + pendingWidget(): object | null { return this.pending; } +} diff --git a/backend/test/session-bridge.test.ts b/backend/test/session-bridge.test.ts new file mode 100644 index 00000000..2957c855 --- /dev/null +++ b/backend/test/session-bridge.test.ts @@ -0,0 +1,42 @@ +import { test, expect, vi } from "vitest"; +import { SessionBridge } from "../src/bridge/session-bridge.js"; + +function fakeRpc() { + const sent: any[] = []; + let evcb: any; + return { + rpc: { + send: (c: any) => sent.push(c), + on: (_: any, cb: any) => { evcb = cb; }, + request: vi.fn(), + } as any, + sent, + fire: (m: any) => evcb(m), + }; +} + +test("extension_ui_request nativo (method:input, title=json) diventa ui_request ed è il pendente", () => { + const { rpc, fire } = fakeRpc(); + const b = new SessionBridge(rpc); + const seen: any[] = []; + b.onClientEvent((e) => seen.push(e)); + const descriptor = { id: "u1", widget: "select" }; + fire({ type: "extension_ui_request", id: "u1", method: "input", title: JSON.stringify(descriptor) }); + expect(seen[0]).toEqual({ type: "ui_request", ui_request: descriptor }); + expect(b.pendingWidget()).toEqual(descriptor); +}); + +test("respond invia extension_ui_response con payload in value e azzera il pendente", () => { + const { rpc, sent, fire } = fakeRpc(); + const b = new SessionBridge(rpc); + fire({ type: "extension_ui_request", id: "u1", method: "input", title: JSON.stringify({ id: "u1", widget: "select" }) }); + b.respond({ id: "u1", choices: ["a"] }); + expect(sent.at(-1)).toEqual({ type: "extension_ui_response", id: "u1", value: JSON.stringify({ id: "u1", choices: ["a"] }) }); + expect(b.pendingWidget()).toBeNull(); +}); + +test("steer invia un comando steer", () => { + const { rpc, sent } = fakeRpc(); + new SessionBridge(rpc).steer("considera solo il 2024"); + expect(sent.at(-1)).toEqual({ type: "steer", message: "considera solo il 2024" }); +});