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
2 changes: 1 addition & 1 deletion .claude/skills/verify-debatelab/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- `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.
- `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.
Expand Down
4 changes: 2 additions & 2 deletions .claude/skills/verify-debatelab/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ Each feature file starts with an H1 title and one paragraph describing the user-
## Features

- [Demo-code sign-in](./auth-demo-login.md): sign-in, reload, rejected code, sign-out (`e2e/full-stack/auth.spec.ts`), and email one-time-code sign-in through Mailpit (`recipes/auth.spec.ts`).
- [Case generation](./case-generation.md): generate, web research, save and reopen from History on a fresh device, PDF and DOCX export, single-section regeneration, provider failure and retry, quota block (`recipes/case-generation.spec.ts`).
- [Case generation](./case-generation.md): generate, web research, save and reopen from History on a fresh device, PDF and DOCX export, single-section regeneration, provider failure and retry, a reply cut off at its length cap, quota block (`recipes/case-generation.spec.ts`).
- [Library sources](./library-sources.md): add, pause, delete, grounding, and URL import (`recipes/library.spec.ts`).
- [AI tools](./ai-tools.md): Attack, Brainstorm, Factoids, and Ask streamed, cited, and with web search (`recipes/ai-tools.spec.ts`).
- [AI tools](./ai-tools.md): Attack, Brainstorm, Factoids, and Ask streamed, cut off at its length cap, cited, and with web search (`recipes/ai-tools.spec.ts`).
- [Live generation](./live-generation.md): every AI tool shows its answer while the model writes it, Cancel mid-stream, and the event stream's shape (`recipes/live-generation.spec.ts`); Crossfire and Sparring requests stream too (`recipes/practice.spec.ts`).
- [Current Debate](./current-debate.md): start, the first-debate offer and the different-motion choice, sources and results scoped to a debate, switching, and the first onboarding step (`recipes/current-debate.spec.ts`); debate context review and pins (`e2e/full-stack/debate-context.spec.ts`).
- [Model defaults](./model-defaults.md): Pro accounts start on Claude Sonnet 5.5 with Opus 5.5 as an opt-in, Basic accounts on Claude Haiku 5.5 with Pro models locked, and retired model IDs move to their successor (`recipes/model-defaults.spec.ts`).
Expand Down
1 change: 1 addition & 0 deletions .claude/skills/verify-debatelab/features/ai-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Preconditions:
- **Brainstorm.** On `/app/brainstorm`, fill the field labelled `Motion / Topic *` and click `Start Brainstorming`. With no Current Debate, the dialog `Start a debate with this motion?` opens first: `chooseForMotion(page, "create")` (or `"once"`). The text `FAKE-PROVIDER idea 1` appears. `providerCall("brainstorm").user` contains `Motion: <motion>`, and the `brainstorms` row is filed in the new debate. With a Current Debate, the field is prefilled and no dialog opens unless the motion differs.
- **Factoids.** On `/app/factoids`, fill the field labelled `Statement *` and click `Find Factoids`. The text `FAKE-PROVIDER search suggestion` appears.
- **Ask, streamed.** On `/app/ask`, fill the placeholder `Ask your debate coach...` and click the button named `Send message`. The text `FAKE-PROVIDER streamed answer: ...` appears. `fake.requests()` features contain `ask-stream` and not `ask`. `ai_requests` gets `{feature: "ask", status: "success"}`.
- **Ask, cut off.** Call `await fake.truncate("ask-stream")` and send. The stream ends with `finish_reason: "length"`, its `final` event's output carries `replyTruncated: true`, and the partial answer stays with `This answer hit its length limit and stops early...` under `Uncertainties:`. No `Response was interrupted` toast. Fake mode only.
- **Ask, cited.** Open the `Options` button above the message box, click `#citation-mode`, close it with Escape, then send. The text `FAKE-PROVIDER answer` appears, and `providerCall("ask")` fired.

- **Stop and errors.** While Ask answers, the send button becomes `Stop answering`; Stop keeps the partial reply and returns focus to the box, which stays editable throughout. A failed request leaves an `alert` above the box with `Retry`, which asks again without adding the question twice. The four form tools show one status line (`Reviewing your case…`, `Finding angles…`, `Searching for evidence…`, `Writing your case…`, or `Researching sources…` while web research runs) with a `Cancel` button until the model starts writing, then the live draft (see [live-generation](./live-generation.md)). Failures stay on the page with `Retry`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,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.
- **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).
Expand Down
20 changes: 19 additions & 1 deletion .claude/skills/verify-debatelab/recipes/ai-tools.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, expectProviderCalls, LIVE, providerCalls, test } from "./fixtures";
import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, expectProviderCalls, fake, LIVE, providerCalls, test } from "./fixtures";

