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