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
4 changes: 3 additions & 1 deletion .claude/skills/verify-debatelab/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ It also prints the commit the stack started at and whether there are uncommitted
- 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.
- `aiResponse(page, feature, operation?)`: the parsed body of the next `ai` Edge Function 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.
- `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.
- `evidence`: `step(line)`, `aria(name, locator?)`, `shot(name)` and `json(name, value)`.
- Automatic failure on any uncaught page error, horizontal overflow, or remote Supabase request.
Expand Down Expand Up @@ -173,6 +174,7 @@ It never touches `.verify/artifacts/` or `.verify/logs/`, and it is safe to run

- Live runs are slow: sparring and crossfire take several minutes each because the model must reason. Use `-g` to run one test while iterating.
- Vite does not reload `tailwind.config.ts` on a running stack: new theme keys, variants and the `future` flags produce no CSS until `$H down && $H up`. Edits to `src/` and `src/index.css` apply on reload as usual.
- The fake provider streams a structured answer in 24-character chunks 10 ms apart when the request asks to stream, so a Generate draft is on screen for about a second in fake mode; `fake.streamDelay(ms)` slows it for one test (the next reset restores it). Short replies (a Crossfire turn) finish in well under that, so prove those with `expectStreamed` rather than by catching the draft.
- The fake provider rejects prompts it doesn't recognize. If a prompt change makes a feature fail with a provider error, update `e2e/full-stack/fake-openrouter.mjs`. Don't mock around it.
- Once, the fake-provider container was SIGKILLed about 20 seconds after `up`, and the cause was never found. Doctor catches this, and `drive` refuses to run until you `down && up`.
- Demo-code users are Pro with a daily limit of 999. To test quota or tier paths, change `user_settings` through `admin` inside the recipe, as `e2e/full-stack/case-generation.spec.ts` does.
Expand Down
1 change: 1 addition & 0 deletions .claude/skills/verify-debatelab/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Each feature file starts with an H1 title and one paragraph describing the user-
- [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`).
- [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`).
- [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 Opus 5.5, Basic accounts on Claude Haiku 5.5 with Pro models locked, and retired model IDs move to Opus 5.5 (`recipes/model-defaults.spec.ts`).
- [Practice](./practice.md): a full Lincoln-Douglas sparring round with verdict, and Crossfire in both directions with debrief, each filed in its debate (`recipes/practice.spec.ts`).
Expand Down
3 changes: 2 additions & 1 deletion .claude/skills/verify-debatelab/features/ai-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ Preconditions:
- **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, 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…`) with a `Cancel` button, and failures stay on the page with `Retry`.
- **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`.
- **Ask, cited and with web search.** These modes answer through the structured endpoint with live generation: the reply bubble fills with the answer's text as the model writes it, and citations, sources, and follow-ups appear when it finishes.
- **Discuss.** A result's `Discuss` button opens Ask, which sends the summary as the user's message through the streaming path.

## Gotchas
Expand Down
33 changes: 33 additions & 0 deletions .claude/skills/verify-debatelab/features/live-generation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Live generation

Every AI answer appears while the model writes it. Generate, Attack, Brainstorm, and Factoids show a live draft: the result's own sections fill in token by token with a caret where text is arriving, under a `Writing live` bar with `Cancel`. Ask fills its reply bubble, a Crossfire opponent's turn and a Sparring opponent's speech appear in place as they are written, and the Crossfire debrief and Sparring scorecard fill in section by section. When the answer is complete, the finished result replaces the draft.

## Sub-features

- `live-generate`: Generate's draft shows `Case Structure` and later sections before the result, then the full result and its toolbar replace it.
- `live-cancel`: `Cancel` on the draft stops the request; no draft, result, or error stays on the page.
- `live-tools`: Attack, Brainstorm, Factoids, and cited Ask each ask for live generation and receive an event stream.
- `live-practice`: Crossfire turns and debriefs and Sparring speeches and scorecards ask for live generation (checked in `recipes/practice.spec.ts` with `expectStreamed`).

## How to get to it (user POV)

- Run any AI tool: `/app/generate`, `/app/attack`, `/app/brainstorm`, `/app/factoids`, `/app/ask` with `Options` turned on, `/app/crossfire`, `/app/sparring`.
- Plain Ask (no options) keeps its own streaming endpoint, `ai-stream` (see [ai-tools](./ai-tools.md)).

## Driving it with verify.sh + Playwright

Preconditions:

- Doctor passes. The user is signed in. `startDebate(page, motion)` avoids the motion dialog.

- **Draft.** Click `Generate Debate Case`. `getByTestId("live-draft")` appears, holding a region named `Case Structure`, one `live-caret`, and a `Cancel` button. It disappears when the result renders.
- **Request.** The page's `ai` request body has `stream: true`. The response is `text/event-stream`: `phase` events (`research`, `writing`, `checking`), many `delta` events with the model's raw text, an optional `reset` when a repair retry starts the answer again, and exactly one final `result` event `{status, body}` holding what a plain request would return. Read it with `readAIResponse(response)`.
- **Provider.** `expectStreamed(["generate"])`: every provider call for the feature had `stream: true`. Research sub-calls (`research-decision`, `research-queries`) are never streamed.
- **Errors.** Checks before the feature (auth, input, quota, model access) still answer with plain JSON and their real status. A failure after streaming starts arrives as a `result` with the error status, which the client turns into the same error a plain request gets.

## Gotchas

- In fake mode the Generate draft is on screen for about a second; short replies (Crossfire turns, Sparring speeches) finish too fast to catch reliably, so prove them with `expectStreamed`, not by waiting for the draft.
- A server from before live generation ignores `stream: true` and answers with JSON. The client accepts both, so a visible result alone does not prove streaming; check the content type or `expectStreamed`.
- Never call `response.json()` on an `ai` response; use `readAIResponse` or `aiResponse`.
- In voice rounds the live text has audio tags removed, as the finished reply does.
23 changes: 20 additions & 3 deletions .claude/skills/verify-debatelab/recipes/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { expect, fake, stackReady, test as stackTest } from "../../../../e2e/ful
export * from "../../../../e2e/full-stack/stack";
// Scripted microphone, recogniser, and browser voice for spoken rounds.
export { say, spokenByBrowser, stubSpeech } from "../../../../e2e/helpers/voice";
import { readAIResponse, type AIResponseBody } from "../../../../e2e/helpers/ai-response";
export { readAIResponse };

if (!stackReady) throw new Error("Stack environment missing; run recipes through `verify.sh drive`");

Expand Down Expand Up @@ -47,12 +49,27 @@ export function aiResponse(page: Page, feature: string, operation?: string) {
} catch {
return false;
}
}, { timeout: AI_TIMEOUT }).then(async (response) => {
expect(response.status(), `ai ${feature} status`).toBe(200);
return response.json() as Promise<{ output: Record<string, unknown>; discoveredSources?: unknown[]; [key: string]: unknown }>;
}, { timeout: AI_TIMEOUT }).then(async (response): Promise<AIResponseBody> => {
// Pages stream the answer live; the final event holds the status and body.
const { status, body } = await readAIResponse(response);
expect(status, `ai ${feature} status`).toBe(200);
return body;
});
}

/**
* Each feature's provider calls asked the model to stream: the page showed
* the answer live instead of waiting for the whole reply.
*/
export async function expectStreamed(features: string[]) {
const calls = await providerCalls();
for (const feature of features) {
const own = calls.filter((call) => call.feature === feature);
expect(own.length, `provider calls for ${feature}`).toBeGreaterThan(0);
expect(own.map((call) => call.stream), `${feature} calls streamed`).toEqual(own.map(() => true));
}
}

/** The provider was called for each feature, and every call succeeded. */
export async function expectProviderCalls(features: string[]) {
const calls = await providerCalls();
Expand Down
112 changes: 112 additions & 0 deletions .claude/skills/verify-debatelab/recipes/live-generation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { Page, Response } from "@playwright/test";
import { AI_TIMEOUT, expect, expectStreamed, fake, readAIResponse, startDebate, test } from "./fixtures";

// Feature map: features/live-generation.md.
const motion = "This House would make public transport free at the point of use";

/** The next `ai` request for a feature, with the body the page sent. */
function aiRequest(page: Page, feature: string): Promise<Response> {
return page.waitForResponse((response) => {
if (!response.url().endsWith("/functions/v1/ai") || response.request().method() !== "POST") return false;
try {
return (response.request().postDataJSON() as { feature?: string }).feature === feature;
} catch {
return false;
}
}, { timeout: AI_TIMEOUT });
}

/** The event stream's shape: token deltas in order, then exactly one result. */
async function expectLiveStream(response: Response) {
expect((response.request().postDataJSON() as { stream?: unknown }).stream, "the page asked for live generation").toBe(true);
expect(response.headers()["content-type"]).toContain("text/event-stream");
const text = await response.text();
const events = text.split("\n\n").flatMap((block) => /^event: (\w+)$/m.exec(block)?.[1] ?? []);
const deltas = events.filter((event) => event === "delta").length;
expect(deltas, "token deltas").toBeGreaterThan(3);
expect(events.filter((event) => event === "result"), "one result event").toHaveLength(1);
expect(events.at(-1)).toBe("result");
const { status, body } = await readAIResponse(response);
expect(status).toBe(200);
return { deltas, body };
}

test("live-generate: a case fills in section by section while the model writes it", async ({ signedIn: page, evidence }) => {
// Fake mode writes a case in about a second; slow it so the draft can be inspected.
await fake.streamDelay(80);
await startDebate(page, motion);
await page.goto("/app/generate");
await expect(page.getByRole("textbox", { name: "Motion / Topic *" })).toHaveValue(motion);
const request = aiRequest(page, "generate");
await page.getByRole("button", { name: "Generate Debate Case" }).click();

// Before the answer is complete, the draft shows what is written so far.
const draft = page.getByTestId("live-draft");
await expect(draft).toBeVisible({ timeout: AI_TIMEOUT });
await expect(draft.getByRole("region", { name: "Case Structure" })).toBeVisible();
await expect(draft.getByTestId("live-caret")).toHaveCount(1);
await expect(draft.getByRole("button", { name: "Cancel" })).toBeVisible();
await evidence.shot("01-writing-live");
await evidence.aria("01-writing-live", draft);

const { deltas, body } = await expectLiveStream(await request);
await evidence.step(`Generate streamed ${deltas} token deltas before its result`);
const thesis = (body.output as { caseStructure: { thesis: string } }).caseStructure.thesis;
// The finished case replaces the draft with the full result and its toolbar.
await expect(draft).toHaveCount(0);
await expect(page.getByText(thesis).first()).toBeVisible();
await expect(page.getByRole("button", { name: "PDF", exact: true })).toBeVisible();
await evidence.shot("02-finished-case");
await expectStreamed(["generate"]);
});

test("live-cancel: Cancel stops a live run and leaves no draft or result", async ({ signedIn: page, evidence }) => {
await startDebate(page, motion);
await page.goto("/app/generate");
await page.getByRole("button", { name: "Generate Debate Case" }).click();
const draft = page.getByTestId("live-draft");
await expect(draft).toBeVisible({ timeout: AI_TIMEOUT });
await draft.getByRole("button", { name: "Cancel" }).click();
await expect(draft).toHaveCount(0);
await expect(page.getByRole("heading", { name: "Ready to Generate" })).toBeVisible();
await expect(page.getByRole("alert")).toHaveCount(0);
await evidence.shot("01-canceled");
});

test("live-tools: Attack, Brainstorm, Factoids, and cited Ask stream their answers", async ({ signedIn: page, evidence }) => {
await startDebate(page, motion);

await page.goto("/app/attack");
await page.getByLabel("Your Argument *").fill("Free transit raises ridership, so it always pays for itself.");
let request = aiRequest(page, "attack");
await page.getByRole("button", { name: "Find Weaknesses" }).click();
await expectLiveStream(await request);
await evidence.shot("01-attack");

await page.goto("/app/brainstorm");
request = aiRequest(page, "brainstorm");
await page.getByRole("button", { name: "Start Brainstorming" }).click();
await expectLiveStream(await request);
await evidence.shot("02-brainstorm");

await page.goto("/app/factoids");
await page.getByLabel("Statement *").fill("Free fares increase public transport ridership.");
request = aiRequest(page, "factoids");
await page.getByRole("button", { name: "Find Factoids" }).click();
await expectLiveStream(await request);
await evidence.shot("03-factoids");

await page.goto("/app/ask");
await page.getByRole("button", { name: "Options" }).click();
await page.locator("#citation-mode").click();
await page.keyboard.press("Escape");
await page.getByPlaceholder("Ask your debate coach...").fill("How should I weigh access against cost?");
request = aiRequest(page, "ask");
await page.getByRole("button", { name: "Send message" }).click();
const { body } = await expectLiveStream(await request);
const answer = (body.output as { response: string }).response;
await expect(page.getByText(answer.slice(0, 30)).first()).toBeVisible();
await evidence.shot("04-ask-cited");

await expectStreamed(["attack", "brainstorm", "factoids", "ask"]);
});
6 changes: 5 additions & 1 deletion .claude/skills/verify-debatelab/recipes/practice.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, expectProviderCalls, test } from "./fixtures";
import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, expectProviderCalls, expectStreamed, test } from "./fixtures";

// Feature map: features/practice.md. Voice rounds are recipes/voice-rounds.spec.ts.
// A new account has no Current Debate, so each round first accepts the offer
Expand Down Expand Up @@ -43,6 +43,8 @@ test("spar-round and spar-verdict: a Lincoln-Douglas round is argued, judged, an
expect(session).toMatchObject({ side: "pro", debates: { motion: sparMotion } });
const calls = await expectProviderCalls(["sparring"]);
expect(calls.some((call) => call.feature === "sparring" && /SPARRING ACTION: score/m.test(call.user ?? ""))).toBe(true);
// Opponent speeches and the scorecard were shown live (features/live-generation.md).
await expectStreamed(["sparring"]);
});

async function startCrossfire(page: import("@playwright/test").Page, mode: "mode-question-ai" | "mode-ai-questions-user") {
Expand Down Expand Up @@ -80,6 +82,8 @@ test("cross-turn and cross-debrief: questioning the AI gets an answer, a Jev sig
await evidence.shot("03-debrief");
await evidence.aria("03-debrief", page.getByTestId("crossfire-debrief"));
await expectCrossfireSaved(await user.userId());
// The turn and the debrief were shown live (features/live-generation.md).
await expectStreamed(["crossfire"]);
});

test("cross-ai-questions: the AI questions the user, answers are judged, and the debrief completes", async ({ signedIn: page, user, evidence }) => {
Expand Down
Loading
Loading