Skip to content

Commit 59d49c7

Browse files
Programcodeclaude
andcommitted
Translate quota refusals where Sparring and Crossfire show them inline
Codex review: those pages printed the server's English reason. The limit dialog's text now comes from one helper that getUserFacingAIError and Sparring also use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent df127d4 commit 59d49c7

5 files changed

Lines changed: 48 additions & 14 deletions

File tree

‎.claude/skills/verify-debatelab/features/usage-limits.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ The daily AI allowance counts units, not requests. An expensive model uses sever
77
- `usage-weighted`: with 4 units left, Opus is refused (`Daily Access Paused`, "This model counts as 5 uses, and you have 4 left today.") without a provider call or a counter change; switching to Claude Haiku 5.5 runs and uses 1 unit.
88
- `usage-monthly`: with `ai_requests.cost_usd` for this month at or above the budget, the next request opens `Monthly AI Allowance Used` with an `OK` button and no upgrade offer, makes no provider call, and leaves the daily counter alone.
99

10+
- `usage-monthly-inline`: Sparring shows refusals inline rather than in the dialog. With the budget reached, the opponent's turn shows the translated monthly message from the catalog, not the server's English reason, and no provider call is made.
11+
1012
## How to get to it (user POV)
1113

1214
- Any AI tool, for example `/app/generate`, once the day's allowance or the month's budget is used up. The dialog opens on submit.
1315

1416
## Driving it with verify.sh + Playwright
1517

16-
- `bin/verify.sh drive recipes/usage-limits` covers both sub-features in one test.
18+
- `bin/verify.sh drive recipes/usage-limits` covers the dialog sub-features in one test and the Sparring inline message in a second.
1719
- The recipe sets `daily_limit_override: 7` on the demo user and inserts a `daily_usage_counters` row (`quota_key: "ai"`, today's UTC date, `request_count: 3`), then inserts an `ai_requests` row with `cost_usd: 3` for the monthly step.
1820
- End states are read from `daily_usage_counters.request_count` and from the recorded provider calls.
1921

‎.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Page } from "@playwright/test";
2-
import { admin, AI_TIMEOUT, aiResponse, expect, providerCalls, startDebate, test } from "./fixtures";
2+
import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, providerCalls, startDebate, test } from "./fixtures";
33

44
// Feature map: features/usage-limits.md.
55
const motion = "This House would make public transport free at the point of use";
@@ -70,3 +70,29 @@ test("usage-weighted: Opus counts as 5 requests, a lighter model still runs, and
7070
await monthly.getByRole("button", { name: "OK" }).click();
7171
await expect(monthly).toHaveCount(0);
7272
});
73+
74+
test("usage-monthly-inline: Sparring shows the translated monthly-cap message, not the server's English reason", async ({ signedIn: page, user, evidence }) => {
75+
const userId = await user.userId();
76+
const { error } = await admin.from("ai_requests").insert({
77+
user_id: userId, feature: "sparring", provider: "openrouter", model: "anthropic/claude-opus-5.5", status: "success", cost_usd: 3,
78+
});
79+
if (error) throw error;
80+
81+
await page.goto("/app/sparring");
82+
await page.getByLabel("Motion").fill(motion);
83+
await page.getByRole("combobox", { name: "Round format" }).click();
84+
await page.getByRole("option", { name: /Lincoln-Douglas · 5 speeches/ }).click();
85+
await page.getByRole("button", { name: "Start round" }).click();
86+
await chooseForMotion(page, "create");
87+
const draft = page.getByRole("textbox", { name: "Draft for Affirmative constructive" });
88+
await expect(draft).toBeVisible({ timeout: AI_TIMEOUT });
89+
await draft.fill("Universal access removes the direct price barrier to work, school, and civic life.");
90+
await page.getByRole("button", { name: "Submit speech" }).click();
91+
92+
// The client's catalog copy ("You've used ...") differs from the server reason ("You've reached ...").
93+
await expect(page.getByText("You've used this month's AI allowance.", { exact: false })).toBeVisible({ timeout: AI_TIMEOUT });
94+
await expect(page.getByText("You've reached this month's AI usage allowance", { exact: false })).toHaveCount(0);
95+
await evidence.shot("04-sparring-monthly-cap");
96+
expect((await providerCalls()).filter((call) => call.feature === "sparring")).toEqual([]);
97+
await evidence.step("Sparring opponent turn refused by the monthly cap with the translated message; no provider call");
98+
});

‎src/components/features/subscription/LimitReachedModal.tsx‎

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
22
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
33
import { Button } from '@/components/ui/button';
44
import { AlertTriangle, Zap, Clock } from 'lucide-react';
5+
import { subscriptionLimitMessage } from '@/lib/ai/error-display';
56

