Skip to content

Commit 633317c

Browse files
committed
Merge remote-tracking branch 'origin/main' into security/preship-pass
# Conflicts: # e2e/full-stack/case-generation.spec.ts # e2e/full-stack/current-debate.spec.ts # e2e/full-stack/debate-context.spec.ts # e2e/full-stack/stack-core.ts # supabase/functions/ai/index.ts
2 parents 0dae75e + 0b3e9f0 commit 633317c

74 files changed

Lines changed: 2315 additions & 297 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.claude/skills/verify-debatelab/SKILL.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ It also prints the commit the stack started at and whether there are uncommitted
8282
- 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.
8383
- `admin`: a service-role client for reading rows as a second view.
8484
- `fake` / `providerCall(feature)`: what the Edge Function actually sent upstream. Use `fake.fail(feature, status)` to force a provider error.
85-
- `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.
85+
- `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.
86+
- `expectStreamed([...])`: every provider call for those features asked the model to stream, which proves the page showed the answer live.
8687
- `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.
8788
- `evidence`: `step(line)`, `aria(name, locator?)`, `shot(name)` and `json(name, value)`.
8889
- Automatic failure on any uncaught page error, horizontal overflow, or remote Supabase request.
@@ -173,6 +174,7 @@ It never touches `.verify/artifacts/` or `.verify/logs/`, and it is safe to run
173174

174175
- Live runs are slow: sparring and crossfire take several minutes each because the model must reason. Use `-g` to run one test while iterating.
175176
- 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.
177+
- 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.
176178
- 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.
177179
- 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`.
178180
- 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.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ Each feature file starts with an H1 title and one paragraph describing the user-
4343
- [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`).
4444
- [Library sources](./library-sources.md): add, pause, delete, grounding, and URL import (`recipes/library.spec.ts`).
4545
- [AI tools](./ai-tools.md): Attack, Brainstorm, Factoids, and Ask streamed, cited, and with web search (`recipes/ai-tools.spec.ts`).
46+
- [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`).
4647
- [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`).
4748
- [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`).
4849
- [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`).

‎.claude/skills/verify-debatelab/features/ai-tools.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ Preconditions:
2727
- **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"}`.
2828
- **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.
2929

30-
- **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`.
30+
- **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`.
31+
- **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.
3132
- **Discuss.** A result's `Discuss` button opens Ask, which sends the summary as the user's message through the streaming path.
3233

3334
## Gotchas
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Live generation
2+
3+
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.
4+
5+
## Sub-features
6+
7+
- `live-generate`: Generate's draft shows `Case Structure` and later sections before the result, then the full result and its toolbar replace it.
8+
- `live-cancel`: `Cancel` on the draft stops the request; no draft, result, or error stays on the page.
9+
- `live-tools`: Attack, Brainstorm, Factoids, and cited Ask each ask for live generation and receive an event stream.
10+
- `live-practice`: Crossfire turns and debriefs and Sparring speeches and scorecards ask for live generation (checked in `recipes/practice.spec.ts` with `expectStreamed`).
11+
12+
## How to get to it (user POV)
13+
14+
- Run any AI tool: `/app/generate`, `/app/attack`, `/app/brainstorm`, `/app/factoids`, `/app/ask` with `Options` turned on, `/app/crossfire`, `/app/sparring`.
15+
- Plain Ask (no options) keeps its own streaming endpoint, `ai-stream` (see [ai-tools](./ai-tools.md)).
16+
17+
## Driving it with verify.sh + Playwright
18+
19+
Preconditions:
20+
21+
- Doctor passes. The user is signed in. `startDebate(page, motion)` avoids the motion dialog.
22+
23+
- **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.
24+
- **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)`.
25+
- **Provider.** `expectStreamed(["generate"])`: every provider call for the feature had `stream: true`. Research sub-calls (`research-decision`, `research-queries`) are never streamed.
26+
- **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.
27+
28+
## Gotchas
29+
30+
- 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.
31+
- 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`.
32+
- Never call `response.json()` on an `ai` response; use `readAIResponse` or `aiResponse`.
33+
- In voice rounds the live text has audio tags removed, as the finished reply does.

