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
61 changes: 61 additions & 0 deletions e2e/authed-pages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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<string, unknown>;
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,
},
});
});
2 changes: 1 addition & 1 deletion src/lib/ai/streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down
6 changes: 6 additions & 0 deletions src/test/ai/streaming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
94 changes: 92 additions & 2 deletions supabase/functions/_shared/control-enforcement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {
response: "r",
Expand Down Expand Up @@ -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");
});

Expand Down
60 changes: 59 additions & 1 deletion supabase/functions/_shared/control-enforcement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,65 @@ export interface AskControls {
}

export function isAskStreamingModeAllowed(mode: unknown): boolean {
return mode !== "clarify";
return mode === "general";
}

const ENUM_CONTROLS: Record<string, Record<string, readonly string[]>> = {
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<string, readonly string[]> = {
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, unknown>): 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.";
Expand Down
28 changes: 20 additions & 8 deletions supabase/functions/ai-stream/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -312,7 +312,7 @@ async function logUsage(
}
}

Deno.serve(async (req) => {
export async function handleRequest(req: Request): Promise<Response> {
const corsHeaders = getCorsHeaders(req.headers.get("Origin"));

if (req.method === "OPTIONS") {
Expand Down Expand Up @@ -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<string, unknown> : {};
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<string, unknown>
: {};
const input: Record<string, unknown> = {
...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);
Expand Down Expand Up @@ -597,4 +605,8 @@ Deno.serve(async (req) => {
"Cache-Control": "no-cache, no-transform",
},
});
});
}

if (import.meta.main) {
Deno.serve(handleRequest);
}
6 changes: 4 additions & 2 deletions supabase/functions/ai/control-matrix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
51 changes: 51 additions & 0 deletions supabase/functions/ai/handler-research.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading