From 37c1baa481e6237ed457b433b603e2bf5dfaee32 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 23 Oct 2026 13:14:43 +0000 Subject: [PATCH 1/5] Keep AI streams alive, recover dropped results, charge Auto Pro as Sonnet (T18) - Register every streamed ai and ai-stream run with EdgeRuntime.waitUntil until the last event is read, so Supabase cannot retire the worker mid-run (EarlyDrop). A run past its budget ends with a retryable timeout result instead of a cut connection. - Store each streamed run's final result in ai_run_results (new, additive table) before sending it; the client recovers it after a dropped, stalled, or unreadable stream instead of showing "check your connection". - Continue a Claude reply cut off at its cap from where it stopped (assistant prefill) instead of writing the whole answer again; no reset, no duplicate text. Other families restart as before. - Derive Auto unit costs from the candidate pools: Auto Pro reserves 2 (Sonnet), not 5. - Refund a run the student got nothing whole from, however much streamed; refund at most once; log exactly one ai_requests row per run (routing and ranking failures included). - Retry the Sparring judge once inside the request on an invalid scorecard or a transient provider failure; mark transient failures retryable and keep them out of Sentry exceptions. The round header shows an error state when scoring fails. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/verify-debatelab/SKILL.md | 2 +- .../features/case-generation.md | 2 +- .../recipes/case-generation.spec.ts | 33 +++- docs/architecture.md | 8 +- e2e/full-stack/fake-openrouter.mjs | 18 +- e2e/full-stack/stack-core.ts | 2 +- .../features/sparring/SparringRound.tsx | 4 +- src/lib/ai/errors.test.ts | 10 + src/lib/ai/errors.ts | 14 +- src/lib/ai/live.ts | 77 +++++++- src/lib/ai/request.ts | 37 +++- src/lib/ai/run-recovery.test.ts | 119 ++++++++++++ src/lib/ai/run-recovery.ts | 73 +++++++ src/lib/observability.ts | 1 + src/locales/en.json | 2 + src/locales/es.json | 2 + src/locales/hu.json | 2 + src/test/ai/live.test.ts | 52 ++++- src/test/sparring/sparring-round.test.tsx | 15 ++ supabase/README.md | 4 +- .../_shared/auto-model-router.test.ts | 11 ++ supabase/functions/_shared/background.ts | 31 +++ supabase/functions/_shared/entitlements.ts | 4 +- supabase/functions/_shared/usage-limits.ts | 22 ++- supabase/functions/ai-stream/index.ts | 10 +- supabase/functions/ai/features/sparring.ts | 76 +++++++- supabase/functions/ai/index.ts | 110 +++++++---- supabase/functions/ai/provider.ts | 170 +++++++++++++++-- supabase/functions/ai/quota-sessions_test.ts | 6 +- supabase/functions/ai/run-results.ts | 84 ++++++++ supabase/functions/ai/sparring-judge_test.ts | 179 ++++++++++++++++++ supabase/functions/ai/stream.test.ts | 144 +++++++++++++- supabase/functions/ai/stream.ts | 167 ++++++++++++---- .../20261023140000_ai_run_results.sql | 77 ++++++++ supabase/tests/account_deletion_test.sql | 1 + supabase/tests/ai_run_results_test.sql | 69 +++++++ 36 files changed, 1487 insertions(+), 151 deletions(-) create mode 100644 src/lib/ai/run-recovery.test.ts create mode 100644 src/lib/ai/run-recovery.ts create mode 100644 supabase/functions/_shared/background.ts create mode 100644 supabase/functions/ai/run-results.ts create mode 100644 supabase/functions/ai/sparring-judge_test.ts create mode 100644 supabase/migrations/20261023140000_ai_run_results.sql create mode 100644 supabase/tests/ai_run_results_test.sql diff --git a/.claude/skills/verify-debatelab/SKILL.md b/.claude/skills/verify-debatelab/SKILL.md index b56f7f983..4f9ef0e0f 100644 --- a/.claude/skills/verify-debatelab/SKILL.md +++ b/.claude/skills/verify-debatelab/SKILL.md @@ -81,7 +81,7 @@ It also prints the commit the stack started at and whether there are uncommitted - The Current Debate helpers: `startDebate(page, motion)` starts one from the header, `chooseForMotion(page, "create" | "once")` answers the motion dialog, and `debateControl(page)` is the visible header control on either viewport. - The voice helpers: `stubSpeech(page)` installs a scripted microphone, recogniser and browser voice; `say(page, words)` speaks; `spokenByBrowser(page)` reads back what the browser voice said. - `admin`: a service-role client for reading rows as a second view. -- `fake` / `providerCall(feature)`: what the Edge Function actually sent upstream. Use `fake.fail(feature, status)` to force a provider error and `fake.truncate(feature)` (fake mode only) to cut the next reply off at its length cap. Each call records the `maxTokens` it asked for. +- `fake` / `providerCall(feature)`: what the Edge Function actually sent upstream. Use `fake.fail(feature, status)` to force a provider error and `fake.truncate(feature)` (fake mode only) to cut the next reply off at its length cap. Each call records the `maxTokens` it asked for, and `continuation: true` when it continues a cut-off reply from an assistant prefill (the fake answers with the rest of its reply). - `aiResponse(page, feature, operation?)`: the parsed body of the next `ai` Edge Function response. Pages ask for live generation, so most `ai` responses are event streams; `aiResponse` and `readAIResponse(response)` read the final `result` event (or a plain JSON body) for you, so never call `response.json()` on an `ai` response. Assert that its fields reach the screen, never on model wording, so a recipe passes in both modes. `LIVE` and `AI_TIMEOUT` (30 s fake, 240 s live) let a recipe branch where a check only makes sense live, and a fake-mode branch adds an `unverified` annotation instead of passing silently. - `expectStreamed([...])`: every provider call for those features asked the model to stream, which proves the page showed the answer live. - `expectProviderCalls([...])`: what reached the provider. Each test writes `provider-calls.json`, and in live mode the fixture fails the test if any chat call was answered by a model other than the pin. diff --git a/.claude/skills/verify-debatelab/features/case-generation.md b/.claude/skills/verify-debatelab/features/case-generation.md index c2dfbdfea..be988c2b5 100644 --- a/.claude/skills/verify-debatelab/features/case-generation.md +++ b/.claude/skills/verify-debatelab/features/case-generation.md @@ -30,7 +30,7 @@ Preconditions: - **Filed in the debate.** `cases` for the user is one row whose `debates(motion)` is the motion. On a fresh browser context, `/app/history` shows it as a heading in the `Cases` tab panel, and `Load` reopens it. - **Save (one-off).** Generate with no debate and choose `chooseForMotion(page, "once")`. Click the button named `Save` (use `exact`), fill the dialog field labelled `Case title`, and click the dialog's `Save` button. `cases` for the user is `[{title, debate_id: null}]`. On a fresh browser context, `/app/history` shows `One-off and unsorted work` with a list item holding the title. - **Error.** Call `await fake.fail("generate", 500)`, start the debate, and generate. An `alert` holding `Generation Failed` stays above the results with a `Retry` button; clicking `Retry` succeeds and removes it. -- **Cut off.** Call `await fake.truncate("generate")`, start the debate, and generate. The first reply stops halfway with `finish_reason: "length"`; the Edge Function retries once with a larger cap, and the case appears with no error. `fake.requests()` has two `generate` entries, the second with a larger `maxTokens`. Fake mode only. +- **Cut off.** Call `await fake.truncate("generate")`, start the debate, and generate. The first reply stops halfway with `finish_reason: "length"`. On Claude (the stack's default and Auto) the Edge Function continues it: the second `generate` entry in `fake.requests()` has `continuation: true`, the stream carries no `reset`, and its deltas join into the case once. The case appears with no error. Other model families restart once with a larger `maxTokens` after a `reset`. Fake mode only. - **Cancel.** While a generation runs, the results column shows `Writing your case…` and a `Cancel` button. Cancel stops the request and shows no error. - **Quota.** Update `user_settings` to set `daily_limit_override: 0` through `admin`, start the debate, then generate. The dialog shows `Daily Access Paused`. `fake.requests()` has no `generate` entry. - **Regenerate a section.** After generating, click the button named `Regenerate Arguments` (the section titles are `Case Structure`, `Arguments`, `Predicted Opponent Case`, `Prepared Counters`). Hold the request with `page.route("**/functions/v1/ai", ...)` to check the in-flight state: a `status` with `Regenerating Arguments` is visible and `Regenerate Case Structure` is disabled. The toast `Section regenerated` follows. A full generation shows no toast: the result appears in place (and scrolls into view on phones). diff --git a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts index e09c0baf8..4de7ac30d 100644 --- a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts @@ -236,20 +236,35 @@ test("gen-error: a provider failure shows an error and the retry succeeds", asyn await evidence.shot("02-retried"); }); -test("gen-cutoff: a case cut off at its length cap is retried once with a larger cap and still appears", async ({ signedIn: page, evidence }) => { +test("gen-cutoff: a case cut off at its length cap is continued from where it stopped and still appears", async ({ signedIn: page, evidence }) => { test.skip(LIVE, "only the fake provider can force a cut-off reply"); await fake.truncate("generate"); await openGenerate(page); - const response = aiResponse(page, "generate"); + // Read the raw events, not just the result, to check what streamed. + const response = page.waitForResponse((candidate) => { + if (!candidate.url().endsWith("/functions/v1/ai") || candidate.request().method() !== "POST") return false; + try { + return (candidate.request().postDataJSON() as { feature?: string }).feature === "generate"; + } catch { + return false; + } + }, { timeout: AI_TIMEOUT }); await page.getByRole("button", { name: "Generate Debate Case" }).click(); - const body = await response; - await expect(page.getByText((body.output as unknown as CaseOutput).caseStructure.thesis).first()).toBeVisible(); + const events = await (await response).text(); + const result = JSON.parse(/event: result\ndata: (.+)\n\n/.exec(events)?.[1] ?? "null") as { status: number; body: { output: CaseOutput } }; + expect(result.status).toBe(200); + await expect(page.getByText(result.body.output.caseStructure.thesis).first()).toBeVisible(); await expect(page.getByRole("alert").filter({ hasText: "Generation Failed" })).toHaveCount(0); - const caps = (await fake.requests()).filter((request) => request.feature === "generate").map((request) => request.maxTokens ?? 0); - await evidence.json("01-generate-max-tokens", caps); - expect(caps).toHaveLength(2); - expect(caps[1]).toBeGreaterThan(caps[0]); - await evidence.shot("02-case-after-retry"); + // Auto Fast runs Claude, which continues the cut-off reply: no reset, and + // the browser saw each part of the case once. + expect(events).not.toContain("event: reset"); + const streamed = [...events.matchAll(/event: delta\ndata: (.+)\n\n/g)].map((match) => JSON.parse(match[1]).text).join(""); + expect(JSON.parse(streamed).caseStructure.thesis).toBe(result.body.output.caseStructure.thesis); + const calls = (await fake.requests()).filter((request) => request.feature === "generate").map((request) => ({ maxTokens: request.maxTokens ?? 0, continuation: request.continuation === true })); + await evidence.json("01-generate-calls", calls); + expect(calls).toHaveLength(2); + expect(calls.map((call) => call.continuation)).toEqual([false, true]); + await evidence.shot("02-case-after-continuation"); }); test("gen-quota: an account without quota is told why and the provider is never called", async ({ signedIn: page, user, evidence }) => { diff --git a/docs/architecture.md b/docs/architecture.md index 81951378e..71115884d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,6 +78,10 @@ Client-side environment selection is a developer convenience, not authorization. Every structured answer is shown while the model writes it. A page passes a `live` callback to its feature wrapper, and the request adds `stream: true`. Every check before the feature handler (authentication, input, debate context, quota, model routing, source ranking) still answers with plain JSON and its real status. After that, [`supabase/functions/ai/stream.ts`](../supabase/functions/ai/stream.ts) answers with server-sent events: `phase` (`research`, `writing`, `checking`), `delta` with the model's raw text, `reset` when a JSON repair retry starts the answer again, and one final `result` holding the status and body a plain request returns. Only the call whose output the user sees streams; research sub-calls and Jev decisions do not. The joined text is parsed exactly as a non-streamed reply, and streamed usage is billed the same way, marked incomplete if the stream breaks. On the client, [`src/lib/ai/live.ts`](../src/lib/ai/live.ts) reads the stream, [`packages/core/src/ai/partial-json.ts`](../packages/core/src/ai/partial-json.ts) turns the JSON written so far into a draft, and the page renders it with the result's own labels. A JSON body is accepted too, so the client works against a server from before live generation. +A streamed run keeps its worker alive with `EdgeRuntime.waitUntil` ([`_shared/background.ts`](../supabase/functions/_shared/background.ts)) from the first event until the HTTP layer has read the last one. Supabase otherwise counts the response as sent once its headers are out and may retire the worker mid-run (`EarlyDrop`). A run still unanswered about 142 s after its request arrived gets a retryable timeout `result` instead of a connection cut at the 150 s wall clock. The request sends its id as `resultKey`. The function then writes a row to `ai_run_results` when the run starts, and stores the `result` there before sending it ([`ai/run-results.ts`](../supabase/functions/ai/run-results.ts); one row per user and feature, readable only by its owner). When the stream drops, stalls (no keepalive for 45 s), or delivers a result it cannot read, [`src/lib/ai/run-recovery.ts`](../src/lib/ai/run-recovery.ts) polls that row until the run's result appears, then resolves or fails exactly as if the event had arrived. A row still without a status long after it started is a run its worker never finished. A browser Stop, reload, or closed tab still cancels the run. The server cannot tell those from a lost connection, so it stops spending as soon as the connection goes. + +A reply that hits its token cap on a Claude model is continued, not restarted. The cut-off text is sent back as the start of the model's own reply (an assistant prefill, without reasoning or a response format, which Claude does not accept with one), and the stream gets only the new text. Other model families restart once with twice the cap, after a `reset`. The Sparring judge retries once inside the request when its first scorecard fails validation, or when Claude is briefly unavailable or its stream breaks. The round has already paid for that retry. + For a validated Crossfire next turn, the server returns the reply first with a short-lived signed sidecar token. The client then requests a Jev classification of only the newest completed question-answer exchange. The token is atomically claimed through its own one-use counter, separate from the product's daily AI quota. The optional directness, concession, and follow-up signal is attached by turn ID to the answer; a missing, slow, replayed, forged, or failed sidecar never delays or invalidates the main reply. Draft version 1 remains compatible because turn signals are optional. ### Voice rounds @@ -154,11 +158,11 @@ Every model call asks for JSON. Calls whose shape the server checks in code (the `Auto Fast` and `Auto Pro` are virtual selections, not provider model IDs. Auto routes only to Claude, which runs on DebateLab's Anthropic key (BYOK in OpenRouter); other families bill OpenRouter credits, so they run only when a student picks one by hand. Auto Fast is available to Basic accounts and resolves to Claude Haiku 5.5. Auto Pro remains Pro-only and resolves to Claude Sonnet 5.5; it never routes or falls back to Opus. With one candidate per pool the Edge Functions skip the TypeSafe Jev routing call; a pool with more than one candidate (all `anthropic/`, which `src/test/ai/anthropic-only-guard.test.ts` enforces) is resolved through Jev on OpenRouter before calling the concrete writing model. Provider calls, usage rows, and response metadata record the concrete resolved model, while response metadata also retains the requested automatic selection. A Jev router call uses the same OpenRouter key and does not consume a second daily quota unit. -The daily AI quota counts units, not requests. `checkRateLimit` in [`supabase/functions/_shared/entitlements.ts`](../supabase/functions/_shared/entitlements.ts) reserves the requested model's weight through `consume_ai_quota` (Claude Opus 5.5 counts as 5, Sonnet as 2, cheap models as 1; Auto Pro counts as its most expensive candidate because the quota is reserved before routing). The same call refuses the request with `weekly_budget_reached` once the user's weekly AI spend reaches the weekly budget ($2 by default, calendar week from Monday 00:00 UTC). Weekly spend is the sum of `ai_requests.cap_cost_usd`: each request's full logged charge, including Claude calls billed to our own BYOK Anthropic key, plus Exa and ElevenLabs charges. A BYOK response without the upstream charge counts OpenRouter's fee, the part that is known. When part of a request's cost is unknown, the known part counts; rows with no known cap spend, and rows logged before the column existed, do not count. Cost is logged after the provider answers, so the cap can be passed by about one request. The weights, the budget, and the tier defaults live in [`supabase/functions/_shared/usage-limits.ts`](../supabase/functions/_shared/usage-limits.ts). Voice, import, share, and Crossfire signal quotas keep calling `consume_daily_quota`, one unit per call, and are not gated by the weekly budget (voice spend still counts toward it). +The daily AI quota counts units, not requests. `checkRateLimit` in [`supabase/functions/_shared/entitlements.ts`](../supabase/functions/_shared/entitlements.ts) reserves the requested model's weight through `consume_ai_quota` (Claude Opus 5.5 counts as 5, Sonnet as 2, cheap models as 1; an Auto selection counts as its most expensive candidate because the quota is reserved before routing. The Auto weights are derived from the candidate pools, so Auto Pro counts as Sonnet's 2 while Sonnet is its only candidate). The same call refuses the request with `weekly_budget_reached` once the user's weekly AI spend reaches the weekly budget ($2 by default, calendar week from Monday 00:00 UTC). Weekly spend is the sum of `ai_requests.cap_cost_usd`: each request's full logged charge, including Claude calls billed to our own BYOK Anthropic key, plus Exa and ElevenLabs charges. A BYOK response without the upstream charge counts OpenRouter's fee, the part that is known. When part of a request's cost is unknown, the known part counts; rows with no known cap spend, and rows logged before the column existed, do not count. Cost is logged after the provider answers, so the cap can be passed by about one request. The weights, the budget, and the tier defaults live in [`supabase/functions/_shared/usage-limits.ts`](../supabase/functions/_shared/usage-limits.ts). Voice, import, share, and Crossfire signal quotas keep calling `consume_daily_quota`, one unit per call, and are not gated by the weekly budget (voice spend still counts toward it). Crossfire and Sparring are charged per round, not per call. The round's first call reserves the model's units through `claim_ai_session_quota` ([`20261022220000_ai_quota_sessions.sql`](../supabase/migrations/20261022220000_ai_quota_sessions.sql)), keyed by the Sparring `sessionId` or the Crossfire round id the client sends as `quotaSession` beside `input`; later turns, the debrief, and the judge in that round reserve nothing. A round past its call cap or lifetime, or switched to a heavier model, pays again (`SESSION_QUOTA` in `usage-limits.ts`), so a reused id cannot buy unlimited AI. A Crossfire request without `quotaSession` (an older client) is charged per call. Before a round starts, both pages send a `quotaCheck` request, which reserves nothing and answers with the round's cost and today's count, so a round the server would refuse never starts. -A run that produces nothing costs nothing. When a request fails, times out, or the browser goes away (reload, closed tab, Stop) before `REFUND_MAX_DELIVERED_CHARS` of streamed output reached it and before its answer was saved, `ai` and `ai-stream` give its units back (`refund_daily_quota`, or `refund_ai_session_quota` for the call that paid for a round) and answer with `charged: false`, which the client shows as "You were not charged". The weekly cap still counts what the run cost. The usage line in Settings and on every tool page reads the same counter through `ai_usage_today()` (the caller's own units today), so per-round charging and refunds show there, and it is read again after each AI call (`refreshAIUsage` in `src/lib/query-client.ts`). When the counter cannot be read (an older database), the line is hidden rather than estimated from `ai_requests` rows. `ai_requests.error_code` holds a short code (`provider_error`, `timeout`, `aborted`, `claude_unavailable`, the refusal codes); `logUsage` maps anything else to one of those and sends the raw text, truncated, to the function log only. +A run that produces nothing costs nothing. When an `ai` request fails, times out, or the browser goes away (reload, closed tab, Stop, a dropped connection) before its answer was whole, and the answer was not saved, the function gives its units back. Streamed text does not count, because a half-written structured answer is a preview, not a result. A run the browser left after the model finished stays charged, and so does a run whose result was stored for recovery. For the plain-text Ask stream, `ai-stream` refunds only before `REFUND_MAX_DELIVERED_CHARS` reached the reader. A refund goes through `refund_daily_quota`, or `refund_ai_session_quota` for the call that paid for a round, at most once per run, and the answer carries `charged: false`, which the client shows as "You were not charged". The weekly cap still counts what the run cost. The usage line in Settings and on every tool page reads the same counter through `ai_usage_today()` (the caller's own units today), so per-round charging and refunds show there, and it is read again after each AI call (`refreshAIUsage` in `src/lib/query-client.ts`). When the counter cannot be read (an older database), the line is hidden rather than estimated from `ai_requests` rows. `ai_requests.error_code` holds a short code (`provider_error`, `timeout`, `aborted`, `claude_unavailable`, the refusal codes); `logUsage` maps anything else to one of those and sends the raw text, truncated, to the function log only. ## Observability diff --git a/e2e/full-stack/fake-openrouter.mjs b/e2e/full-stack/fake-openrouter.mjs index e7d64c0d0..f9d4454df 100644 --- a/e2e/full-stack/fake-openrouter.mjs +++ b/e2e/full-stack/fake-openrouter.mjs @@ -206,10 +206,16 @@ function classify(body) { const messages = Array.isArray(body.messages) ? body.messages : []; const systemMessage = messages.find((message) => message.role === "system"); const system = messageText(systemMessage); - const user = String(messages.at(-1)?.content ?? ""); + // A continuation sends the cut-off reply back as the start of the + // assistant's turn; the prompt is the user message before it. + const last = messages.at(-1); + const prefill = last?.role === "assistant" ? messageText(last) : null; + const user = String((prefill === null ? last : messages.at(-2))?.content ?? ""); const shape = { messageCount: messages.length, cacheBreakpoint: Array.isArray(systemMessage?.content) && systemMessage.content.some((part) => part?.cache_control), + continuation: prefill !== null, + prefill, }; if (body.stream === true && system.includes("Answer in clear Markdown that can stream smoothly")) { return { feature: "ask-stream", system, user, ...shape }; @@ -395,7 +401,7 @@ createServer(async (req, res) => { } const body = JSON.parse(await readBody(req)); - const { output, ...record } = classify(body); + const { output, prefill, ...record } = classify(body); const { feature } = record; requests.push({ ...record, model: body.model, stream: body.stream === true, maxTokens: body.max_tokens }); if (!feature) return json(res, 500, { error: { message: "Fake OpenRouter does not recognize this prompt" } }); @@ -411,7 +417,13 @@ createServer(async (req, res) => { const finishReason = truncation ? "length" : "stop"; if (feature === "ask-stream") return stream(res, finishReason); if (output === null) return json(res, 500, { error: { message: `Fake ${feature} could not parse the request` } }); - const content = truncation ? JSON.stringify(output).slice(0, Math.floor(JSON.stringify(output).length / 2)) : JSON.stringify(output); + const full = JSON.stringify(output); + let content = truncation ? full.slice(0, Math.floor(full.length / 2)) : full; + if (prefill !== null) { + // A model continuing a prefill writes only the rest of its reply. + if (!full.startsWith(prefill)) return json(res, 500, { error: { message: `Fake ${feature} continuation does not match its reply` } }); + content = full.slice(prefill.length); + } if (body.stream === true) return streamJson(res, content, finishReason); return json(res, 200, { id: `fake-${requests.length}`, diff --git a/e2e/full-stack/stack-core.ts b/e2e/full-stack/stack-core.ts index 0b5480f24..873656779 100644 --- a/e2e/full-stack/stack-core.ts +++ b/e2e/full-stack/stack-core.ts @@ -22,7 +22,7 @@ export const admin = stackReady ? createClient(supabaseUrl!, serviceKey!, { auth: { persistSession: false } }) : (null as never); -export type FakeRequest = { feature: string | null; model?: string; stream?: boolean; maxTokens?: number; system: string; user: string }; +export type FakeRequest = { feature: string | null; model?: string; stream?: boolean; maxTokens?: number; system: string; user: string; continuation?: boolean }; export const fake = { async reset() { diff --git a/src/components/features/sparring/SparringRound.tsx b/src/components/features/sparring/SparringRound.tsx index 47f1eb044..797a543d8 100644 --- a/src/components/features/sparring/SparringRound.tsx +++ b/src/components/features/sparring/SparringRound.tsx @@ -167,7 +167,7 @@ export function SparringRound({
{t(`sparring.format.${session.format}`, session.format.toUpperCase())} - {t('sparring.round.inProgress', 'Round in progress')} + {judging && judgingError ? t('sparring.round.scoringStopped', 'Scoring stopped') : t('sparring.round.inProgress', 'Round in progress')} {session.userSide === 'pro' ? t('sparring.round.youArePro', 'You are Pro') : t('sparring.round.youAreCon', 'You are Con')}
@@ -188,7 +188,7 @@ export function SparringRound({

{judging ? t('sparring.round.complete', 'Round complete') : currentTurn ? t('sparring.round.turnOf', 'Turn {{current}} of {{total}}', { current: session.currentTurnIndex + 1, total: session.turns.length }) : t('sparring.round.preparingResult', 'Preparing your result')}

-

{judging ? t('sparring.round.judgeReading', 'The judge is reading the transcript.') : currentTurn ? t(`sparring.turns.${currentTurn.id}`, currentTurn.label) : t('sparring.round.scoring', 'Scoring the round')}

+

{judging ? (judgingError ? t('sparring.round.judgeFailed', 'The judge could not finish scoring.') : t('sparring.round.judgeReading', 'The judge is reading the transcript.')) : currentTurn ? t(`sparring.turns.${currentTurn.id}`, currentTurn.label) : t('sparring.round.scoring', 'Scoring the round')}