fix(backend): echo Pi's RPC id so reviewer gates unblock after answer

ctx.ui.input in `pi --mode rpc` correlates extension_ui_response on its own
top-level RPC id (crypto.randomUUID), not the descriptor id the gate carries
in `title`. SessionBridge replied with the descriptor id, so Pi silently
dropped the response and the model never resumed — every reviewer widget hung
after the human answered.

SessionBridge now stores Pi's top-level m.id (pendingPiId) and replies
extension_ui_response{ id: pendingPiId, value: <uiResponse> }; value still
carries the descriptor id so the gate's internal resp.id === descriptor.id
check still holds.

The fake-pi double had masked the bug by forcing m.id == descriptor.id; it now
mirrors real Pi (distinct randomUUID, correlate on it, drop unknown ids), with
a negative regression test. SKILL.md Phase 1 also now steers multi-answer
disambiguation to reviewer_decide (multiselect).

Tests: backend 67/67, tsc clean, fake-pi contract 2/2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 10:43:49 +02:00
co-authored by Claude Opus 4.8
parent 4f60b38ca2
commit 418187a4ad
8 changed files with 169 additions and 35 deletions
+11 -2
View File
@@ -8,6 +8,11 @@ export type ClientEvent =
export class SessionBridge {
private pending: any = null;
// Pi (rpc-mode createDialogPromise) assegna a ogni ctx.ui.input un id RPC PROPRIO
// (crypto.randomUUID) e correla extension_ui_response su quell'id — NON sull'id interno
// del descriptor (che viaggia opaco nel `title`). Va memorizzato e rimandato indietro,
// altrimenti Pi scarta la risposta e ctx.ui.input non si risolve mai (stuck senza output).
private pendingPiId: string | null = null;
private cbs = new Set<(e: ClientEvent) => void>();
constructor(private rpc: RpcClient) {
@@ -16,6 +21,7 @@ export class SessionBridge {
let descriptor: any;
try { descriptor = JSON.parse(m.title); } catch { return; }
this.pending = descriptor;
this.pendingPiId = m.id;
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 ?? "" });
@@ -38,8 +44,11 @@ export class SessionBridge {
onClientEvent(cb: (e: ClientEvent) => void): void { this.cbs.add(cb); }
respond(uiResponse: object & { id: string }): void {
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;
// Correla sull'id RPC di Pi; `value` porta l'uiResponse (con l'id del descriptor) cosi'
// il check interno del gate (resp.id === descriptor.id) regge.
const piId = this.pendingPiId ?? uiResponse.id;
this.rpc.send({ type: "extension_ui_response", id: piId, value: JSON.stringify(uiResponse) });
if (this.pending && uiResponse.id === this.pending.id) { this.pending = null; this.pendingPiId = null; }
}
steer(text: string): void { this.rpc.send({ type: "steer", message: text }); }
+33 -7
View File
@@ -7,7 +7,28 @@ import { loadConfig } from "../src/config.js";
const FAKE = path.resolve("../harness/tests/fake_pi/fake_pi_rpc.mjs");
const SCRIPT = path.resolve("../harness/tests/fake_pi/scripts/f1_disambiguation.json");
test("loop F1: crea sessione → SSE riceve il widget → risponde → 204", async () => {
// Legge dallo stream SSE finche' `predicate(acc)` e' vero o scade il timeout.
async function readUntil(
reader: ReadableStreamDefaultReader<Uint8Array>,
predicate: (acc: string) => boolean,
timeoutMs: number,
): Promise<string> {
const dec = new TextDecoder();
let acc = "";
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res: any = await Promise.race([
reader.read(),
new Promise((r) => setTimeout(() => r({ timeout: true }), deadline - Date.now())),
]);
if (res.timeout || res.done) break;
acc += dec.decode(res.value);
if (predicate(acc)) return acc;
}
return acc;
}
test("loop F1: crea sessione → SSE riceve il widget → risponde → il modello riparte (follow-up)", async () => {
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
ollamaEnsure: async () => ({ ok: true }),
@@ -30,15 +51,13 @@ test("loop F1: crea sessione → SSE riceve il widget → risponde → 204", asy
// 2. Small delay to let fake-pi process the prompt and fill pendingWidget
await new Promise((r) => setTimeout(r, 50));
// 3. SSE: read the first event (pendingWidget re-emit via subscribe)
// 3. SSE: the pending widget is re-emitted on subscribe
const es = await fetch(`${base}/sessions/s1/events`);
const reader = es.body!.getReader();
const chunk = await reader.read();
const text = new TextDecoder().decode(chunk.value);
expect(text).toContain("ui_request");
await reader.cancel();
const first = await readUntil(reader, (t) => t.includes("ui_request"), 2000);
expect(first).toContain("ui_request");
// 4. POST response — widget id is "u1" from f1_disambiguation.json
// 4. POST response — widget id is "u1" from f1_disambiguation.json (id INTERNO del descriptor)
const resp = await fetch(`${base}/sessions/s1/response`, {
method: "POST",
headers: { "content-type": "application/json" },
@@ -46,5 +65,12 @@ test("loop F1: crea sessione → SSE riceve il widget → risponde → 204", asy
});
expect(resp.status).toBe(204);
// 5. Prova del fix: la risposta deve essere correlata sull'id RPC di Pi, cosi' ctx.ui.input
// si risolve e il modello produce il follow-up. Col bug, la risposta veniva scartata e
// nessun follow-up arrivava ("stuck senza output").
const after = await readUntil(reader, (t) => t.includes("Procedo."), 2000);
expect(after).toContain("Procedo.");
await reader.cancel();
await app.close();
}, 10000);
+11 -5
View File
@@ -28,23 +28,29 @@ test("real Pi message_update (assistantMessageEvent text_delta) becomes a text_d
expect(seen).toHaveLength(1);
});
test("extension_ui_request nativo (method:input, title=json) diventa ui_request ed è il pendente", () => {
test("extension_ui_request nativo (method:input, title=json) diventa ui_request col descriptor ed è il pendente", () => {
const { rpc, fire } = fakeRpc();
const b = new SessionBridge(rpc);
const seen: any[] = [];
b.onClientEvent((e) => seen.push(e));
const descriptor = { id: "u1", widget: "select" };
fire({ type: "extension_ui_request", id: "u1", method: "input", title: JSON.stringify(descriptor) });
// Pi assegna a ctx.ui.input un proprio id RPC (crypto.randomUUID), distinto dall'id
// interno del descriptor che il gate mette nel `title`. Al frontend va il descriptor.
fire({ type: "extension_ui_request", id: "pi-req-1", 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 con payload in value e azzera il pendente", () => {
test("respond correla sull'id RPC di Pi (non sull'id del descriptor) e azzera il pendente", () => {
const { rpc, sent, fire } = fakeRpc();
const b = new SessionBridge(rpc);
fire({ type: "extension_ui_request", id: "u1", method: "input", title: JSON.stringify({ id: "u1", widget: "select" }) });
// Pi emette la richiesta con il SUO id RPC ("pi-req-1"); il descriptor nel title ha id "u1".
fire({ type: "extension_ui_request", id: "pi-req-1", method: "input", title: JSON.stringify({ id: "u1", widget: "select" }) });
// Il frontend rimanda l'id del descriptor ("u1").
b.respond({ id: "u1", choices: ["a"] });
expect(sent.at(-1)).toEqual({ type: "extension_ui_response", id: "u1", value: JSON.stringify({ id: "u1", choices: ["a"] }) });
// Pi correla la risposta sul SUO id ("pi-req-1") per risolvere ctx.ui.input; il value
// continua a portare l'id del descriptor, cosi' il check interno del gate regge.
expect(sent.at(-1)).toEqual({ type: "extension_ui_response", id: "pi-req-1", value: JSON.stringify({ id: "u1", choices: ["a"] }) });
expect(b.pendingWidget()).toBeNull();
});