From 3e5b857c630bae33c7129ba68ad04016e01140f5 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 3 Oct 2026 01:03:29 +0300 Subject: [PATCH] quick-fixes round: quiz-only teach-check reveal, ESC-abort confirm popup + question dismissal, confidence prompt diet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - requiresReveal gates on pending.tool === 'quiz' (owner: only graded quizzes arrive collapsed; ask/explain always open) - ESC-abort chain opens AbortTurnDialog everywhere (owner ruling); from a pending question abort IS dismissal — dismissedAnswer rides the answer path, the teacher acknowledges the skip in its next turn; popup-Esc is claimed via the state gate (two window listeners fire per Esc) - 25-confidence.md: stripped the slider/JSON/calibration mechanics — pure reading doctrine remains - toolQuestionKind named once in core (dock parity), dock switched to it --- components/abort-turn-dialog.tsx | 124 ++++++++++++++++++++++ components/chat.tsx | 50 +++++++-- components/question-dock.tsx | 3 +- src/agent/prompts/shared/25-confidence.md | 2 +- src/core/pending.ts | 6 ++ src/core/question-core.ts | 17 +++ tests/question-core.test.ts | 32 ++++++ tests/render-abort-turn-dialog.test.tsx | 47 ++++++++ 8 files changed, 271 insertions(+), 10 deletions(-) create mode 100644 components/abort-turn-dialog.tsx create mode 100644 tests/render-abort-turn-dialog.test.tsx diff --git a/components/abort-turn-dialog.tsx b/components/abort-turn-dialog.tsx new file mode 100644 index 0000000..90e8e01 --- /dev/null +++ b/components/abort-turn-dialog.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect } from "react"; +import { createPortal } from "react-dom"; + +/** + * The ESC-abort guard (owner, 2026-10-02): the global Esc → abort chain + * opens this confirm popup EVERYWHERE — mid-stream aborts and pending- + * question dismissals alike. Esc on the popup itself cancels (claimed — + * it must never chain into another abort), which is also why Chat's + * abort handler ignores events while this dialog is open: two window + * listeners fire per Esc and stopPropagation alone cannot order them. + * + * MINIMAL, the delete-node dialog's tone: title, one line, Cancel + + * action. No busy state — both actions are local (a fetch that fires + * and forgets, an answer submit whose own machinery shows progress). + * + * What "abort" means is mode-dependent: from a running turn it kills + * the turn (partial stays); from a pending question there is no turn to + * kill — abort IS dismissal (QuestionAnswerData.dismissed), and the + * teacher acknowledges the skip in its next turn like any answer. + */ + +/** What Esc is aborting — drives copy and the confirm action. */ +export type AbortMode = "turn" | "question"; + +const COPY: Record = { + turn: { + title: "Abort this turn?", + line: "The partial answer stays in the transcript.", + action: "Abort", + }, + question: { + title: "Dismiss this question?", + line: "Skipped — the teacher moves on without it.", + action: "Dismiss", + }, +}; + +/** The dialog's pure body — exported for SSR pins (the delete-node + * pattern). The wrapper owns effects + the portal. */ +export function AbortTurnBody({ + mode, + onClose, + onConfirm, +}: { + mode: AbortMode; + onClose: () => void; + onConfirm: () => void; +}) { + const copy = COPY[mode]; + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
e.stopPropagation()} + > +

+ {copy.title} +

+

{copy.line}