67
export type LimitModalVariant = 'limit_reached' | 'trial_expired' | 'monthly_budget_reached';
78

@@ -20,22 +21,13 @@ export function LimitReachedModal({ open, onOpenChange, variant, limit, current,
2021
const { t } = useTranslation();
2122
const isTrialExpired = variant === 'trial_expired';
2223
const isMonthlyBudget = variant === 'monthly_budget_reached';
23-
const remaining = Math.max(0, (limit || 0) - (current || 0));
24-
// A weighted model can be refused while lighter requests still fit today.
25-
const isWeightedRefusal = !isTrialExpired && !isMonthlyBudget && (units ?? 1) > 1 && remaining > 0;
2624

2725
const title = isTrialExpired
2826
? t('limitModal.trialEnded')
2927
: isMonthlyBudget
3028
? t('limitModal.monthlyReached')
3129
: t('limitModal.limitReached');
32-
const description = isTrialExpired
33-
? t('limitModal.trialEndedDesc')
34-
: isMonthlyBudget
35-
? t('limitModal.monthlyReachedDesc')
36-
: isWeightedRefusal
37-
? t('limitModal.weightedDesc', { units, remaining })
38-
: t('limitModal.limitReachedDesc', { current: current || 0, limit: limit || 0 });
30+
const description = subscriptionLimitMessage({ code: variant, limit, current, units });
3931

4032
return (
4133
<Dialog open={open} onOpenChange={onOpenChange}>

‎src/lib/ai/error-display.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import i18n from '@/i18n';
2-
import { AIProviderError } from './client';
2+
import { AIProviderError, isAISubscriptionError } from './client';
33

44
const PROVIDER_LABELS: Record<string, string> = {
55
openai: 'OpenAI',
@@ -16,7 +16,19 @@ function providerLabel(provider?: string, message = ''): string {
1616
return i18n.t('aiErrors.genericProvider', 'AI provider');
1717
}
1818

19+
/** The limit dialog's explanation, also used where a page shows the error inline. */
20+
export function subscriptionLimitMessage({ code, limit, current, units }: { code?: string; limit?: number; current?: number; units?: number }): string {
21+
if (code === 'trial_expired') return i18n.t('limitModal.trialEndedDesc');
22+
if (code === 'monthly_budget_reached') return i18n.t('limitModal.monthlyReachedDesc');
23+
const remaining = Math.max(0, (limit || 0) - (current || 0));
24+
// A weighted model can be refused while lighter requests still fit today.
25+
if ((units ?? 1) > 1 && remaining > 0) return i18n.t('limitModal.weightedDesc', { units, remaining });
26+
return i18n.t('limitModal.limitReachedDesc', { current: current || 0, limit: limit || 0 });
27+
}
28+
1929
export function getUserFacingAIError(error: unknown): string {
30+
if (isAISubscriptionError(error)) return subscriptionLimitMessage(error);
31+
2032
if (error instanceof AIProviderError || (error instanceof Error && error.name === 'AIProviderError')) {
2133
const provider = providerLabel((error as AIProviderError).provider, error.message);
2234
const raw = error.message;

‎src/pages/SparringPage.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { ArrowLeft, CircleHelp } from 'lucide-react';
55
import { useLocation, useNavigate } from 'react-router-dom';
66
import { useSparringStore } from '@/lib/stores/sparring-store';
77
import { useSettingsStore } from '@/lib/stores/settings-store';
8-
import { generateSparringTurn, judgeSparringSession } from '@/lib/ai/client';
8+
import { generateSparringTurn, isAISubscriptionError, judgeSparringSession } from '@/lib/ai/client';
9+
import { getUserFacingAIError } from '@/lib/ai/error-display';
910
import type { SparringRespondInput, SparringScoreInput, SparringSpeech } from '@/lib/ai/schemas/sparring';
1011
import { SparringSetup, type SparringSetupValues } from '@/components/features/sparring/SparringSetup';
1112
import { SparringRound } from '@/components/features/sparring/SparringRound';
@@ -26,6 +27,7 @@ import type { SpeechDelivery } from '@/lib/voice/delivery';
2627
import { opponentVoice } from '@/lib/voice/opponent-voice';
2728

2829
function readableError(error: unknown, fallback: string) {
30+
if (isAISubscriptionError(error)) return getUserFacingAIError(error);
2931
if (error instanceof Error && error.message.trim()) return error.message;
3032
return fallback;
3133
}

0 commit comments

Comments
 (0)