Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions e2e/specs/contextual-guidance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Comment thread
msrivas-7 marked this conversation as resolved.
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",
Expand Down
8 changes: 6 additions & 2 deletions e2e/specs/free-tier.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
19 changes: 19 additions & 0 deletions frontend/src/components/TutorResponseChrome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/components/TutorResponseChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
16 changes: 13 additions & 3 deletions frontend/src/features/learning/components/GuidedTutorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 15 additions & 3 deletions frontend/src/features/learning/pages/LessonPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "",
Expand All @@ -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,
Comment thread
msrivas-7 marked this conversation as resolved.
});
const contextualGuideVisible =
contextualGuide.decision.kind === "result_bridge";
Expand All @@ -1209,8 +1217,6 @@ export default function LessonPage({
}, [contextualGuideVisible, isPhoneNative, layout.editorRef, shortCompactViewport]);
const [contextualTutorAvailability, setContextualTutorAvailability] =
useState<ContextualTutorAvailability>("loading");
const [contextualRuntimeUnavailable, setContextualRuntimeUnavailable] =
useState(false);
useEffect(() => {
setContextualRuntimeUnavailable(false);
}, [courseId, lessonId]);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2172,6 +2182,7 @@ export default function LessonPage({
onContextualTutorAskComplete={handleContextualTutorAskComplete}
onContextualTutorOfferInvalidated={handleContextualTutorOfferInvalidated}
onTutorAskInterrupted={handleTutorAskInterrupted}
onTutorAskRecovered={handleTutorAskRecovered}
/>
</section>
</div>
Expand Down Expand Up @@ -2936,6 +2947,7 @@ export default function LessonPage({
onContextualTutorAskComplete={handleContextualTutorAskComplete}
onContextualTutorOfferInvalidated={handleContextualTutorOfferInvalidated}
onTutorAskInterrupted={handleTutorAskInterrupted}
onTutorAskRecovered={handleTutorAskRecovered}
/>
</motion.aside>
</motion.main>
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/util/useTutorAsk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: (
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
Loading