‎.claude/skills/verify-debatelab/recipes/fixtures.ts‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { expect, fake, stackReady, test as stackTest } from "../../../../e2e/ful
1212
export * from "../../../../e2e/full-stack/stack";
1313
// Scripted microphone, recogniser, and browser voice for spoken rounds.
1414
export { say, spokenByBrowser, stubSpeech } from "../../../../e2e/helpers/voice";
15+
import { readAIResponse, type AIResponseBody } from "../../../../e2e/helpers/ai-response";
16+
export { readAIResponse };
1517

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

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

60+
/**
61+
* Each feature's provider calls asked the model to stream: the page showed
62+
* the answer live instead of waiting for the whole reply.
63+
*/
64+
export async function expectStreamed(features: string[]) {
65+
const calls = await providerCalls();
66+
for (const feature of features) {
67+
const own = calls.filter((call) => call.feature === feature);
68+
expect(own.length, `provider calls for ${feature}`).toBeGreaterThan(0);
69+
expect(own.map((call) => call.stream), `${feature} calls streamed`).toEqual(own.map(() => true));
70+
}
71+
}
72+
5673
/** The provider was called for each feature, and every call succeeded. */
5774
export async function expectProviderCalls(features: string[]) {
5875
const calls = await providerCalls();
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import type { Page, Response } from "@playwright/test";
2+
import { AI_TIMEOUT, expect, expectStreamed, fake, readAIResponse, startDebate, test } from "./fixtures";
3+
4+
// Feature map: features/live-generation.md.
5+
const motion = "This House would make public transport free at the point of use";
6+
7+
/** The next `ai` request for a feature, with the body the page sent. */
8+
function aiRequest(page: Page, feature: string): Promise<Response> {
9+
return page.waitForResponse((response) => {
10+
if (!response.url().endsWith("/functions/v1/ai") || response.request().method() !== "POST") return false;
11+
try {
12+
return (response.request().postDataJSON() as { feature?: string }).feature === feature;
13+
} catch {
14+
return false;
15+
}
16+
}, { timeout: AI_TIMEOUT });
17+
}
18+
19+
/** The event stream's shape: token deltas in order, then exactly one result. */
20+
async function expectLiveStream(response: Response) {
21+
expect((response.request().postDataJSON() as { stream?: unknown }).stream, "the page asked for live generation").toBe(true);
22+
expect(response.headers()["content-type"]).toContain("text/event-stream");
23+
const text = await response.text();
24+
const events = text.split("\n\n").flatMap((block) => /^event: (\w+)$/m.exec(block)?.[1] ?? []);
25+
const deltas = events.filter((event) => event === "delta").length;
26+
expect(deltas, "token deltas").toBeGreaterThan(3);
27+
expect(events.filter((event) => event === "result"), "one result event").toHaveLength(1);
28+
expect(events.at(-1)).toBe("result");
29+
const { status, body } = await readAIResponse(response);
30+
expect(status).toBe(200);
31+
return { deltas, body };
32+
}
33+
34+
test("live-generate: a case fills in section by section while the model writes it", async ({ signedIn: page, evidence }) => {
35+
// Fake mode writes a case in about a second; slow it so the draft can be inspected.
36+
await fake.streamDelay(80);
37+
await startDebate(page, motion);
38+
await page.goto("/app/generate");
39+
await expect(page.getByRole("textbox", { name: "Motion / Topic *" })).toHaveValue(motion);
40+
const request = aiRequest(page, "generate");
41+
await page.getByRole("button", { name: "Generate Debate Case" }).click();
42+
43+
// Before the answer is complete, the draft shows what is written so far.
44+
const draft = page.getByTestId("live-draft");
45+
await expect(draft).toBeVisible({ timeout: AI_TIMEOUT });
46+
await expect(draft.getByRole("region", { name: "Case Structure" })).toBeVisible();
47+
await expect(draft.getByTestId("live-caret")).toHaveCount(1);
48+
await expect(draft.getByRole("button", { name: "Cancel" })).toBeVisible();
49+
await evidence.shot("01-writing-live");
50+
await evidence.aria("01-writing-live", draft);
51+
52+
const { deltas, body } = await expectLiveStream(await request);
53+
await evidence.step(`Generate streamed ${deltas} token deltas before its result`);
54+
const thesis = (body.output as { caseStructure: { thesis: string } }).caseStructure.thesis;
55+
// The finished case replaces the draft with the full result and its toolbar.
56+
await expect(draft).toHaveCount(0);
57+
await expect(page.getByText(thesis).first()).toBeVisible();
58+
await expect(page.getByRole("button", { name: "PDF", exact: true })).toBeVisible();
59+
await evidence.shot("02-finished-case");
60+
await expectStreamed(["generate"]);
61+
});
62+
63+
test("live-cancel: Cancel stops a live run and leaves no draft or result", async ({ signedIn: page, evidence }) => {
64+
await startDebate(page, motion);
65+
await page.goto("/app/generate");
66+
await page.getByRole("button", { name: "Generate Debate Case" }).click();
67+
const draft = page.getByTestId("live-draft");
68+
await expect(draft).toBeVisible({ timeout: AI_TIMEOUT });
69+
await draft.getByRole("button", { name: "Cancel" }).click();
70+
await expect(draft).toHaveCount(0);
71+
await expect(page.getByRole("heading", { name: "Ready to Generate" })).toBeVisible();
72+
await expect(page.getByRole("alert")).toHaveCount(0);
73+
await evidence.shot("01-canceled");
74+
});
75+
76+
test("live-tools: Attack, Brainstorm, Factoids, and cited Ask stream their answers", async ({ signedIn: page, evidence }) => {
77+
await startDebate(page, motion);
78+
79+
await page.goto("/app/attack");
80+
await page.getByLabel("Your Argument *").fill("Free transit raises ridership, so it always pays for itself.");
81+
let request = aiRequest(page, "attack");
82+
await page.getByRole("button", { name: "Find Weaknesses" }).click();
83+
await expectLiveStream(await request);
84+
await evidence.shot("01-attack");
85+
86+
await page.goto("/app/brainstorm");
87+
request = aiRequest(page, "brainstorm");
88+
await page.getByRole("button", { name: "Start Brainstorming" }).click();
89+
await expectLiveStream(await request);
90+
await evidence.shot("02-brainstorm");
91+
92+
await page.goto("/app/factoids");
93+
await page.getByLabel("Statement *").fill("Free fares increase public transport ridership.");
94+
request = aiRequest(page, "factoids");
95+
await page.getByRole("button", { name: "Find Factoids" }).click();
96+
await expectLiveStream(await request);
97+
await evidence.shot("03-factoids");
98+
99+
await page.goto("/app/ask");
100+
await page.getByRole("button", { name: "Options" }).click();
101+
await page.locator("#citation-mode").click();
102+
await page.keyboard.press("Escape");
103+
await page.getByPlaceholder("Ask your debate coach...").fill("How should I weigh access against cost?");
104+
request = aiRequest(page, "ask");
105+
await page.getByRole("button", { name: "Send message" }).click();
106+
const { body } = await expectLiveStream(await request);
107+
const answer = (body.output as { response: string }).response;
108+
await expect(page.getByText(answer.slice(0, 30)).first()).toBeVisible();
109+
await evidence.shot("04-ask-cited");
110+
111+
await expectStreamed(["attack", "brainstorm", "factoids", "ask"]);
112+
});

‎.claude/skills/verify-debatelab/recipes/practice.spec.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, expectProviderCalls, test } from "./fixtures";
1+
import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, expectProviderCalls, expectStreamed, test } from "./fixtures";
22

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

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

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

0 commit comments

Comments
 (0)