docs(plans): correggi wire contract post-spike (ctx.ui.input nativo, niente sendRaw)
Lo spike Task 1 ha provato che ctx.sendRaw non esiste e pi.on(extension_ui_response) non e' dispatchato. Aggiornati: Piano1 Task2 (mock ctx.ui), Task4 (rewrite gate a ctx.ui.input con descriptor in title), Task10 (fake-pi-rpc shape nativa); Piano2 Task3/Task4 (SessionBridge decodifica title<->value). Contratto FE invariato. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
|
||||
- **Prerequisito:** il Piano Harness (`2026-06-27-harness-rpc-readiness.md`) deve essere completato — il backend dipende da: gate RPC-ready, `THT_SESSION` id injection, `tht sql preview --json/--offset`, `tht session list/show --json`, campi manifest, `fake-pi-rpc`.
|
||||
- **Framing RPC: LF-only JSONL** — `JSON.stringify(v)+"\n"`; lettura split su `\n`, strip `\r` finale. MAI `readline`. Riferimento: `@earendil-works/pi-coding-agent/dist/modes/rpc/jsonl.js`.
|
||||
- **WIRE CONTRACT (corretto post-spike, Piano Harness Task 1):** il gate usa l'API UI nativa di Pi. Sul wire arriva `{type:"extension_ui_request", id, method:"input", title:"<widget-descriptor JSON>"}`; il backend **decodifica `title` → descriptor**, lo espone al FE come `ui_request`, e risponde `{type:"extension_ui_response", id, value:"<ui_response JSON>"}` (o `{id, cancelled:true}`). `ctx.sendRaw` non esiste; non esistono eventi `{ui_request:…}` nidificati. Il contratto verso il FE (`ui_request`/`ui_response`, architettura §4) resta invariato — la traduzione native↔FE è responsabilità del `SessionBridge`.
|
||||
- **Deployment MVP:** localhost, mono-operatore (D12-B). Auth default `none` (utente `dev@local`).
|
||||
- **Il backend NON tocca il DB**: ogni esecuzione SQL passa da `tht` (BE-2). Nessun driver `pg`/REST nel backend.
|
||||
- **Posizione harness:** path configurabile (`THT_HARNESS_DIR`, default `../harness` rispetto al backend); `tht` invocato dal venv harness; spawn di Pi con `cwd = THT_HARNESS_DIR`.
|
||||
@@ -238,7 +239,8 @@ test("prompt emette un evento extension_ui_request", async () => {
|
||||
const got = new Promise<any>((resolve) => rpc.on("event", (e) => e.type === "extension_ui_request" && resolve(e)));
|
||||
rpc.send({ type: "prompt", message: "/nuova-domanda \"x\"" });
|
||||
const evt = await got;
|
||||
expect(evt.ui_request.widget).toBe("select");
|
||||
expect(evt.method).toBe("input"); // shape nativa di Pi
|
||||
expect(JSON.parse(evt.title).widget).toBe("select"); // descriptor nel title
|
||||
child.stdin.end();
|
||||
});
|
||||
```
|
||||
@@ -301,9 +303,10 @@ git commit -m "feat(backend): RpcClient (spawn/send/request/event) over JSONL"
|
||||
- Produces: `class SessionBridge`:
|
||||
- `constructor(rpc: RpcClient)`
|
||||
- `onClientEvent(cb: (e: ClientEvent) => void)` — emette verso il FE: `{type:"ui_request"|"info"|"text_delta"|"system_event", ...}`
|
||||
- `respond(uiResponse: object): void` — invia `{type:"extension_ui_response", ...}` a Pi
|
||||
- `respond(uiResponse: object & {id:string}): void` — invia a Pi `{type:"extension_ui_response", id, value: JSON.stringify(uiResponse)}` (shape NATIVA: il payload va in `value`)
|
||||
- `steer(text: string): void` — invia `{type:"steer", message: text}`
|
||||
- `pendingWidget(): object | null` — l'ultima `ui_request` non ancora risposta (per re-emit)
|
||||
- `pendingWidget(): object | null` — l'ultimo descriptor non ancora risposto (per re-emit)
|
||||
- **Decodifica della shape nativa (WIRE CONTRACT):** un evento `{type:"extension_ui_request", id, method:"input", title}` → il bridge fa `JSON.parse(title)` → descriptor, lo espone come `ui_request`; `method:"notify"` → `info`; altri `method` (setStatus/setWidget) ignorati in MVP.
|
||||
- Tipi: `ClientEvent = {type:"ui_request", ui_request:object} | {type:"text_delta", text:string} | {type:"info",...} | {type:"system_event",...}`.
|
||||
|
||||
- [ ] **Step 1: Scrivere il test**
|
||||
@@ -318,21 +321,22 @@ function fakeRpc() {
|
||||
sent, fire: (m:any)=>evcb(m) };
|
||||
}
|
||||
|
||||
test("extension_ui_request diventa ui_request verso il FE ed è il widget pendente", () => {
|
||||
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));
|
||||
fire({ type:"extension_ui_request", ui_request:{ id:"u1", widget:"select" } });
|
||||
expect(seen[0]).toEqual({ type:"ui_request", ui_request:{ id:"u1", widget:"select" } });
|
||||
expect(b.pendingWidget()).toEqual({ id:"u1", widget:"select" });
|
||||
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 e azzera il pendente", () => {
|
||||
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", ui_request:{ id:"u1", widget:"select" } });
|
||||
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", choices:["a"] });
|
||||
expect(sent.at(-1)).toEqual({ type:"extension_ui_response", id:"u1", value: JSON.stringify({ id:"u1", choices:["a"] }) });
|
||||
expect(b.pendingWidget()).toBeNull();
|
||||
});
|
||||
|
||||
@@ -364,22 +368,25 @@ export class SessionBridge {
|
||||
private cbs = new Set<(e: ClientEvent) => void>();
|
||||
constructor(private rpc: RpcClient) {
|
||||
rpc.on("event", (m) => {
|
||||
if (m.type === "extension_ui_request") {
|
||||
this.pending = m.ui_request;
|
||||
this.fan({ type: "ui_request", ui_request: m.ui_request });
|
||||
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 === "info" || m.type === "system_event") {
|
||||
} else if (m.type === "system_event") {
|
||||
this.fan(m as ClientEvent);
|
||||
}
|
||||
// altri eventi Pi (agent_end, tool_call, …) non sono inoltrati al FE in MVP
|
||||
// altri method nativi (setStatus/setWidget) e altri eventi Pi (agent_end, tool_call) non inoltrati in MVP
|
||||
});
|
||||
}
|
||||
private fan(e: ClientEvent) { for (const cb of this.cbs) cb(e); }
|
||||
onClientEvent(cb: (e: ClientEvent) => void) { this.cbs.add(cb); }
|
||||
respond(uiResponse: object & { id: string }) {
|
||||
this.rpc.send({ type: "extension_ui_response", ...uiResponse });
|
||||
if (this.pending && (uiResponse as any).id === this.pending.id) this.pending = null;
|
||||
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) { this.rpc.send({ type: "steer", message: text }); }
|
||||
pendingWidget() { return this.pending; }
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- **Node ≥ 20**; JS test runner: `node --test` (come `harness/package.json` → `npm test`).
|
||||
- **Python 3.13**, ambiente in `harness/.venv` (`pip install -e ".[dev]"`); test: `pytest` da `harness/`.
|
||||
- **Framing RPC: LF-only JSONL** — serializzazione `JSON.stringify(value) + "\n"`; lettura: split su `\n`, strip di un eventuale `\r` finale. MAI `readline` (spezza su separatori Unicode validi dentro le stringhe JSON). Riferimento canonico: `@earendil-works/pi-coding-agent/dist/modes/rpc/jsonl.js`.
|
||||
- **WIRE CONTRACT (corretto post-spike Task 1):** `ctx.sendRaw` NON esiste in Pi e `pi.on("extension_ui_response")` NON viene dispatchato. L'unico meccanismo UI estensione in RPC è `ctx.ui.select/confirm/input(...)`, che instradano la risposta via `pendingExtensionRequests`. Il gate trasporta il widget-descriptor completo come **JSON nel `title` di `ctx.ui.input`**: Pi emette `{type:"extension_ui_request", id, method:"input", title:"<descriptor JSON>"}` e il client risponde `{type:"extension_ui_response", id, value:"<ui_response JSON>"}` (oppure `{id, cancelled:true}` → no-limbo, re-loop). Il **contratto verso il frontend** (`ui_request`/`ui_response`, architettura §4) resta invariato: la traduzione native↔FE avviene nel backend (Piano 2).
|
||||
- **`--json` mantiene stdout puro**: in modalità JSON nessun warning/tabella umana su stdout (pattern già in `tht/cli/search_cmd.py`). Output: `typer.echo(json.dumps(data, ensure_ascii=False, indent=2))`.
|
||||
- **Nessun segreto nel codice/test**: le credenziali stanno solo in `harness/.env` (gitignored). I task L2/spike che toccano Pi reale richiedono `.env` + VPN e NON girano in CI.
|
||||
- **Il gate resta load-bearing**: gli invarianti verbatim del gate (anti-bypass `tool_call` hook, input-lock, no-limbo) non vanno indeboliti dagli adattamenti RPC.
|
||||
@@ -111,11 +112,11 @@ git commit -m "spike(harness): probe gate behavior in pi --mode rpc + findings"
|
||||
- Test: `harness/.pi/extensions/gate/__tests__/fake_pi_runtime.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `createFakePi()` → `{ pi, ctx, sent, emit, lastSent() }` dove
|
||||
- `pi.on(event, handler)` registra handler; `pi.registerTool(def, fn)` li memorizza.
|
||||
- Produces: `createFakePi()` → `{ pi, ctx, tools, emit, enqueueUi }` dove
|
||||
- `pi.on(event, handler)` registra handler; `pi.registerTool(def, fn)` li memorizza in `tools` (Map per nome).
|
||||
- `pi.emit(event, payload)` invoca i handler registrati (await se async) e ritorna il loro valore (per testare i ritorni `{block, action}` dei hook).
|
||||
- `ctx.sendRaw(obj)` accoda in `sent[]`; `ctx.ui.notify(msg, level)` accoda in `ctx.notifications[]`; `ctx.hasUI = true`; `ctx.cwd = "<tmp>"`.
|
||||
- `lastSent()` ritorna l'ultimo oggetto passato a `sendRaw`.
|
||||
- `ctx.ui.notify(msg, level)` accoda in `ctx.notifications[]`; `ctx.ui.input/select/confirm(...)` registrano la chiamata in `ctx.uiCalls[]` e ritornano il prossimo valore da `ctx.uiQueue` (FIFO) — questo modella l'API UI NATIVA di Pi (non esiste `sendRaw`); `ctx.hasUI = true`; `ctx.cwd = "<tmp>"`.
|
||||
- `enqueueUi(value)` mette in coda la prossima risposta che `ctx.ui.*` restituirà (es. `JSON.stringify(uiResponse)` per `input`, o `undefined` per simulare cancel → no-limbo).
|
||||
- Consumes: nulla.
|
||||
|
||||
- [ ] **Step 1: Scrivere il test del runtime mock**
|
||||
@@ -132,11 +133,18 @@ test("pi.on + emit invoca il handler e ne ritorna il valore", async () => {
|
||||
assert.equal(await pi.emit("tool_call", { toolName: "y" }), undefined);
|
||||
});
|
||||
|
||||
test("ctx.sendRaw registra e lastSent ritorna l'ultimo", () => {
|
||||
const { ctx, lastSent } = createFakePi();
|
||||
ctx.sendRaw({ a: 1 });
|
||||
ctx.sendRaw({ a: 2 });
|
||||
assert.deepEqual(lastSent(), { a: 2 });
|
||||
test("ctx.ui.input registra la chiamata e ritorna il valore in coda (API nativa)", async () => {
|
||||
const { ctx, enqueueUi } = createFakePi();
|
||||
enqueueUi('{"id":"u1","choices":["a"]}');
|
||||
const v = await ctx.ui.input("title-json", "");
|
||||
assert.equal(v, '{"id":"u1","choices":["a"]}');
|
||||
assert.equal(ctx.uiCalls[0].method, "input");
|
||||
assert.equal(ctx.uiCalls[0].title, "title-json");
|
||||
});
|
||||
|
||||
test("ctx.ui.input senza valore in coda ritorna undefined (modella cancel)", async () => {
|
||||
const { ctx } = createFakePi();
|
||||
assert.equal(await ctx.ui.input("t", ""), undefined);
|
||||
});
|
||||
```
|
||||
|
||||
@@ -149,16 +157,23 @@ Expected: FAIL — `Cannot find module './fake_pi_runtime.js'`.
|
||||
|
||||
```javascript
|
||||
// fake_pi_runtime.js — minimal mock of the Pi extension runtime for gate tests.
|
||||
// Models Pi's NATIVE extension UI API (ctx.ui.select/confirm/input). There is NO
|
||||
// ctx.sendRaw in Pi; the gate awaits ctx.ui.* and the runtime routes the response.
|
||||
function createFakePi() {
|
||||
const handlers = new Map();
|
||||
const tools = new Map();
|
||||
const sent = [];
|
||||
const ctx = {
|
||||
hasUI: true,
|
||||
cwd: "/tmp/fake-pi-session",
|
||||
notifications: [],
|
||||
sendRaw: (obj) => sent.push(obj),
|
||||
ui: { notify: async (message, level = "info") => ctx.notifications.push({ message, level }) },
|
||||
uiCalls: [],
|
||||
uiQueue: [],
|
||||
ui: {
|
||||
notify: async (message, level = "info") => ctx.notifications.push({ message, level }),
|
||||
input: async (title, placeholder, opts) => { ctx.uiCalls.push({ method: "input", title, placeholder, opts }); return ctx.uiQueue.shift(); },
|
||||
select: async (title, options, opts) => { ctx.uiCalls.push({ method: "select", title, options, opts }); return ctx.uiQueue.shift(); },
|
||||
confirm: async (title, message, opts) => { ctx.uiCalls.push({ method: "confirm", title, message, opts }); return ctx.uiQueue.shift(); },
|
||||
},
|
||||
};
|
||||
const pi = {
|
||||
on: (event, handler) => {
|
||||
@@ -172,7 +187,7 @@ function createFakePi() {
|
||||
return result;
|
||||
},
|
||||
};
|
||||
return { pi, ctx, sent, emit: pi.emit, lastSent: () => sent[sent.length - 1] };
|
||||
return { pi, ctx, tools, emit: pi.emit, enqueueUi: (v) => ctx.uiQueue.push(v) };
|
||||
}
|
||||
module.exports = { createFakePi };
|
||||
```
|
||||
@@ -267,74 +282,89 @@ git commit -m "fix(harness): gate kickoff/lock entry works in RPC mode (not only
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Gate — round-trip del widget in RPC mode
|
||||
### Task 4: Gate — round-trip del widget via API nativa `ctx.ui.input` (rewrite)
|
||||
|
||||
> Applica la decisione del Task 1 (osservazioni #2/#3). Garantisce che un widget emesso dal gate riceva la risposta dell'utente. Variante A (default): si conferma che `sendRaw` + `pi.on("extension_ui_response")` funziona. Variante B (se lo spike mostra che la risposta viene assorbita): emettere via il meccanismo nativo che registra in `pendingExtensionRequests`.
|
||||
> **Corretto post-spike (Task 1):** `ctx.sendRaw` NON esiste e `pi.on("extension_ui_response")` non viene dispatchato — il meccanismo attuale di `emitAndWait` crasherebbe. Si riscrive `emitAndWait` per usare l'API UI NATIVA di Pi: il widget-descriptor viaggia come JSON nel `title` di `ctx.ui.input`; la risposta torna come stringa `value` (o `undefined` su cancel → no-limbo). Si rimuovono `_pending`, `handleUiResponse` e la registrazione `pi.on("extension_ui_response", …)`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `harness/.pi/extensions/tht-gate.js` (`emitAndWait` / registrazione `extension_ui_response`, ~riga 148-175 / 289)
|
||||
- Modify: `harness/.pi/extensions/tht-gate.js` (`emitAndWait`, ~riga 148-175; rimozione `handleUiResponse` ~189-195 e `pi.on("extension_ui_response", …)` ~289)
|
||||
- Test: `harness/.pi/extensions/gate/__tests__/gate_roundtrip.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `createFakePi()` (Task 2). Estensione necessaria del mock: capacità di consegnare una `extension_ui_response` al gate — il test la inietta chiamando l'handler registrato dal gate.
|
||||
- Produces: invariante "una `ui_response` con `id` correlato e `control !== 'cancel'` risolve `emitAndWait`; una `cancel`/`undefined` ri-emette lo stesso widget (no-limbo)".
|
||||
- Consumes: `createFakePi()`/`enqueueUi` (Task 2). Aggiungere a `tht-gate.js` un **named export** `emitAndWait` per testarlo direttamente.
|
||||
- Produces: `emitAndWait(ctx, descriptor): Promise<uiResponse>` — chiama `ctx.ui.input(JSON.stringify(descriptor), "")`; se `value` è `undefined`/`null` o JSON non valido o `resp.control === "cancel"` → ri-presenta (no-limbo); altrimenti ritorna `JSON.parse(value)`. Invariante: il `title` passato a `ctx.ui.input` è esattamente `JSON.stringify(descriptor)`.
|
||||
|
||||
- [ ] **Step 1: Estendere il fake-pi-runtime per consegnare risposte**
|
||||
|
||||
In `fake_pi_runtime.js` aggiungere, dentro `createFakePi`, un helper che invoca i handler registrati su un evento con payload (già coperto da `pi.emit`). Nessuna modifica se `pi.emit("extension_ui_response", resp)` raggiunge l'handler del gate; verificarlo nel test sotto.
|
||||
|
||||
- [ ] **Step 2: Scrivere il test del round-trip**
|
||||
- [ ] **Step 1: Scrivere il test del round-trip (via export diretto)**
|
||||
|
||||
```javascript
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { createFakePi } = require("./fake_pi_runtime.js");
|
||||
const installGate = require("../../tht-gate.js").default ?? require("../../tht-gate.js");
|
||||
const { emitAndWait } = require("../../tht-gate.js");
|
||||
|
||||
test("una ui_response correlata risolve l'attesa del widget", async () => {
|
||||
const { pi, lastSent } = createFakePi();
|
||||
installGate(pi);
|
||||
// accede a emitAndWait tramite un widget reale: si avvia un reviewer tool che emette un select.
|
||||
// Qui si testa il contratto osservabile: dopo l'emit, sendRaw contiene un extension_ui_request;
|
||||
// inviando la risposta correlata, la promise si risolve (nessun ri-invio).
|
||||
// (Il tool che emette è invocato dal gate; vedi nota implementativa nel piano.)
|
||||
// ... arrange: invoca il path che chiama emitAndWait con descriptor.id = "u1"
|
||||
// act: consegna la risposta
|
||||
await pi.emit("extension_ui_response", { id: "u1", control: "freetext", text: "ok" });
|
||||
const sent = lastSent();
|
||||
assert.equal(sent.type, "extension_ui_request");
|
||||
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)));
|
||||
});
|
||||
```
|
||||
|
||||
> Nota implementativa: il widget è emesso da `emitAndWait`, chiamata dai reviewer tool (`reviewer_select`/`reviewer_confirm`). Il test invoca il tool registrato via `tools` del mock (Task 2 espone `registerTool`) e poi consegna la risposta. Concretizzare l'arrange invocando `tools.get("reviewer_select").fn(...)` con un descriptor a `id` noto.
|
||||
|
||||
- [ ] **Step 3: Eseguire il test (deve fallire o appendersi)**
|
||||
- [ ] **Step 2: Eseguire il test (deve fallire)**
|
||||
|
||||
Run: `cd harness && node --test .pi/extensions/gate/__tests__/gate_roundtrip.test.js`
|
||||
Expected: FAIL (assert) o timeout (se la risposta non raggiunge `handleUiResponse`).
|
||||
Expected: FAIL — `emitAndWait` non è esportato / usa ancora `ctx.sendRaw`.
|
||||
|
||||
- [ ] **Step 4: Applicare la variante decisa dallo spike**
|
||||
- [ ] **Step 3: Riscrivere `emitAndWait` ed esportarlo**
|
||||
|
||||
- **Variante A (sendRaw funziona):** nessuna modifica al meccanismo; assicurarsi solo che `handleUiResponse` sia registrato (`pi.on("extension_ui_response", handleUiResponse)`) e che `emitAndWait` correli per `descriptor.id`. Il test passa così com'è.
|
||||
- **Variante B (risposta assorbita da rpc-mode):** modificare `emitAndWait` per emettere il widget tramite il meccanismo UI nativo che registra in `pendingExtensionRequests` (così la `extension_ui_response` viene instradata al gate), trasportando il widget-descriptor nel payload nativo (es. `method:"input"` con il descriptor serializzato in `title`/campo dedicato) e decodificando la risposta nativa (`{value}`) nel formato `ui_response` interno. Mantenere invariata la firma di `emitAndWait` e l'invariante no-limbo.
|
||||
In `tht-gate.js` sostituire il blocco `_pending`/`emitAndWait`/`handleUiResponse`:
|
||||
|
||||
```javascript
|
||||
// Variante B (estratto): emit via meccanismo nativo, decodifica la risposta nativa.
|
||||
ctx.sendRaw({ type: "extension_ui_request", id: descriptor.id, method: "input",
|
||||
title: JSON.stringify({ widget_descriptor: descriptor }) });
|
||||
// la risposta nativa { id, value } viene normalizzata in { id, ...JSON.parse(value) }
|
||||
// widget emission + wait — usa l'API UI NATIVA di Pi (ctx.ui.input). Il descriptor
|
||||
// viaggia come JSON nel title; la risposta torna come stringa `value`. No ctx.sendRaw,
|
||||
// no pi.on("extension_ui_response"): Pi instrada la risposta via pendingExtensionRequests.
|
||||
export async function emitAndWait(ctx, descriptor) {
|
||||
for (;;) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
async function reLoop(ctx) {
|
||||
if (ctx.hasUI) await ctx.ui.notify(
|
||||
"Esc non chiude il gate: usa Torna indietro / Esci / Altro dalle opzioni.", "warning");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Eseguire i test (devono passare) + no-limbo**
|
||||
Rimuovere: la `Map _pending`, la funzione `handleUiResponse` e la riga `pi.on("extension_ui_response", handleUiResponse)`. Le chiamate esistenti a `emitAndWait(ctx, descriptor)` (dai reviewer tool) restano invariate (stessa firma e stesso ritorno).
|
||||
|
||||
Run: `cd harness && node --test .pi/extensions/gate/__tests__/gate_roundtrip.test.js`
|
||||
Expected: PASS. Aggiungere un caso che invia `{ id:"u1", control:"cancel" }` e verifica che il widget venga ri-emesso (secondo `sendRaw` con lo stesso `id`).
|
||||
- [ ] **Step 4: Eseguire i test (devono passare)**
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
Run: `cd harness && node --test .pi/extensions/gate/__tests__/gate_roundtrip.test.js && npm test`
|
||||
Expected: PASS (2 nuovi test + nessuna regressione su builder/entry/runtime).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add harness/.pi/extensions/tht-gate.js harness/.pi/extensions/gate/__tests__/
|
||||
git commit -m "fix(harness): gate widget round-trip verified/adapted for RPC mode (no-limbo preserved)"
|
||||
git add harness/.pi/extensions/tht-gate.js harness/.pi/extensions/gate/__tests__/gate_roundtrip.test.js
|
||||
git commit -m "fix(harness): gate widget round-trip via native ctx.ui.input (no sendRaw); no-limbo"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -789,19 +819,19 @@ git commit -m "chore(harness): quietStartup + project-local trust for clean RPC
|
||||
- Test: `harness/tests/fake_pi/test_fake_pi_contract.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `fake_pi_rpc.mjs` — eseguibile con `node fake_pi_rpc.mjs <script.json>`:
|
||||
- legge comandi JSONL su stdin (LF-only); su `{type:"prompt"}` emette la sequenza di eventi dello script (es. un `extension_ui_request`); su `{type:"extension_ui_response", id}` correla e emette l'evento successivo dello script; risponde a `{type:"get_available_models"}` con un set fisso; eco di `{type:"response", command, success:true}` per i comandi che lo richiedono.
|
||||
- Produces: `fake_pi_rpc.mjs` — eseguibile con `node fake_pi_rpc.mjs <script.json>`. **Emette la shape NATIVA di Pi** (corretto post-spike): per ogni descriptor in `on_prompt`, emette `{type:"extension_ui_request", id:<descriptor.id>, method:"input", title: JSON.stringify(descriptor)}`. Su `{type:"extension_ui_response", id}` correla per id ed emette gli eventi `on_response[id]` (la risposta nativa porta `{id, value}`). Risponde a `{type:"get_available_models"}` con un set fisso; eco `{type:"response", command, success:true}` per `steer`/`get_state`.
|
||||
- framing su stdout: `JSON.stringify(evt) + "\n"`.
|
||||
- Consumes: lo schema widget-descriptor (architettura §4) per gli eventi di esempio.
|
||||
- Consumes: lo schema widget-descriptor (architettura §4) per i descriptor di esempio.
|
||||
|
||||
- [ ] **Step 1: Definire lo scenario scriptato (golden)**
|
||||
|
||||
> Lo scenario tiene il descriptor come oggetto (`ui_request_descriptor`); è il fake a serializzarlo nel `title` nativo, così lo scenario resta leggibile.
|
||||
|
||||
```json
|
||||
// harness/tests/fake_pi/scripts/f1_disambiguation.json
|
||||
{
|
||||
"on_prompt": [
|
||||
{ "type": "extension_ui_request", "id": "u1",
|
||||
"ui_request": { "type": "ui_request", "id": "u1", "schema_version": 1,
|
||||
{ "ui_request_descriptor": { "type": "ui_request", "id": "u1", "schema_version": 1,
|
||||
"phase": "F1_chiarimento", "title": "Disambigua",
|
||||
"widget": "select",
|
||||
"options": [ {"id":"a","label":"interpretazione A"}, {"id":"b","label":"interpretazione B"} ],
|
||||
@@ -842,11 +872,15 @@ test("on prompt emette il widget F1; on response avanza", async () => {
|
||||
const sp = path.join(import.meta.dirname, "scripts/f1_disambiguation.json");
|
||||
const events = await drive(sp, [
|
||||
{ type: "prompt", message: "/nuova-domanda \"x\"" },
|
||||
{ type: "extension_ui_response", id: "u1", choices: ["a"], decision: { type: "concept_clarified" } },
|
||||
{ type: "extension_ui_response", id: "u1",
|
||||
value: JSON.stringify({ id: "u1", choices: ["a"], decision: { type: "concept_clarified" } }) },
|
||||
]);
|
||||
const widget = events.find((e) => e.type === "extension_ui_request");
|
||||
assert.equal(widget.ui_request.widget, "select");
|
||||
assert.equal(widget.ui_request.id, "u1");
|
||||
assert.equal(widget.method, "input"); // shape nativa di Pi
|
||||
assert.equal(widget.id, "u1");
|
||||
const descriptor = JSON.parse(widget.title); // il descriptor viaggia nel title
|
||||
assert.equal(descriptor.widget, "select");
|
||||
assert.equal(descriptor.id, "u1");
|
||||
assert.ok(events.some((e) => e.type === "agent_end"));
|
||||
});
|
||||
```
|
||||
@@ -872,7 +906,12 @@ process.stdin.on("data", (chunk) => {
|
||||
if (!line) continue;
|
||||
let cmd; try { cmd = JSON.parse(line); } catch { continue; }
|
||||
if (cmd.type === "prompt") {
|
||||
for (const evt of script.on_prompt ?? []) out(evt);
|
||||
for (const step of script.on_prompt ?? []) {
|
||||
if (step.ui_request_descriptor) {
|
||||
const d = step.ui_request_descriptor;
|
||||
out({ type: "extension_ui_request", id: d.id, method: "input", title: JSON.stringify(d) });
|
||||
} else { out(step); } // eventi non-UI (text_delta, agent_end, …) passano tali e quali
|
||||
}
|
||||
} else if (cmd.type === "extension_ui_response") {
|
||||
for (const evt of (script.on_response ?? {})[cmd.id] ?? []) out(evt);
|
||||
} else if (cmd.type === "get_available_models") {
|
||||
|
||||
Reference in New Issue
Block a user