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
+48 -4
View File
@@ -1,6 +1,6 @@
# ThothII — Project State
> Starting-point snapshot for new sessions. Last updated: 2026-06-29.
> Starting-point snapshot for new sessions. Last updated: 2026-06-30.
> Point a fresh session here ("read PROJECT_STATE.md") before substantial work.
## What ThothII is
@@ -53,7 +53,7 @@ Opens frontend at http://localhost:5173 → backend :8787.
- frontend: `cd frontend && npm run dev` (Vite; `VITE_BACKEND_URL` → backend)
- harness install: `cd harness && python -m venv .venv && pip install -e ".[dev]"` → `tht` on PATH
## How to test (all green as of 2026-06-29: harness 248 / backend 59 / frontend 73)
## How to test (all green as of 2026-06-29: harness 248 / backend 67 / frontend 73)
- harness: `cd harness && .venv/bin/pytest -q` (5 L2/real-DB tests are deselected by default)
- backend: `cd backend && npx vitest run` · typecheck `npx tsc --noEmit -p .`
- frontend: `cd frontend && npx vitest run` · typecheck `npx tsc -b` · e2e `npm run e2e` (Playwright)
@@ -78,6 +78,40 @@ Opens frontend at http://localhost:5173 → backend :8787.
- Global user rules (`~/.claude/CLAUDE.md`): think before coding, simplicity first, surgical
changes, goal-driven verification.
## Most recent work — F1 reviewer-widget hang fix + multiselect guidance (committed 2026-06-30; authored 2026-06-29)
Two fixes, **committed to `main`** (7 files):
1. **Bug: every reviewer widget hung "stuck with no output" after the human answered** — F1
disambiguation (and any gate) dead-ended. Root cause, confirmed from Pi's own source
(`@mariozechner/pi-coding-agent` `dist/modes/rpc/rpc-mode.js`, `createDialogPromise`):
`ctx.ui.input` assigns its OWN RPC id (`crypto.randomUUID`) and correlates
`extension_ui_response` on THAT id, silently dropping unknown ids. The gate puts a
different id (`u${Date.now()}`) inside the descriptor carried in `title`. `SessionBridge`
was replying with the **descriptor** id, so real Pi never resolved `ctx.ui.input` → the
model never continued. **Fix:** `SessionBridge` now stores Pi's top-level `m.id`
(`pendingPiId`) on the incoming request and replies `extension_ui_response{ id: pendingPiId,
value: <uiResponse JSON> }` (value still carries the descriptor id, so the gate's internal
`resp.id === descriptor.id` check holds). File: `backend/src/bridge/session-bridge.ts`.
Full write-up: memory `pi-ui-input-id-correlation.md`.
- **The test double was masking it:** `harness/tests/fake_pi/fake_pi_rpc.mjs` had forced
`m.id == descriptor.id`. Corrected to mirror real Pi (distinct `randomUUID` top-level id,
correlate on it, drop unknown ids); `test_fake_pi_contract.mjs` gained a negative
regression test ("respond with descriptor id → no follow-up").
- TDD: `backend/test/session-bridge.test.ts` (unit) + `backend/test/e2e-f1.test.ts`
(integration — now asserts the model's follow-up arrives after the answer) went
RED→GREEN.
2. **UX: multi-answer disambiguation** — `harness/.pi/skills/tht-sessione/SKILL.md` Phase 1
now tells the model to use `reviewer_decide` (the existing multiselect/checkbox widget)
when an ambiguity admits several simultaneously-true answers, instead of single-pick
`reviewer_select`. Guidance-only — no new widget (`frontend MultiselectWidget` already
exists).
Verified this session: backend `npx vitest run` **67/67 green**; `tsc --noEmit -p .` **OK**;
fake-pi contract `node --test test_fake_pi_contract.mjs` **2/2 green**. Harness pytest and
frontend NOT re-run (untouched by these changes). **NOT verified live** — see open item 4.
## Most recent feature — Session management (MERGED to main @ 2c21e46)
Full session management modeled on Claude's UI, all three layers:
- **Read-only "split view" panel** (left drawer, `SessionDocumentsPanel`) showing a session's
@@ -102,6 +136,15 @@ Full session management modeled on Claude's UI, all three layers:
(today gated by `load_session`); `close_session` could reuse `_save_touched` (DRY);
delete-via-kebab integration test skipped (base-ui Menu portal not drivable in jsdom —
the dialog itself is unit-tested); a couple of test-file lint nits.
4. **Live-verify the F1 reviewer-widget hang fix (committed 2026-06-30).** The fix is committed
and proven against Pi's source + a now-faithful fake, but the browser round-trip is still
unproven. With VPN up: start the stack, drive an F1 session past the first answer, confirm
the widget unblocks and the model continues. (On 2026-06-29 DWH REST was unreachable with
VPN off, so the live e2e couldn't run; Ollama was reachable and `pi` present.)
5. **Model caveat (likely "struggles to finish the task" symptom).** Settings use
`deepseek-v4-flash` (thinking high) but the harness is tuned for GLM 5.2, and F1 needs
many autonomous tool calls; even with the hang fixed a "flash" model may not complete F1.
Separate config decision — try GLM 5.2 when validating live.
## Where design history lives
- Specs: `docs/superpowers/specs/` · Plans: `docs/superpowers/plans/`
@@ -110,5 +153,6 @@ Full session management modeled on Claude's UI, all three layers:
(notes on the Pi RPC event vocabulary and the Omics Portal/GSD design system).
## Git
`main` @ `2c21e46`, pushed to `origin` (github.com/mptyl/ThothII), in sync. Other local
branches (`feat/settings-menu`, `feat/thothII-debugging`) are pre-existing, untouched.
`main` tracks `origin` (github.com/mptyl/ThothII). The F1 hang-fix (7 files + this
`PROJECT_STATE.md`) is **committed on `main` on top of `4f60b38`, not yet pushed**. Other
local branches (`feat/settings-menu`, `feat/thothII-debugging`) are pre-existing, untouched.
+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();
});
+15 -4
View File
@@ -100,10 +100,21 @@ Prerequisite: you must already be in Phase 1.
over real values) and `tht search find --kind evidence "<term>"`. The LSH exposes
EVERY column where a value appears — it does not collapse to a single best match,
so a value like "ablazione" may anchor on multiple columns.
2. For each ambiguity (clinical term, population, time window, outcome), present a
`reviewer_select` with the candidate interpretations (`recommended:true` on the
best) + "Altro". When a clarification is settled, move on. Pass the FULL list of
clarifications, not only the latest, when you close.
2. For each ambiguity (clinical term, population, time window, outcome), present the
candidate interpretations (`recommended:true` on the best) + "Altro". Pick the widget
by the question's shape:
- **Exactly one interpretation is correct** (mutually exclusive) → `reviewer_select`
(single-pick; it only asks — then record the choice with a `reviewer_decide`
`concept_clarified`).
- **Several answers can be simultaneously true** (e.g. more than one valid population,
procedure code, or time window) → do NOT use `reviewer_select`: single-pick buttons
force one answer and mislead the reviewer. Use `reviewer_decide` directly (it emits a
**multiselect checkbox** widget), one option per candidate, each carrying its own
`concept_clarified` decision; the reviewer checks all that apply. Keep `advance:false`
(Phase 1 still closes via the phase gate in step 3).
When a clarification is settled, move on. Pass the FULL list of clarifications, not
only the latest, when you close.
3. To close Phase 1: `reviewer_confirm kind:"phase"` (the deliberate "I'm done
clarifying" gate). Do NOT add a separate `reviewer_confirm` after each individual
clarification — those advance via `reviewer_decide` (`concept_clarified`), not via
+16 -2
View File
@@ -1,8 +1,16 @@
// harness/tests/fake_pi/fake_pi_rpc.mjs — scripted RPC test double (LF-only JSONL).
import fs from "node:fs";
import crypto from "node:crypto";
const script = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const out = (evt) => process.stdout.write(JSON.stringify(evt) + "\n");
// Mirror real Pi (rpc-mode createDialogPromise): ctx.ui.input assegna un id RPC PROPRIO
// (crypto.randomUUID), distinto dall'id interno del descriptor che viaggia opaco nel
// `title`. La risposta si correla su quell'id RPC; un id sconosciuto viene scartato in
// silenzio (esattamente cio' che provocava lo "stuck senza output" quando l'host
// rispondeva con l'id del descriptor invece dell'id RPC).
const pendingUi = new Map(); // piId (RPC) -> descriptor.id
let buf = "";
process.stdin.on("data", (chunk) => {
buf += chunk.toString("utf8");
@@ -14,11 +22,17 @@ process.stdin.on("data", (chunk) => {
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) });
const piId = crypto.randomUUID(); // id RPC proprio di Pi (≠ descriptor.id)
pendingUi.set(piId, d.id);
out({ type: "extension_ui_request", id: piId, 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);
const descId = pendingUi.get(cmd.id); // correla SOLO sull'id RPC di Pi
if (descId !== undefined) {
pendingUi.delete(cmd.id);
for (const evt of (script.on_response ?? {})[descId] ?? []) 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 ?? [] } });
@@ -6,6 +6,9 @@
"options": [ {"id":"a","label":"interpretazione A"}, {"id":"b","label":"interpretazione B"} ],
"reserved": ["back","exit","other"] } }
],
"on_response": { "u1": [ { "type": "agent_end" } ] },
"on_response": { "u1": [
{ "type": "message_update", "assistantMessageEvent": { "type": "text_delta", "contentIndex": 0, "delta": "Procedo." } },
{ "type": "agent_end" }
] },
"available_models": [ {"provider":"zai","id":"glm-5.2"} ]
}
+31 -10
View File
@@ -4,7 +4,10 @@ import assert from "node:assert";
import { spawn } from "node:child_process";
import path from "node:path";
function drive(scriptPath, commands) {
// Avvia il fake, manda il prompt e — quando arriva il widget — risponde con l'id RPC
// indicato da `respondWith` (una funzione widget -> id). Mirror del vero Pi: la risposta
// si correla sull'id RPC top-level, non sull'id interno del descriptor.
function driveReactive(scriptPath, respondWith) {
return new Promise((resolve) => {
const fp = spawn("node", [path.join(import.meta.dirname, "fake_pi_rpc.mjs"), scriptPath]);
const events = []; let buf = "";
@@ -12,27 +15,45 @@ function drive(scriptPath, commands) {
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));
if (!line) continue;
const evt = JSON.parse(line);
events.push(evt);
if (evt.type === "extension_ui_request" && evt.method === "input") {
const desc = JSON.parse(evt.title);
fp.stdin.write(JSON.stringify({
type: "extension_ui_response",
id: respondWith(evt),
value: JSON.stringify({ id: desc.id, choices: ["a"], decision: { type: "concept_clarified" } }),
}) + "\n");
}
}
});
fp.on("exit", () => resolve(events));
for (const cmd of commands) fp.stdin.write(JSON.stringify(cmd) + "\n");
fp.stdin.write(JSON.stringify({ type: "prompt", message: '/nuova-domanda "x"' }) + "\n");
setTimeout(() => fp.stdin.end(), 300);
});
}
test("on prompt emette il widget F1; on response avanza", async () => {
test("on prompt emette il widget F1 con id RPC distinto dal descriptor; rispondere con l'id RPC sblocca", 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" } }) },
]);
// Host corretto: risponde con l'id RPC top-level del widget.
const events = await driveReactive(sp, (w) => w.id);
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.notEqual(widget.id, descriptor.id); // l'id RPC di Pi NON e' l'id del descriptor
// La risposta correlata sblocca il follow-up (testo + agent_end).
assert.ok(events.some((e) => e.type === "message_update"));
assert.ok(events.some((e) => e.type === "agent_end"));
});
test("rispondere con l'id del descriptor (sbagliato) viene scartato: nessun follow-up", async () => {
const sp = path.join(import.meta.dirname, "scripts/f1_disambiguation.json");
// Host bug: risponde con l'id interno del descriptor invece dell'id RPC -> drop.
const events = await driveReactive(sp, (w) => JSON.parse(w.title).id);
assert.ok(events.some((e) => e.type === "extension_ui_request"));
assert.ok(!events.some((e) => e.type === "agent_end")); // mai sbloccato
assert.ok(!events.some((e) => e.type === "message_update"));
});