fix(harness): gate widget round-trip via native ctx.ui.input (no sendRaw); no-limbo

This commit is contained in:
2026-06-27 20:02:19 +02:00
parent c55df2ad0f
commit 8c4a1798da
2 changed files with 43 additions and 37 deletions
@@ -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)));
});
+18 -37
View File
@@ -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({