diff --git a/docs/superpowers/plans/2026-06-27-backend-implementation.md b/docs/superpowers/plans/2026-06-27-backend-implementation.md index d0259100..d41e0d1e 100644 --- a/docs/superpowers/plans/2026-06-27-backend-implementation.md +++ b/docs/superpowers/plans/2026-06-27-backend-implementation.md @@ -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:""}`; il backend **decodifica `title` → descriptor**, lo espone al FE come `ui_request`, e risponde `{type:"extension_ui_response", id, value:""}` (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((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; } diff --git a/docs/superpowers/plans/2026-06-27-harness-rpc-readiness.md b/docs/superpowers/plans/2026-06-27-harness-rpc-readiness.md index e1106403..95fdb7f9 100644 --- a/docs/superpowers/plans/2026-06-27-harness-rpc-readiness.md +++ b/docs/superpowers/plans/2026-06-27-harness-rpc-readiness.md @@ -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:""}` e il client risponde `{type:"extension_ui_response", id, value:""}` (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 = ""`. - - `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 = ""`. + - `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` — 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 `: - - 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 `. **Emette la shape NATIVA di Pi** (corretto post-spike): per ogni descriptor in `on_prompt`, emette `{type:"extension_ui_request", 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") {