fix: multi-select Other coexistence + the malformed-assessment probe-done crash

Two live bugs from the modal-realism stress runs:

1. Multi-select ask: picking the custom 'Other…' row cleared every
   already-picked option, and picking any option cleared a typed custom
   answer — single-select displacement logic applied to both modes. The
   dock's selection is now a pure state machine in question-core
   (toggleOption/toggleOther over DockSelection, unit-tested): single
   displaces (unchanged), multi coexists — neither channel clears the
   other; the typed text always survives.

2. ProbeDoneCard crashed on a REJECTED probe_done call: the model
   stringified 'assessment' (the same double-encoding habit as the
   options bug), zod rejected the call, the model retried — but the
   card's input-preview fallback trusted the errored part's unvalidated
   input shape and died on assessment.strands.map. The preview now reads
   input only while the call is pending or streaming (guarded with
   Array.isArray); an output-error part renders a quiet
   malformed-and-retried note instead of the garbage.

Pinned: question-core.test.ts (3 dock-selection machine cases),
render-cards.test.tsx (4 probe-done card cases incl. the exact live
stringified shape, plus the multi-select ask markup pin).

Gates: tsc, lint, build, 315/315.
This commit is contained in:
amosblomqvist
2026-09-12 04:45:58 +03:00
parent 4baf8385be
commit e821c3277d
5 changed files with 215 additions and 46 deletions
+38
View File
@@ -278,6 +278,44 @@ export interface AnswerRequest {
note?: string;
}
// ─── dock selection (the client state machine, pure for tests) ─────────────
/** The question dock's selection state (0-based indices in display order). */
export interface DockSelection {
selected: Set<number>;
otherSelected: boolean;
dontKnow: boolean;
}
export const emptyDockSelection = (): DockSelection => ({ selected: new Set(), otherSelected: false, dontKnow: false });
/**
* Toggling a listed option. Single-select: the option becomes THE answer and
* displaces "Other". Multi-select: the option toggles alongside its siblings
* AND "Other" — selections are independent (picking options must never clear
* a custom answer, and picking the custom row must never clear them).
*/
export function toggleOption(mode: SelectMode, sel: DockSelection, idx: number): DockSelection {
if (mode === "multi-select") {
const selected = new Set(sel.selected);
if (selected.has(idx)) selected.delete(idx);
else selected.add(idx);
return { selected, otherSelected: sel.otherSelected, dontKnow: false };
}
return { selected: new Set([idx]), otherSelected: false, dontKnow: false };
}
/**
* Toggling the ask "Other…" row. Single-select: picking it makes it THE
* answer, displacing the listed options (the typed text always survives).
* Multi-select: it toggles alongside the listed selections and clears
* nothing.
*/
export function toggleOther(mode: SelectMode, sel: DockSelection): DockSelection {
if (mode === "multi-select") return { ...sel, otherSelected: !sel.otherSelected, dontKnow: false };
return { selected: new Set(), otherSelected: true, dontKnow: false };
}
export const QUESTION_ENTRY = "lh_question";
export const QUESTION_ANSWERED_ENTRY = "lh_question_answered";