Skip to content

Commit 0901f93

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix158
# Conflicts: # src/lib/stores/session-store.ts
2 parents d141363 + 4f76712 commit 0901f93

27 files changed

Lines changed: 588 additions & 51 deletions

‎docs/architecture.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,12 @@ A configured mock provider bypasses Supabase for local and test behavior. It is
9090

9191
Shared research, source ranking, URL validation, and prompt construction live under [`supabase/functions/_shared/`](../supabase/functions/_shared/).
9292

93+
Research makes two small model calls of its own: Ask decides whether a question needs web search, and deep research writes follow-up search queries. Both run on `BACKGROUND_MODEL` (Claude Haiku 5.5 at low effort) in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts), not on the model the user picked; only the answer uses that model. Their usage is still added to the parent request's row.
94+
9395
The import endpoint validates public HTTP URLs, resolves redirect targets, blocks private networks, and enforces response timeout, content type, and size limits. Research paths bound source count and content before prompt construction.
9496

97+
[`supabase/functions/_shared/prompt-budget.ts`](../supabase/functions/_shared/prompt-budget.ts) holds the limits that set how many input tokens a request pays for: characters kept per fetched page, search snippets per research prompt, earlier Ask messages, and the Ask context cap. A research prompt lists each source once: a page read in full is not repeated as a search snippet, and a URL that several queries found appears a single time. For `anthropic/*` models the system prompt carries a `cache_control` breakpoint, so a repeat of the same prefix within five minutes is billed at the cache-read price; a prompt below the model's minimum cacheable length is sent uncached at the normal price.
98+
9599
Every AI request in a Current Debate carries a `debateContext` of IDs and preferences, never text: the debate, the side the request prepares, the Library source IDs, and the saved results to read. [`src/lib/debate/context.ts`](../src/lib/debate/context.ts) chooses the default with the rules in [`supabase/functions/_shared/debate-context.ts`](../supabase/functions/_shared/debate-context.ts): the debate's active sources, pinned results first, then up to three of the newest results of the types that feature uses, for the requested side. A compact line above each tool's primary button shows that selection, and Review changes it for one request, or for a whole Crossfire or Sparring round, where Review appears only before the round starts; pins persist in `debate_context_pins`. [`supabase/functions/_shared/debate-context-loader.ts`](../supabase/functions/_shared/debate-context-loader.ts) loads the motion, settings, instructions, sources, and result digests with every query filtered by the caller and the one debate, before quota or provider work. The prompt renders that material in delimited blocks followed by rules that it cannot override. Live sparring stays transcript-only: the scorecard sees only the motion and settings. Crossfire omits free-form instructions. Whole results are dropped when their budget is spent, and a Crossfire round that would pass its 100,000-character prompt budget drops its saved results. In both cases `meta.debateContext` reports what reached the model and what was set aside. A debate deleted on another device returns 404, and the browser reloads its debate list. One-off requests send no debate context. The retired per-user custom system prompt is no longer sent or honored; operator prompts in `global_prompts` remain. Jev scores relevance, importance, and evidence strength after the existing deterministic quality floor, while failures preserve the prior order. Generate also records a bounded source-use snapshot in the saved case output. Semantic counts and the latest case-use flag live in the existing source `metadata` JSON, so the Library can default to Smart ordering without a schema migration.
96100

97101
Ordinary Ask remains stateless while a conversation is in progress. The browser sends only the newest eight messages within a UTF-8 byte budget. Inside a debate, each finished exchange is recorded into one chat for that debate; in one-off mode, pressing Save persists the chat and its messages; Workspace threads use their separate durable server-side context path. Legacy local-only sources are not sent to the authenticated AI endpoint because they have no persisted UUID to authorize and load.

‎e2e/full-stack/fake-openrouter.mjs‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -176,16 +176,28 @@ const routes = [
176176
["ask", "You are a debate coach chat bot", ask],
177177
];
178178

