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
@@ -12,6 +12,7 @@
- **Prerequisito:** il Piano Harness (`2026-06-27-harness-rpc-readiness.md`) deve essere completato — il backend dipende da: gate RPC-ready, `THT_SESSION` id injection, `tht sql preview --json/--offset`, `tht session list/show --json`, campi manifest, `fake-pi-rpc`.
- **Framing RPC: LF-only JSONL** — `JSON.stringify(v)+"\n"`; lettura split su `\n`, strip `\r` finale. MAI `readline`. Riferimento: `@earendil-works/pi-coding-agent/dist/modes/rpc/jsonl.js`.
- **WIRE CONTRACT (corretto post-spike, Piano Harness Task 1):** il gate usa l'API UI nativa di Pi. Sul wire arriva `{type:"extension_ui_request", id, method:"input", title:"<widget-descriptor JSON>"}`; il backend **decodifica `title` → descriptor**, lo espone al FE come `ui_request`, e risponde `{type:"extension_ui_response", id, value:"<ui_response JSON>"}` (o `{id, cancelled:true}`). `ctx.sendRaw` non esiste; non esistono eventi `{ui_request:…}` nidificati. Il contratto verso il FE (`ui_request`/`ui_response`, architettura §4) resta invariato — la traduzione native↔FE è responsabilità del `SessionBridge`.
- **Deployment MVP:** localhost, mono-operatore (D12-B). Auth default `none` (utente `dev@local`).
- **Il backend NON tocca il DB**: ogni esecuzione SQL passa da `tht` (BE-2). Nessun driver `pg`/REST nel backend.
- **Posizione harness:** path configurabile (`THT_HARNESS_DIR`, default `../harness` rispetto al backend); `tht` invocato dal venv harness; spawn di Pi con `cwd = THT_HARNESS_DIR`.
@@ -238,7 +239,8 @@ test("prompt emette un evento extension_ui_request", async () => {
const got = new Promise<any>((resolve) => rpc.on("event", (e) => e.type === "extension_ui_request" && resolve(e)));
rpc.send({ type: "prompt", message: "/nuova-domanda \"x\"" });
const evt = await got;
expect(evt.ui_request.widget).toBe("select");
expect(evt.method).toBe("input"); // shape nativa di Pi
expect(JSON.parse(evt.title).widget).toBe("select"); // descriptor nel title
child.stdin.end();
});
```
@@ -301,9 +303,10 @@ git commit -m "feat(backend): RpcClient (spawn/send/request/event) over JSONL"
- Produces: `class SessionBridge`:
- `constructor(rpc: RpcClient)`
- `onClientEvent(cb: (e: ClientEvent) => void)` — emette verso il FE: `{type:"ui_request"|"info"|"text_delta"|"system_event", ...}`
- `respond(uiResponse: object): void` — invia `{type:"extension_ui_response", ...}` a Pi
- `respond(uiResponse: object & {id:string}): void` — invia a Pi `{type:"extension_ui_response", id, value: JSON.stringify(uiResponse)}` (shape NATIVA: il payload va in `value`)
- `steer(text: string): void` — invia `{type:"steer", message: text}`
- `pendingWidget(): object | null` — l'ultima `ui_request` non ancora risposta (per re-emit)
- `pendingWidget(): object | null` — l'ultimo descriptor non ancora risposto (per re-emit)
- **Decodifica della shape nativa (WIRE CONTRACT):** un evento `{type:"extension_ui_request", id, method:"input", title}` → il bridge fa `JSON.parse(title)` → descriptor, lo espone come `ui_request`; `method:"notify"` → `info`; altri `method` (setStatus/setWidget) ignorati in MVP.
- Tipi: `ClientEvent = {type:"ui_request", ui_request:object} | {type:"text_delta", text:string} | {type:"info",...} | {type:"system_event",...}`.
- [ ] **Step 1: Scrivere il test**
@@ -318,21 +321,22 @@ function fakeRpc() {
sent, fire: (m:any)=>evcb(m) };
}
test("extension_ui_request diventa ui_request verso il FE ed è il widget pendente", () => {
test("extension_ui_request nativo (method:input, title=json) diventa ui_request ed è il pendente", () => {
const { rpc, fire } = fakeRpc();
const b = new SessionBridge(rpc);
const seen: any[] = []; b.onClientEvent((e)=>seen.push(e));
fire({ type:"extension_ui_request", ui_request:{ id:"u1", widget:"select" } });
expect(seen[0]).toEqual({ type:"ui_request", ui_request:{ id:"u1", widget:"select" } });
expect(b.pendingWidget()).toEqual({ id:"u1", widget:"select" });
const descriptor = { id:"u1", widget:"select" };
fire({ type:"extension_ui_request", id:"u1", method:"input", title: JSON.stringify(descriptor) });
expect(seen[0]).toEqual({ type:"ui_request", ui_request: descriptor });
expect(b.pendingWidget()).toEqual(descriptor);
});
test("respond invia extension_ui_response e azzera il pendente", () => {
test("respond invia extension_ui_response con payload in value e azzera il pendente", () => {
const { rpc, sent, fire } = fakeRpc();
const b = new SessionBridge(rpc);
fire({ type:"extension_ui_request", ui_request:{ id:"u1", widget:"select" } });
fire({ type:"extension_ui_request", id:"u1", method:"input", title: JSON.stringify({ id:"u1", widget:"select" }) });
b.respond({ id:"u1", choices:["a"] });
expect(sent.at(-1)).toEqual({ type:"extension_ui_response", id:"u1", choices:["a"] });
expect(sent.at(-1)).toEqual({ type:"extension_ui_response", id:"u1", value: JSON.stringify({ id:"u1", choices:["a"] }) });
expect(b.pendingWidget()).toBeNull();
});
@@ -364,22 +368,25 @@ export class SessionBridge {
private cbs = new Set<(e: ClientEvent) => void>();
constructor(private rpc: RpcClient) {
rpc.on("event", (m) => {
if (m.type === "extension_ui_request") {
this.pending = m.ui_request;
this.fan({ type: "ui_request", ui_request: m.ui_request });
if (m.type === "extension_ui_request" && m.method === "input") {
let descriptor: any; try { descriptor = JSON.parse(m.title); } catch { return; }
this.pending = descriptor;
this.fan({ type: "ui_request", ui_request: descriptor });
} else if (m.type === "extension_ui_request" && m.method === "notify") {
this.fan({ type: "info", level: m.notifyType ?? "info", text: m.message ?? "" });
} else if (m.type === "text_delta") {
this.fan({ type: "text_delta", text: m.text ?? "" });
} else if (m.type === "info" || m.type === "system_event") {
} else if (m.type === "system_event") {
this.fan(m as ClientEvent);
}
// altri eventi Pi (agent_end, tool_call, …) non sono inoltrati al FE in MVP
// altri method nativi (setStatus/setWidget) e altri eventi Pi (agent_end, tool_call) non inoltrati in MVP
});
}
private fan(e: ClientEvent) { for (const cb of this.cbs) cb(e); }
onClientEvent(cb: (e: ClientEvent) => void) { this.cbs.add(cb); }
respond(uiResponse: object & { id: string }) {
this.rpc.send({ type: "extension_ui_response", ...uiResponse });
if (this.pending && (uiResponse as any).id === this.pending.id) this.pending = null;
this.rpc.send({ type: "extension_ui_response", id: uiResponse.id, value: JSON.stringify(uiResponse) });
if (this.pending && uiResponse.id === this.pending.id) this.pending = null;
}
steer(text: string) { this.rpc.send({ type: "steer", message: text }); }
pendingWidget() { return this.pending; }