fix(gate): read reviewer_select/confirm choice from the choices[] array

The frontend's reviewer widgets uniformly send the picked option in a `choices`
array (SelectWidget/ArtifactGateWidget: `choices: [optionId]`), but the gate's
reviewer_select and reviewer_confirm(reject) handlers read `resp.choice`
(singular). Result: every single-select gate saw an undefined choice, answered
"Nessuna scelta ricevuta", and re-presented forever — the workflow could never
pass F1. (reviewer_decide/multiselect already read `resp.choices`, so it worked.)

Add a shared selectedChoice(resp) helper reading choices[0] (falling back to the
legacy singular choice); both handlers use it.

TDD: gate/__tests__/gate_choice.test.js RED->GREEN; full gate suite 28/28.
Verified LIVE (Playwright -> real Pi -> GLM 5.2): a single-select F1 answer is
now accepted and the workflow advances (clarification 2/4 -> 3/4). The same run
also live-verified the F1 hang fix (418187a).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 12:48:56 +02:00
co-authored by Claude Opus 4.8
parent 418187a4ad
commit d27afd0896
3 changed files with 74 additions and 21 deletions
@@ -0,0 +1,25 @@
const test = require("node:test");
const assert = require("node:assert");
const { selectedChoice } = require("../../tht-gate.js");
// The frontend's reviewer widgets (SelectWidget, ArtifactGateWidget) always send the
// picked option in a `choices` ARRAY: { id, kind, choices: [optionId] }. The gate's
// select / artifact-gate handlers must read that array — reading a singular `choice`
// yields undefined, so the gate answers "no choice received" and re-presents forever.
test("selectedChoice reads the frontend's choices[] array (select widget)", () => {
assert.equal(selectedChoice({ id: "u1", kind: "select", choices: ["old75_frozen"] }), "old75_frozen");
});
test("selectedChoice reads choices[] for the artifact-gate reject", () => {
assert.equal(selectedChoice({ id: "u1", kind: "artifact-gate", choices: ["reject"] }), "reject");
});
test("selectedChoice falls back to the legacy singular choice", () => {
assert.equal(selectedChoice({ id: "u1", choice: "a" }), "a");
});
test("selectedChoice returns undefined when nothing is selected", () => {
assert.equal(selectedChoice({ id: "u1", choices: [] }), undefined);
assert.equal(selectedChoice({ id: "u1" }), undefined);
});
+12 -3
View File
@@ -207,6 +207,14 @@ function advanceIfReady(ctx, session) {
// accepted as a final answer — the widget is re-presented. Real escapes (Back/Exit/Other)
// are always present as selectable options in the descriptor, so cancel has no
// legitimate meaning.
// The frontend's reviewer widgets always carry the picked option(s) in a `choices`
// ARRAY (SelectWidget/ArtifactGateWidget send `choices: [optionId]`). Single-pick
// handlers read the first element; `choice` (singular) is accepted as a legacy form.
export function selectedChoice(resp) {
if (Array.isArray(resp?.choices)) return resp.choices[0];
return resp?.choice;
}
export async function emitAndWait(ctx, descriptor) {
for (;;) {
const value = await ctx.ui.input(JSON.stringify(descriptor), "");
@@ -398,9 +406,10 @@ export default function (pi) {
return textResult("Il reviewer vuole tornare indietro.");
if (resp.control === "exit")
return textResult("Il reviewer vuole uscire.");
const chosen = opts.find((o) => o.id === resp.choice);
const choice = selectedChoice(resp);
const chosen = opts.find((o) => o.id === choice);
return textResult(
`Scelta del reviewer: ${chosen ? chosen.label : resp.choice}`,
`Scelta del reviewer: ${chosen ? chosen.label : choice}`,
);
},
});
@@ -522,7 +531,7 @@ export default function (pi) {
action: { kind: "approve_reject", prompt: "Approvi o rifiuti?" },
});
const resp = await emitAndWait(ctx, widget);
if (resp.control === "freetext" || resp.choice === "reject") {
if (resp.control === "freetext" || selectedChoice(resp) === "reject") {
return textResult(
`Rifiutato${resp.text ? ` (motivo: ${resp.text})` : ""}: rivedi e riprova.`,
);