179+
// A cacheable system prompt arrives as text parts instead of a plain string.
180+
function messageText(message) {
181+
const content = message?.content;
182+
if (Array.isArray(content)) return content.map((part) => part?.text ?? "").join("");
183+
return String(content ?? "");
184+
}
185+
179186
function classify(body) {
180187
const messages = Array.isArray(body.messages) ? body.messages : [];
181-
const system = String(messages.find((message) => message.role === "system")?.content ?? "");
188+
const systemMessage = messages.find((message) => message.role === "system");
189+
const system = messageText(systemMessage);
182190
const user = String(messages.at(-1)?.content ?? "");
191+
const shape = {
192+
messageCount: messages.length,
193+
cacheBreakpoint: Array.isArray(systemMessage?.content) && systemMessage.content.some((part) => part?.cache_control),
194+
};
183195
if (body.stream === true && system.includes("Answer in clear Markdown that can stream smoothly")) {
184-
return { feature: "ask-stream", system, user };
196+
return { feature: "ask-stream", system, user, ...shape };
185197
}
186198
const route = routes.find(([, marker]) => system.includes(marker));
187-
if (!route) return { feature: null, system, user };
188-
return { feature: route[0], system, user, output: route[2](user, system) };
199+
if (!route) return { feature: null, system, user, ...shape };
200+
return { feature: route[0], system, user, ...shape, output: route[2](user, system) };
189201
}
190202

