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:
+48
-4
@@ -1,6 +1,6 @@
|
|||||||
# ThothII — Project State
|
# 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.
|
> Point a fresh session here ("read PROJECT_STATE.md") before substantial work.
|
||||||
|
|
||||||
## What ThothII is
|
## 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)
|
- 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
|
- 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)
|
- 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 .`
|
- 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)
|
- 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
|
- Global user rules (`~/.claude/CLAUDE.md`): think before coding, simplicity first, surgical
|
||||||
changes, goal-driven verification.
|
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)
|
## Most recent feature — Session management (MERGED to main @ 2c21e46)
|
||||||
Full session management modeled on Claude's UI, all three layers:
|
Full session management modeled on Claude's UI, all three layers:
|
||||||
- **Read-only "split view" panel** (left drawer, `SessionDocumentsPanel`) showing a session's
|
- **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);
|
(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 —
|
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.
|
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
|
## Where design history lives
|
||||||
- Specs: `docs/superpowers/specs/` · Plans: `docs/superpowers/plans/`
|
- 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).
|
(notes on the Pi RPC event vocabulary and the Omics Portal/GSD design system).
|
||||||
|
|
||||||
## Git
|
## Git
|
||||||
`main` @ `2c21e46`, pushed to `origin` (github.com/mptyl/ThothII), in sync. Other local
|
`main` tracks `origin` (github.com/mptyl/ThothII). The F1 hang-fix (7 files + this
|
||||||
branches (`feat/settings-menu`, `feat/thothII-debugging`) are pre-existing, untouched.
|
`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.
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ export type ClientEvent =
|
|||||||
|
|
||||||
export class SessionBridge {
|
export class SessionBridge {
|
||||||
private pending: any = null;
|
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>();
|
private cbs = new Set<(e: ClientEvent) => void>();
|
||||||
|
|
||||||
constructor(private rpc: RpcClient) {
|
constructor(private rpc: RpcClient) {
|
||||||
@@ -16,6 +21,7 @@ export class SessionBridge {
|
|||||||
let descriptor: any;
|
let descriptor: any;
|
||||||
try { descriptor = JSON.parse(m.title); } catch { return; }
|
try { descriptor = JSON.parse(m.title); } catch { return; }
|
||||||
this.pending = descriptor;
|
this.pending = descriptor;
|
||||||
|
this.pendingPiId = m.id;
|
||||||
this.fan({ type: "ui_request", ui_request: descriptor });
|
this.fan({ type: "ui_request", ui_request: descriptor });
|
||||||
} else if (m.type === "extension_ui_request" && m.method === "notify") {
|
} else if (m.type === "extension_ui_request" && m.method === "notify") {
|
||||||
this.fan({ type: "info", level: m.notifyType ?? "info", text: m.message ?? "" });
|
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); }
|
onClientEvent(cb: (e: ClientEvent) => void): void { this.cbs.add(cb); }
|
||||||
|
|
||||||
respond(uiResponse: object & { id: string }): void {
|
respond(uiResponse: object & { id: string }): void {
|
||||||
this.rpc.send({ type: "extension_ui_response", id: uiResponse.id, value: JSON.stringify(uiResponse) });
|
// Correla sull'id RPC di Pi; `value` porta l'uiResponse (con l'id del descriptor) cosi'
|
||||||
if (this.pending && uiResponse.id === this.pending.id) this.pending = null;
|
// 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 }); }
|
steer(text: string): void { this.rpc.send({ type: "steer", message: text }); }
|
||||||
|
|||||||
@@ -7,7 +7,28 @@ import { loadConfig } from "../src/config.js";
|
|||||||
const FAKE = path.resolve("../harness/tests/fake_pi/fake_pi_rpc.mjs");
|
const FAKE = path.resolve("../harness/tests/fake_pi/fake_pi_rpc.mjs");
|
||||||
const SCRIPT = path.resolve("../harness/tests/fake_pi/scripts/f1_disambiguation.json");
|
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" }), {
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||||
thtRunner: {
|
thtRunner: {
|
||||||
ollamaEnsure: async () => ({ ok: true }),
|
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
|
// 2. Small delay to let fake-pi process the prompt and fill pendingWidget
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
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 es = await fetch(`${base}/sessions/s1/events`);
|
||||||
const reader = es.body!.getReader();
|
const reader = es.body!.getReader();
|
||||||
const chunk = await reader.read();
|
const first = await readUntil(reader, (t) => t.includes("ui_request"), 2000);
|
||||||
const text = new TextDecoder().decode(chunk.value);
|
expect(first).toContain("ui_request");
|
||||||
expect(text).toContain("ui_request");
|
|
||||||
await reader.cancel();
|
|
||||||
|
|
||||||
// 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`, {
|
const resp = await fetch(`${base}/sessions/s1/response`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
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);
|
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();
|
await app.close();
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|||||||
@@ -28,23 +28,29 @@ test("real Pi message_update (assistantMessageEvent text_delta) becomes a text_d
|
|||||||
expect(seen).toHaveLength(1);
|
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 { rpc, fire } = fakeRpc();
|
||||||
const b = new SessionBridge(rpc);
|
const b = new SessionBridge(rpc);
|
||||||
const seen: any[] = [];
|
const seen: any[] = [];
|
||||||
b.onClientEvent((e) => seen.push(e));
|
b.onClientEvent((e) => seen.push(e));
|
||||||
const descriptor = { id: "u1", widget: "select" };
|
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(seen[0]).toEqual({ type: "ui_request", ui_request: descriptor });
|
||||||
expect(b.pendingWidget()).toEqual(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 { rpc, sent, fire } = fakeRpc();
|
||||||
const b = new SessionBridge(rpc);
|
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"] });
|
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();
|
expect(b.pendingWidget()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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,
|
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.
|
so a value like "ablazione" may anchor on multiple columns.
|
||||||
2. For each ambiguity (clinical term, population, time window, outcome), present a
|
2. For each ambiguity (clinical term, population, time window, outcome), present the
|
||||||
`reviewer_select` with the candidate interpretations (`recommended:true` on the
|
candidate interpretations (`recommended:true` on the best) + "Altro". Pick the widget
|
||||||
best) + "Altro". When a clarification is settled, move on. Pass the FULL list of
|
by the question's shape:
|
||||||
clarifications, not only the latest, when you close.
|
- **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
|
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
|
clarifying" gate). Do NOT add a separate `reviewer_confirm` after each individual
|
||||||
clarification — those advance via `reviewer_decide` (`concept_clarified`), not via
|
clarification — those advance via `reviewer_decide` (`concept_clarified`), not via
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
// harness/tests/fake_pi/fake_pi_rpc.mjs — scripted RPC test double (LF-only JSONL).
|
// harness/tests/fake_pi/fake_pi_rpc.mjs — scripted RPC test double (LF-only JSONL).
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import crypto from "node:crypto";
|
||||||
const script = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
const script = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||||
const out = (evt) => process.stdout.write(JSON.stringify(evt) + "\n");
|
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 = "";
|
let buf = "";
|
||||||
process.stdin.on("data", (chunk) => {
|
process.stdin.on("data", (chunk) => {
|
||||||
buf += chunk.toString("utf8");
|
buf += chunk.toString("utf8");
|
||||||
@@ -14,11 +22,17 @@ process.stdin.on("data", (chunk) => {
|
|||||||
for (const step of script.on_prompt ?? []) {
|
for (const step of script.on_prompt ?? []) {
|
||||||
if (step.ui_request_descriptor) {
|
if (step.ui_request_descriptor) {
|
||||||
const d = 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 { out(step); } // eventi non-UI (text_delta, agent_end, …) passano tali e quali
|
||||||
}
|
}
|
||||||
} else if (cmd.type === "extension_ui_response") {
|
} 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") {
|
} else if (cmd.type === "get_available_models") {
|
||||||
out({ type: "response", command: "get_available_models", id: cmd.id, success: true,
|
out({ type: "response", command: "get_available_models", id: cmd.id, success: true,
|
||||||
data: { models: script.available_models ?? [] } });
|
data: { models: script.available_models ?? [] } });
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
"options": [ {"id":"a","label":"interpretazione A"}, {"id":"b","label":"interpretazione B"} ],
|
"options": [ {"id":"a","label":"interpretazione A"}, {"id":"b","label":"interpretazione B"} ],
|
||||||
"reserved": ["back","exit","other"] } }
|
"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"} ]
|
"available_models": [ {"provider":"zai","id":"glm-5.2"} ]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import assert from "node:assert";
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import path from "node:path";
|
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) => {
|
return new Promise((resolve) => {
|
||||||
const fp = spawn("node", [path.join(import.meta.dirname, "fake_pi_rpc.mjs"), scriptPath]);
|
const fp = spawn("node", [path.join(import.meta.dirname, "fake_pi_rpc.mjs"), scriptPath]);
|
||||||
const events = []; let buf = "";
|
const events = []; let buf = "";
|
||||||
@@ -12,27 +15,45 @@ function drive(scriptPath, commands) {
|
|||||||
buf += c.toString("utf8");
|
buf += c.toString("utf8");
|
||||||
for (let nl; (nl = buf.indexOf("\n")) !== -1; ) {
|
for (let nl; (nl = buf.indexOf("\n")) !== -1; ) {
|
||||||
const line = buf.slice(0, nl).replace(/\r$/, ""); buf = buf.slice(nl + 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));
|
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);
|
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 sp = path.join(import.meta.dirname, "scripts/f1_disambiguation.json");
|
||||||
const events = await drive(sp, [
|
// Host corretto: risponde con l'id RPC top-level del widget.
|
||||||
{ type: "prompt", message: "/nuova-domanda \"x\"" },
|
const events = await driveReactive(sp, (w) => w.id);
|
||||||
{ 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");
|
const widget = events.find((e) => e.type === "extension_ui_request");
|
||||||
assert.equal(widget.method, "input"); // shape nativa di Pi
|
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
|
const descriptor = JSON.parse(widget.title); // il descriptor viaggia nel title
|
||||||
assert.equal(descriptor.widget, "select");
|
assert.equal(descriptor.widget, "select");
|
||||||
assert.equal(descriptor.id, "u1");
|
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"));
|
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"));
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user