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/features/current-debate.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Preconditions:
- **Scope.** Add a source on `/app/library` with a unique marker (see [library-sources](./library-sources.md)); the test id `library-debate` names the motion. Run Brainstorm or Generate: the motion field already holds the debate's motion and no dialog opens. End state: the newest `providerCalls()` entry for the feature has this debate's marker in `system` and not another debate's. `sources.debate_id`, `cases.debate_id`, and `brainstorms.debate_id` point at the debate they were made in.
- **Offer.** With no debate, fill a tool's motion and submit. The dialog `Start a debate with this motion?` contains the motion. Click its `Start debate`. End state: the result shows, the strip reads `Working in: <motion>`, and `debates` holds exactly that motion.
- **Use once.** With a debate, submit a different motion. The dialog `This is a different motion` opens. `chooseForMotion(page, "once")`. End state: the provider call's `system` has no debate marker, and the header still names the old debate.
- **Dashboard.** `/app/debate`: `current-debate-motion` has the motion, `debate-side-pro` reads `In favor`, `debate-side-con` reads `Against`, and `debate-sources` and `debate-recent` list only this debate's work.
- **Dashboard.** `/app/debate`: `current-debate-motion` has the motion, `debate-side-pro` reads `In favor`, `debate-side-con` reads `Against`, and `debate-sources` and `debate-recent` list only this debate's work. Each row reads `<kind> · <side> · <time>` (a side column leaves out its own side), and its Load button's name ends with the title, kind and side.
- **Switch.** Click `debateControl(page)`, then the dialog's `Switch debate`, then `Switch to <motion>`. End state: the Library shows only that debate's sources. In History, the `Brainstorms` and `Cases` tabs show only its results.
- **Tour.** Seed `debate-trainer-onboarding` with `hasCompletedOnboarding: false` once per session (see the recipe), sign in, and click the tooltip's `Start debate`. Fill `Motion`, click `Start debate`, click `[data-test-id="button-primary"]` twice, then `Get started`. End state: one `debates` row is current, and Generate opens prefilled with no dialog.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ test("debate-start, debate-scope, debate-once, debate-switch: two debates keep t
await expect(page.getByTestId("debate-sources")).toContainText("Zoo study");
await expect(page.getByTestId("debate-sources")).not.toContainText("Homework study");
await expect(page.getByTestId("debate-recent")).toContainText(ZOOS);
// Every row is titled with the motion, so the side tells a Pro case from a Con one.
await expect(page.getByTestId("debate-recent")).toContainText("Case · In favor");
await evidence.shot("05-zoo-dashboard");

// Switching back switches the Library and History with it.
Expand Down
25 changes: 18 additions & 7 deletions .github/workflows/agentic-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,9 @@ jobs:
needs: plan-explore
runs-on: ubuntu-latest
timeout-minutes: 45
# Findings are leads to reproduce, not verdicts: a red exploration does not
# fail the workflow. docs/testing.md has the bug-bash procedure.
continue-on-error: true
# Findings are leads to reproduce, not verdicts, so they pass with a warning;
# a broken run (crash, missing key, stack down) fails the job.
# docs/testing.md has the bug-bash procedure.
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -198,10 +198,21 @@ jobs:
GOAL: ${{ matrix.leg.goal }}
PERSONA: ${{ matrix.leg.persona }}
TARGET: ${{ matrix.leg.target }}
run: >-
.claude/skills/verify-debatelab/bin/verify.sh e2e explore "$GOAL"
--session student --agent "$PERSONA" --target "$TARGET"
--trace=on --video=on --reporter list,markdown
# e2e explore exits 1 when it reports an issue, and also when no step ran.
# Only the first is the bug bash's output; every other non-zero exit is
# the harness or the stack failing.
run: |
status=0
.claude/skills/verify-debatelab/bin/verify.sh e2e explore "$GOAL" \
--session student --agent "$PERSONA" --target "$TARGET" \
--trace=on --video=on --reporter list,markdown || status=$?
report=$(ls -d .verify/artifacts/*-e2e-explore-*/report.json 2>/dev/null | tail -n 1)
issues=$(jq '[.run.explore.findings[]? | select(.kind == "issue")] | length' "$report" 2>/dev/null || echo 0)
if [ "$status" -eq 1 ] && [ "${issues:-0}" -gt 0 ]; then
echo "::warning title=Exploration findings::$PERSONA on $TARGET reported $issues issues to reproduce; see the job summary."
exit 0
fi
exit "$status"

