fix(harness): gate widget round-trip via native ctx.ui.input (no sendRaw); no-limbo
This commit is contained in:
@@ -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)));
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user