Skip to content

Commit 0760dd2

Browse files
authored
Merge pull request #264 from Programcode/fix/ai-failure-reasons-and-funnel
Say why AI requests fail, offer the next step, and measure activation
2 parents c06378c + e604dd7 commit 0760dd2

34 files changed

Lines changed: 1098 additions & 101 deletions

‎.claude/skills/verify-debatelab/features/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ Each feature file starts with an H1 title and one paragraph describing the user-
5151
- [Workspace autosave](./workspace-autosave.md): a slow Crossfire or Sparring save from a Workspace never overwrites newer text (`recipes/workspace-autosave.spec.ts`). Needs `up --collab`; skips without it.
5252
- [Account data](./account-data.md): Download my data and Delete account, each proven against a bystander (`recipes/account-data.spec.ts`); Delete all my data scoped against a bystander (`e2e/full-stack/account-data.spec.ts`).
5353
- Account: output language reaching the model from a new device, flowchart node delete (at once, with an `Undo` toast; no confirm dialog), save and load, and the speech timer (`R` resets only while paused) (`recipes/account.spec.ts`).
54+
- [AI failures](./ai-failures.md): the reason and next step for a failed AI request: Sign in again after a refused session in Generate, Ask, Sparring and Crossfire, Retry after a network failure in Ask, and the plan dialog for a limit (`recipes/ai-failures.spec.ts`).
5455
- [Usage limits](./usage-limits.md): Opus refused when its weight does not fit today while Haiku still runs, and the weekly AI budget dialog (`recipes/usage-limits.spec.ts`).
5556
- Billing: expired account blocked, promo code restores access, and a discount code claimed once (a second Apply does not count again) stays in the pricing dialog and is sent with the upgrade (`recipes/billing.spec.ts`); each billing state (trial, extended access, active Pro, Pro set to cancel, promo Pro without Stripe, payment failed, subscription ended) shown the same way by Settings, the pricing dialog (its one plan button and Current badge) and a refused AI request, in English and Hungarian, light and dark, at 360 and 375 px (mobile project) and 768 and 1280 px (desktop project); the usage line on every tool page matching Settings and updating after a run; the trial banner; and `/app/billing` landing on the Settings subscription card (`recipes/billing-states.spec.ts`). The pricing dialog's Manage path stubs `create-portal` with its own no-subscription answer, since the portal needs Stripe; signed, forged, and replayed Stripe webhooks, including a failed renewal that keeps Pro (`e2e/full-stack/billing.spec.ts`).
5657
- [Localization](./localization.md): tab titles per route and language, translated form validation and server refusals, the language switcher on sign-in and 404, and `?lang=` with the `/es` and `/hu` pages (`recipes/localization.spec.ts`).
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# AI failures: the reason and the next step
2+
3+
When an AI request fails, the tool says why in one plain sentence and offers the step that can work. A daily, weekly or plan limit opens the same plan dialog every tool uses, and a Pro-only model opens the upgrade dialog. When the server refuses the session (401, or a 403 that is not about the model), the error offers `Sign in again` instead of Retry, because sending again would be refused the same way. It signs the tab out and opens `/login` with `Your session expired. Sign in again.`, and signing in returns to the page. Timeouts, network failures, provider errors, malformed answers and server errors keep Retry. Crossfire and Sparring retries reuse the round's quota session, so a retry is not charged again. A request that is too large has no Retry. The mapping is `aiFailureRecovery` in `src/lib/ai/failure-reason.ts`, and the same file gives `ai_request_failed` its `error_code` and `stage`.
4+
5+
## Sub-features
6+
7+
- `fail-signin-tool`: Generate answered 401 shows `Sign-in required` with `Sign in again` and no Retry; the button lands on `/login` with the expired-session notice.
8+
- `fail-network-ask`: an Ask question whose request never reached the server shows `Check your connection and try again.` with Retry; once the network is back, Retry answers it and the question appears once.
9+
- `fail-signin-ask`: Ask answered 401 shows `Sign in again` in the chat, with no Retry.
10+
- `fail-limit-tool`: Brainstorm with `daily_limit_override: 0` opens `Daily AI uses used up`.
11+
- `fail-signin-sparring`: the opponent's turn answered 401 shows `The opponent could not answer` with `Sign in again` instead of `Retry opponent turn`.
12+
- `fail-signin-crossfire`: the AI's first question in an AI-led round answered 401 shows `Sign in again` instead of `retry-ai`.
13+
14+
## How to get to it (user POV)
15+
16+
- Any AI tool when its request fails: `/app/generate`, `/app/attack`, `/app/brainstorm`, `/app/factoids`, `/app/ask`, `/app/sparring`, `/app/crossfire`.
17+
18+
## Driving it with verify.sh + Playwright
19+
20+
- `bin/verify.sh drive recipes/ai-failures` runs each sub-feature in its own test, on both viewports.
21+
- The recipe forces each failure in the browser with `page.route`: a 401 JSON answer for `functions/v1/ai` (letting a round's `quotaCheck` through) or `functions/v1/ai-stream`, or `route.abort("internetdisconnected")` for a network failure. The limit step sets `daily_limit_override: 0` through `admin`.
22+
- Provider failures with Retry are `gen-error` in `recipes/case-generation.spec.ts`; the Ask limit with Retry is `usage-ask-not-sent` in `recipes/usage-limits.spec.ts`.
23+
24+
## Gotchas
25+
26+
- A real 401 from a function makes the client refresh the session once (`verifySessionAfterRejection`). On this stack the refresh succeeds, so the tab stays signed in until `Sign in again` is clicked.
27+
- A timeout needs the 200-second client budget to pass, so no recipe forces one. `src/lib/ai/failure-reason.test.ts` covers its mapping.
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import type { Page, Route } from "@playwright/test";
2+
import { admin, AI_TIMEOUT, chooseForMotion, expect, startDebate, test } from "./fixtures";
3+
4+
// Feature map: features/ai-failures.md. Each failure is forced in the browser,
5+
// so the provider is never called and the recipe runs the same in both modes.
6+
const motion = "This House would make public transport free at the point of use";
7+
const resolution = "Resolved: cities should ban private cars downtown.";
8+
9+
/** Answers every `ai` request except a round's quota check as the server does for a refused session. */
10+
async function refuseSession(page: Page): Promise<void> {
11+
await page.route("**/functions/v1/ai", async (route: Route) => {
12+
if (route.request().method() === "POST" && (route.request().postDataJSON() as { quotaCheck?: boolean } | null)?.quotaCheck) {
13+
await route.fallback();
14+
return;
15+
}
16+
await route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ error: "Invalid JWT" }) });
17+
});
18+
}
19+
20+
async function expectBackOnLogin(page: Page): Promise<void> {
21+
await expect(page).toHaveURL(/\/login$/, { timeout: AI_TIMEOUT });
22+
await expect(page.getByRole("status").filter({ hasText: "Your session expired. Sign in again." })).toBeVisible();
23+
}
24+
25+
test("fail-signin-tool: a refused session on Generate offers Sign in again, not Retry, and it opens the login page", async ({ signedIn: page, evidence }) => {
26+
await startDebate(page, motion);
27+
await page.goto("/app/generate");
28+
await refuseSession(page);
29+
await page.getByRole("button", { name: "Generate debate case" }).click();
30+
const failure = page.getByRole("alert").filter({ hasText: "Sign-in required" });
31+
await expect(failure).toBeVisible({ timeout: AI_TIMEOUT });
32+
await expect(failure.getByRole("button", { name: "Retry" })).toHaveCount(0);
33+
await evidence.shot("01-generate-sign-in-again");
34+
await evidence.aria("01-generate-sign-in-again", failure);
35+
36+
await failure.getByRole("button", { name: "Sign in again" }).click();
37+
await expectBackOnLogin(page);
38+
await evidence.shot("02-login-after-sign-in-again");
39+
});
40+
41+
test("fail-network-ask: a question that never reached the server stays with Retry, and Retry answers it once", async ({ signedIn: page, evidence }) => {
42+
const question = "What is the strongest objection to free public transport?";
43+
await page.goto("/app/ask");
44+
await page.route("**/functions/v1/ai-stream", (route) => route.abort("internetdisconnected"));
45+
await page.route("**/functions/v1/ai", (route) => route.abort("internetdisconnected"));
46+
await page.getByRole("textbox", { name: "Message to your debate coach" }).fill(question);
47+
await page.keyboard.press("Enter");
48+
const failure = page.getByRole("alert").filter({ hasText: "Check your connection and try again." });
49+
await expect(failure).toBeVisible({ timeout: AI_TIMEOUT });
50+
await evidence.shot("01-ask-network-retry");
51+
await evidence.aria("01-ask-network-retry", failure);
52+
53+
await page.unroute("**/functions/v1/ai-stream");
54+
await page.unroute("**/functions/v1/ai");
55+
const answer = page.waitForResponse((response) => response.url().endsWith("/functions/v1/ai-stream"), { timeout: AI_TIMEOUT });
56+
await failure.getByRole("button", { name: "Retry" }).click();
57+
await answer;
58+
await expect(failure).toHaveCount(0);
59+
await expect(page.getByText(question)).toHaveCount(1);
60+
await evidence.shot("02-ask-answered-after-retry");
61+
});
62+
63+
test("fail-signin-ask: a refused session in Ask offers Sign in again", async ({ signedIn: page, evidence }) => {
64+
await page.goto("/app/ask");
65+
await page.route("**/functions/v1/ai-stream", (route) => route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ error: "Invalid JWT" }) }));
66+
await refuseSession(page);
67+
await page.getByRole("textbox", { name: "Message to your debate coach" }).fill("How do I weigh two impacts?");
68+
await page.keyboard.press("Enter");
69+
const failure = page.getByRole("alert").filter({ has: page.getByRole("button", { name: "Sign in again" }) });
70+
await expect(failure).toBeVisible({ timeout: AI_TIMEOUT });
71+
await expect(failure.getByRole("button", { name: "Retry" })).toHaveCount(0);
72+
await evidence.shot("01-ask-sign-in-again");
73+
await evidence.aria("01-ask-sign-in-again", failure);
74+
});
75+
76+
test("fail-limit-tool: a tool refused by the daily limit opens the plan dialog", async ({ signedIn: page, user, evidence }) => {
77+
await admin.from("user_settings").update({ daily_limit_override: 0 }).eq("user_id", await user.userId());
78+
await page.goto("/app/brainstorm");
79+
await page.getByLabel("Motion / topic *").fill(motion);
80+
await page.getByRole("button", { name: "Start brainstorming" }).click();
81+
await chooseForMotion(page, "once");
82+
const dialog = page.getByRole("dialog").filter({ hasText: "Daily AI uses used up" });
83+
await expect(dialog).toBeVisible({ timeout: AI_TIMEOUT });
84+
await evidence.shot("01-brainstorm-limit-dialog");
85+
await evidence.aria("01-brainstorm-limit-dialog", dialog);
86+
});
87+
88+
test("fail-signin-sparring: a refused session for the opponent's turn offers Sign in again instead of Retry", async ({ signedIn: page, evidence }) => {
89+
await page.goto("/app/sparring");
90+
await page.getByLabel("Motion").fill(motion);
91+
await page.getByRole("combobox", { name: "Round format" }).click();
92+
await page.getByRole("option", { name: /Lincoln-Douglas · 5 speeches/ }).click();
93+
await page.getByRole("button", { name: "Start round" }).click();
94+
await chooseForMotion(page, "create");
95+
const draft = page.getByRole("textbox", { name: "Draft for Affirmative constructive" });
96+
await expect(draft).toBeVisible({ timeout: AI_TIMEOUT });
97+
await refuseSession(page);
98+
await draft.fill("Universal access removes the direct price barrier to participation in work, school, and civic life.");
99+
await page.getByRole("button", { name: "Submit speech" }).click();
100+
await expect(page.getByRole("heading", { name: "The opponent could not answer" })).toBeVisible({ timeout: AI_TIMEOUT });
101+
await expect(page.getByRole("button", { name: "Retry opponent turn" })).toHaveCount(0);
102+
await expect(page.getByRole("button", { name: "Sign in again" })).toBeVisible();
103+
await evidence.shot("01-sparring-sign-in-again");
104+
});
105+
106+
test("fail-signin-crossfire: a refused session for the AI's question offers Sign in again instead of Retry", async ({ signedIn: page, evidence }) => {
107+
await page.goto("/app/crossfire");
108+
await page.getByTestId("crossfire-resolution").fill(resolution);
109+
await page.getByTestId("crossfire-argument").fill("Private cars create congestion and unsafe streets.");
110+
await page.getByTestId("mode-ai-questions-user").click();
111+
await refuseSession(page);
112+
await page.getByTestId("start-crossfire").click();
113+
await chooseForMotion(page, "create");
114+
await expect(page.getByRole("button", { name: "Sign in again" })).toBeVisible({ timeout: AI_TIMEOUT });
115+
await expect(page.getByTestId("retry-ai")).toHaveCount(0);
116+
await evidence.shot("01-crossfire-sign-in-again");
117+
});

