- present() = presentBlockingWidget: transport + no-limbo loop + response validation; both branches share validateUiResponse; the id invariant must be validated explicitly in the TUI branch (emitAndWait no longer runs there) - TUI renderer uses numbered options + index parsing, never label mapping (frontend already answers with ids); artifact-gate editor is viewer-only, returned content ignored - guards resolved: both TUI-only notices now ctx.mode === "tui" - interactive-render.js as two layers (renderTuiDescriptor + validateSyntheticResponse); add negative test cases + a real TUI smoke Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
229 lines
15 KiB
Markdown
229 lines
15 KiB
Markdown
# Valutazione: doppia gestione del gate (TUI nativo interattivo + widget-descriptor RPC)
|
|
|
|
Date: 2026-07-04
|
|
Status: valutazione (nessuna implementazione — solo analisi di fattibilità)
|
|
Pi runtime di riferimento: `@earendil-works/pi-coding-agent` **0.80.3** — versione live sul
|
|
PATH dal 2026-07-04 (migrazione dal vecchio scope `@mariozechner`, congelato a 0.73.1;
|
|
validata via `model-matrix` con GLM 5.2, scenari `new`+`resume` entrambi CHAINED, e uno smoke
|
|
full-stack con gate F1 risposto). Tutte le citazioni `types.d.ts` sotto sono relative a
|
|
**questa** versione.
|
|
|
|
## Context
|
|
|
|
Domanda: è pensabile una **doppia gestione** del gate HITL? Interattiva con le
|
|
primitive UI standard di Pi quando `pi` è lanciato da CLI, e quella attuale
|
|
(widget-descriptor JSON verso il frontend) quando è lanciato in RPC.
|
|
|
|
Oggi il gate ha **una sola** via di emissione: `emitAndWait` →
|
|
`ctx.ui.input(JSON.stringify(descriptor), "")` ([tht-gate.js:300](../../../harness/.pi/extensions/tht-gate.js)).
|
|
In RPC il backend fa da bridge (`extension_ui_request` method `input` → SSE) e il
|
|
frontend React rende i descrittori come widget veri. In interattivo, invece,
|
|
`ctx.ui.input` mostra il **JSON grezzo** come prompt e pretende una risposta
|
|
`UiResponse` in JSON digitata a mano → non usabile. Il design ha volutamente
|
|
sostituito i widget TUI nativi con l'emissione descriptor — banner REWRITE punto (2),
|
|
[tht-gate.js:8-10](../../../harness/.pi/extensions/tht-gate.js): *"the reviewer interaction is
|
|
emitted as a widget-descriptor JSON … instead of rendered by blocking native TUI primitives
|
|
(ctx.ui.select/custom)"*.
|
|
|
|
## Verdetto: SÌ, è pensabile ed è architetturalmente supportato
|
|
|
|
Pi **è già mode-aware**, quindi la doppia gestione non va "inventata", va solo
|
|
sfruttata:
|
|
|
|
- **`ctx.mode: ExtensionMode`** (types.d.ts:207,212) — `"tui" | "rpc" | "json" | "print"`,
|
|
con commento dei tipi: *"Use 'tui' to guard terminal-only UI such as custom components"*.
|
|
È **il** discriminatore corretto per il ramo interattivo: guardare `ctx.mode === "tui"`.
|
|
- **`ctx.hasUI: boolean`** (types.d.ts:213) — commento reale: *"Whether dialog-capable UI is
|
|
available (**true in TUI and RPC modes**)"*. ⚠️ È `true` **sia** in TUI **sia** in RPC:
|
|
quindi **non** distingue interattivo da RPC e **non** è un fallback valido per il ramo TUI —
|
|
solo `ctx.mode === "tui"` lo fa. (Nota migrazione: il gate usa oggi `if (ctx.hasUI)` a
|
|
[tht-gate.js:319,397](../../../harness/.pi/extensions/tht-gate.js) con la vecchia semantica
|
|
0.73.1, dove `hasUI` era `false` in RPC. Su 0.80.3 quelle due guardie scattano **anche** in
|
|
RPC → vedi *Note implementative*.)
|
|
- **`ExtensionUIContext`** (types.d.ts:63-67) — "Each mode (interactive, RPC, print)
|
|
provides its own implementation". Espone nativamente:
|
|
`select(title, options: string[], opts?) → Promise<string|undefined>` (69),
|
|
`confirm(title, message, opts?) → Promise<boolean>` (71),
|
|
`input(title, placeholder?, opts?) → Promise<string|undefined>` (73),
|
|
`notify(message, type?)` (75), `editor(title, prefill?) → Promise<string|undefined>`
|
|
(134), e `custom<T>(factory) → Promise<T>` (116, componente TUI a pieno controllo).
|
|
`notify` funziona già in entrambe le modalità.
|
|
|
|
## `UiResponse` è un tipo INTERNO ThothII (non un export di Pi)
|
|
|
|
Tutta la logica a valle consuma un `UiResponse`: **non** è un tipo di Pi, è il
|
|
contratto condiviso tra il gate e il frontend/renderer per la risposta a un
|
|
descriptor. Va documentato esplicitamente (JSDoc typedef nel modulo del gate):
|
|
|
|
```js
|
|
/**
|
|
* @typedef {Object} UiResponse Risposta a un widget-descriptor. Tipo INTERNO
|
|
* ThothII (NON esportato da Pi): contratto condiviso gate ↔ frontend/renderer.
|
|
* @property {string} [id] deve combaciare con descriptor.id (invariante emitAndWait)
|
|
* @property {string[]} [choices] id opzione/i scelte (select: 1 elem; multiselect: N)
|
|
* @property {string} [choice] forma singola, alternativa a choices
|
|
* @property {string} [control] "back" | "exit" | "freetext" | "cancel"
|
|
* @property {string} [text] testo libero (freetext) o input "Altro — specificare"
|
|
*/
|
|
```
|
|
|
|
## Design proposto (una sola cucitura, riuso di tutto il resto)
|
|
|
|
L'eleganza sta nel fatto che a valle tutto consuma un `UiResponse`, classificato da
|
|
`resolveSelectOutcome` / `resolveConfirmOutcome` / `selectedChoice`, con l'invariante
|
|
id-match in `emitAndWait` ([tht-gate.js:312](../../../harness/.pi/extensions/tht-gate.js):
|
|
`if (resp && resp.control !== "cancel" && resp.id === descriptor.id)`). Quindi:
|
|
|
|
- **Invariati**: `gate/builders.js` (costruzione descrittori) e i classificatori di
|
|
esito. Sono il contratto condiviso tra le due modalità.
|
|
- **Unica modifica**: sostituire `emitAndWait(ctx, descriptor)` con
|
|
`presentBlockingWidget(ctx, descriptor): Promise<UiResponse>` **mode-aware**. Il confine
|
|
**non** è solo il trasporto: è **presentazione + loop no-limbo + validazione della
|
|
risposta** — oggi `emitAndWait` fa già trasporto → `JSON.parse` → check `cancel` →
|
|
id-match → re-present su risposta invalida ([tht-gate.js:298](../../../harness/.pi/extensions/tht-gate.js)).
|
|
Entrambi i rami confluiscono in un **unico validatore condiviso**
|
|
`validateUiResponse(descriptor, resp)`:
|
|
- `if (ctx.mode !== "tui")` → percorso ATTUALE (RPC/json/print):
|
|
`ctx.ui.input(JSON.stringify(descriptor))` + `JSON.parse` → `validateUiResponse`.
|
|
Nessun cambiamento nel frontend.
|
|
- `else` (`ctx.mode === "tui"`) → `renderTuiDescriptor` con le primitive native →
|
|
**sintetizza** un `UiResponse` → **stesso** `validateUiResponse`. ⚠️ **Critico**: in TUI
|
|
l'id-match di `emitAndWait` **non gira più** (present lo sostituisce), quindi
|
|
l'invariante `resp.id === descriptor.id` va **validato esplicitamente** qui — non è più
|
|
protetto in automatico.
|
|
|
|
I **3 gate bloccanti** che passano da `emitAndWait` sono l'intera superficie da rendere:
|
|
`select` ([tht-gate.js:492](../../../harness/.pi/extensions/tht-gate.js)), `multiselect` (576),
|
|
`artifact-gate` (639). La colonna **no-limbo** dice cosa sintetizzare quando la primitiva
|
|
nativa ritorna `undefined` (Esc / cancel / timeout / `signal`):
|
|
|
|
| widget | render interattivo | no-limbo (nativo → `undefined`) | UiResponse sintetizzato |
|
|
|---|---|---|---|
|
|
| `select` | opzioni **numerate** (`1. label … / b) Torna / e) Esci / o) Altro`); parsing **per indice → id**, MAI per label | re-present una volta; se ancora `undefined` → `{control:"exit"}` | `{id, choices:[optId]}` o `{control:"back"/"exit"/"freetext", text}` |
|
|
| `multiselect` | `ctx.ui.custom` (checkbox) **oppure** `ctx.ui.input` **indicizzato** (`1,3,4`) → dedupe + ordine stabile + **rifiuto indici invalidi** | re-present una volta; poi `{control:"exit"}` | `{id, choices:[TUTTI gli id scelti]}` — vedi nota reviewer_decide sotto |
|
|
| `artifact-gate` | artefatto mostrato con `ctx.ui.editor(title, testo)` **come viewer** (il contenuto ritornato è **ignorato** — il gate approva/rifiuta, non modifica) + scelta approve/reject **numerata** | re-present (advance solo su approve/reject **esplicito**, mai implicito) | `{id, choices:["approve"/"reject"]}` o control |
|
|
|
|
**Non sono widget-descriptor** (nessuna riga `present()` dedicata):
|
|
- **`freetext`** non è un widget a sé: è il `control:"freetext"` restituito dall'opzione
|
|
«Altro/Other» di `select`/`artifact-gate` → nel ramo TUI si raccoglie con `ctx.ui.input`
|
|
dentro il render di quei due.
|
|
- **`info`** non passa da `present()`: sono `ctx.ui.notify(..., "info")` scritti a mano
|
|
(es. [tht-gate.js:567](../../../harness/.pi/extensions/tht-gate.js), avviso F2 memoria vuota),
|
|
già mode-agnostic. `buildInfoRequest` **esiste** in `builders.js` ma **non è cablato** nel
|
|
gate (l'import a tht-gate.js:29-31 porta solo select/multiselect/artifact-gate) — builder morto.
|
|
|
|
## Vincoli del runtime Pi verificati (0.80.3)
|
|
|
|
- **`ctx.ui.select` ritorna la label** (string), non l'id — `options: string[]` →
|
|
`Promise<string|undefined>` (types.d.ts:69). **NON rimappare per label** (label
|
|
duplicate/localizzate collidono): opzioni **numerate** + parsing **per indice → id**. Il
|
|
contratto ThothII è sugli id, e il frontend risponde già sempre con id, non label
|
|
([SelectWidget:26](../../../frontend/src/widgets/SelectWidget.tsx),
|
|
[MultiselectWidget:66](../../../frontend/src/widgets/MultiselectWidget.tsx)).
|
|
- **Nessuna multiselect nativa**: `ExtensionUIContext` non la espone → `custom`
|
|
(checkbox, livello A) o fallback a input indicizzato (livello C).
|
|
- **Dismiss / no-limbo nativo**: `select`/`confirm`/`input`/`editor` ritornano
|
|
`undefined` su cancel/Esc; `ExtensionUIDialogOptions` espone `signal?: AbortSignal`
|
|
(types.d.ts:37) e `timeout?` per chiudere il dialogo programmaticamente. Il renderer
|
|
**deve** gestire l'`undefined` esplicitamente (colonna no-limbo).
|
|
- **`editor` multi-linea** disponibile (types.d.ts:134) — preferibile a `notify` per
|
|
il testo dell'artefatto nel TUI.
|
|
- (Non verificabile dai soli tipi, e comunque **irrilevante**: se `custom` sia
|
|
supportato in RPC — lo usiamo **solo** nel ramo interattivo.)
|
|
|
|
## Contratto d'estensibilità (nuovo `widget` kind)
|
|
|
|
Per mitigare a livello di codice il rischio "due percorsi da tenere in sync": il seam
|
|
interattivo ha un `default:` esplicito. Un `widget` privo di renderer TUI dedicato
|
|
**non** deve half-funzionare in silenzio → `ctx.ui.notify("<kind> non supportato in
|
|
TUI", "warning")` + re-present/abort controllato. Così `builders.js` + classificatori
|
|
restano l'unica fonte condivisa, e un kind nuovo **fallisce forte** in TUI finché non
|
|
gli si dà un renderer, invece di derivare silenziosamente dal percorso RPC.
|
|
|
|
## Due livelli di implementazione (scegliibili in seguito)
|
|
|
|
- **A — widget nativi Pi** (`select/confirm/editor/custom`): esperienza TUI curata; il
|
|
pezzo più costoso è la **multiselect** (Pi non ha una multiselect nativa → serve
|
|
`ctx.ui.custom`).
|
|
- **C — render testuale + input numerato** (minimo): in interattivo stampo il
|
|
descrittore ben formattato (titolo/intro/opzioni numerate) e leggo un numero/lettera
|
|
via `ctx.ui.input`, poi mappo su `UiResponse`. Molto meno lavoro, sufficiente "a
|
|
fini di valutazione/dev". **Consigliato** dato lo scopo dichiarato.
|
|
|
|
## Superficie di modifica (se/quando si implementa)
|
|
|
|
- `harness/.pi/extensions/tht-gate.js`: introdurre `present(ctx, descriptor)` e
|
|
sostituire le 3 chiamate a `emitAndWait` (reviewer_select/decide/confirm) con
|
|
essa; il ramo RPC è l'attuale `emitAndWait`.
|
|
- Nuovo modulo puro `harness/.pi/extensions/gate/interactive-render.js` a **due strati**:
|
|
`renderTuiDescriptor(descriptor)` (descriptor → prompt numerato/native) e
|
|
`validateSyntheticResponse(descriptor, resp)` (= `validateUiResponse`, **condiviso** dal
|
|
ramo TUI e dal ramo RPC dopo `JSON.parse`). Entrambi L1-testabili come `builders.js`.
|
|
- Nessun cambiamento a `builders.js`, ai classificatori, al backend o al frontend.
|
|
|
|
## Note implementative (checklist prima di scrivere present())
|
|
|
|
- **Il commento a [tht-gate.js:227](../../../harness/.pi/extensions/tht-gate.js)** dice
|
|
*"usa l'API UI NATIVA di Pi (ctx.ui.input)"*. Quando `emitAndWait` viene incapsulato in
|
|
`present()` con un ramo che usa i **widget TUI nativi** (non `ctx.ui.input`), quel commento
|
|
diventa fuorviante → va riscritto insieme al codice.
|
|
- **`ctx.ui.notify` da razionalizzare.** 6 notify nel gate; solo 319 e 397 sono guardate da
|
|
`ctx.hasUI`, le altre 4 sono **non guardate** (567, 651, 824, 831). Oggi in RPC sono
|
|
fire-and-forget verso il frontend; in TUI diventerebbero warning a terminale. Decidere
|
|
per-notify se appartiene a entrambe le modalità — in particolare la **651** è dentro il loop
|
|
di re-present del confirm gate.
|
|
- **Guardie modo-dipendenti (risolte).** Entrambi i messaggi TUI-only sono ora guardati da
|
|
`ctx.mode === "tui"`: reLoop anti-limbo
|
|
([tht-gate.js:322](../../../harness/.pi/extensions/tht-gate.js)) e steering-durante-lock
|
|
([tht-gate.js:400](../../../harness/.pi/extensions/tht-gate.js)). In RPC il testo libero
|
|
durante il lock resta comunque `{action:"handled"}` (silenziato) — cambia solo che il warning
|
|
da terminale non viene più inviato al browser. Nessuna guardia `ctx.hasUI` residua per messaggi
|
|
TUI-only.
|
|
|
|
## Test (il vero guardrail contro la deriva)
|
|
|
|
- **Property test round-trip** (non solo "descriptor→UiResponse"): per **ogni**
|
|
descriptor prodotto da `builders.js`, il `UiResponse` sintetizzato da
|
|
`interactive-render`, passato ai classificatori esistenti (`resolveSelectOutcome` /
|
|
`resolveConfirmOutcome` / `selectedChoice`), deve produrre **lo stesso** `{kind, ...}`
|
|
del `UiResponse` che il frontend invierebbe per lo stesso descriptor. È **questo** il
|
|
guardrail contro la deriva tra i due percorsi, non un test di sola forma.
|
|
- **reviewer_decide (multiselect) — lettura inline**: `reviewer_decide` **non** passa
|
|
dai classificatori; legge `resp.choices` **direttamente**
|
|
([tht-gate.js:594](../../../harness/.pi/extensions/tht-gate.js):
|
|
`opts.filter(o => (resp.choices ?? []).includes(o.id))`). Il test di non-regressione
|
|
deve quindi verificare che la sintesi interattiva del multiselect popoli **tutti** gli
|
|
id scelti (non solo il primo), altrimenti in TUI si perderebbero selezioni.
|
|
- **Invariante id-match**: in TUI `emitAndWait` non gira più → `validateUiResponse` deve
|
|
verificare `resp.id === descriptor.id` **esplicitamente** (non basta che la sintesi imposti
|
|
l'id: va validato, come faceva tht-gate.js:312).
|
|
- **Casi negativi** (oltre al round-trip): id sbagliato, scelta inesistente, **label
|
|
duplicate**, multiselect con **duplicati**, `allow_empty=false` con input vuoto, `undefined`
|
|
ripetuto (no-limbo), `control:"cancel"`.
|
|
- **Smoke TUI reale su Pi 0.80.3**: i property test coprono la *deriva del contratto* ma
|
|
**non** il comportamento delle primitive native (`select`/`input`/`editor`/`custom`) → serve
|
|
almeno uno smoke interattivo vero, come già fatto per il percorso RPC.
|
|
- **RPC invariato**: backend bridge + 115 test frontend + 53 test harness restano da
|
|
rigirare per non-regressione; il percorso descriptor non cambia.
|
|
|
|
## Trade-off e limiti (onesti)
|
|
|
|
- **Fedeltà interattiva ridotta**: niente modal ricco, niente **erDiagram**/grafo
|
|
schema-linking, niente rendering strutturato dell'artefatto — in TUI l'artefatto è
|
|
testo (via `editor`). Va benissimo per debug/valutazione, **non** per la review
|
|
clinica completa.
|
|
- **Multiselect**: nessuna primitiva nativa → `ctx.ui.custom` (livello A) o fallback
|
|
a input indicizzato (livello C).
|
|
- **Testabilità**: il render nativo richiede il runtime TUI di Pi (difficile da
|
|
unit-testare); la parte pura descriptor→`UiResponse` è testabile in L1 (round-trip
|
|
sopra). Il percorso RPC resta invariato e già coperto.
|
|
|
|
## Conclusione
|
|
|
|
Fattibile e pulito: la doppia gestione si ottiene con **una sola cucitura mode-aware**
|
|
(`present`) attorno a `emitAndWait`, sfruttando che Pi è già mode-aware via `ctx.mode`
|
|
(`=== "tui"` per il ramo interattivo; **non** `hasUI`, che su 0.80.3 è `true` anche in RPC)
|
|
e che a valle tutto consuma un `UiResponse` interno. Il costo dipende dal livello di fedeltà
|
|
interattiva voluto (C minimo, A curato); il guardrail è il property test round-trip. Nessun
|
|
impatto su RPC/frontend.
|