diff --git a/e2e/authed-pages.spec.ts b/e2e/authed-pages.spec.ts index 563a8eca0..1b6e28ec3 100644 --- a/e2e/authed-pages.spec.ts +++ b/e2e/authed-pages.spec.ts @@ -54,3 +54,64 @@ for (const locale of LOCALES) { } }); } + +test('visible Ask advanced controls use the non-streaming contract', async ({ page }) => { + await setLocale(page, 'en'); + await stubSupabaseSession(page); + await page.addInitScript(() => { + window.localStorage.setItem('debate-trainer-settings', JSON.stringify({ + state: { + language: 'en', + outputLanguage: 'en', + theme: 'light', + featureConfigs: { + ask: { provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 }, + }, + }, + version: 0, + })); + }); + + let requestBody: Record | undefined; + let streamCalls = 0; + await page.route('**/functions/v1/ai-stream', async (route) => { + streamCalls += 1; + await route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"unexpected stream"}' }); + }); + await page.route('**/functions/v1/ai', async (route) => { + requestBody = route.request().postDataJSON() as Record; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + output: { + response: 'Clarification contract response', + followUpQuestions: ['Which debate format are you using?'], + uncertainties: [], + }, + }), + }); + }); + + await page.goto('/app/ask', { waitUntil: 'domcontentloaded' }); + await page.getByRole('button', { name: 'Clarify' }).click(); + for (const name of ['Citations', 'Web search', 'Pros/Cons', 'Arg Map']) { + await page.getByRole('switch', { name }).click(); + } + await page.getByPlaceholder('Ask your debate coach...').fill('Which assumptions should I define?'); + await page.getByRole('button', { name: 'Send message' }).click(); + + await expect(page.getByText('Clarification contract response')).toBeVisible(); + expect(streamCalls).toBe(0); + expect(requestBody).toMatchObject({ + feature: 'ask', + input: { + message: 'Which assumptions should I define?', + mode: 'clarify', + citationMode: true, + webSearch: true, + prosConsMode: true, + argumentMapMode: true, + }, + }); +}); diff --git a/src/lib/ai/streaming.ts b/src/lib/ai/streaming.ts index 96ea61c6c..984d07574 100644 --- a/src/lib/ai/streaming.ts +++ b/src/lib/ai/streaming.ts @@ -26,7 +26,7 @@ export type ParsedSSEEvent = { export function isAskStreamingSupported(config: ProviderConfig, options: AskStreamOptions = {}) { const provider = config.provider === 'lovable' ? 'openai' : config.provider; const advancedStructuredMode = Boolean( - options.mode === 'clarify' || + (options.mode ?? 'general') !== 'general' || options.citationMode || options.webSearch || options.prosConsMode || diff --git a/src/test/ai/streaming.test.ts b/src/test/ai/streaming.test.ts index 83c659f7c..6fac53950 100644 --- a/src/test/ai/streaming.test.ts +++ b/src/test/ai/streaming.test.ts @@ -114,6 +114,12 @@ describe('AI streaming helpers', () => { { provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 }, { mode: 'general' } )).toBe(true); + for (const mode of ['clarify', 'summarize', 'proscons', 'argumentmap']) { + expect(isAskStreamingSupported( + { provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 }, + { mode } + )).toBe(false); + } }); describe('telemetry', () => { diff --git a/supabase/functions/_shared/control-enforcement.test.ts b/supabase/functions/_shared/control-enforcement.test.ts index 2c3da40bf..8dbb9a620 100644 --- a/supabase/functions/_shared/control-enforcement.test.ts +++ b/supabase/functions/_shared/control-enforcement.test.ts @@ -4,8 +4,96 @@ import { enforceBrainstormControls, enforceGenerateControls, isAskStreamingModeAllowed, + validateControlInput, } from "./control-enforcement.ts"; +const ENUM_CONTROLS = { + ask: { + mode: ["general", "clarify", "summarize", "proscons", "argumentmap"], + }, + attack: { + aggressiveness: ["mild", "moderate", "ruthless"], + judgeParadigm: ["logic", "values", "policy"], + }, + brainstorm: { + side: ["pro", "con", "both"], + outputMode: ["mindmap", "buckets", "topfive"], + }, + factoids: { + direction: ["for", "against"], + sourceType: ["academic", "government", "news", "any"], + }, + generate: { + side: ["pro", "con"], + format: ["custom", "bp", "policy", "classroom"], + role: ["intro", "r1", "r2", "conc", "general"], + length: ["brief", "standard", "comprehensive"], + audience: ["beginner", "intermediate", "advanced"], + tone: ["formal", "conversational", "aggressive"], + }, +} as const; + +const BOOLEAN_CONTROLS = { + ask: ["citationMode", "webSearch", "prosConsMode", "argumentMapMode"], + attack: ["detectFallacies", "analyzeWeighing"], +} as const; + +Deno.test("control validator accepts every enum value and omitted defaults", () => { + for (const [feature, controls] of Object.entries(ENUM_CONTROLS)) { + if (validateControlInput(feature, {}) !== null) throw new Error(`${feature} rejected omitted controls`); + for (const [control, values] of Object.entries(controls)) { + for (const value of values) { + const error = validateControlInput(feature, { [control]: value }); + if (error !== null) throw new Error(`${feature}.${control} rejected ${value}: ${error}`); + } + } + } +}); + +Deno.test("control validator rejects every unsupported enum", () => { + for (const [feature, controls] of Object.entries(ENUM_CONTROLS)) { + for (const control of Object.keys(controls)) { + const error = validateControlInput(feature, { [control]: "unsupported" }); + if (!error?.includes(`${feature}.${control}`)) throw new Error(`${feature}.${control} accepted an unsupported value`); + } + } +}); + +Deno.test("control validator accepts booleans and rejects wrong boolean types", () => { + for (const [feature, controls] of Object.entries(BOOLEAN_CONTROLS)) { + for (const control of controls) { + for (const value of [true, false]) { + if (validateControlInput(feature, { [control]: value }) !== null) { + throw new Error(`${feature}.${control} rejected ${value}`); + } + } + const error = validateControlInput(feature, { [control]: "false" }); + if (!error?.includes(`${feature}.${control}`)) throw new Error(`${feature}.${control} accepted a string boolean`); + } + } +}); + +Deno.test("factoids typed controls enforce their existing boundaries", () => { + for (const sourceCount of [3, 5, 15]) { + if (validateControlInput("factoids", { sourceCount }) !== null) throw new Error(`rejected sourceCount ${sourceCount}`); + } + if (validateControlInput("factoids", { timeframe: "2020-2026" }) !== null) throw new Error("rejected string timeframe"); + + for (const input of [ + { sourceCount: "5" }, + { sourceCount: Number.NaN }, + { sourceCount: Number.POSITIVE_INFINITY }, + { sourceCount: 2 }, + { sourceCount: 16 }, + { timeframe: 2026 }, + ]) { + if (validateControlInput("factoids", input) === null) { + throw new Error(`accepted invalid factoids control ${JSON.stringify(input)}`); + } + } + if (validateControlInput("unknown", { mode: "unsupported" }) !== null) throw new Error("unknown feature must be ignored"); +}); + Deno.test("ask pros/cons and argument map toggles deterministically shape the output", () => { const output: Record = { response: "r", @@ -48,8 +136,10 @@ Deno.test("clarify mode guarantees a visible uncertainty note when no follow-up if ((good.uncertainties as string[]).length !== 0) throw new Error("clarify with follow-ups must not add noise"); }); -Deno.test("clarify mode is excluded from streaming while general mode remains eligible", () => { - if (isAskStreamingModeAllowed("clarify")) throw new Error("clarify mode must not stream"); +Deno.test("only general ask mode is eligible for streaming", () => { + for (const mode of ["clarify", "summarize", "proscons", "argumentmap", "unsupported", undefined]) { + if (isAskStreamingModeAllowed(mode)) throw new Error(`${mode} mode must not stream`); + } if (!isAskStreamingModeAllowed("general")) throw new Error("general mode should remain streamable"); }); diff --git a/supabase/functions/_shared/control-enforcement.ts b/supabase/functions/_shared/control-enforcement.ts index 2fecca7e6..2a761ac1a 100644 --- a/supabase/functions/_shared/control-enforcement.ts +++ b/supabase/functions/_shared/control-enforcement.ts @@ -11,7 +11,65 @@ export interface AskControls { } export function isAskStreamingModeAllowed(mode: unknown): boolean { - return mode !== "clarify"; + return mode === "general"; +} + +const ENUM_CONTROLS: Record> = { + ask: { + mode: ["general", "clarify", "summarize", "proscons", "argumentmap"], + }, + attack: { + aggressiveness: ["mild", "moderate", "ruthless"], + judgeParadigm: ["logic", "values", "policy"], + }, + brainstorm: { + side: ["pro", "con", "both"], + outputMode: ["mindmap", "buckets", "topfive"], + }, + factoids: { + direction: ["for", "against"], + sourceType: ["academic", "government", "news", "any"], + }, + generate: { + side: ["pro", "con"], + format: ["custom", "bp", "policy", "classroom"], + role: ["intro", "r1", "r2", "conc", "general"], + length: ["brief", "standard", "comprehensive"], + audience: ["beginner", "intermediate", "advanced"], + tone: ["formal", "conversational", "aggressive"], + }, +}; + +const BOOLEAN_CONTROLS: Record = { + ask: ["citationMode", "webSearch", "prosConsMode", "argumentMapMode"], + attack: ["detectFallacies", "analyzeWeighing"], +}; + +/** Returns a safe client-facing error for malformed, present AI controls. */ +export function validateControlInput(feature: string, input: Record): string | null { + for (const [name, allowed] of Object.entries(ENUM_CONTROLS[feature] ?? {})) { + if (Object.hasOwn(input, name) && (typeof input[name] !== "string" || !allowed.includes(input[name]))) { + return `${feature}.${name} is invalid`; + } + } + + for (const name of BOOLEAN_CONTROLS[feature] ?? []) { + if (Object.hasOwn(input, name) && typeof input[name] !== "boolean") { + return `${feature}.${name} must be a boolean`; + } + } + + if (feature === "factoids") { + if (Object.hasOwn(input, "timeframe") && typeof input.timeframe !== "string") { + return "factoids.timeframe must be a string"; + } + if (Object.hasOwn(input, "sourceCount") && + (typeof input.sourceCount !== "number" || !Number.isFinite(input.sourceCount) || input.sourceCount < 3 || input.sourceCount > 15)) { + return "factoids.sourceCount must be a number from 3 to 15"; + } + } + + return null; } const CLARIFY_NOTE = "Clarify mode was requested, but no clarifying questions were returned."; diff --git a/supabase/functions/ai-stream/index.ts b/supabase/functions/ai-stream/index.ts index 7c661a367..3e875e808 100644 --- a/supabase/functions/ai-stream/index.ts +++ b/supabase/functions/ai-stream/index.ts @@ -23,7 +23,7 @@ function getCorsHeaders(origin: string | null) { import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; import { getAskModeGuidance } from "../ai/prompt-builder.ts"; -import { isAskStreamingModeAllowed } from "../_shared/control-enforcement.ts"; +import { isAskStreamingModeAllowed, validateControlInput } from "../_shared/control-enforcement.ts"; import { calculateRequestCost, type CostResult, @@ -312,7 +312,7 @@ async function logUsage( } } -Deno.serve(async (req) => { +export async function handleRequest(req: Request): Promise { const corsHeaders = getCorsHeaders(req.headers.get("Origin")); if (req.method === "OPTIONS") { @@ -341,16 +341,24 @@ Deno.serve(async (req) => { return jsonResponse({ error: "Invalid JSON body" }, 400, corsHeaders); } - const model = sanitizeText(body.model, 80) || "gpt-5.4-mini"; - const input = body.input && typeof body.input === "object" ? body.input as Record : {}; - const mode = sanitizeText(input.mode, 40) || "general"; - if (body.feature !== "ask") { return jsonResponse({ error: "Streaming currently supports Ask only" }, 400, corsHeaders); } + const model = sanitizeText(body.model, 80) || "gpt-5.4-mini"; + const rawInput = body.input && typeof body.input === "object" && !Array.isArray(body.input) + ? body.input as Record + : {}; + const input: Record = { + ...rawInput, + ...(typeof rawInput.mode === "string" ? { mode: sanitizeText(rawInput.mode, 40) } : {}), + }; + const controlError = validateControlInput("ask", input); + if (controlError) return jsonResponse({ error: controlError }, 400, corsHeaders); + + const mode = typeof input.mode === "string" ? input.mode : "general"; if (!isAskStreamingModeAllowed(mode)) { - return jsonResponse({ error: "Streaming is not available for clarify mode" }, 400, corsHeaders); + return jsonResponse({ error: `Streaming is not available for ${mode} mode` }, 400, corsHeaders); } const advancedMode = Boolean(input.citationMode || input.webSearch || input.prosConsMode || input.argumentMapMode); @@ -597,4 +605,8 @@ Deno.serve(async (req) => { "Cache-Control": "no-cache, no-transform", }, }); -}); +} + +if (import.meta.main) { + Deno.serve(handleRequest); +} diff --git a/supabase/functions/ai/control-matrix.test.ts b/supabase/functions/ai/control-matrix.test.ts index b5d16fede..a776f7640 100644 --- a/supabase/functions/ai/control-matrix.test.ts +++ b/supabase/functions/ai/control-matrix.test.ts @@ -154,7 +154,9 @@ Deno.test("ask toggles and modes appear verbatim in the request prompt", () => { assert(Array.isArray(stripped.citations) && stripped.citations.length === 0, "ask citations off clears list"); }); -Deno.test("ask clarify mode is not eligible for streaming", () => { - assert(!isAskStreamingModeAllowed("clarify"), "ask.clarify streaming bypass"); +Deno.test("only plain general ask mode is eligible for streaming", () => { + for (const mode of ["clarify", "summarize", "proscons", "argumentmap"]) { + assert(!isAskStreamingModeAllowed(mode), `ask.${mode} streaming bypass`); + } assert(isAskStreamingModeAllowed("general"), "ask.general streaming eligibility"); }); diff --git a/supabase/functions/ai/handler-research.test.ts b/supabase/functions/ai/handler-research.test.ts index 89f17cff2..10ebb8c58 100644 --- a/supabase/functions/ai/handler-research.test.ts +++ b/supabase/functions/ai/handler-research.test.ts @@ -16,6 +16,57 @@ const environment = { for (const [key, value] of Object.entries(environment)) Deno.env.set(key, value); const { handleRequest } = await import("./index.ts"); +const { handleRequest: handleStreamRequest } = await import("../ai-stream/index.ts"); + +Deno.test("AI handlers reject malformed controls before quota or provider work", async () => { + const originalFetch = globalThis.fetch; + const calls: string[] = []; + + globalThis.fetch = (async (input: Request | URL | string) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + calls.push(url.href); + if (url.origin === "https://supabase.test" && url.pathname === "/auth/v1/user") { + return jsonResponse({ id: "user-1", aud: "authenticated" }); + } + throw new Error(`Malformed controls reached unexpected work: ${url}`); + }) as typeof fetch; + + try { + const response = await handleRequest(new Request("http://127.0.0.1/functions/v1/ai", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + feature: "ask", + model: "gpt-5.4-mini", + input: { message: "Question", mode: "\u200Bgeneral", citationMode: "false" }, + }), + })); + if (response.status !== 400) throw new Error(`non-streaming handler returned ${response.status}`); + if (!(await response.text()).includes("ask.citationMode")) throw new Error("non-streaming error hid the invalid control"); + if (calls.length !== 0) throw new Error(`non-streaming validation performed remote work: ${calls.join(", ")}`); + + const streamResponse = await handleStreamRequest(new Request("http://127.0.0.1/functions/v1/ai-stream", { + method: "POST", + headers: { + Authorization: "Bearer test-user-token", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + feature: "ask", + model: "gpt-5.4-mini", + input: { message: "Question", mode: "general", citationMode: "false" }, + }), + })); + if (streamResponse.status !== 400) throw new Error(`streaming handler returned ${streamResponse.status}`); + if (!(await streamResponse.text()).includes("ask.citationMode")) throw new Error("streaming error hid the invalid control"); + const observedCalls: string[] = Array.from(calls); + if (observedCalls.length !== 1 || !observedCalls[0].includes("/auth/v1/user")) { + throw new Error(`streaming validation performed quota or provider work: ${observedCalls.join(", ")}`); + } + } finally { + globalThis.fetch = originalFetch; + } +}); Deno.test("factoids handler carries Exa filters and consulted-source metadata across the request boundary", async () => { const originalFetch = globalThis.fetch; diff --git a/supabase/functions/ai/index.ts b/supabase/functions/ai/index.ts index 8b15b6522..1cfe8b675 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -69,6 +69,7 @@ import { enforceAttackControls, enforceBrainstormControls, enforceGenerateControls, + validateControlInput, } from "../_shared/control-enforcement.ts"; import { boundPromptSources, @@ -1103,6 +1104,11 @@ export async function handleRequest(req: Request): Promise { return badRequest(sanitizeResult.rejected, corsHeaders); } const sanitizedInput = sanitizeResult.sanitized; + const controlError = validateControlInput(feature, sanitizedInput); + if (controlError) { + log(requestId, "WARN", "AI control validation failed", { reason: controlError }); + return badRequest(controlError, corsHeaders); + } let responseTruncated = sanitizeResult.truncated || crossfireSourcesTruncated; const sources: PromptSource[] = feature === "crossfire" ? crossfireSources