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:
2026-06-27 19:47:21 +02:00
co-authored by Claude Opus 4.8
parent 59bd6135e2
commit aecc86f328
2 changed files with 125 additions and 79 deletions
@@ -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") {