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
20 changes: 10 additions & 10 deletions src/lib/ai/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
type SourceProvenance,
type SourceSemanticMetadata,
} from '../stores/session-store';
import { supabase } from '@/lib/supabase/client';
import { getActiveAccessToken, getActiveUserId, invokeAuthenticatedFunction, supabase } from '@/lib/supabase/client';
import { toast } from '@/hooks/use-toast';
import { captureException, captureProductEvent } from '@/lib/observability';
import { validateUrl } from '@/lib/validation/url';
Expand Down Expand Up @@ -469,8 +469,9 @@ async function callSupabaseAI<T = unknown>(
else options.signal?.addEventListener('abort', abortFromCaller, { once: true });

try {
const { data: sessionData } = await waitForAbortable(() => supabase.auth.getSession(), controller.signal);
const accessToken = sessionData.session?.access_token;
const accessToken = getActiveAccessToken() ?? (
await waitForAbortable(() => supabase.auth.getSession(), controller.signal)
).data.session?.access_token;

if (DEBUG) {
console.log(`[AI Request ${requestId}]`, {
Expand Down Expand Up @@ -498,7 +499,7 @@ async function callSupabaseAI<T = unknown>(
workspaceContext: options.workspaceContext,
};

const { data, error } = await supabase.functions.invoke('ai', {
const { data, error } = await invokeAuthenticatedFunction<Record<string, unknown>>('ai', {
headers: { Authorization: `Bearer ${accessToken}` },
body: requestBody,
signal: controller.signal,
Expand Down Expand Up @@ -1360,13 +1361,12 @@ function validateSparringScoreResponse(output: unknown): SparringScoreResponse {
const pendingSparringTurns = new Map<string, Promise<SparringTurnResponse>>();
const pendingSparringScores = new Map<string, Promise<SparringScoreResponse>>();

async function sparringRequestKey(
function sparringRequestKey(
input: SparringRespondInput | SparringScoreInput,
config: ProviderConfig,
workspaceContext?: WorkspaceToolContext,
): Promise<string> {
const { data } = await supabase.auth.getSession();
const authScope = data.session?.user?.id ?? data.session?.access_token ?? 'signed-out';
): string {
const authScope = getActiveUserId() ?? getActiveAccessToken() ?? 'signed-out';
return JSON.stringify([authScope, workspaceContext?.workspaceId ?? null, input, config.provider, config.model, config.temperature, config.reasoningEffort]);
}

Expand All @@ -1386,7 +1386,7 @@ export async function generateSparringTurn(
details: { issues: inputResult.success ? [{ message: 'Expected respond action.' }] : inputResult.error.issues },
});
}
const requestKey = await sparringRequestKey(inputResult.data, config, workspaceContext);
const requestKey = sparringRequestKey(inputResult.data, config, workspaceContext);
const pending = pendingSparringTurns.get(requestKey);
if (pending) return pending;

Expand Down Expand Up @@ -1437,7 +1437,7 @@ export async function judgeSparringSession(
details: { issues: inputResult.success ? [{ message: 'Expected score action.' }] : inputResult.error.issues },
});
}
const requestKey = await sparringRequestKey(inputResult.data, config, workspaceContext);
const requestKey = sparringRequestKey(inputResult.data, config, workspaceContext);
const pending = pendingSparringScores.get(requestKey);
if (pending) return pending;

Expand Down
10 changes: 6 additions & 4 deletions src/lib/ai/streaming.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { supabase, SUPABASE_URL } from '@/lib/supabase/client';
import { getActiveAccessToken, supabase, SUPABASE_URL } from '@/lib/supabase/client';
import { captureProductEvent } from '@/lib/observability';
import { AIAuthError, AIResponseError, REQUEST_TIMEOUT_MS, TimeoutError, mapAIError, reportAIFailure, withOutputLanguage } from './client';
import type { ProviderConfig } from './providers/types';
Expand Down Expand Up @@ -130,14 +130,16 @@ export async function streamAskChat(
throw error;
}

let sessionData: Awaited<ReturnType<typeof supabase.auth.getSession>>['data'];
let accessToken = getActiveAccessToken();
try {
({ data: sessionData } = await waitForCaller(supabase.auth.getSession(), handlers.signal));
if (!accessToken) {
const { data } = await waitForCaller(supabase.auth.getSession(), handlers.signal);
accessToken = data.session?.access_token ?? null;
}
} catch (error) {
captureStreamFailure(error, provider, config.model, startTime, requestId, Boolean(handlers.signal?.aborted));
throw error;
}
const accessToken = sessionData.session?.access_token;
if (!accessToken) {
const error = new AIAuthError('Please sign in to use AI features', { requestId });
captureStreamFailure(error, provider, config.model, startTime, requestId, Boolean(handlers.signal?.aborted));
Expand Down
6 changes: 5 additions & 1 deletion src/lib/supabase/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import type { Session, User } from '@supabase/supabase-js';
import { supabase } from './client';
import { setActiveAccessToken, supabase } from './client';
import { useSessionStore } from '@/lib/stores/session-store';
import { useSettingsStore } from '@/lib/stores/settings-store';
import { useSparringStore } from '@/lib/stores/sparring-store';
Expand Down Expand Up @@ -163,6 +163,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (!mounted) return;

if (oauthSession) {
setActiveAccessToken(oauthSession.access_token, oauthSession.user.id);
setSession(oauthSession);
currentUserIdRef.current = oauthSession.user?.id;
setJamUserId(oauthSession.user?.id);
Expand All @@ -183,6 +184,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (!mounted) return;
const { data, error } = getSessionResult;
if (error) console.warn('[Auth] getSession error:', error.message);
setActiveAccessToken(data.session?.access_token ?? null, data.session?.user.id ?? null);
setSession(data.session ?? null);
currentUserIdRef.current = data.session?.user?.id;
setJamUserId(data.session?.user?.id);
Expand All @@ -192,6 +194,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} catch (err) {
console.warn('[Auth] init failed:', err);
if (!mounted) return;
setActiveAccessToken(null);
setSession(null);
currentUserIdRef.current = undefined;
setJamUserId(undefined);
Expand All @@ -210,6 +213,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
clearCrossfireDraftOnAuthChange(prevUserId, nextUserId);
currentUserIdRef.current = nextUserId;
setJamUserId(nextUserId);
setActiveAccessToken(newSession?.access_token ?? null, newSession?.user.id ?? null);
setSession(newSession);
setLoading(false);

Expand Down
57 changes: 56 additions & 1 deletion src/lib/supabase/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { createClient } from '@supabase/supabase-js';
import {
createClient,
FunctionsFetchError,
FunctionsHttpError,
FunctionsRelayError,
} from '@supabase/supabase-js';
import { getActiveEnv, SUPABASE_URL, SUPABASE_ANON_KEY } from './env';

export { SUPABASE_URL };
Expand All @@ -13,3 +18,53 @@ export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
storageKey: `debate-auth-${getActiveEnv()}`,
},
});

let activeAccessToken: string | null = null;
let activeUserId: string | null = null;

export function setActiveAccessToken(accessToken: string | null, userId: string | null = null): void {
activeAccessToken = accessToken;
activeUserId = userId;
}

export function getActiveAccessToken(): string | null {
return activeAccessToken;
}

export function getActiveUserId(): string | null {
return activeUserId;
}

type AuthenticatedFunctionResult<T> =
| { data: T; error: null }
| { data: null; error: FunctionsFetchError | FunctionsHttpError | FunctionsRelayError };

export async function invokeAuthenticatedFunction<T = unknown>(
functionName: string,
options: { body: Record<string, unknown>; headers: Record<string, string>; signal?: AbortSignal },
): Promise<AuthenticatedFunctionResult<T>> {
let response: Response;
try {
response = await fetch(`${SUPABASE_URL}/functions/v1/${functionName}`, {
method: 'POST',
headers: {
apikey: SUPABASE_ANON_KEY,
'Content-Type': 'application/json',
...options.headers,
},
body: JSON.stringify(options.body),
signal: options.signal,
});
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') throw error;
return { data: null, error: new FunctionsFetchError(error) };
}

if (response.headers.get('x-relay-error') === 'true') {
return { data: null, error: new FunctionsRelayError(response) };
}
if (!response.ok) return { data: null, error: new FunctionsHttpError(response) };

const data: T = await response.json();
return { data, error: null };
}
2 changes: 2 additions & 0 deletions src/test/ai/client-analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ vi.mock('@/lib/observability', () => ({
}));

vi.mock('@/lib/supabase/client', () => ({
getActiveAccessToken: (): string | null => null,
invokeAuthenticatedFunction: mocks.invoke,
supabase: {
auth: { getSession: mocks.getSession },
functions: { invoke: mocks.invoke },
Expand Down
2 changes: 2 additions & 0 deletions src/test/ai/client-branches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const {
});

vi.mock('@/lib/supabase/client', () => ({
getActiveAccessToken: (): string | null => null,
invokeAuthenticatedFunction: invokeMock,
supabase: {
auth: {
getSession: getSessionMock,
Expand Down
2 changes: 2 additions & 0 deletions src/test/ai/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ const clientMocks = vi.hoisted(() => ({
// actually it's better to use doMock or inline return.

vi.mock('@/lib/supabase/client', () => ({
getActiveAccessToken: (): string | null => null,
invokeAuthenticatedFunction: supabaseMock.functions.invoke,
supabase: supabaseMock
}));

Expand Down
6 changes: 5 additions & 1 deletion src/test/ai/control-payload-matrix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { supabaseMock, resetMockState, setMockSessionToken } from '../mocks/supabase';

vi.mock('@/lib/supabase/client', () => ({ supabase: supabaseMock }));
vi.mock('@/lib/supabase/client', () => ({
getActiveAccessToken: (): string | null => null,
invokeAuthenticatedFunction: supabaseMock.functions.invoke,
supabase: supabaseMock,
}));

import { generateCase, attackCase, brainstorm, factoids, askChat } from '@/lib/ai/client';
import type { ProviderConfig } from '@/lib/ai/providers/types';
Expand Down
2 changes: 2 additions & 0 deletions src/test/ai/crossfire-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const mocks = vi.hoisted(() => ({
}));

vi.mock('@/lib/supabase/client', () => ({
getActiveAccessToken: (): string | null => null,
invokeAuthenticatedFunction: mocks.invoke,
supabase: {
auth: { getSession: mocks.getSession },
functions: { invoke: mocks.invoke },
Expand Down
109 changes: 109 additions & 0 deletions src/test/ai/request-dispatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { createElement } from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { afterEach, expect, it, vi } from 'vitest';

const sessionState = {
sourceLibrary: [] as unknown[],
hydrateFromServer: vi.fn(),
resetLocalData: vi.fn(),
};

const settingsState = {
customSystemPrompt: '',
outputLanguage: 'en',
hydrateFromServer: vi.fn(),
resetLocalData: vi.fn(),
};

vi.mock('@/lib/stores/settings-store', () => ({
useSettingsStore: Object.assign(
(selector: (state: typeof settingsState) => unknown) => selector(settingsState),
{ getState: () => settingsState },
),
}));

vi.mock('@/lib/stores/session-store', () => ({
useSessionStore: Object.assign(
(selector: (state: typeof sessionState) => unknown) => selector(sessionState),
{ getState: () => sessionState },
),
}));

vi.mock('@/lib/stores/sparring-store', () => ({
useSparringStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
hydrateSavedSessions: vi.fn(),
prepareForUser: vi.fn(),
resetLocalData: vi.fn(),
}),
}));

vi.mock('@/lib/observability', () => ({
captureException: vi.fn(),
captureProductEvent: vi.fn(),
}));

import { askChat } from '@/lib/ai/client';
import { AuthProvider, useAuth } from '@/lib/supabase/AuthProvider';
import { setActiveAccessToken, supabase } from '@/lib/supabase/client';

afterEach(() => {
setActiveAccessToken(null);
vi.restoreAllMocks();
});

function AuthState() {
const { loading, user } = useAuth();
return createElement('span', { 'data-testid': 'auth-state' }, loading ? 'loading' : user?.id ?? 'signed-out');
}

it('calls the AI fetch boundary without another session lookup', async () => {
const getSession = vi.spyOn(supabase.auth, 'getSession')
.mockResolvedValue({
data: { session: { access_token: 'test-access-token', user: { id: 'user-1' } } },
error: null,
} as Awaited<ReturnType<typeof supabase.auth.getSession>>);
vi.spyOn(supabase.auth, 'onAuthStateChange').mockReturnValue({
data: { subscription: { id: 'test', callback: vi.fn(), unsubscribe: vi.fn() } },
} as ReturnType<typeof supabase.auth.onAuthStateChange>);
const fetchRequest = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
output: {
response: 'Mock AI response.',
followUpQuestions: [],
uncertainties: [],
},
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved

render(createElement(AuthProvider, null, createElement(AuthState)));
await waitFor(() => expect(screen.getByTestId('auth-state')).toHaveTextContent('user-1'));
const authInitializationLookups = getSession.mock.calls.length;

const result = await askChat('Hello', [], 'general', {
provider: 'openrouter',
model: 'openai/gpt-5.6-luna',
temperature: 0.7,
reasoningEffort: 'low',
});

expect(fetchRequest).toHaveBeenCalledOnce();
expect(result).toMatchObject({ response: 'Mock AI response.' });
expect(getSession).toHaveBeenCalledTimes(authInitializationLookups);
});

it('maps a rejected fetch through the Edge Function error contract', async () => {
setActiveAccessToken('test-access-token');
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('Failed to fetch'));

await expect(askChat('Hello', [], 'general', {
provider: 'openrouter',
model: 'openai/gpt-5.6-luna',
temperature: 0.7,
reasoningEffort: 'low',
})).rejects.toMatchObject({
name: 'AIError',
message: 'AI service error: Failed to send a request to the Edge Function. Please try again.',
statusCode: 500,
});
});
Loading
Loading