968 lines
46 KiB
Markdown
968 lines
46 KiB
Markdown
# Harness RPC-readiness Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Rendere l'harness `tht` pienamente pilotabile da un client RPC esterno (il backend): il gate funziona in `pi --mode rpc` (kickoff + round-trip widget), il backend possiede l'id di sessione, il CLI espone le uscite `--json` necessarie, ed esistono i due test-double che chiudono il loop in CI.
|
||
|
||
**Architecture:** Si parte da uno **spike** che osserva il comportamento reale del gate dentro `pi --mode rpc` (mai testato finora, cf. `docs/l2-run-report-2026-06-27.md`). Le scoperte dello spike fissano l'esatta forma del wire e guidano l'adattamento del gate. Tutto l'adattamento del gate è coperto da un **fake-pi-runtime** (mock dell'API estensione `pi`) eseguibile in CI; le aggiunte al CLI sono TDD deterministiche; un **fake-pi-rpc** (processo che parla il protocollo JSONL su stdio) viene consegnato qui come asset condiviso per il Piano Backend, con un golden test di contratto (D10).
|
||
|
||
**Tech Stack:** Python 3.13 + Typer + pytest (CLI `tht`); JavaScript ESM/CJS + `node --test` (gate + test-double); Pi = `@earendil-works/pi-coding-agent` (binario `pi`, modalità `--mode rpc`).
|
||
|
||
## Global Constraints
|
||
|
||
- **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.
|
||
- **Test setup per importare il gate (Task 3–5):** `tht-gate.js` è ESM e importa `typebox` (fornito a runtime da Pi, NON presente in `harness/`). Per testarlo, aggiungere `typebox` come **devDependency** in `harness/package.json` (versione allineata a Pi: `"typebox": "1.1.38"`) ed eseguire `npm install` una volta (Task 3, prima di scrivere i test). Su Node ≥ 22 `require()` di un modulo ESM senza top-level await funziona; se in questo ambiente dà problemi, usare `await import("../../tht-gate.js")` dentro un test `async` (i file di test possono restare `node --test`). Verificare l'import del gate PRIMA di scrivere asserzioni.
|
||
|
||
---
|
||
|
||
### Task 1: Spike — comportamento del gate in `pi --mode rpc`
|
||
|
||
> Spike di osservazione (non TDD): mai verificato finora. L'esito fissa la forma esatta del wire e decide i Task 3–4. Richiede Pi reale + `.env` + VPN.
|
||
|
||
**Files:**
|
||
- Create: `harness/scripts/rpc_probe.mjs` (driver manuale usa-e-getta, committato come strumento)
|
||
- Create: `harness/docs/rpc-readiness-findings.md` (referto delle osservazioni + decisioni)
|
||
|
||
**Interfaces:**
|
||
- Produces: `harness/docs/rpc-readiness-findings.md` con le risposte alle 4 domande sotto, citate dai Task 3 e 4.
|
||
|
||
- [ ] **Step 1: Scrivere il driver di probe**
|
||
|
||
`harness/scripts/rpc_probe.mjs` fa spawn di Pi in RPC, manda l'avvio del workflow come comando `prompt`, stampa ogni riga JSONL ricevuta con un prefisso, e quando arriva un `extension_ui_request` risponde con un `extension_ui_response` correlato per `id`.
|
||
|
||
```javascript
|
||
// rpc_probe.mjs — manual probe: drive `pi --mode rpc` and observe the gate.
|
||
// Usage (from harness/, with .env loaded + VPN up): node scripts/rpc_probe.mjs
|
||
import { spawn } from "node:child_process";
|
||
|
||
const pi = spawn("pi", ["--mode", "rpc"], { cwd: process.cwd(), env: process.env });
|
||
|
||
const send = (obj) => {
|
||
const line = JSON.stringify(obj) + "\n";
|
||
process.stdout.write(`>>> SEND ${line}`);
|
||
pi.stdin.write(line);
|
||
};
|
||
|
||
let buf = "";
|
||
pi.stdout.on("data", (chunk) => {
|
||
buf += chunk.toString("utf8");
|
||
for (let nl; (nl = buf.indexOf("\n")) !== -1; ) {
|
||
const line = buf.slice(0, nl).replace(/\r$/, "");
|
||
buf = buf.slice(nl + 1);
|
||
if (!line) continue;
|
||
console.log(`<<< RECV ${line}`);
|
||
let msg;
|
||
try { msg = JSON.parse(line); } catch { continue; }
|
||
if (msg.type === "extension_ui_request") {
|
||
// Reply in BOTH the gate's expected shape and Pi's native shape; observe which one unblocks.
|
||
const id = msg.id ?? msg.ui_request?.id;
|
||
send({ type: "extension_ui_response", id, control: "freetext", text: "PROBE-ANSWER" });
|
||
}
|
||
}
|
||
});
|
||
pi.stderr.on("data", (d) => process.stdout.write(`!!! STDERR ${d}`));
|
||
pi.on("exit", (code) => console.log(`### pi exited ${code}`));
|
||
|
||
// Kick off the workflow via an RPC prompt command (NOT interactive keystrokes).
|
||
setTimeout(() => send({ type: "prompt", message: '/nuova-domanda "quante cardioversioni nel 2024"' }), 500);
|
||
setTimeout(() => { pi.stdin.end(); }, 60000);
|
||
```
|
||
|
||
- [ ] **Step 2: Eseguire il probe e catturare l'output**
|
||
|
||
Run (da `harness/`, con `.env` caricato e VPN attiva):
|
||
```bash
|
||
set -a; . ./.env; set +a
|
||
node scripts/rpc_probe.mjs | tee /tmp/rpc_probe.log
|
||
```
|
||
Expected: una sequenza di righe `<<< RECV {...}`. Osservare in particolare se compare `text_delta`/eventi del modello e se compare una riga `extension_ui_request`.
|
||
|
||
- [ ] **Step 3: Registrare le 4 osservazioni decisive in `rpc-readiness-findings.md`**
|
||
|
||
Documentare con evidenza (righe del log) le risposte a:
|
||
1. **Kickoff**: inviando il workflow come comando `prompt`, il gate attiva kickoff + input-lock? (cioè: il modello riceve le istruzioni operative e parte dalla Fase 1, oppure l'`input` hook — che filtra `event.source === "interactive"` — non scatta?)
|
||
2. **Emissione widget**: quando il gate chiama `emitAndWait`, su stdout appare `{"type":"extension_ui_request","ui_request":{...}}` (envelope custom del gate) oppure no?
|
||
3. **Routing risposta**: inviando `extension_ui_response` con `id` correlato, il gate prosegue (la `handleUiResponse` risolve la promise) oppure la risposta viene assorbita da `rpc-mode` (`pendingExtensionRequests`) e il gate resta appeso?
|
||
4. **Source dell'input `!`**: lo steering (`prompt`/`steer` con testo `!...`) raggiunge il modello?
|
||
|
||
- [ ] **Step 4: Decisione esplicita per i Task 3–4**
|
||
|
||
In coda al referto, scrivere la decisione: per il **kickoff** (Task 3) e per il **round-trip** (Task 4), indicare se basta confermare il meccanismo esistente o serve adattarlo, e come. Casi attesi:
|
||
- Se (1) è NO → il gate deve riconoscere l'avvio del workflow anche per `event.source !== "interactive"` (Task 3).
|
||
- Se (3) è "assorbita" → il gate deve emettere il widget tramite il meccanismo nativo che registra in `pendingExtensionRequests` (Task 4, variante B), invece del `sendRaw` custom (variante A).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add harness/scripts/rpc_probe.mjs harness/docs/rpc-readiness-findings.md
|
||
git commit -m "spike(harness): probe gate behavior in pi --mode rpc + findings"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: fake-pi-runtime — mock dell'API estensione `pi` per i test del gate
|
||
|
||
**Files:**
|
||
- Create: `harness/.pi/extensions/gate/__tests__/fake_pi_runtime.js`
|
||
- Test: `harness/.pi/extensions/gate/__tests__/fake_pi_runtime.test.js`
|
||
|
||
**Interfaces:**
|
||
- 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.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**
|
||
|
||
```javascript
|
||
const test = require("node:test");
|
||
const assert = require("node:assert");
|
||
const { createFakePi } = require("./fake_pi_runtime.js");
|
||
|
||
test("pi.on + emit invoca il handler e ne ritorna il valore", async () => {
|
||
const { pi } = createFakePi();
|
||
pi.on("tool_call", (e) => (e.toolName === "x" ? { block: true } : undefined));
|
||
assert.deepEqual(await pi.emit("tool_call", { toolName: "x" }), { block: true });
|
||
assert.equal(await pi.emit("tool_call", { toolName: "y" }), undefined);
|
||
});
|
||
|
||
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);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Eseguire il test (deve fallire)**
|
||
|
||
Run: `cd harness && node --test .pi/extensions/gate/__tests__/fake_pi_runtime.test.js`
|
||
Expected: FAIL — `Cannot find module './fake_pi_runtime.js'`.
|
||
|
||
- [ ] **Step 3: Implementare il runtime mock**
|
||
|
||
```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 ctx = {
|
||
hasUI: true,
|
||
cwd: "/tmp/fake-pi-session",
|
||
notifications: [],
|
||
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) => {
|
||
if (!handlers.has(event)) handlers.set(event, []);
|
||
handlers.get(event).push(handler);
|
||
},
|
||
registerTool: (def, fn) => tools.set(def?.name ?? def, { def, fn }),
|
||
emit: async (event, payload) => {
|
||
let result;
|
||
for (const h of handlers.get(event) ?? []) result = await h(payload, ctx);
|
||
return result;
|
||
},
|
||
};
|
||
return { pi, ctx, tools, emit: pi.emit, enqueueUi: (v) => ctx.uiQueue.push(v) };
|
||
}
|
||
module.exports = { createFakePi };
|
||
```
|
||
|
||
- [ ] **Step 4: Eseguire il test (deve passare)**
|
||
|
||
Run: `cd harness && node --test .pi/extensions/gate/__tests__/fake_pi_runtime.test.js`
|
||
Expected: PASS (2 test).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add harness/.pi/extensions/gate/__tests__/fake_pi_runtime.js harness/.pi/extensions/gate/__tests__/fake_pi_runtime.test.js
|
||
git commit -m "test(harness): fake-pi-runtime mock for gate CI tests"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Gate — kickoff + input-lock all'avvio del workflow in RPC mode
|
||
|
||
> Applica la decisione del Task 1 (osservazione #1). Il gate deve attivare kickoff + input-lock quando il workflow parte via comando RPC `prompt`, non solo da input interattivo.
|
||
|
||
**Files:**
|
||
- Modify: `harness/.pi/extensions/tht-gate.js` (handler `pi.on("input", …)`, ~riga 220-235)
|
||
- Test: `harness/.pi/extensions/gate/__tests__/gate_entry.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `createFakePi()` (Task 2); il `default export` di `tht-gate.js` (la funzione `(pi) => {…}`).
|
||
- Produces: invariante "dopo un input di avvio workflow, `lockActive` è attivo e `pendingKickoff` è impostato", indipendentemente dal `source`.
|
||
|
||
- [ ] **Step 1: Scrivere il test di entry RPC**
|
||
|
||
```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");
|
||
|
||
test("avvio workflow via input non-interattivo attiva il lock (free text bloccato)", async () => {
|
||
const { pi } = createFakePi();
|
||
installGate(pi);
|
||
// entry del workflow con source 'rpc' (come un comando prompt RPC)
|
||
await pi.emit("input", { source: "rpc", text: '/nuova-domanda "x"' });
|
||
// dopo l'entry, un testo libero senza '!' deve essere bloccato (lock attivo)
|
||
const res = await pi.emit("input", { source: "rpc", text: "promuovi la tabella pazienti" });
|
||
assert.equal(res.action, "handled");
|
||
});
|
||
|
||
test("testo con '!' passa al modello (steer) anche con lock attivo", async () => {
|
||
const { pi } = createFakePi();
|
||
installGate(pi);
|
||
await pi.emit("input", { source: "rpc", text: "/nuova-domanda \"x\"" });
|
||
const res = await pi.emit("input", { source: "rpc", text: "!considera solo il 2024" });
|
||
assert.deepEqual(res, { action: "transform", text: "considera solo il 2024" });
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Eseguire il test (deve fallire)**
|
||
|
||
Run: `cd harness && node --test .pi/extensions/gate/__tests__/gate_entry.test.js`
|
||
Expected: FAIL — l'entry detection filtra `source === "interactive"`, quindi il lock non si attiva e il primo `emit` ritorna `{action:"continue"}` (non `handled`).
|
||
|
||
- [ ] **Step 3: Adattare l'entry detection nel gate**
|
||
|
||
In `tht-gate.js`, nel handler `pi.on("input", …)`: l'entry del workflow (`/nuova-domanda` | `/riprendi-sessione`) deve essere riconosciuta a prescindere dal `source`; il blocco del free-text resta valido per gli input dell'utente in sessione (interattivi o via RPC `prompt`). Sostituire la condizione di entry e quella di filtro:
|
||
|
||
```javascript
|
||
// entry detection: workflow-start funziona sia da TUI sia da comando RPC `prompt`.
|
||
if (/^\/(nuova-domanda|riprendi-sessione)\b/.test(raw)) {
|
||
lockActive = true;
|
||
lastSteered = false;
|
||
pendingKickoff = /^\/nuova-domanda\b/.test(raw) ? NUOVA_DOMANDA_KICKOFF : RIPRENDI_KICKOFF;
|
||
}
|
||
// free-input block: attivo quando il lock è su, per qualsiasi input utente (non solo interattivo).
|
||
if (!lockActive) return { action: "continue" };
|
||
```
|
||
|
||
(Rimuovere i due `event.source === "interactive"` su entry e filtro. Conservare invariati: passthrough `/…`, canale `!` → `transform`, notify.)
|
||
|
||
- [ ] **Step 4: Eseguire i test (devono passare)**
|
||
|
||
Run: `cd harness && node --test .pi/extensions/gate/__tests__/gate_entry.test.js`
|
||
Expected: PASS (2 test). Poi `npm test` per assicurare nessuna regressione sui builder.
|
||
Expected: tutti verdi.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add harness/.pi/extensions/tht-gate.js harness/.pi/extensions/gate/__tests__/gate_entry.test.js
|
||
git commit -m "fix(harness): gate kickoff/lock entry works in RPC mode (not only interactive)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Gate — round-trip del widget via API nativa `ctx.ui.input` (rewrite)
|
||
|
||
> **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`, ~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()`/`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: 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 { 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)));
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Eseguire il test (deve fallire)**
|
||
|
||
Run: `cd harness && node --test .pi/extensions/gate/__tests__/gate_roundtrip.test.js`
|
||
Expected: FAIL — `emitAndWait` non è esportato / usa ancora `ctx.sendRaw`.
|
||
|
||
- [ ] **Step 3: Riscrivere `emitAndWait` ed esportarlo**
|
||
|
||
In `tht-gate.js` sostituire il blocco `_pending`/`emitAndWait`/`handleUiResponse`:
|
||
|
||
```javascript
|
||
// 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");
|
||
}
|
||
```
|
||
|
||
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).
|
||
|
||
- [ ] **Step 4: Eseguire i test (devono passare)**
|
||
|
||
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__/gate_roundtrip.test.js
|
||
git commit -m "fix(harness): gate widget round-trip via native ctx.ui.input (no sendRaw); no-limbo"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Sessione con id fornito dall'esterno (BE-5)
|
||
|
||
> Il backend pre-crea la sessione e ne possiede l'id; il gate deve USARE quell'id invece di istruire il modello a crearne uno. Veicolo: variabile d'ambiente `THT_SESSION` (già referenziata dal gate per `/torna`).
|
||
|
||
**Files:**
|
||
- Modify: `harness/.pi/extensions/tht-gate.js` (payload `NUOVA_DOMANDA_KICKOFF` + selezione kickoff, ~riga 44-62, 226-231)
|
||
- Test: `harness/.pi/extensions/gate/__tests__/gate_provided_session.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `process.env.THT_SESSION` (impostata dal backend allo spawn).
|
||
- Produces: quando `THT_SESSION` è valorizzata, il kickoff iniettato istruisce il modello a USARE quell'id (niente `tht session new`); altrimenti comportamento attuale (il modello crea la sessione).
|
||
|
||
- [ ] **Step 1: Scrivere il test**
|
||
|
||
```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");
|
||
|
||
test("con THT_SESSION il kickoff usa l'id fornito e NON crea la sessione", async () => {
|
||
process.env.THT_SESSION = "2026-06-27-100000-test";
|
||
try {
|
||
const { pi, ctx } = createFakePi();
|
||
installGate(pi);
|
||
await pi.emit("input", { source: "rpc", text: '/nuova-domanda "x"' });
|
||
const injected = await pi.emit("before_agent_start", { });
|
||
const text = injected?.appendMessage ?? injected?.text ?? "";
|
||
assert.match(text, /2026-06-27-100000-test/);
|
||
assert.doesNotMatch(text, /tht session new/);
|
||
} finally { delete process.env.THT_SESSION; }
|
||
});
|
||
```
|
||
|
||
> Nota: adeguare il nome del campo ritornato da `before_agent_start` a come il gate inietta il kickoff (vedi handler ~riga 250). Il test asserisce il contenuto del testo iniettato.
|
||
|
||
- [ ] **Step 2: Eseguire il test (deve fallire)**
|
||
|
||
Run: `cd harness && node --test .pi/extensions/gate/__tests__/gate_provided_session.test.js`
|
||
Expected: FAIL — il kickoff contiene sempre `tht session new`.
|
||
|
||
- [ ] **Step 3: Implementare il kickoff a id fornito**
|
||
|
||
Aggiungere un secondo payload e selezionarlo quando `THT_SESSION` è presente:
|
||
|
||
```javascript
|
||
const NUOVA_DOMANDA_KICKOFF_PROVIDED = (sessionId) =>
|
||
"Istruzioni operative — sessione ThothII (workflow human-in-the-middle). " +
|
||
`La sessione è GIÀ creata: usa l'id \`${sessionId}\` in OGNI comando \`tht\`. ` +
|
||
"NON eseguire `tht session new`.\n" +
|
||
"1. Carica la skill leggendo `.pi/skills/tht-sessione/SKILL.md` con il tool `read`, " +
|
||
`poi segui il workflow dalla Fase 1 usando l'id \`${sessionId}\`.\n` +
|
||
"Regole non negoziabili: una domanda al reviewer per volta; mai promuovere/escludere/" +
|
||
"correggere senza conferma; le interazioni passano dai tool reviewer_*; testo libero col prefisso '!'. " +
|
||
"MAI `tht phase advance|reopen` né `tht decision add` da shell.";
|
||
|
||
// nella selezione del kickoff (input hook):
|
||
pendingKickoff = /^\/nuova-domanda\b/.test(raw)
|
||
? (process.env.THT_SESSION ? NUOVA_DOMANDA_KICKOFF_PROVIDED(process.env.THT_SESSION) : NUOVA_DOMANDA_KICKOFF)
|
||
: RIPRENDI_KICKOFF;
|
||
```
|
||
|
||
- [ ] **Step 4: Eseguire i test (devono passare)**
|
||
|
||
Run: `cd harness && node --test .pi/extensions/gate/__tests__/gate_provided_session.test.js && npm test`
|
||
Expected: PASS, nessuna regressione.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add harness/.pi/extensions/tht-gate.js harness/.pi/extensions/gate/__tests__/gate_provided_session.test.js
|
||
git commit -m "feat(harness): gate uses externally-provided THT_SESSION id (BE-5)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: CLI — `tht sql preview --json` + `--offset`
|
||
|
||
> Alimenta la paginazione AGGrid del backend. `do_run` ritorna già `rows/columns/execution_ms/truncated`; serve l'uscita JSON e l'iniezione di OFFSET.
|
||
|
||
**Files:**
|
||
- Modify: `harness/tht/cli/sql_cmd.py` (`preview_cmd`, ~riga 157; `do_run`, ~riga 105)
|
||
- Modify: `harness/tht/execute/__init__.py` e `harness/tht/rest/execute.py` (firma `run_controlled*` con `offset`)
|
||
- Test: `harness/tests/test_sql_preview_json.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `do_run(cfg, sql, *, limit, offset=0)`.
|
||
- Produces: `tht sql preview <file> --json [--limit N] [--offset M] [--session S]` stampa su stdout `{"columns": [...], "rows": [[...]], "execution_ms": int, "truncated": bool, "limit": int, "offset": int}` e nient'altro.
|
||
|
||
- [ ] **Step 1: Scrivere il test (offset injection + json shape)**
|
||
|
||
```python
|
||
# harness/tests/test_sql_preview_json.py
|
||
import json
|
||
from tht.execute.limit import inject_limit_offset # helper puro da creare
|
||
|
||
def test_inject_limit_offset_wraps_query():
|
||
sql = "SELECT a FROM t ORDER BY a"
|
||
out = inject_limit_offset(sql, limit=10, offset=20)
|
||
assert "LIMIT 10" in out and "OFFSET 20" in out
|
||
# la query originale resta una sottoquery (niente clobber di un LIMIT esistente)
|
||
assert "SELECT a FROM t ORDER BY a" in out
|
||
|
||
def test_inject_limit_offset_zero_offset_no_offset_clause():
|
||
out = inject_limit_offset("SELECT 1", limit=5, offset=0)
|
||
assert "LIMIT 5" in out
|
||
assert "OFFSET" not in out
|
||
```
|
||
|
||
- [ ] **Step 2: Eseguire il test (deve fallire)**
|
||
|
||
Run: `cd harness && pytest tests/test_sql_preview_json.py -v`
|
||
Expected: FAIL — `ModuleNotFoundError: tht.execute.limit`.
|
||
|
||
- [ ] **Step 3: Implementare l'helper di iniezione**
|
||
|
||
```python
|
||
# harness/tht/execute/limit.py
|
||
def inject_limit_offset(sql: str, *, limit: int, offset: int = 0) -> str:
|
||
"""Wrappa la query come sottoquery e applica LIMIT/OFFSET in modo non distruttivo.
|
||
Evita di sovrascrivere un LIMIT già presente nella query dell'utente."""
|
||
inner = sql.strip().rstrip(";")
|
||
clause = f"LIMIT {int(limit)}" + (f" OFFSET {int(offset)}" if offset else "")
|
||
return f"SELECT * FROM (\n{inner}\n) AS _tht_page {clause}"
|
||
```
|
||
|
||
- [ ] **Step 4: Eseguire il test (deve passare)**
|
||
|
||
Run: `cd harness && pytest tests/test_sql_preview_json.py -v`
|
||
Expected: PASS (2 test).
|
||
|
||
- [ ] **Step 5: Cablare `--json`/`--offset` in `preview_cmd` e `do_run`**
|
||
|
||
In `do_run` aggiungere `offset: int = 0` e usare `inject_limit_offset` quando `offset > 0` (altrimenti il path attuale a solo LIMIT). In `preview_cmd` aggiungere `offset` e `json_out`; in modalità JSON sopprimere tabella rich e warning, stampare solo il dict:
|
||
|
||
```python
|
||
@sql_app.command("preview")
|
||
def preview_cmd(
|
||
file: Path = typer.Argument(...),
|
||
limit: int = typer.Option(None, "--limit"),
|
||
offset: int = typer.Option(0, "--offset"),
|
||
session: str = typer.Option(None, "--session"),
|
||
json_out: bool = typer.Option(False, "--json", help="Output JSON puro per il backend."),
|
||
config: Path = CONFIG_OPT,
|
||
) -> None:
|
||
cfg = _load_config_or_exit(config)
|
||
require_action(cfg, "preview")
|
||
sql = _read_sql(file)
|
||
check = validate_or_exit(cfg, sql, session)
|
||
effective_limit = limit if limit is not None else cfg.execution.max_preview_rows
|
||
try:
|
||
result = do_run(cfg, sql, limit=effective_limit, offset=offset)
|
||
except ExecutionError as e:
|
||
if json_out:
|
||
typer.echo(json.dumps({"error": str(e)}, ensure_ascii=False)); raise typer.Exit(code=1)
|
||
typer.secho(f"ERRORE: {e}", fg=typer.colors.RED, err=True); raise typer.Exit(code=1)
|
||
if json_out:
|
||
typer.echo(json.dumps({
|
||
"columns": list(result.columns),
|
||
"rows": [list(r) for r in result.rows],
|
||
"execution_ms": result.execution_ms,
|
||
"truncated": result.truncated,
|
||
"limit": effective_limit, "offset": offset,
|
||
}, ensure_ascii=False))
|
||
return
|
||
# ... (path umano esistente invariato)
|
||
```
|
||
|
||
- [ ] **Step 6: Test del contratto JSON (fixture senza DB)**
|
||
|
||
Aggiungere a `test_sql_preview_json.py` un test che invoca `preview_cmd` con `do_run` monkeypatchato a un risultato fittizio e verifica che stdout sia JSON puro con le chiavi attese.
|
||
|
||
```python
|
||
def test_preview_json_pure_stdout(monkeypatch, tmp_path, capsys):
|
||
from tht.cli import sql_cmd
|
||
from types import SimpleNamespace
|
||
fake = SimpleNamespace(columns=["a"], rows=[[1],[2]], execution_ms=3, truncated=False)
|
||
monkeypatch.setattr(sql_cmd, "do_run", lambda *a, **k: fake)
|
||
monkeypatch.setattr(sql_cmd, "validate_or_exit", lambda *a, **k: SimpleNamespace(ast=None))
|
||
monkeypatch.setattr(sql_cmd, "require_action", lambda *a, **k: None)
|
||
monkeypatch.setattr(sql_cmd, "_load_config_or_exit",
|
||
lambda *a, **k: SimpleNamespace(execution=SimpleNamespace(max_preview_rows=100)))
|
||
f = tmp_path / "q.sql"; f.write_text("SELECT 1")
|
||
sql_cmd.preview_cmd(file=f, limit=None, offset=0, session=None, json_out=True, config=None)
|
||
out = capsys.readouterr().out.strip()
|
||
data = json.loads(out) # deve parsare: stdout puro
|
||
assert data["columns"] == ["a"] and data["rows"] == [[1],[2]]
|
||
```
|
||
|
||
Run: `cd harness && pytest tests/test_sql_preview_json.py -v`
|
||
Expected: PASS (tutti).
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add harness/tht/execute/limit.py harness/tht/cli/sql_cmd.py harness/tht/execute/__init__.py harness/tht/rest/execute.py harness/tests/test_sql_preview_json.py
|
||
git commit -m "feat(harness): tht sql preview --json + --offset for AGGrid paging (BE-2)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: CLI — `tht session list --json` + `tht session show --json`
|
||
|
||
> Alimenta la lista e il dettaglio sessioni nel FE. `list` è nuovo; `show` oggi stampa testo umano.
|
||
|
||
**Files:**
|
||
- Modify: `harness/tht/cli/session_cmd.py` (nuovo `list_cmd`; `show_cmd` con `--json`)
|
||
- Test: `harness/tests/test_session_list_json.py`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `tht session list --json` → `[{"id","status","question","summary","created_at","updated_at","author"}, ...]` ordinato per `created_at` desc.
|
||
- `tht session show <id> --json` → manifest completo + `{"phase": <int derivata>, "has_schema_linking": bool}`.
|
||
|
||
- [ ] **Step 1: Scrivere il test**
|
||
|
||
```python
|
||
# harness/tests/test_session_list_json.py
|
||
import json
|
||
from tht.session.store import create_session
|
||
from tht.session.models import SessionManifest
|
||
|
||
def test_list_json_lists_created_sessions(tmp_path):
|
||
from tht.config import DatabaseConfig
|
||
db = DatabaseConfig(database="d", schema="s", transport="rest") # adattare ai campi reali
|
||
m1 = create_session("prima domanda", db, tmp_path)
|
||
m2 = create_session("seconda domanda", db, tmp_path)
|
||
from tht.cli.session_cmd import _list_sessions # helper puro
|
||
rows = _list_sessions(tmp_path)
|
||
ids = [r["id"] for r in rows]
|
||
assert m1.id in ids and m2.id in ids
|
||
assert set(["id","status","question","created_at"]).issubset(rows[0].keys())
|
||
```
|
||
|
||
- [ ] **Step 2: Eseguire il test (deve fallire)**
|
||
|
||
Run: `cd harness && pytest tests/test_session_list_json.py -v`
|
||
Expected: FAIL — `_list_sessions` non esiste.
|
||
|
||
- [ ] **Step 3: Implementare helper + comandi**
|
||
|
||
```python
|
||
def _list_sessions(sessions_root: Path) -> list[dict]:
|
||
out = []
|
||
for d in sorted([p for p in sessions_root.iterdir() if (p / "session_manifest.yaml").exists()]):
|
||
m = SessionManifest.from_yaml(d / "session_manifest.yaml")
|
||
out.append({"id": m.id, "status": m.status, "question": m.question,
|
||
"summary": m.summary, "created_at": m.created_at.isoformat(),
|
||
"updated_at": m.updated_at.isoformat() if m.updated_at else None,
|
||
"author": m.author})
|
||
out.sort(key=lambda r: r["created_at"], reverse=True)
|
||
return out
|
||
|
||
@session_app.command("list")
|
||
def list_cmd(json_out: bool = typer.Option(False, "--json"), config: Path = CONFIG_OPT) -> None:
|
||
cfg = _load_config_or_exit(config)
|
||
rows = _list_sessions(cfg.paths.sessions)
|
||
if json_out:
|
||
typer.echo(json.dumps(rows, ensure_ascii=False, indent=2)); return
|
||
for r in rows:
|
||
typer.echo(f"{r['id']} [{r['status']}] {r['summary']}")
|
||
```
|
||
|
||
E in `show_cmd` aggiungere `json_out: bool = typer.Option(False, "--json")`; in modalità JSON stampare il manifest (`model_dump(mode="json", by_alias=True)`) + `phase` (da `current_phase`) + `has_schema_linking`.
|
||
|
||
- [ ] **Step 4: Eseguire il test (deve passare)**
|
||
|
||
Run: `cd harness && pytest tests/test_session_list_json.py -v`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add harness/tht/cli/session_cmd.py harness/tests/test_session_list_json.py
|
||
git commit -m "feat(harness): tht session list/show --json for FE session list"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Manifest — campi provider/model/thinking/name + opzioni di `tht session new`
|
||
|
||
> Persistono la scelta di modello/thinking/provider e il nome, riapplicati al resume dal backend (BE-6/BE-7).
|
||
|
||
**Files:**
|
||
- Modify: `harness/tht/session/models.py` (`SessionManifest`)
|
||
- Modify: `harness/tht/session/store.py` (`create_session`)
|
||
- Modify: `harness/tht/cli/session_cmd.py` (`new_cmd` opzioni)
|
||
- Test: `harness/tests/test_manifest_pi_fields.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `create_session(question, db, sessions_root, *, author=None, summary=None, provider=None, model=None, thinking=None, name=None)`.
|
||
- Produces: manifest con campi opzionali `provider`, `model`, `thinking`, `name`; `tht session new <q> [--provider P --model M --thinking T --name N] [--json]` (con `--json` stampa `{"id": ...}` su stdout puro).
|
||
|
||
- [ ] **Step 1: Scrivere il test**
|
||
|
||
```python
|
||
# harness/tests/test_manifest_pi_fields.py
|
||
from tht.session.store import create_session
|
||
from tht.session.models import SessionManifest
|
||
|
||
def test_manifest_persists_pi_fields(tmp_path):
|
||
from tht.config import DatabaseConfig
|
||
db = DatabaseConfig(database="d", schema="s", transport="rest")
|
||
m = create_session("q", db, tmp_path, provider="zai", model="glm-5.2",
|
||
thinking="medium", name="sessione test")
|
||
reload = SessionManifest.from_yaml(tmp_path / m.id / "session_manifest.yaml")
|
||
assert reload.provider == "zai" and reload.model == "glm-5.2"
|
||
assert reload.thinking == "medium" and reload.name == "sessione test"
|
||
|
||
def test_manifest_pi_fields_optional(tmp_path):
|
||
from tht.config import DatabaseConfig
|
||
db = DatabaseConfig(database="d", schema="s", transport="rest")
|
||
m = create_session("q", db, tmp_path)
|
||
assert m.provider is None and m.model is None and m.thinking is None and m.name is None
|
||
```
|
||
|
||
- [ ] **Step 2: Eseguire il test (deve fallire)**
|
||
|
||
Run: `cd harness && pytest tests/test_manifest_pi_fields.py -v`
|
||
Expected: FAIL — `create_session` non accetta `provider`, ecc.
|
||
|
||
- [ ] **Step 3: Aggiungere i campi al modello e a create_session**
|
||
|
||
In `SessionManifest` (dopo `schema_version`):
|
||
```python
|
||
provider: str | None = None
|
||
model: str | None = None
|
||
thinking: str | None = None
|
||
name: str | None = None
|
||
```
|
||
In `create_session` aggiungere i parametri keyword e passarli al costruttore del manifest:
|
||
```python
|
||
def create_session(question, db, sessions_root, *, author=None, summary=None,
|
||
provider=None, model=None, thinking=None, name=None):
|
||
...
|
||
manifest = SessionManifest(
|
||
id=session_id, created_at=now, question=question,
|
||
database=db.database, schema=db.db_schema,
|
||
author=who, summary=summary or _summarize(question),
|
||
updated_at=now, updated_by=who, schema_version=schema_version,
|
||
provider=provider, model=model, thinking=thinking, name=name,
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 4: Eseguire il test (deve passare)**
|
||
|
||
Run: `cd harness && pytest tests/test_manifest_pi_fields.py -v`
|
||
Expected: PASS (2 test).
|
||
|
||
- [ ] **Step 5: Aggiungere le opzioni a `tht session new` + `--json`**
|
||
|
||
```python
|
||
@session_app.command("new")
|
||
def new_cmd(
|
||
question: str = typer.Argument(...),
|
||
provider: str = typer.Option(None, "--provider"),
|
||
model: str = typer.Option(None, "--model"),
|
||
thinking: str = typer.Option(None, "--thinking"),
|
||
name: str = typer.Option(None, "--name"),
|
||
json_out: bool = typer.Option(False, "--json"),
|
||
config: Path = CONFIG_OPT,
|
||
) -> None:
|
||
from tht.session.store import create_session
|
||
cfg = _load_config_or_exit(config)
|
||
manifest = create_session(question, cfg.database, cfg.paths.sessions,
|
||
provider=provider, model=model, thinking=thinking, name=name)
|
||
if json_out:
|
||
typer.echo(json.dumps({"id": manifest.id}, ensure_ascii=False)); return
|
||
typer.secho(f"OK: sessione creata in {session_dir(cfg, manifest.id)}", fg=typer.colors.GREEN)
|
||
typer.echo(manifest.id)
|
||
```
|
||
|
||
Run: `cd harness && pytest tests/test_manifest_pi_fields.py tests/test_session_list_json.py -v`
|
||
Expected: PASS (regressione esistente verde).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add harness/tht/session/models.py harness/tht/session/store.py harness/tht/cli/session_cmd.py harness/tests/test_manifest_pi_fields.py
|
||
git commit -m "feat(harness): manifest provider/model/thinking/name + session new options (BE-6/7)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: `.pi/settings.json` — quietStartup + trust
|
||
|
||
> Correttezza dello spawn RPC: niente rumore di avvio su stdout (sporcherebbe il JSONL), file project-local fidati (niente prompt di trust che appende il loop).
|
||
|
||
**Files:**
|
||
- Modify: `harness/.pi/settings.json`
|
||
- Test: `harness/.pi/extensions/gate/__tests__/settings.test.js`
|
||
|
||
**Interfaces:**
|
||
- Produces: `.pi/settings.json` contiene almeno `{"theme": "thothii-mono", "quietStartup": true}`; il trust dei file project-local è documentato (verifica empirica nello spike/Task 10).
|
||
|
||
- [ ] **Step 1: Scrivere il test (forma del settings)**
|
||
|
||
```javascript
|
||
const test = require("node:test");
|
||
const assert = require("node:assert");
|
||
const fs = require("node:fs");
|
||
const path = require("node:path");
|
||
|
||
test("settings.json abilita quietStartup", () => {
|
||
const s = JSON.parse(fs.readFileSync(path.join(__dirname, "../../settings.json"), "utf8"));
|
||
assert.equal(s.quietStartup, true);
|
||
assert.equal(s.theme, "thothii-mono");
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Eseguire il test (deve fallire)**
|
||
|
||
Run: `cd harness && node --test .pi/extensions/gate/__tests__/settings.test.js`
|
||
Expected: FAIL — `quietStartup` assente.
|
||
|
||
- [ ] **Step 3: Aggiornare settings.json**
|
||
|
||
```json
|
||
{
|
||
"theme": "thothii-mono",
|
||
"quietStartup": true
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Eseguire il test (deve passare)**
|
||
|
||
Run: `cd harness && node --test .pi/extensions/gate/__tests__/settings.test.js`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Documentare il trust + commit**
|
||
|
||
Aggiungere a `harness/docs/rpc-readiness-findings.md` una nota: come è stato concesso il trust dei file project-local allo spawn RPC (verificato che NON compaia un prompt di trust che blocca il loop — confermato nel Task 10 / spike).
|
||
|
||
```bash
|
||
git add harness/.pi/settings.json harness/.pi/extensions/gate/__tests__/settings.test.js harness/docs/rpc-readiness-findings.md
|
||
git commit -m "chore(harness): quietStartup + project-local trust for clean RPC spawn"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: fake-pi-rpc — test-double del protocollo RPC + golden test di contratto (D10)
|
||
|
||
> Asset condiviso consegnato qui per il Piano Backend: un processo che parla il protocollo JSONL su stdio, scriptabile per emettere sequenze di eventi e accettare comandi. Un golden test fissa il contratto del widget-descriptor (D10).
|
||
|
||
**Files:**
|
||
- Create: `harness/tests/fake_pi/fake_pi_rpc.mjs`
|
||
- Create: `harness/tests/fake_pi/scripts/f1_disambiguation.json` (scenario scriptato)
|
||
- 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>`. **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 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": [
|
||
{ "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"} ],
|
||
"reserved": ["back","exit","other"] } }
|
||
],
|
||
"on_response": { "u1": [ { "type": "agent_end" } ] },
|
||
"available_models": [ {"provider":"zai","id":"glm-5.2"} ]
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Scrivere il test di contratto**
|
||
|
||
```javascript
|
||
// harness/tests/fake_pi/test_fake_pi_contract.mjs — run: node --test
|
||
import test from "node:test";
|
||
import assert from "node:assert";
|
||
import { spawn } from "node:child_process";
|
||
import path from "node:path";
|
||
|
||
function drive(scriptPath, commands) {
|
||
return new Promise((resolve) => {
|
||
const fp = spawn("node", [path.join(import.meta.dirname, "fake_pi_rpc.mjs"), scriptPath]);
|
||
const events = []; let buf = "";
|
||
fp.stdout.on("data", (c) => {
|
||
buf += c.toString("utf8");
|
||
for (let nl; (nl = buf.indexOf("\n")) !== -1; ) {
|
||
const line = buf.slice(0, nl).replace(/\r$/, ""); buf = buf.slice(nl + 1);
|
||
if (line) events.push(JSON.parse(line));
|
||
}
|
||
});
|
||
fp.on("exit", () => resolve(events));
|
||
for (const cmd of commands) fp.stdin.write(JSON.stringify(cmd) + "\n");
|
||
setTimeout(() => fp.stdin.end(), 300);
|
||
});
|
||
}
|
||
|
||
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",
|
||
value: JSON.stringify({ id: "u1", choices: ["a"], decision: { type: "concept_clarified" } }) },
|
||
]);
|
||
const widget = events.find((e) => e.type === "extension_ui_request");
|
||
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"));
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Eseguire il test (deve fallire)**
|
||
|
||
Run: `cd harness && node --test tests/fake_pi/test_fake_pi_contract.mjs`
|
||
Expected: FAIL — `fake_pi_rpc.mjs` non esiste.
|
||
|
||
- [ ] **Step 4: Implementare il fake-pi-rpc**
|
||
|
||
```javascript
|
||
// harness/tests/fake_pi/fake_pi_rpc.mjs — scripted RPC test double (LF-only JSONL).
|
||
import fs from "node:fs";
|
||
const script = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||
const out = (evt) => process.stdout.write(JSON.stringify(evt) + "\n");
|
||
|
||
let buf = "";
|
||
process.stdin.on("data", (chunk) => {
|
||
buf += chunk.toString("utf8");
|
||
for (let nl; (nl = buf.indexOf("\n")) !== -1; ) {
|
||
const line = buf.slice(0, nl).replace(/\r$/, ""); buf = buf.slice(nl + 1);
|
||
if (!line) continue;
|
||
let cmd; try { cmd = JSON.parse(line); } catch { continue; }
|
||
if (cmd.type === "prompt") {
|
||
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") {
|
||
out({ type: "response", command: "get_available_models", id: cmd.id, success: true,
|
||
data: { models: script.available_models ?? [] } });
|
||
} else if (cmd.type === "steer") {
|
||
out({ type: "response", command: "steer", id: cmd.id, success: true });
|
||
} else if (cmd.type === "get_state") {
|
||
out({ type: "response", command: "get_state", id: cmd.id, success: true,
|
||
data: { sessionId: "fake", thinkingLevel: "medium", isStreaming: false } });
|
||
}
|
||
}
|
||
});
|
||
process.stdin.on("end", () => process.exit(0));
|
||
```
|
||
|
||
- [ ] **Step 5: Eseguire il test (deve passare)**
|
||
|
||
Run: `cd harness && node --test tests/fake_pi/test_fake_pi_contract.mjs`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Validazione end-to-end con Pi reale (L2, informativo, non-CI)**
|
||
|
||
Con `.env` + VPN, ri-eseguire `node scripts/rpc_probe.mjs` (Task 1) e confermare che, dopo i Task 3–5, il gate: (a) parte sul `prompt`, (b) emette il widget, (c) riceve la risposta e avanza. Annotare l'esito in `rpc-readiness-findings.md`. Questo chiude il rischio "path RPC mai testato".
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add harness/tests/fake_pi/
|
||
git commit -m "test(harness): fake-pi-rpc protocol double + F1 widget contract golden (D10)"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
**Spec coverage** (vs `2026-06-27-backend-design.md` §7 + decisioni BE):
|
||
- BE-5 (id fornito): Task 5 ✓
|
||
- BE-6/BE-7 (model/thinking/provider/name nel manifest): Task 8 ✓; settings spawn (quietStartup/trust): Task 9 ✓
|
||
- §7.1 (preview --json/--offset): Task 6 ✓
|
||
- §7.2 (kickoff con id): Task 5 ✓
|
||
- §7.3 (campi manifest): Task 8 ✓
|
||
- §7.4 (settings.json): Task 9 ✓
|
||
- §7.5 (session list/show --json): Task 7 ✓
|
||
- §7.6 (fake-Pi condiviso): Task 10 (fake-pi-rpc) + Task 2 (fake-pi-runtime) ✓
|
||
- Rischio "gate RPC mai testato": Task 1 (spike) + Task 3/4 (adattamento) + Task 10 Step 6 (validazione reale) ✓
|
||
|
||
**Placeholder scan:** Task 1 è uno spike dichiarato (osservazione, non TDD) — i suoi "step" sono azioni concrete con output atteso. Le varianti A/B del Task 4 sono entrambe specificate; la scelta è guidata dall'evidenza dello spike, non un TBD.
|
||
|
||
**Type consistency:** `create_session(..., provider, model, thinking, name)` (Task 8) coerente con i campi del manifest (Task 8) e con l'uso del backend (Piano 2). `inject_limit_offset(sql, *, limit, offset)` (Task 6) usato da `do_run(..., offset=)`. `_list_sessions` (Task 7) ritorna le chiavi usate dal FE. Envelope `{"type":"extension_ui_request","ui_request":{...}}` (Task 10) coerente con l'emissione del gate (Task 4) e con ciò che il backend tradurrà (Piano 2).
|
||
|
||
**Nota di sequenza:** il Task 1 (spike) richiede Pi reale + VPN; se non disponibile al momento dell'esecuzione, i Task 2 e 6–9 (CI puri) possono procedere in parallelo; i Task 3–4 (adattamento gate) richiedono la decisione dello spike e vanno dopo.
|