- name: Publish the report
if: always()
Expand Down
2 changes: 1 addition & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ To write a test, import `test`, `expect`, and the fixtures from [`agentic/suppor

A bug bash runs several explorations in parallel, one charter per area and persona, then proves each finding with a repro test. Ask an agent to "bug bash <area>"; the `e2e` skill's `bug-bash` topic has the procedure. A finding counts only when its repro test fails for the reported reason. Tag repro tests `{ tags: ["repro"] }` so the scheduled suite skips them until the fix lands, then remove the tag so they become regression tests. The client runs on a Vite dev server, so a route that is still compiling can look like a dead link to an explorer. Reproduce before you believe it.

[`.github/workflows/agentic-e2e.yml`](../.github/workflows/agentic-e2e.yml) runs the suite on Wednesdays at 03:47 UTC and on demand (`gh workflow run agentic-e2e.yml --ref <branch>`), never on pull requests, in four jobs: the page sweep and everything else, on each viewport. On the 1st of each month at 05:13 UTC it runs a bug bash instead: one exploration per persona, each with its own charter. On demand with a goal, it runs one exploration with the chosen persona and viewport. It uses the `LIVE_OPENROUTER_API_KEY` secret (the `E2E_AGENT_MODEL` and `E2E_JUDGE_MODEL` repository variables override the models), fails when the key is missing, and writes the report to the job summary. Explorations never fail the workflow: their findings are leads to reproduce. The suite is not a required check: model judgments can flake, and a red weekly run is a finding to triage.
[`.github/workflows/agentic-e2e.yml`](../.github/workflows/agentic-e2e.yml) runs the suite on Wednesdays at 03:47 UTC and on demand (`gh workflow run agentic-e2e.yml --ref <branch>`), never on pull requests, in four jobs: the page sweep and everything else, on each viewport. On the 1st of each month at 05:13 UTC it runs a bug bash instead: one exploration per persona, each with its own charter. On demand with a goal, it runs one exploration with the chosen persona and viewport. It uses the `LIVE_OPENROUTER_API_KEY` secret (the `E2E_AGENT_MODEL` and `E2E_JUDGE_MODEL` repository variables override the models), fails when the key is missing, and writes the report to the job summary. Findings never fail the workflow: an exploration that reports issues (`e2e explore` exit 1) passes with a warning annotation, because its findings are leads to reproduce. Any other non-zero exit, or exit 1 with no issue recorded, is the harness or the stack failing, and fails the job. The suite is not a required check: model judgments can flake, and a red weekly run is a finding to triage.

e2e is pre-1.0 and its minor releases have broken APIs, so it is pinned exactly. Read its changelog before upgrading, refresh the vendored skill as its header says, and expect the replay cache to re-record after a minor bump.

Expand Down
2 changes: 2 additions & 0 deletions e2e/full-stack/current-debate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ test("two debates keep their motions, sources, and results apart", async ({ sign
await expect(page.getByTestId("debate-sources")).toContainText("Zoo study");
await expect(page.getByTestId("debate-sources")).not.toContainText("Homework study");
await expect(page.getByTestId("debate-recent")).toContainText(ZOOS);
// Every row is titled with the motion, so the side tells a Pro case from a Con one.
await expect(page.getByTestId("debate-recent")).toContainText("Case · In favor");
await snap(page, "4-zoo-dashboard");

// Switching back switches the Library and History with it.
Expand Down
19 changes: 19 additions & 0 deletions e2e/quality-backlog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,22 @@ test('language menu closes once a language is chosen', async ({ page }) => {
await expect(page.getByRole('radiogroup')).toHaveCount(0);
await expect(page.locator('html')).toHaveAttribute('lang', 'hu');
});

// Bug bash 2026-11-01: a motion or argument of only spaces passed validation and spent an AI request.
test('a motion or argument of only spaces counts as blank', async ({ page }) => {
await stubSupabaseSession(page);
const aiCalls: string[] = [];
page.on('request', (request) => { if (request.url().includes('/functions/v1/ai')) aiCalls.push(request.url()); });

await page.goto('/app/generate');
await page.getByLabel('Motion / topic *').fill(' ');
await page.getByRole('button', { name: 'Generate debate case' }).click();
await expect(page.getByText('Motion is required')).toBeVisible();

await page.goto('/app/attack');
await page.getByRole('textbox', { name: /argument/i }).first().fill(' ');
await page.getByRole('button', { name: 'Find weaknesses' }).click();
await expect(page.getByText('Case text is required')).toBeVisible();

expect(aiCalls).toEqual([]);
});
2 changes: 1 addition & 1 deletion packages/core/src/ai/schemas/attack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { z } from 'zod';

export const AttackInputSchema = z.object({
// A catalog key, not text: the form shows it in the interface language.
caseText: z.string().min(1, 'validation.caseTextRequired'),
caseText: z.string().trim().min(1, 'validation.caseTextRequired'),
aggressiveness: z.enum(['mild', 'moderate', 'ruthless']),
judgeParadigm: z.enum(['logic', 'values', 'policy']),
detectFallacies: z.boolean(),
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/ai/schemas/brainstorm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { z } from 'zod';

export const BrainstormInputSchema = z.object({
// A catalog key, not text: the form shows it in the interface language.
motion: z.string().min(1, 'validation.motionRequired'),
motion: z.string().trim().min(1, 'validation.motionRequired'),
side: z.enum(['pro', 'con', 'both']),
context: z.string().optional(),
outputMode: z.enum(['mindmap', 'buckets', 'topfive']),
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/ai/schemas/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { z } from 'zod';

export const GenerateInputSchema = z.object({
// A catalog key, not text: the form shows it in the interface language.
motion: z.string().min(1, 'validation.motionRequired'),
motion: z.string().trim().min(1, 'validation.motionRequired'),
side: z.enum(['pro', 'con']),
format: z.enum(['custom', 'bp', 'policy', 'classroom']), // Kept others for backward compatibility but UI will restrict
role: z.enum(['intro', 'r1', 'r2', 'conc', 'general']),
Expand Down
2 changes: 1 addition & 1 deletion src/components/features/sparring/SparringRound.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ export function SparringRound({
{!judging && <ClockAnnouncer secondsLeft={session.timerRemainingSeconds} running={session.timerRunning} />}
</div>
</div>
<Progress value={judging ? 0 : timerProgress} className={cn('mt-5 h-2', tone === 'over' ? '[&>div]:bg-destructive' : isUrgent ? '[&>div]:bg-amber-500' : '[&>div]:bg-primary')} aria-label={t('sparring.round.timeRemaining', 'Time remaining')} />
<Progress value={judging ? 0 : timerProgress} className={cn('mt-5 h-2', tone === 'over' ? '[&>div]:bg-destructive' : isUrgent ? '[&>div]:bg-amber-500' : '[&>div]:bg-primary')} aria-label={t('sparring.round.timeRemaining', '{{time}} remaining', { time: formatRoundTime(session.timerRemainingSeconds) })} />
{awaitingStart && (
<div className="mt-4 flex flex-wrap items-center gap-3" data-testid="sparring-ready">
{!voice && (
Expand Down
13 changes: 9 additions & 4 deletions src/pages/CurrentDebatePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,22 +28,27 @@ function ViewAll({ to, count, shown }: { to: string; count: number; shown: numbe
);
}

function ItemRow({ item }: { item: DebateItem }) {
// Every row's title is the motion, so the side is what tells a Pro case from a Con case saved the same minute.
// A side column omits its own side; a both-sides item still says so, since it is listed in both columns.
function ItemRow({ item, omitSide }: { item: DebateItem; omitSide?: 'pro' | 'con' }) {
const { t } = useTranslation();
const openItem = useOpenDebateItem();
const canOpen = item.kind !== 'crossfire';
const showSide = item.side && item.side !== 'neutral' && item.side !== omitSide;
return (
<li className="flex items-center justify-between gap-3 border-b border-border/60 py-2 last:border-b-0">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{item.title}</p>
<p className="text-xs text-muted-foreground">
{t(`debate.kinds.${item.kind}`)} · {formatEuropeanDateTime(item.createdAt)}
{t(`debate.kinds.${item.kind}`)}
{showSide && <> · {t(sideLabelKey(item.side!))}</>}
{' · '}{formatEuropeanDateTime(item.createdAt)}
</p>
</div>
{/* The name starts with the visible word (WCAG 2.5.3) and ends with which item it loads. */}
{canOpen && (
<Button variant="ghost" size="sm" className="shrink-0" onClick={() => openItem(item)}>
{t('historyPage.load', 'Load')}<span className="sr-only"> {item.title}</span>
{t('historyPage.load', 'Load')}<span className="sr-only"> {item.title}, {t(`debate.kinds.${item.kind}`)}{item.side && item.side !== 'neutral' ? `, ${t(sideLabelKey(item.side))}` : ''}</span>
</Button>
)}
</li>
Expand All @@ -62,7 +67,7 @@ function SideSection({ side, items }: { side: 'pro' | 'con'; items: DebateItem[]
<CardContent>
{relevant.length === 0
? <p className="text-sm text-muted-foreground">{t('debate.sideEmpty', 'No work for this side yet.')}</p>
: <ul>{relevant.slice(0, 4).map((item) => <ItemRow key={`${item.kind}-${item.id}`} item={item} />)}</ul>}
: <ul>{relevant.slice(0, 4).map((item) => <ItemRow key={`${item.kind}-${item.id}`} item={item} omitSide={side} />)}</ul>}
<ViewAll to="/app/history" count={relevant.length} shown={4} />
</CardContent>
</Card>
Expand Down
Loading