‎docs/architecture.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,10 @@ A run that produces nothing costs nothing. When an `ai` request fails on the ser
170170

171171
Client startup loads Sentry and PostHog asynchronously when configured. Product events and exceptions use the privacy-filtering facade in [`src/lib/observability.ts`](../src/lib/observability.ts), so SDK loading or failure cannot block rendering. Sentry events carry the build's commit SHA as the release and the Vercel environment name. Route crashes keep the original error type and stack frames but replace the message, which can carry user text. Vercel Analytics and Speed Insights run only in the web build and load outside the application render boundary.
172172

173+
The facade accepts only the event names and property values listed in it. `ai_request_failed` says why a request failed: `tool`, `error_code` (one of `limit_reached`, `model_restricted`, `auth_expired`, `timeout`, `network`, `aborted`, `provider_error`, `invalid_response`, `server_error`, `unknown`, mapped from the client's AI error classes in [`src/lib/ai/failure-reason.ts`](../src/lib/ai/failure-reason.ts)), `http_status`, `stage` (`request`, `stream`, `parse`), `streaming` and `duration_ms`. The same file decides each failure's next step: the plan dialog, Sign in again, or Retry. The activation events in [`src/lib/activation-events.ts`](../src/lib/activation-events.ts) are `signup_completed` (`method`), `trial_started`, `paywall_shown` (`reason`, `tool`), `first_ai_success` (`tool`) and `welcome_completed`. The once-per-account ones remember what they sent in this browser's local storage. `first_ai_success` is sent only for an account made in the last seven days, so an older account's next answer is not counted as its first. PostHog keeps its own state in memory and is never told who the user is, so it cannot join one student's events across page loads.
174+
175+
Every `React.lazy` component and route loader goes through `loadChunk` in [`src/lib/lazy-chunk.ts`](../src/lib/lazy-chunk.ts). After a deploy removes an old file, the page reloads once per path in ten minutes (the time is kept in `sessionStorage` and shared with the `vite:preloadError` listener, so a file still missing after the reload stays an error while a later deploy can reload again) instead of crashing on a module that loaded as nothing.
176+
173177
CI and staging must not upload authenticated browser captures. Logging must not expose tokens, provider keys, user content, or protected staging values.
174178

175179
## PWA and offline behavior

0 commit comments

Comments
 (0)