191203
function jevAnswers(questions) {
@@ -358,8 +370,9 @@ createServer(async (req, res) => {
358370
}
359371

360372
const body = JSON.parse(await readBody(req));
361-
const { feature, system, user, output } = classify(body);
362-
requests.push({ feature, model: body.model, stream: body.stream === true, system, user });
373+
const { output, ...record } = classify(body);
374+
const { feature } = record;
375+
requests.push({ ...record, model: body.model, stream: body.stream === true });
363376
if (!feature) return json(res, 500, { error: { message: "Fake OpenRouter does not recognize this prompt" } });
364377

365378
const failure = failures.find((item) => item.count > 0 && (!item.feature || item.feature === feature));

‎src/lib/stores/session-store.ts‎

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,12 @@ export interface SessionState {
172172
setFactoidsForm: (data: Partial<FactoidsInput>) => void;
173173

174174
setGenerateResult: (result: GenerateOutput | null) => void;
175+
/**
176+
* Debate the current Generate result belongs to. Null is one-off work.
177+
* Undefined means a result restored before this was recorded.
178+
*/
179+
generateResultDebateId: string | null | undefined;
180+
setGenerateResultDebateId: (debateId: string | null) => void;
175181
setGenerateLoading: (loading: boolean) => void;
176182
setGenerateRegeneratingSection: (section: string | null) => void;
177183
saveCurrentCase: (title: string) => Promise<void>;
@@ -316,6 +322,7 @@ function sessionWorkForAccount(ownerUserId: string | null): Partial<SessionState
316322
askStreamingAssistant: null,
317323
pendingFlowchartData: null,
318324
generateForm: {},
325+
generateResultDebateId: undefined,
319326
attackForm: {},
320327
brainstormForm: {},
321328
factoidsForm: {},
@@ -541,11 +548,13 @@ export const useSessionStore = create<SessionState>()(
541548
sourceLibrary: [] as Source[],
542549

543550
generateForm: {},
551+
generateResultDebateId: undefined as string | null | undefined,
544552
attackForm: {},
545553
brainstormForm: {},
546554
factoidsForm: {},
547555

548556
setGenerateForm: (data) => set((state) => ({ generateForm: mergeFormState(state.generateForm, data) })),
557+
setGenerateResultDebateId: (debateId) => set({ generateResultDebateId: debateId }),
549558
setAttackForm: (data) => set((state) => ({ attackForm: mergeFormState(state.attackForm, data) })),
550559
setBrainstormForm: (data) => set((state) => ({ brainstormForm: mergeFormState(state.brainstormForm, data) })),
551560
setFactoidsForm: (data) => set((state) => ({ factoidsForm: mergeFormState(state.factoidsForm, data) })),
@@ -895,13 +904,37 @@ export const useSessionStore = create<SessionState>()(
895904
set((state) => ({ savedCases: [savedItem, ...state.savedCases] }));
896905
},
897906
deleteSavedCase: async (id) => {
898-
set((state) => ({ savedCases: state.savedCases.filter((c) => c.id !== id) }));
899-
const userId = await getUserId();
900-
await applyUserScopedIdFilter(supabase.from('cases').delete(), userId, id);
907+
const previous = get().savedCases;
908+
set({ savedCases: previous.filter((item) => item.id !== id) });
909+
try {
910+
const userId = await getUserId();
911+
// A missing session must not report success for a row the server still has.
912+
if (!userId) throw new Error('[SessionStore] Could not confirm the signed-in user to delete a case');
913+
throwOnWriteError(
914+
await applyUserScopedIdFilter(supabase.from('cases').delete(), userId, id),
915+
'delete case',
916+
);
917+
} catch (error) {
918+
set({ savedCases: previous });
919+
throw error;
920+
}
901921
},
902922
loadSavedCase: (id) => {
903923
const item = get().savedCases.find((c) => c.id === id);
904-
if (item) set({ generateResult: item.data });
924+
if (!item) return;
925+
const motion = item.data.metadata?.motion?.trim();
926+
const side = item.data.metadata?.side;
927+
set({
928+
generateResult: item.data,
929+
// Null means this case is one-off, so a later regenerate does not
930+
// borrow whichever debate happens to be open.
931+
generateResultDebateId: item.debateId ?? null,
932+
generateForm: {
933+
...get().generateForm,
934+
...(motion ? { motion } : {}),
935+
...(side ? { side } : {}),
936+
},
937+
});
905938
},
906939

907940
setAttackResult: (result) => {
@@ -1307,6 +1340,7 @@ export const useSessionStore = create<SessionState>()(
13071340
askPersistedCount: 0,
13081341
pendingFlowchartData: null,
13091342
generateForm: {},
1343+
generateResultDebateId: undefined,
13101344
attackForm: {},
13111345
brainstormForm: {},
13121346
factoidsForm: {},

‎src/pages/GeneratePage.tsx‎

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,16 @@ import { useDebateMotionGate } from '@/components/features/debate/DebateMotionGa
3636

3737
export default function GeneratePage() {
3838
const workspace = useWorkspaceToolScope('generate');
39-
const { generateResult, generateLoading, generateRegeneratingSection: regeneratingSection, generateForm, setGenerateResult, setGenerateLoading, setGenerateRegeneratingSection, saveCurrentCase, setPendingFlowchartData, autosavedCases, recordResult, updateRecordedResult } = useSessionStore(useShallow((state) => ({
39+
const { generateResult, generateLoading, generateRegeneratingSection: regeneratingSection, generateForm, generateResultDebateId, setGenerateResult, setGenerateLoading, setGenerateRegeneratingSection, setGenerateResultDebateId, saveCurrentCase, setPendingFlowchartData, autosavedCases, recordResult, updateRecordedResult } = useSessionStore(useShallow((state) => ({
4040
generateResult: state.generateResult,
4141
generateLoading: state.generateLoading,
4242
generateRegeneratingSection: state.generateRegeneratingSection,
4343
generateForm: state.generateForm,
44+
generateResultDebateId: state.generateResultDebateId,
4445
setGenerateResult: state.setGenerateResult,
4546
setGenerateLoading: state.setGenerateLoading,
4647
setGenerateRegeneratingSection: state.setGenerateRegeneratingSection,
48+
setGenerateResultDebateId: state.setGenerateResultDebateId,
4749
saveCurrentCase: state.saveCurrentCase,
4850
setPendingFlowchartData: state.setPendingFlowchartData,
4951
autosavedCases: state.autosavedCases,
@@ -107,6 +109,7 @@ export default function GeneratePage() {
107109
setGenerateResult(result);
108110
setRecordedCaseId(null);
109111
setResultDebateId(target.debateId);
112+
setGenerateResultDebateId(target.debateId);
110113
await workspace.attach(data, result);
111114
if (target.debateId) {
112115
setRecordedCaseId(await recordInDebate(() => recordResult('case', { title: data.motion, input: data, output: result, debateId: target.debateId, side: data.side })));
@@ -135,26 +138,31 @@ export default function GeneratePage() {
135138

136139
const handleRegenerateSection = (section: string) => {
137140
if (generateLoading || regeneratingSection) return;
138-
if (!generateForm?.motion || !generateResult) {
141+
const motion = generateResult?.metadata?.motion?.trim() || generateForm?.motion;
142+
if (!motion || !generateResult) {
139143
toast({ title: t('generate.regenerateUnavailable', 'Cannot regenerate yet'), description: t('generate.regenerateUnavailableDesc', 'Generate a case first, then retry a section.'), variant: 'destructive' });
140144
return;
141145
}
142146

143147
const data: GenerateInput = {
144-
motion: generateForm.motion,
145-
side: generateForm.side || 'pro',
146-
format: generateForm.format || 'custom',
147-
role: generateForm.role || 'r1',
148-
length: generateForm.length || 'standard',
149-
audience: generateForm.audience || 'intermediate',
150-
tone: generateForm.tone || 'formal',
148+
motion,
149+
side: generateResult.metadata?.side || generateForm?.side || 'pro',
150+
format: generateForm?.format || 'custom',
151+
role: generateForm?.role || 'r1',
152+
length: generateForm?.length || 'standard',
153+
audience: generateForm?.audience || 'intermediate',
154+
tone: generateForm?.tone || 'formal',
151155
};
152156

153157
// Regenerate against the result's own debate, never whichever is current now.
158+
// A loaded case records that debate on the store; this page's state only
159+
// knows it until the student leaves and comes back.
154160
const current = selectCurrentDebate(useDebateStore.getState());
155161
const debateId = resultDebateId !== undefined
156162
? resultDebateId
157-
: sameMotion(current?.motion, data.motion) ? activeDebateId() : null;
163+
: generateResultDebateId !== undefined
164+
? generateResultDebateId
165+
: sameMotion(current?.motion, data.motion) ? activeDebateId() : null;
158166
// One section at a time: each regeneration merges into the result it started from.
159167
const base = generateResult;
160168
setGenerateRegeneratingSection(section);

‎src/test/checkout-security.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "vitest";
22
import {
33
findCustomerOwnedByUser,
4+
hasOpenSubscription,
45
isAllowedCheckoutRedirectUrl,
56
} from "../../supabase/functions/create-checkout/checkout-security";
67

@@ -28,4 +29,15 @@ describe("checkout security", () => {
2829
expect(findCustomerOwnedByUser(customers, "user-123")).toBe("cus_correct");
2930
expect(findCustomerOwnedByUser(customers, "missing-user")).toBeNull();
3031
});
32+
33+
it("treats a subscription that can still bill as a reason to refuse another checkout", () => {
34+
expect(hasOpenSubscription([{ status: "active" }, { status: "canceled" }])).toBe(true);
35+
expect(hasOpenSubscription([{ status: "trialing" }])).toBe(true);
36+
expect(hasOpenSubscription([{ status: "past_due" }])).toBe(true);
37+
expect(hasOpenSubscription([{ status: "paused" }])).toBe(true);
38+
expect(hasOpenSubscription([{ status: "unpaid" }])).toBe(true);
39+
expect(hasOpenSubscription([{ status: "canceled" }, { status: "incomplete" }, { status: "incomplete_expired" }])).toBe(false);
40+
expect(hasOpenSubscription([])).toBe(false);
41+
expect(hasOpenSubscription(null)).toBe(false);
42+
});
3143
});

‎src/test/pages/GeneratePage.test.tsx‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ const mockAddAskMessage = vi.fn();
4545
const mockSetAskLoading = vi.fn();
4646
let mockGenerateResult: any = null;
4747
let mockGenerateForm: any = { motion: 'Existing motion', side: 'pro', format: 'bp', role: 'intro', length: 'brief', audience: 'beginner', tone: 'formal' };
48+
let mockGenerateResultDebateId: string | null | undefined;
49+
const mockSetGenerateResultDebateId = vi.fn((debateId: string | null) => {
50+
mockGenerateResultDebateId = debateId;
51+
});
4852

4953
let mockRegeneratingSection: string | null = null;
5054
const mockStoreListeners = new Set<() => void>();
@@ -60,10 +64,12 @@ vi.mock('@/lib/stores/session-store', async () => {
6064
generateLoading: false,
6165
generateRegeneratingSection: mockRegeneratingSection,
6266
generateForm: mockGenerateForm,
67+
generateResultDebateId: mockGenerateResultDebateId,
6368
autosavedCases: [] as any[],
6469
setGenerateResult: mockSetGenerateResult,
6570
setGenerateLoading: mockSetGenerateLoading,
6671
setGenerateRegeneratingSection: mockSetRegeneratingSection,
72+
setGenerateResultDebateId: mockSetGenerateResultDebateId,
6773
saveCurrentCase: mockSaveCurrentCase,
6874
addAskMessage: mockAddAskMessage,
6975
setAskLoading: mockSetAskLoading,
@@ -127,6 +133,7 @@ describe('GeneratePage Integration', () => {
127133
vi.clearAllMocks();
128134
mockGenerateResult = null;
129135
mockRegeneratingSection = null;
136+
mockGenerateResultDebateId = undefined;
130137
mockGenerateForm = { motion: 'Existing motion', side: 'pro', format: 'bp', role: 'intro', length: 'brief', audience: 'beginner', tone: 'formal' };
131138
});
132139

@@ -331,6 +338,29 @@ describe('GeneratePage Integration', () => {
331338
expect((generateCase as Mock).mock.calls[1][3]).toBeNull();
332339
});
333340

341+
it('regenerates a loaded case with that case\'s motion and debate', async () => {
342+
seedCurrentDebate({ motion: 'Existing motion' });
343+
mockGenerateResultDebateId = 'loaded-debate';
344+
mockGenerateResult = {
345+
caseStructure: { thesis: 'Loaded thesis' },
346+
contentions: [] as any[],
347+
predictedOpponentCase: {},
348+
counters: [] as any[],
349+
metadata: { side: 'con', format: 'custom', motion: 'Loaded motion', generatedAt: '2026-01-01T00:00:00Z' },
350+
};
351+
(generateCase as Mock).mockResolvedValue({ result: mockGenerateResult, discoveredSources: [] });
352+
353+
render(<BrowserRouter><GeneratePage /></BrowserRouter>);
354+
fireEvent.click(screen.getByTestId('regenerate-section'));
355+
356+
await waitFor(() => expect(generateCase).toHaveBeenCalledTimes(1));
357+
expect((generateCase as Mock).mock.calls[0][0]).toEqual(expect.objectContaining({
358+
motion: 'Loaded motion',
359+
side: 'con',
360+
}));
361+
expect((generateCase as Mock).mock.calls[0][3]).toBe('loaded-debate');
362+
});
363+
334364
it('after a reload, regenerates in the Current Debate only when the motion matches', async () => {
335365
const result = { caseStructure: { thesis: 'Restored' }, contentions: [] as any[], predictedOpponentCase: {}, counters: [] as any[], metadata: {} };
336366
(generateCase as Mock).mockResolvedValue({ result, discoveredSources: [] });

0 commit comments

Comments
 (0)