// Feature map: features/ai-tools.md.

Expand Down Expand Up @@ -91,6 +91,24 @@ test("ask-stream: Ask streams a coaching answer and records usage", async ({ sig
}, { timeout: AI_TIMEOUT }).toContainEqual(expect.objectContaining({ feature: "ask", status: "success" }));
});

test("ask-cutoff: a streamed answer cut off at its length cap says so under the answer", async ({ signedIn: page, evidence }) => {
test.skip(LIVE, "only the fake provider can force a cut-off reply");
await fake.truncate("ask-stream");
await page.goto("/app/ask");
await page.getByPlaceholder("Ask your debate coach...").fill("How should I weigh access against cost?");
const stream = page.waitForResponse((response) => response.url().endsWith("/functions/v1/ai-stream"), { timeout: AI_TIMEOUT });
await page.getByRole("button", { name: "Send message" }).click();
const events = await (await stream).text();
expect(events).toContain('"replyTruncated":true');
// The partial answer stays, with the notice under it rather than an error.
await expect(page.getByText("weigh access against cost.").first()).toBeVisible();
await expect(page.getByText("This answer hit its length limit and stops early.", { exact: false })).toBeVisible();
await expect(page.getByText("Response was interrupted")).toHaveCount(0);
await evidence.shot("01-cut-off-notice");
const features = (await providerCalls()).map((call) => call.feature);
expect(features).toEqual(["ask-stream"]);
});

test("ask-cited: Ask in citation mode uses the structured endpoint", async ({ signedIn: page, evidence }) => {
await page.goto("/app/ask");
// The Ask switches live in the Options popover above the message box.
Expand Down
16 changes: 16 additions & 0 deletions .claude/skills/verify-debatelab/recipes/case-generation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,22 @@ 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.skip(LIVE, "only the fake provider can force a cut-off reply");
await fake.truncate("generate");
await openGenerate(page);
const response = aiResponse(page, "generate");
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();
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");
});

test("gen-quota: an account without quota is told why and the provider is never called", async ({ signedIn: page, user, evidence }) => {
await admin.from("user_settings").update({ daily_limit_override: 0 }).eq("user_id", await user.userId());
await openGenerate(page);
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ Supabase Auth owns user sessions. Protected routes provide a client navigation b

Checkout and portal functions create Stripe sessions after authenticated ownership and redirect validation. The webhook updates subscription state and records event processing for idempotency. AI handlers record the provider-reported usage and charge for each request.

All model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`. The shared ten-model catalog, its defaults (Claude Haiku 5.5 for Basic, Claude Sonnet 5.5 for Pro; Claude Opus 5.5 runs only when picked), Pro-only entries, retired-ID successors, and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. The opponent's voice is the one exception to the OpenRouter route: the `voice` function calls ElevenLabs with `ELEVENLABS_API_KEY`. ElevenLabs reports billed characters, not dollars, so [`supabase/functions/_shared/cost.ts`](../supabase/functions/_shared/cost.ts) prices those rows from a dated list-price snapshot; promotions and plan credits are not modelled, so a voice row can overstate the charge.
All model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`. The shared ten-model catalog, its defaults (Claude Haiku 5.5 for Basic, Claude Sonnet 5.5 for Pro; Claude Opus 5.5 runs only when picked), Pro-only entries, retired-ID successors, and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). Each feature's reply length cap lives in [`supabase/functions/_shared/output-caps.ts`](../supabase/functions/_shared/output-caps.ts); `max_tokens` adds room for the reasoning effort on top. A JSON reply cut off at its cap is retried once with twice the budget, and a streamed Ask answer cut off there ends with a notice under it. OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. The opponent's voice is the one exception to the OpenRouter route: the `voice` function calls ElevenLabs with `ELEVENLABS_API_KEY`. ElevenLabs reports billed characters, not dollars, so [`supabase/functions/_shared/cost.ts`](../supabase/functions/_shared/cost.ts) prices those rows from a dated list-price snapshot; promotions and plan credits are not modelled, so a voice row can overstate the charge.

`Auto Fast` and `Auto Pro` are virtual selections, not provider model IDs. The Edge Functions resolve them through TypeSafe Jev on OpenRouter before calling the concrete writing model. Auto Fast is available to Basic accounts, routes only among the Basic models (Claude Haiku 5.5, GPT-6 Luna, Muse Spark 1.3 Contributor), and normally resolves to Claude Haiku 5.5. Auto Pro remains Pro-only, routes between Claude Sonnet 5.5 and GPT-6.1 Sol, and normally resolves to Claude Sonnet 5.5; it never routes or falls back to Opus. 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.

Expand Down
Loading
Loading