+
+ + +
+
+
+ ); +} + +export function AbortTurnDialog({ + mode, + onClose, + onConfirm, +}: { + /** What Esc is aborting; null = closed (nothing rendered). */ + mode: AbortMode | null; + onClose: () => void; + onConfirm: (mode: AbortMode) => void; +}) { + // Esc cancels the popup (claimed — never a chained abort). + useEffect(() => { + if (mode === null) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation(); + onClose(); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [mode, onClose]); + + if (mode === null || typeof document === "undefined") return null; + return createPortal( + onConfirm(mode)} />, + document.body, + ); +} diff --git a/components/chat.tsx b/components/chat.tsx index 2b966f0..d4bf8de 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -30,6 +30,7 @@ import { isProviderKeyRefusal, needsContinuation, type ChatInput, type ChatSetti import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block"; import { PlanMarginalia, planExists } from "@/components/plan-window"; import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay"; +import { AbortTurnDialog, type AbortMode } from "@/components/abort-turn-dialog"; import { WaypointsIcon } from "lucide-react"; import type { PlanBlockData } from "@/src/core"; import { QuestionDock } from "@/components/question-dock"; @@ -38,7 +39,7 @@ import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsag import type { LhViewMessage } from "@/src/core"; import { PhaseChip, PhaseDivider } from "@/components/phase-divider"; import { CoverageRing } from "@/components/extraction-coverage"; -import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core"; +import { dismissedAnswer, findPendingToolCall, phaseAt3, phaseTimeline, toolQuestionKind } from "@/src/core"; import { slotsConfigured, type ModelsSettings } from "@/src/core"; import { useSettingsSaved } from "@/components/settings-signal"; @@ -298,13 +299,39 @@ export function Chat({ // 409 not_running is the benign raced-settle: the stream's finish // already settled the view (or the poll fallback adopts) — nothing owed. }, [chatId, stop]); + // ESC-abort guard (owner, 2026-10-02): the abort chain opens a confirm + // popup EVERYWHERE (streams and pending questions alike). Both window + // listeners fire per Esc, so the state gate here — not stopPropagation + // ordering — is what keeps the popup's own Esc from chaining. + const [abortAsk, setAbortAsk] = useState(null); + const abortAskRef = useRef(null); + abortAskRef.current = abortAsk; useEffect(() => { const onAbort = () => { - if (busyRef.current) abortInFlightTurn(); + if (abortAskRef.current !== null) return; // the popup's Esc — claimed + if (pendingRef.current) setAbortAsk("question"); + else if (busyRef.current) setAbortAsk("turn"); }; window.addEventListener("lh:kbd-abort-turn", onAbort); return () => window.removeEventListener("lh:kbd-abort-turn", onAbort); - }, [abortInFlightTurn]); + }, []); + const confirmAbort = useCallback( + (mode: AbortMode) => { + setAbortAsk(null); + if (mode === "turn") { + abortInFlightTurn(); + return; + } + // From a pending question there is no turn to kill — abort IS + // dismissal, riding the same answer path as any answer (the + // teacher acknowledges the skip in its next turn). + const p = pendingRef.current; + if (!p) return; + const question = (p.input as { question?: string } | null)?.question ?? ""; + void submitAnswer(p.tool, p.toolCallId, dismissedAnswer(toolQuestionKind(p.tool), question)); + }, + [abortInFlightTurn, submitAnswer], + ); // Mount continuation: if the stored transcript ends with a fulfilled tool // (a job result landed while no client was open), resume the lesson — the // SDK's sendAutomaticallyWhen only fires inside addToolOutput, so reloads @@ -323,6 +350,10 @@ export function Chat({ }, []); const pending = findPendingToolCall(messages); + // The abort handler reads the pending question from a ref (the window + // listener is mount-scoped; the value it needs is render-scoped). + const pendingRef = useRef(pending); + pendingRef.current = pending; const localStream = status === "submitted" || status === "streaming"; const busy = localStream || turnInFlight; const busyRef = useRef(busy); @@ -351,13 +382,15 @@ export function Chat({ void resumeStream(); }, [resumeStream]); - // Teach-phase check (W5, vision §2): a pending question that lands once a + // Teach-phase check (W5, vision §2): a pending QUIZ that lands once a // plan node is OCCUPIED (phaseAt3 "teach") starts collapsed behind a // "Check your understanding" reveal button; probe- and plan-phase - // questions auto-open as today. A pending question can never precede its - // phase's seam in the transcript (the teacher's turn ends at the question - // call), so the session's current phase IS the question's phase. - const requiresReveal = pending !== null && phaseAt3(messages) === "teach"; + // questions auto-open as today. Graded quizzes only (owner, 2026-10-02) + // — ask/explain questions always arrive open. A pending question can + // never precede its phase's seam in the transcript (the teacher's turn + // ends at the question call), so the session's current phase IS the + // question's phase. + const requiresReveal = pending !== null && pending.tool === "quiz" && phaseAt3(messages) === "teach"; // The phase at every message (one linear fold): the transcript draws a // color-coded dotted divider at each boundary; the header chip reads the @@ -1228,6 +1261,7 @@ export function Chat({ setMermaidFocus(null)} /> )} {planOpen && setPlanOpen(false)} />} + setAbortAsk(null)} onConfirm={confirmAbort} /> diff --git a/components/question-dock.tsx b/components/question-dock.tsx index dca9a6f..d45772a 100644 --- a/components/question-dock.tsx +++ b/components/question-dock.tsx @@ -16,6 +16,7 @@ import { resolveCorrect, seededRandom, shuffleOptions, + toolQuestionKind, parseAskInput, parseQuizInput, parseExplainInput, @@ -208,7 +209,7 @@ function QuestionDockForm({ onSubmit, onEscape, }: Omit) { - const kind = toolName === "quiz" ? ("quiz" as const) : toolName === "explain" ? ("explain" as const) : ("ask" as const); + const kind = toolQuestionKind(toolName); // Canonical QuestionData, built once per question. Quiz options are // deterministically shuffled (seeded by toolCallId) so position never leaks diff --git a/src/agent/prompts/shared/25-confidence.md b/src/agent/prompts/shared/25-confidence.md index b503c73..8aad741 100644 --- a/src/agent/prompts/shared/25-confidence.md +++ b/src/agent/prompts/shared/25-confidence.md @@ -1,6 +1,6 @@ ## Confidence — reading the learner's self-assessment -Every quiz answer arrives as a compact JSON record; its `confidence` field is the learner's stated probability that his selected answer is correct, set on a snapped slider (0, 30, 50, 70, 90, 100). This is calibration data, not decoration. Read it against the verdict: +Every quiz answer carries `confidence`: the learner's stated probability, as a percentage, that the answer he picked is correct. Read it against the verdict — it changes what the outcome means: - 50 is a genuine coin flip. 90–100 claims certainty; 0–30 claims real doubt. - For an n-option question, chance is 1/n. Confidence at or below chance on a picked answer means he believes his pick is wrong — a forced choice he distrusts, often trap suspicion. Treat it as honesty, not error. diff --git a/src/core/pending.ts b/src/core/pending.ts index 8b8185b..e16b87b 100644 --- a/src/core/pending.ts +++ b/src/core/pending.ts @@ -41,6 +41,12 @@ export interface PendingToolCall { /** Tools that render in the question dock and gate the composer. */ export const QUESTION_TOOLS = ["quiz", "ask_user_question", "explain"] as const; +/** The tool→kind map (the dock's own fold, named once — the dismissal + * path and the dock must never disagree on what a tool is). */ +export function toolQuestionKind(tool: string): "quiz" | "ask" | "explain" { + return tool === "quiz" ? "quiz" : tool === "explain" ? "explain" : "ask"; +} + /** Tools fulfilled by background sub-agent jobs (W2/W6). */ export const JOB_TOOLS = ["research", "demo", "visual"] as const; diff --git a/src/core/question-core.ts b/src/core/question-core.ts index dc314a9..da42a04 100644 --- a/src/core/question-core.ts +++ b/src/core/question-core.ts @@ -615,6 +615,23 @@ export function buildAnswer(qd: QuestionData, req: AnswerRequest): QuestionAnswe * note. The full graded QuestionAnswerData rides `details` for the record * and the UI; this is the JSON the agent actually reads. */ +/** The dismissal record (ESC-abort from a pending question, owner + * 2026-10-02): the minimum honest answer. The model sees + * {kind, dismissed:true} via compactAnswerForModel; the answered card + * renders the question text + "skipped". Rides the same answer path — + * the teacher acknowledges the skip in its next turn, exactly like any + * answer. */ +export function dismissedAnswer(kind: QuestionKind, questionText: string): QuestionAnswerData { + return { + kind, + questionText, + dontKnow: false, + answers: [], + dismissed: true, + answeredAt: new Date().toISOString(), + }; +} + export function compactAnswerForModel(ad: QuestionAnswerData): Record { if (ad.dismissed) return { kind: ad.kind, dismissed: true }; const out: Record = { kind: ad.kind }; diff --git a/tests/question-core.test.ts b/tests/question-core.test.ts index 64ac2c2..e4e0e9d 100644 --- a/tests/question-core.test.ts +++ b/tests/question-core.test.ts @@ -12,6 +12,7 @@ import { askOptionProblems, buildAnswer, compactAnswerForModel, + dismissedAnswer, coerceCorrectAnswer, CONFIDENCE_STOPS, DEFAULT_CONFIDENCE_INDEX, @@ -31,6 +32,7 @@ import { type QuestionData, toClientQuestion, } from "../src/core/question-core.ts"; +import { toolQuestionKind } from "../src/core/pending.ts"; // ─── the authoring contract (TypeBox schemas + execute-time refinements) ── // Schema-valid ⇔ gradeable: a quiz whose correctAnswer can't be resolved @@ -458,6 +460,36 @@ describe("compactAnswerForModel", () => { }); }); +// ─── dismissedAnswer / toolQuestionKind (ESC-abort dismissal, 2026-10-02) ── + +describe("dismissedAnswer (the ESC-abort record)", () => { + it("the minimum honest answer: kind, questionText, dismissed — nothing else claimed", () => { + const ad = dismissedAnswer("ask", "What drives you?"); + assert.equal(ad.kind, "ask"); + assert.equal(ad.questionText, "What drives you?"); + assert.equal(ad.dismissed, true); + assert.equal(ad.dontKnow, false); // a skip is not a gap claim + assert.deepEqual(ad.answers, []); + assert.ok(ad.answeredAt); + }); + + it("compacts to the one-field model diet", () => { + assert.deepEqual(compactAnswerForModel(dismissedAnswer("quiz", "Pick one")), { + kind: "quiz", + dismissed: true, + }); + }); +}); + +describe("toolQuestionKind (tool→kind parity with the dock)", () => { + it("the three question tools map; unknown tools fall to ask", () => { + assert.equal(toolQuestionKind("quiz"), "quiz"); + assert.equal(toolQuestionKind("explain"), "explain"); + assert.equal(toolQuestionKind("ask_user_question"), "ask"); + assert.equal(toolQuestionKind("anything-else"), "ask"); + }); +}); + // ─── toClientQuestion (leak safety) ───────────────────────────────────────── describe("toClientQuestion", () => { diff --git a/tests/render-abort-turn-dialog.test.tsx b/tests/render-abort-turn-dialog.test.tsx new file mode 100644 index 0000000..5d28e56 --- /dev/null +++ b/tests/render-abort-turn-dialog.test.tsx @@ -0,0 +1,47 @@ +/** + * SSR pins — abort-turn-dialog (ESC-abort guard, owner 2026-10-02): + * the confirm popup EVERYWHERE ruling. AbortTurnBody is the pure seam + * (the DeleteNodeBody pattern); the wrapper owns effects + portal. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { AbortTurnBody } from "@/components/abort-turn-dialog"; + +describe("AbortTurnBody", () => { + it("turn mode: names the turn, the partial-stays promise, Abort", () => { + const html = renderToStaticMarkup( + {}} onConfirm={() => {}} />, + ); + assert.ok(html.includes('data-testid="abort-turn-dialog"')); + assert.match(html, /Abort this turn\?/); + assert.match(html, /The partial answer stays in the transcript\./); + assert.match(html, /Cancel/); + assert.match(html, /Abort/); + }); + + it("question mode: names the dismissal, the skip, Dismiss", () => { + const html = renderToStaticMarkup( + {}} onConfirm={() => {}} />, + ); + assert.match(html, /Dismiss this question\?/); + assert.match(html, /Skipped — the teacher moves on without it\./); + assert.match(html, /Dismiss/); + // the abort copy must not leak into the dismissal mode + assert.ok(!html.includes("Abort this turn")); + }); + + it("the confirm action is the destructive pair, never the cancel", () => { + const html = renderToStaticMarkup( + {}} onConfirm={() => {}} />, + ); + const confirmAt = html.indexOf('data-testid="abort-turn-confirm"'); + const cancelAt = html.indexOf('data-testid="abort-turn-cancel"'); + assert.ok(confirmAt > -1 && cancelAt > -1); + const confirmBtn = html.slice(confirmAt, html.indexOf("", confirmAt)); + assert.match(confirmBtn, /destructive/); + const cancelBtn = html.slice(cancelAt, html.indexOf("", cancelAt)); + assert.ok(!cancelBtn.includes("destructive")); + }); +});