diff --git a/e2e/specs/contextual-guidance.spec.ts b/e2e/specs/contextual-guidance.spec.ts index ca2b7c2..2125d33 100644 --- a/e2e/specs/contextual-guidance.spec.ts +++ b/e2e/specs/contextual-guidance.spec.ts @@ -423,6 +423,9 @@ test.describe("contextual guidance and Tutor offer", () => { await setMonacoValue(page, 'print("Hello, learner"\n'); await runCode(page); await page.getByTestId("contextual-guide-ask").click(); + await expect(page.getByText("Tutor couldn't answer", { exact: true })).toBeVisible(); + await expect(page.getByText(/Your code is safe/i)).toBeVisible(); + await expect(page.getByText(/provider unavailable|internal-/i)).toHaveCount(0); await expect(page.getByRole("button", { name: /retry the last question/i })).toBeVisible(); await expect(page.getByTestId("contextual-guide-bridge")).toHaveCount(0); await page.getByRole("button", { name: /retry the last question/i }).click(); @@ -711,10 +714,89 @@ test.describe("contextual guidance and Tutor offer", () => { await page.getByRole("button", { name: /retry the last question/i }).click(); await expect(page.getByText(/count the opening and closing parentheses/i)).toBeVisible(); + await expect(page.getByTestId("contextual-guide-bridge")).toHaveCount(0); expect(askCalls).toBe(2); expect(cancelCalls).toBe(1); }); + test("an interrupted Tutor retry cannot erase the guide when admission then fails", criticalTest({ + risk: "p0", + owner: "learning", + browsers: ["chromium", "webkit"], + devices: ["desktop"], + quarantine: { state: "none" }, + }), async ({ page }) => { + const firstRelease = deferred(); + const firstStarted = deferred(); + let askCalls = 0; + let cancelCalls = 0; + await page.route("**/api/anon/ai/ask/cancel", async (route) => { + cancelCalls += 1; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ canceled: true, refunded: true }), + }); + }); + await page.route("**/api/anon/ai/ask/stream", async (route) => { + askCalls += 1; + if (askCalls === 1) { + firstStarted.resolve(); + await firstRelease.promise; + await route.abort().catch(() => {}); + return; + } + if (askCalls === 2) { + await route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ error: "AI_ADMISSION_UNAVAILABLE" }), + }); + return; + } + const sections = { + intent: "debug", + hint: "Count the opening and closing parentheses on the cited line.", + checkQuestions: ["Which opening parenthesis still needs its partner?"], + }; + await route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: `data: ${JSON.stringify({ done: true, raw: JSON.stringify(sections), sections })}\n\n`, + }); + }); + + await page.goto(PATH); + await waitForMonacoReady(page); + await setMonacoValue(page, 'print("Hello"\n'); + await runCode(page); + await setMonacoValue(page, 'print("Hello, learner"\n'); + await runCode(page); + await page.getByTestId("contextual-guide-ask").click(); + await firstStarted.promise; + + await page.setViewportSize({ width: 390, height: 844 }); + await expect.poll(() => cancelCalls).toBe(1); + firstRelease.resolve(); + await expect(page.getByText("Tutor view changed", { exact: true })).toBeVisible(); + await expect(page.getByTestId("contextual-guide-question")).toBeVisible(); + await page.getByRole("button", { name: /retry the last question/i }).click(); + + await expect(page.getByText("Tutor admission temporarily unavailable")).toBeVisible(); + await expect(page.getByText(/AI_ADMISSION_UNAVAILABLE/)).toHaveCount(0); + await expect(page.getByTestId("contextual-guide-question")).toBeVisible(); + await expect(page.getByRole("button", { name: "Jump to line 1" })).toBeVisible(); + await expect(page.getByRole("button", { name: /retry the last question/i })).toHaveCount(0); + expect(askCalls).toBe(2); + + const composer = page.getByLabel(/ask the tutor/i); + await composer.fill("Can you help me reason through this error?"); + await composer.press("Enter"); + await expect(page.getByText(/count the opening and closing parentheses/i)).toBeVisible(); + await expect(page.getByTestId("contextual-guide-bridge")).toHaveCount(0); + expect(askCalls).toBe(3); + }); + test("a lesson-context refusal preserves the free guide without replaying consent", criticalTest({ risk: "p0", owner: "learning", diff --git a/e2e/specs/free-tier.spec.ts b/e2e/specs/free-tier.spec.ts index 65f4a08..dc77930 100644 --- a/e2e/specs/free-tier.spec.ts +++ b/e2e/specs/free-tier.spec.ts @@ -986,10 +986,14 @@ test.describe("free AI tier", () => { const input = page.getByRole("textbox", { name: /ask/i }).first(); await input.fill("fail pls"); await input.press("Enter"); - // Error banner appears. Pill is still 30/30. - await expect(page.getByText(/server overloaded|please retry/i).first()).toBeVisible({ + // The private recovery surface appears without exposing the provider's + // raw payload. The failed turn still leaves both Retry and 30/30 intact. + await expect(page.getByText("Tutor couldn't answer", { exact: true })).toBeVisible({ timeout: 5_000, }); + await expect(page.getByText(/Your code is safe/i)).toBeVisible(); + await expect(page.getByText(/server overloaded|please retry/i)).toHaveCount(0); + await expect(page.getByRole("button", { name: /retry the last question/i })).toBeVisible(); await expect(page.getByText(/30\/30/)).toBeVisible(); }); diff --git a/frontend/src/components/TutorResponseChrome.test.ts b/frontend/src/components/TutorResponseChrome.test.ts index de97028..5868a34 100644 --- a/frontend/src/components/TutorResponseChrome.test.ts +++ b/frontend/src/components/TutorResponseChrome.test.ts @@ -18,6 +18,25 @@ describe("tutor recovery copy", () => { }); }); + it("keeps unexpected provider payloads private while preserving a useful retry", () => { + const raw = JSON.stringify({ error: "provider unavailable", requestId: "internal-123" }); + + expect(classifyAskError(raw)).toEqual({ + kind: "generic", + title: "Tutor couldn't answer", + hint: "Your code is safe. Try the question again; if it keeps happening, you can continue working without the tutor.", + showDetails: false, + }); + + const markup = renderToStaticMarkup(createElement(AskErrorView, { + message: raw, + onRetry: vi.fn(), + })); + expect(markup).not.toContain("provider unavailable"); + expect(markup).not.toContain("internal-123"); + expect(markup).toMatch(/Try again/i); + }); + it("makes timeouts recoverable without blocking lesson progress", () => { const result = classifyAskError("upstream timed out"); expect(result.title).toBe("Tutor took too long"); diff --git a/frontend/src/components/TutorResponseChrome.tsx b/frontend/src/components/TutorResponseChrome.tsx index 9c6cce2..3ff6278 100644 --- a/frontend/src/components/TutorResponseChrome.tsx +++ b/frontend/src/components/TutorResponseChrome.tsx @@ -304,5 +304,10 @@ export function classifyAskError(raw: string): { if (m.includes("incorrect api key") || m.includes("invalid_api_key") || m.includes(" 401")) { return { kind: "auth", title: "Key rejected", hint: "The API key is no longer valid. Open Settings and validate a fresh key." }; } - return { kind: "generic", title: "Request failed" }; + return { + kind: "generic", + title: "Tutor couldn't answer", + hint: "Your code is safe. Try the question again; if it keeps happening, you can continue working without the tutor.", + showDetails: false, + }; } diff --git a/frontend/src/features/learning/components/GuidedTutorPanel.tsx b/frontend/src/features/learning/components/GuidedTutorPanel.tsx index ceebe10..98abe3f 100644 --- a/frontend/src/features/learning/components/GuidedTutorPanel.tsx +++ b/frontend/src/features/learning/components/GuidedTutorPanel.tsx @@ -103,6 +103,8 @@ interface GuidedTutorPanelProps { ) => void; /** Reports an in-flight ask canceled by a responsive panel replacement. */ onTutorAskInterrupted?: () => void; + /** Retires retained contextual guidance after ordinary Tutor recovery succeeds. */ + onTutorAskRecovered?: () => void; /** Promotes an admission refusal to lesson scope before this panel can remount. */ onContextualTutorOfferInvalidated?: ( invalidation: "disabled" | "model" | "quota" | "availability", @@ -159,7 +161,7 @@ export function resolveTutorSource( return hasKey ? "byok" : (statusSource ?? "none"); } -export function GuidedTutorPanel({ lessonMeta, totalLessons, progressSummary, priorConcepts, activePracticeExercise, onCollapse, onOpenSettings, resetNonce, inputLocked, clearHidden, mode = "authed", onAnonExhausted, onAnonTrialPaused, onAnonSaveRequested, onSkipWelcome, initialAnonTutorState, onContextualTutorAvailabilityChange, onContextualTutorAskComplete, onContextualTutorOfferInvalidated, onTutorAskInterrupted, externalAskReady = true, contextualRuntimeUnavailable: parentContextualRuntimeUnavailable = false }: GuidedTutorPanelProps) { +export function GuidedTutorPanel({ lessonMeta, totalLessons, progressSummary, priorConcepts, activePracticeExercise, onCollapse, onOpenSettings, resetNonce, inputLocked, clearHidden, mode = "authed", onAnonExhausted, onAnonTrialPaused, onAnonSaveRequested, onSkipWelcome, initialAnonTutorState, onContextualTutorAvailabilityChange, onContextualTutorAskComplete, onContextualTutorOfferInvalidated, onTutorAskInterrupted, onTutorAskRecovered, externalAskReady = true, contextualRuntimeUnavailable: parentContextualRuntimeUnavailable = false }: GuidedTutorPanelProps) { const incrementHint = useProgressStore((s) => s.incrementHint); // Derive the hint cap from the DB-backed hint_count (not local component // state) so the limit survives navigation + reload. Local state rewinds on @@ -437,9 +439,17 @@ export function GuidedTutorPanel({ lessonMeta, totalLessons, progressSummary, pr } } }, - onAskComplete: ({ ok, interruption }) => { + onAskComplete: ({ ok, interruption, contextual }) => { if (interruption === "panel-remounted") onTutorAskInterrupted?.(); - if (activeContextualOfferRef.current) { + if (ok && !contextual) { + // Failed contextual admission or a responsive interruption keeps the + // deterministic guide until ordinary Tutor help actually succeeds. + // At that point the Tutor owns attention again, so retire the guide + // and clear both panel- and lesson-owned refusal latches. + setContextualRuntimeUnavailable(false); + onTutorAskRecovered?.(); + } + if (contextual) { onContextualTutorAskComplete?.( ok, contextualInvalidationRef.current ?? undefined, diff --git a/frontend/src/features/learning/pages/LessonPage.tsx b/frontend/src/features/learning/pages/LessonPage.tsx index e1e090e..030b047 100644 --- a/frontend/src/features/learning/pages/LessonPage.tsx +++ b/frontend/src/features/learning/pages/LessonPage.tsx @@ -1170,6 +1170,8 @@ export default function LessonPage({ mode === "authed" && courseId && lessonId ? lessonProgressMap[`${courseId}/${lessonId}`]?.status === "completed" : false; + const [contextualRuntimeUnavailable, setContextualRuntimeUnavailable] = + useState(false); const contextualGuide = useContextualGuide({ enabled: contextualGuideEnabled, courseId: courseId ?? "", @@ -1185,7 +1187,13 @@ export default function LessonPage({ validator.showComplete || (isChoreographed && firstRunStep !== "done") || contextualAskPending, - learnerRequestedTutor: tutorAsking && !contextualAskPending, + // A responsive-remount or authoritative refusal deliberately leaves the + // deterministic guide as the learner's non-spending recovery surface. + // Retrying that interrupted Tutor turn is not a new decision to hand off + // ownership to the Tutor, so do not permanently dismiss the guide while + // the lesson-owned runtime-unavailable latch is active. + learnerRequestedTutor: + tutorAsking && !contextualAskPending && !contextualRuntimeUnavailable, }); const contextualGuideVisible = contextualGuide.decision.kind === "result_bridge"; @@ -1209,8 +1217,6 @@ export default function LessonPage({ }, [contextualGuideVisible, isPhoneNative, layout.editorRef, shortCompactViewport]); const [contextualTutorAvailability, setContextualTutorAvailability] = useState("loading"); - const [contextualRuntimeUnavailable, setContextualRuntimeUnavailable] = - useState(false); useEffect(() => { setContextualRuntimeUnavailable(false); }, [courseId, lessonId]); @@ -1318,6 +1324,10 @@ export default function LessonPage({ // the same spending action while request completion is still unwinding. setContextualRuntimeUnavailable(true); }, []); + const handleTutorAskRecovered = useCallback(() => { + setContextualRuntimeUnavailable(false); + contextualGuide.accept(); + }, [contextualGuide]); useEffect(() => { const outcome = contextualAskOutcomeRef.current; if (!outcome || tutorAsking) return; @@ -2172,6 +2182,7 @@ export default function LessonPage({ onContextualTutorAskComplete={handleContextualTutorAskComplete} onContextualTutorOfferInvalidated={handleContextualTutorOfferInvalidated} onTutorAskInterrupted={handleTutorAskInterrupted} + onTutorAskRecovered={handleTutorAskRecovered} /> @@ -2936,6 +2947,7 @@ export default function LessonPage({ onContextualTutorAskComplete={handleContextualTutorAskComplete} onContextualTutorOfferInvalidated={handleContextualTutorOfferInvalidated} onTutorAskInterrupted={handleTutorAskInterrupted} + onTutorAskRecovered={handleTutorAskRecovered} /> diff --git a/frontend/src/util/useTutorAsk.ts b/frontend/src/util/useTutorAsk.ts index 930e72b..c947eab 100644 --- a/frontend/src/util/useTutorAsk.ts +++ b/frontend/src/util/useTutorAsk.ts @@ -153,6 +153,7 @@ export interface UseTutorAskOpts { onAskComplete?: (outcome: { ok: boolean; interruption?: "panel-remounted"; + contextual: boolean; }) => void; /** Invalidates a contextual offer whose signed evidence can no longer be used. */ onContextualOfferInvalidated?: ( @@ -391,7 +392,11 @@ export function useTutorAsk(opts: UseTutorAskOpts): UseTutorAskResult { ): void => { if (completionNotified) return; completionNotified = true; - opts.onAskComplete?.({ ok, interruption }); + opts.onAskComplete?.({ + ok, + interruption, + contextual: Boolean(options.contextualOffer), + }); }; // Read askOk at cleanup time so a route transition that races just after // onDone does not relabel an already-completed answer as a failed turn. @@ -500,6 +505,11 @@ export function useTutorAsk(opts: UseTutorAskOpts): UseTutorAskResult { clearStream(); committed = true; askOk = true; + // pushAssistant advances the conversation revision, so the + // operation-level tail below intentionally stops being current. + // Report the accepted terminal outcome here, while freshness has + // just been proven, instead of letting finally mislabel success. + notifyCompletion(true); // P-H6: optimistic local decrement avoids a /ai-status refetch per // turn. The 30s cache + next natural fetch reconciles if we drift. // Anon: skip — there's no /api/user/ai-status cache to decrement,