diff --git a/harness/.pi/extensions/gate/__tests__/gate_roundtrip.test.js b/harness/.pi/extensions/gate/__tests__/gate_roundtrip.test.js new file mode 100644 index 00000000..c8d717ef --- /dev/null +++ b/harness/.pi/extensions/gate/__tests__/gate_roundtrip.test.js @@ -0,0 +1,25 @@ +const test = require("node:test"); +const assert = require("node:assert"); +const { createFakePi } = require("./fake_pi_runtime.js"); +const { emitAndWait } = require("../../tht-gate.js"); + +test("emitAndWait trasporta il descriptor come JSON nel title e ritorna la ui_response parsata", async () => { + const { ctx, enqueueUi } = createFakePi(); + const descriptor = { id: "u1", widget: "select", options: [{ id: "a", label: "A" }] }; + enqueueUi(JSON.stringify({ id: "u1", choices: ["a"] })); + const resp = await emitAndWait(ctx, descriptor); + assert.deepEqual(resp, { id: "u1", choices: ["a"] }); + assert.equal(ctx.uiCalls[0].method, "input"); + assert.equal(ctx.uiCalls[0].title, JSON.stringify(descriptor)); +}); + +test("no-limbo: undefined (cancel) ri-presenta lo stesso widget", async () => { + const { ctx, enqueueUi } = createFakePi(); + const descriptor = { id: "u1", widget: "select", options: [] }; + enqueueUi(undefined); // 1° giro: cancel + enqueueUi(JSON.stringify({ id: "u1", choices: ["a"] })); // 2° giro: risposta valida + const resp = await emitAndWait(ctx, descriptor); + assert.deepEqual(resp, { id: "u1", choices: ["a"] }); + assert.equal(ctx.uiCalls.length, 2); // ri-presentato una volta + assert.ok(ctx.notifications.some((n) => /Esc non chiude/.test(n.message))); +}); diff --git a/harness/.pi/extensions/tht-gate.js b/harness/.pi/extensions/tht-gate.js index c190472e..07695859 100644 --- a/harness/.pi/extensions/tht-gate.js +++ b/harness/.pi/extensions/tht-gate.js @@ -133,46 +133,30 @@ function advanceIfReady(ctx, session) { } } -// --- widget emission + wait (NEW: replaces ctx.ui.* blocking primitives) ------- +// --- widget emission + wait — usa l'API UI NATIVA di Pi (ctx.ui.input) -------- // -// emitAndWait(ctx, descriptor) sends a ui_request widget and awaits the correlated -// ui_response by id. This is the correlation-by-id layer the reference implementation never had (its -// native TUI primitives handled it implicitly). The descriptor is built by the pure -// ./gate/builders.js (L1-tested). +// emitAndWait(ctx, descriptor) sends the widget-descriptor as JSON in the `title` +// of ctx.ui.input and awaits the response as a parsed string value. No ctx.sendRaw, +// no pi.on("extension_ui_response"): Pi routes the response via pendingExtensionRequests. // -// The no-limbo invariant: a control response of "cancel"/undefined is NEVER accepted -// as a final answer -- the widget is re-presented. Real escapes (Back/Exit/Other) are -// always present as selectable options in the descriptor (reserved field), so cancel -// has no legitimate meaning. -let _pending = new Map(); // id -> {resolve, reject} - -async function emitAndWait(ctx, descriptor) { +// The no-limbo invariant: undefined/null/invalid-JSON/control:"cancel" are NEVER +// accepted as a final answer — the widget is re-presented. Real escapes (Back/Exit/Other) +// are always present as selectable options in the descriptor, so cancel has no +// legitimate meaning. +export async function emitAndWait(ctx, descriptor) { for (;;) { - const promise = new Promise((resolve, reject) => { - _pending.set(descriptor.id, { resolve, reject }); - }); - ctx.sendRaw({ type: "extension_ui_request", ui_request: descriptor }); - const resp = await promise; - // no-limbo: a cancel/undefined response re-presents the same widget - if (resp && resp.control !== "cancel" && resp.id === descriptor.id) { - return resp; - } - if (ctx.hasUI) { - await ctx.ui.notify( - "Esc non chiude il gate: usa Torna indietro / Esci / Altro dalle opzioni.", - "warning", - ); - } + const value = await ctx.ui.input(JSON.stringify(descriptor), ""); + if (value === undefined || value === null) { await reLoop(ctx); continue; } + let resp; + try { resp = JSON.parse(value); } catch { await reLoop(ctx); continue; } + if (resp && resp.control !== "cancel" && resp.id === descriptor.id) return resp; + await reLoop(ctx); } } -// The runtime invokes this when a ui_response arrives (RPC reply or SSE from FE). -function handleUiResponse(event) { - const slot = _pending.get(event.id); - if (slot) { - _pending.delete(event.id); - slot.resolve(event); - } +async function reLoop(ctx) { + if (ctx.hasUI) await ctx.ui.notify( + "Esc non chiude il gate: usa Torna indietro / Esci / Altro dalle opzioni.", "warning"); } // --- the extension ------------------------------------------------------------ @@ -284,9 +268,6 @@ export default function (pi) { } }); - // 6) UI_RESPONSE dispatch: correlate an incoming response to its awaiting widget. - pi.on("extension_ui_response", handleUiResponse); - // --- the four reviewer tools (widget-descriptor emit + await) --------------- pi.registerTool({