Add interactive ValidationDialog for handling 403 VALIDATION_REQUIRED errors. (#16231)

This commit is contained in:
Gaurav
2026-01-20 16:23:01 -08:00
committed by GitHub
parent aceb06a587
commit 3b626e7c61
18 changed files with 1060 additions and 12 deletions

View File

@@ -28,6 +28,7 @@ import {
processRestorableToolCalls,
recordToolCallInteractions,
ToolErrorType,
ValidationRequiredError,
coreEvents,
CoreEvent,
MCPDiscoveryState,
@@ -1100,6 +1101,12 @@ export const useGeminiStream = (
spanMetadata.error = error;
if (error instanceof UnauthorizedError) {
onAuthError('Session expired or is unauthorized.');
} else if (
// Suppress ValidationRequiredError if it was marked as handled (e.g. user clicked change_auth or cancelled)
error instanceof ValidationRequiredError &&
error.userHandled
) {
// Error was handled by validation dialog, don't display again
} else if (!isNodeError(error) || error.name !== 'AbortError') {
addItem(
{

View File

@@ -498,4 +498,186 @@ To disable gemini-3-pro-preview, disable "Preview features" in /settings.`,
);
});
});
describe('Validation Handler', () => {
let setValidationHandlerSpy: SpyInstance;
beforeEach(() => {
setValidationHandlerSpy = vi.spyOn(mockConfig, 'setValidationHandler');
});
it('should register a validation handler on initialization', () => {
renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
);
expect(setValidationHandlerSpy).toHaveBeenCalledTimes(1);
expect(setValidationHandlerSpy.mock.calls[0][0]).toBeInstanceOf(Function);
});
it('should set a validation request when handler is called', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
);
const handler = setValidationHandlerSpy.mock.calls[0][0] as (
validationLink?: string,
validationDescription?: string,
learnMoreUrl?: string,
) => Promise<'verify' | 'change_auth' | 'cancel'>;
let promise: Promise<'verify' | 'change_auth' | 'cancel'>;
act(() => {
promise = handler(
'https://example.com/verify',
'Please verify',
'https://example.com/help',
);
});
const request = result.current.validationRequest;
expect(request).not.toBeNull();
expect(request?.validationLink).toBe('https://example.com/verify');
expect(request?.validationDescription).toBe('Please verify');
expect(request?.learnMoreUrl).toBe('https://example.com/help');
// Simulate user choosing verify
act(() => {
result.current.handleValidationChoice('verify');
});
const intent = await promise!;
expect(intent).toBe('verify');
expect(result.current.validationRequest).toBeNull();
});
it('should handle race conditions by returning cancel for subsequent requests', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
);
const handler = setValidationHandlerSpy.mock.calls[0][0] as (
validationLink?: string,
) => Promise<'verify' | 'change_auth' | 'cancel'>;
let promise1: Promise<'verify' | 'change_auth' | 'cancel'>;
act(() => {
promise1 = handler('https://example.com/verify1');
});
const firstRequest = result.current.validationRequest;
expect(firstRequest).not.toBeNull();
let result2: 'verify' | 'change_auth' | 'cancel';
await act(async () => {
result2 = await handler('https://example.com/verify2');
});
// The lock should have stopped the second request
expect(result2!).toBe('cancel');
expect(result.current.validationRequest).toBe(firstRequest);
// Complete the first request
act(() => {
result.current.handleValidationChoice('verify');
});
const intent1 = await promise1!;
expect(intent1).toBe('verify');
expect(result.current.validationRequest).toBeNull();
});
it('should add info message when change_auth is chosen', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
);
const handler = setValidationHandlerSpy.mock.calls[0][0] as (
validationLink?: string,
) => Promise<'verify' | 'change_auth' | 'cancel'>;
let promise: Promise<'verify' | 'change_auth' | 'cancel'>;
act(() => {
promise = handler('https://example.com/verify');
});
act(() => {
result.current.handleValidationChoice('change_auth');
});
const intent = await promise!;
expect(intent).toBe('change_auth');
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[0][0];
expect(lastCall.type).toBe(MessageType.INFO);
expect(lastCall.text).toBe('Use /auth to change authentication method.');
});
it('should not add info message when cancel is chosen', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
);
const handler = setValidationHandlerSpy.mock.calls[0][0] as (
validationLink?: string,
) => Promise<'verify' | 'change_auth' | 'cancel'>;
let promise: Promise<'verify' | 'change_auth' | 'cancel'>;
act(() => {
promise = handler('https://example.com/verify');
});
act(() => {
result.current.handleValidationChoice('cancel');
});
const intent = await promise!;
expect(intent).toBe('cancel');
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
});
it('should do nothing if handleValidationChoice is called without pending request', () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
);
act(() => {
result.current.handleValidationChoice('verify');
});
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
});
});
});

View File

@@ -9,6 +9,8 @@ import {
type Config,
type FallbackModelHandler,
type FallbackIntent,
type ValidationHandler,
type ValidationIntent,
TerminalQuotaError,
ModelNotFoundError,
type UserTierId,
@@ -19,7 +21,10 @@ import {
import { useCallback, useEffect, useRef, useState } from 'react';
import { type UseHistoryManagerReturn } from './useHistoryManager.js';
import { MessageType } from '../types.js';
import { type ProQuotaDialogRequest } from '../contexts/UIStateContext.js';
import {
type ProQuotaDialogRequest,
type ValidationDialogRequest,
} from '../contexts/UIStateContext.js';
interface UseQuotaAndFallbackArgs {
config: Config;
@@ -36,7 +41,10 @@ export function useQuotaAndFallback({
}: UseQuotaAndFallbackArgs) {
const [proQuotaRequest, setProQuotaRequest] =
useState<ProQuotaDialogRequest | null>(null);
const [validationRequest, setValidationRequest] =
useState<ValidationDialogRequest | null>(null);
const isDialogPending = useRef(false);
const isValidationPending = useRef(false);
// Set up Flash fallback handler
useEffect(() => {
@@ -120,6 +128,36 @@ export function useQuotaAndFallback({
config.setFallbackModelHandler(fallbackHandler);
}, [config, historyManager, userTier, setModelSwitchedFromQuotaError]);
// Set up validation handler for 403 VALIDATION_REQUIRED errors
useEffect(() => {
const validationHandler: ValidationHandler = async (
validationLink,
validationDescription,
learnMoreUrl,
): Promise<ValidationIntent> => {
if (isValidationPending.current) {
return 'cancel'; // A validation dialog is already active
}
isValidationPending.current = true;
const intent: ValidationIntent = await new Promise<ValidationIntent>(
(resolve) => {
// Call setValidationRequest directly - same pattern as proQuotaRequest
setValidationRequest({
validationLink,
validationDescription,
learnMoreUrl,
resolve,
});
},
);
return intent;
};
config.setValidationHandler(validationHandler);
}, [config]);
const handleProQuotaChoice = useCallback(
(choice: FallbackIntent) => {
if (!proQuotaRequest) return;
@@ -148,9 +186,35 @@ export function useQuotaAndFallback({
[proQuotaRequest, historyManager, config, setModelSwitchedFromQuotaError],
);
const handleValidationChoice = useCallback(
(choice: ValidationIntent) => {
// Guard against double-execution (e.g. rapid clicks) and stale requests
if (!isValidationPending.current || !validationRequest) return;
// Immediately clear the flag to prevent any subsequent calls from passing the guard
isValidationPending.current = false;
validationRequest.resolve(choice);
setValidationRequest(null);
if (choice === 'change_auth') {
historyManager.addItem(
{
type: MessageType.INFO,
text: 'Use /auth to change authentication method.',
},
Date.now(),
);
}
},
[validationRequest, historyManager],
);
return {
proQuotaRequest,
handleProQuotaChoice,
validationRequest,
handleValidationChoice,
};
}