1
0
Fork 0

feat(v5): Back port cost control errors (#2965)

This commit is contained in:
Paul Jankowski 2026-08-06 14:40:33 -04:00 committed by GitHub
parent 8c84c4d823
commit ee9fddb152
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 810 additions and 97 deletions

View file

@ -0,0 +1,6 @@
---
'@docsearch/css': patch
'@docsearch/react': patch
---
Surface Agent Studio cost-control errors and block prompts until the user can recover. [#2878](https://github.com/algolia/docsearch/pull/2878)

View file

@ -2,7 +2,7 @@
"files": [
{
"path": "packages/docsearch-css/dist/style.css",
"maxSize": "8.1 kB"
"maxSize": "8.2 kB"
},
{
"path": "packages/docsearch-react/dist/umd/index.js",

View file

@ -537,10 +537,15 @@
color: var(--docsearch-text-color);
font-size: 12px;
border: 1px solid var(--docsearch-error-soft-color);
animation: slide-down 0.3s ease-out;
width: 100%;
}
@media (prefers-reduced-motion: no-preference) {
.DocSearch-AskAiScreen-Error--ThreadDepth {
animation: slide-down 0.3s ease-out;
}
}
@keyframes slide-down {
from {
opacity: 0;
@ -572,6 +577,11 @@
color: rgb(153 27 27);
}
.DocSearch-ThreadDepthError-Link:focus-visible {
outline: 2px solid var(--docsearch-focus-color);
outline-offset: 2px;
}
.DocSearch-AskAiScreen-FeedbackText {
font-size: 0.7em;
font-weight: 400;

View file

@ -405,6 +405,28 @@
gap: 0.5rem;
}
.DocSearch-Sidepanel-PromptBlockingBanner {
padding: 0.75rem;
color: var(--docsearch-sidepanel-text-base);
font-size: 0.75rem;
border: 1px solid var(--docsearch-error-soft-color);
border-radius: 0.25rem;
background: var(--docsearch-sidepanel-background);
}
.DocSearch-Sidepanel-PromptBlockingBanner p {
margin: 0;
}
.DocSearch-Sidepanel-PromptBlockingBanner p + p {
margin-top: 0.5rem;
}
.DocSearch-Sidepanel-PromptBlockingBanner .DocSearch-ThreadDepthError-Link:focus-visible {
outline: 2px solid var(--docsearch-sidepanel-primary);
outline-offset: 2px;
}
.DocSearch-Sidepanel-Prompt--form:focus-within {
border-color: var(--docsearch-sidepanel-primary);
}

View file

@ -18,8 +18,11 @@ import type {
import { type AIMessage, type ToolCalls } from './types/AskiAi';
import {
extractLinksFromMessage,
getAskAiBlockingBannerMessage,
getMessageContent,
isAskAiPromptBlockingError,
isThreadDepthError,
showAskAiBlockingBannerNewConversationLink,
isAIToolPart,
getAgentPromptSuggestions,
} from './utils/ai';
@ -199,7 +202,7 @@ function AskAiExchangeCard({
[translations]
);
const isThreadDepth = isThreadDepthError(askAiError);
const isPromptBlockingError = isAskAiPromptBlockingError(askAiError);
const assistantContent = useMemo(
() => getMessageContent(assistantMessage),
@ -252,7 +255,7 @@ function AskAiExchangeCard({
{loadingStatus === 'error' &&
askAiError &&
isLastExchange &&
!isThreadDepth && (
!isPromptBlockingError && (
<div className="DocSearch-AskAiScreen-Error" role="alert">
<AlertIcon aria-hidden="true" />
<div className="DocSearch-AskAiScreen-Error-Content">
@ -398,10 +401,15 @@ export function AskAiScreen({
const { messages, tools, askAiError, status, memoryEnabled } = props;
// Check if there's a thread depth error
const hasThreadDepthError = useMemo(() => {
return status === 'error' && isThreadDepthError(askAiError);
const hasPromptBlockingError = useMemo(() => {
return status === 'error' && isAskAiPromptBlockingError(askAiError);
}, [status, askAiError]);
const blockingMessage = useMemo(
() => getAskAiBlockingBannerMessage(askAiError),
[askAiError]
);
const showNewConversationLink =
showAskAiBlockingBannerNewConversationLink(askAiError);
// Group messages into exchanges (user + assistant pairs)
const exchanges: Exchange[] = useMemo(() => {
@ -418,48 +426,47 @@ export function AskAiScreen({
}
}
// If there's a thread depth error, remove the last exchange (the one that triggered the error)
// We only want to show successful exchanges
if (hasThreadDepthError && grouped.length > 0) {
// Check if the last exchange has no assistant message (failed to complete)
const lastExchange = grouped[grouped.length - 1];
if (!lastExchange.assistantMessage) {
grouped.pop();
}
}
return grouped;
}, [messages, hasThreadDepthError]);
}, [messages]);
const handleSearchQueryClick = (query: string): void => {
props.onAskAiToggle(false);
props.setQuery(query);
};
// Only show the thread depth error if we have assistant messages
const showThreadDepthError =
hasThreadDepthError && messages.some((m) => m.role === 'assistant');
const showBlockingBanner =
hasPromptBlockingError &&
(!isThreadDepthError(askAiError) ||
messages.some((message) => message.role === 'assistant'));
return (
<div className="DocSearch-AskAiScreen DocSearch-AskAiScreen-Container">
{/* Thread Depth Error */}
{showThreadDepthError && (
<div className="DocSearch-AskAiScreen-MessageContent DocSearch-AskAiScreen-Error DocSearch-AskAiScreen-Error--ThreadDepth">
<div className="DocSearch-AskAiScreen-Error-Content">
<p>
{threadDepthExceededMessage}{' '}
<button
type="button"
className="DocSearch-ThreadDepthError-Link"
onClick={props.onNewConversation}
>
{startNewConversationButtonText}
</button>{' '}
to continue.
</p>
<div id={props.promptBlockingErrorId} role="alert">
{showBlockingBanner && (
<div className="DocSearch-AskAiScreen-MessageContent DocSearch-AskAiScreen-Error DocSearch-AskAiScreen-Error--ThreadDepth">
<div className="DocSearch-AskAiScreen-Error-Content">
<p className="DocSearch-AskAiScreen-Error-Title">
{blockingMessage ??
(isThreadDepthError(askAiError)
? threadDepthExceededMessage
: 'This conversation cannot continue.')}
</p>
{showNewConversationLink && (
<p>
<button
type="button"
className="DocSearch-ThreadDepthError-Link"
onClick={props.onNewConversation}
>
{startNewConversationButtonText}
</button>{' '}
to continue.
</p>
)}
</div>
</div>
</div>
)}
)}
</div>
<AskAiScreenDisclaimer disclaimerText={disclaimerText} />

View file

@ -80,6 +80,7 @@ export interface AskAiScreenStateProps<
onNewConversation: () => void;
memoryEnabled?: boolean;
resultBadgeKey?: string;
promptBlockingErrorId?: string;
}
export const AskAiScreenState = React.memo(

View file

@ -45,6 +45,8 @@ import {
} from './utils';
import {
buildDummyAskAiHit,
isAgentStudioTokenOutputLimitError,
isAskAiPromptBlockingError,
isThreadDepthError,
EMPTY_TOOLS,
} from './utils/ai';
@ -145,6 +147,7 @@ export function DocSearchAskAiModal({
const { containerRef, modalRef, formElementRef, dropdownRef, inputRef } =
useModalRefs();
const promptBlockingErrorId = React.useId();
const { initialQuery, initialQueryFromSelection } =
useInitialModalQuery(initialQueryFromProp);
@ -258,12 +261,30 @@ export function DocSearchAskAiModal({
chatId,
]);
// Check if there's a thread depth error (AI-217)
const hasThreadDepthError = React.useMemo(() => {
const hasPromptBlockingError = React.useMemo(() => {
return (
status === 'error' && isThreadDepthError(askAiError as Error | undefined)
status === 'error' &&
isAskAiPromptBlockingError(askAiError as Error | undefined)
);
}, [status, askAiError]);
const shouldBlockPrompt =
hasPromptBlockingError &&
(!isThreadDepthError(askAiError) ||
messages.some((message) => message.role === 'assistant'));
const promptBlockingChrome = React.useMemo(() => {
if (
!shouldBlockPrompt ||
askAiState === 'new-conversation' ||
askAiState === 'conversation-history'
) {
return undefined;
}
return isAgentStudioTokenOutputLimitError(askAiError)
? ('minimal' as const)
: ('full' as const);
}, [askAiError, askAiState, shouldBlockPrompt]);
const saveRecentSearch = useSaveRecentSearch({
favoriteSearches,
@ -550,9 +571,8 @@ export function DocSearchAskAiModal({
askAiError={askAiError}
askAiState={askAiState}
setAskAiState={setAskAiState}
isThreadDepthError={
hasThreadDepthError && askAiState !== 'new-conversation'
}
promptBlockingChrome={promptBlockingChrome}
promptBlockingErrorId={promptBlockingErrorId}
onClose={onClose}
onAskAiToggle={onAskAiToggle}
onAskAgain={(query) => {
@ -601,6 +621,7 @@ export function DocSearchAskAiModal({
selectSuggestedQuestion={selectSuggestedQuestion}
memoryEnabled={memoryEnabled}
resultBadgeKey={props.resultBadgeKey}
promptBlockingErrorId={promptBlockingErrorId}
onAskAiToggle={onAskAiToggle}
onNewConversation={handleNewConversation}
onItemClick={(item, event) => {

View file

@ -18,6 +18,7 @@ import {
EMPTY_TOOLS,
isAIToolPart,
getAgentPromptSuggestions,
isAskAiPromptBlockingError,
} from '../utils/ai';
import { groupConsecutiveToolResults } from '../utils/groupConsecutiveToolResults';
@ -178,22 +179,25 @@ const ConversationExchange = React.forwardRef<
</div>
<div className="DocSearch-AskAiScreen-Message DocSearch-AskAiScreen-Message--assistant">
<div className="DocSearch-AskAiScreen-MessageContent">
{status === 'error' && streamError && isLastExchange && (
<div className="DocSearch-AskAiScreen-Error" role="alert">
<AlertIcon aria-hidden="true" />
<div className="DocSearch-AskAiScreen-Error-Content">
<h4 className="DocSearch-AskAiScreen-Error-Title">
{errorTitleText}
</h4>
<MemoizedMarkdown
content={streamError.message}
copyButtonText=""
copyButtonCopiedText=""
isStreaming={false}
/>
{status === 'error' &&
streamError &&
isLastExchange &&
!isAskAiPromptBlockingError(streamError) && (
<div className="DocSearch-AskAiScreen-Error" role="alert">
<AlertIcon aria-hidden="true" />
<div className="DocSearch-AskAiScreen-Error-Content">
<h4 className="DocSearch-AskAiScreen-Error-Title">
{errorTitleText}
</h4>
<MemoizedMarkdown
content={streamError.message}
copyButtonText=""
copyButtonCopiedText=""
isStreaming={false}
/>
</div>
</div>
</div>
)}
)}
{assistantParts.map((part, idx) => {
const index = idx;

View file

@ -16,6 +16,9 @@ export type PromptFormTranslations = Partial<{
promptDisclaimerText: string;
promptLabelText: string;
promptAriaLabelText: string;
startNewConversationButtonText: string;
blockingErrorContinueText: string;
blockingErrorFallbackText: string;
}>;
type Props = {
@ -24,18 +27,33 @@ type Props = {
translations?: PromptFormTranslations;
onSend: (prompt: string) => void;
onStopStreaming: () => void;
blockingErrorMessage?: string;
showBlockingError?: boolean;
showNewConversationLink?: boolean;
onStartNewConversation: () => void;
};
const MAX_PROMPT_ROWS = 8;
export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
(
{ exchanges, isStreaming, translations = {}, onSend, onStopStreaming },
{
exchanges,
isStreaming,
translations = {},
onSend,
onStopStreaming,
blockingErrorMessage,
showBlockingError = false,
showNewConversationLink = true,
onStartNewConversation,
},
ref
): JSX.Element => {
const isMobile = useIsMobile();
const [userPrompt, setUserPrompt] = React.useState('');
const promptRef = React.useRef<HTMLTextAreaElement>(null);
const blockingErrorId = React.useId();
React.useImperativeHandle(
ref,
@ -49,6 +67,9 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
promptDisclaimerText = 'Answers are generated with AI which can make mistakes.',
promptLabelText = 'Press Enter to send, or Shift and Enter for new line.',
promptAriaLabelText = 'Prompt input',
startNewConversationButtonText = 'Start a new conversation',
blockingErrorContinueText = 'to continue.',
blockingErrorFallbackText = 'This conversation cannot continue.',
} = translations;
const managePromptHeight = (): void => {
@ -72,7 +93,7 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
};
const handleSend = (): void => {
if (isStreaming) return;
if (isStreaming || showBlockingError) return;
const prompt = userPrompt.trim();
@ -93,7 +114,7 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
e: React.KeyboardEvent<HTMLTextAreaElement>
): void => {
// Allow Enter to work normally (new line) when streaming
if (isStreaming) return;
if (isStreaming || showBlockingError) return;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@ -116,12 +137,31 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
return (
<div className="DocSearch-Sidepanel-Prompt">
<div id={blockingErrorId} role="alert">
{showBlockingError && (
<div className="DocSearch-Sidepanel-PromptBlockingBanner">
<p>{blockingErrorMessage ?? blockingErrorFallbackText}</p>
{showNewConversationLink && (
<p>
<button
type="button"
className="DocSearch-ThreadDepthError-Link"
onClick={onStartNewConversation}
>
{startNewConversationButtonText}
</button>{' '}
{blockingErrorContinueText}
</p>
)}
</div>
)}
</div>
<form
className="DocSearch-Sidepanel-Prompt--form"
onSubmit={(e) => {
e.preventDefault();
if (isStreaming) return;
if (isStreaming || showBlockingError) return;
handleSend();
}}
@ -133,6 +173,9 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
value={userPrompt}
aria-label={promptAriaLabelText}
aria-labelledby="prompt-label"
aria-describedby={showBlockingError ? blockingErrorId : undefined}
aria-disabled={showBlockingError}
readOnly={showBlockingError}
autoComplete="off"
translate="no"
rows={isMobile ? 1 : 2}
@ -161,6 +204,7 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
title="Send question"
className="DocSearch-Sidepanel-Prompt--submit"
aria-disabled={userPrompt === ''}
disabled={showBlockingError}
>
<SendIcon />
</button>

View file

@ -12,7 +12,14 @@ import { useAskAi } from '../useAskAi';
import { useIsMobile } from '../useIsMobile';
import { useSearchClient } from '../useSearchClient';
import { useSuggestedQuestions } from '../useSuggestedQuestions';
import { EMPTY_TOOLS, buildDummyAskAiHit } from '../utils/ai';
import {
EMPTY_TOOLS,
buildDummyAskAiHit,
getAskAiBlockingBannerMessage,
isAskAiPromptBlockingError,
isThreadDepthError,
showAskAiBlockingBannerNewConversationLink,
} from '../utils/ai';
import { ConversationHistoryScreen } from './ConversationHistoryScreen';
import type { ConversationScreenTranslations } from './ConversationScreen';
@ -208,6 +215,12 @@ function SidepanelInner(
});
const prevStatus = React.useRef(status);
const showPromptBlockingError =
sidepanelState === 'conversation' &&
status === 'error' &&
isAskAiPromptBlockingError(askAiError) &&
(!isThreadDepthError(askAiError) ||
messages.some((message) => message.role === 'assistant'));
const handleSend = React.useCallback(
(prompt: string): void => {
@ -434,6 +447,12 @@ function SidepanelInner(
translations={translations.promptForm}
onSend={handleSend}
onStopStreaming={handleStopStreaming}
blockingErrorMessage={getAskAiBlockingBannerMessage(askAiError)}
showBlockingError={showPromptBlockingError}
showNewConversationLink={showAskAiBlockingBannerNewConversationLink(
askAiError
)}
onStartNewConversation={handleStartNewConversation}
/>
<footer className="DocSearch-Sidepanel-Footer">
<span className="DocSearch-Logo DocSearch-Sidepanel--powered-by">

View file

@ -1,10 +1,11 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import type { UIMessage } from 'ai';
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { AskAiScreen } from '../AskAiScreen';
import { PromptForm } from '../Sidepanel/PromptForm';
const baseProps = {
indexName: 'idx',
@ -90,4 +91,126 @@ describe('AskAiScreen', () => {
'Let me look that up.\n\nDocusaurus is a static site generator.'
);
});
it('shows cost-control errors in a blocking banner', () => {
const messages: UIMessage[] = [
{
id: '1',
role: 'user',
parts: [{ type: 'text', text: 'hello' }],
},
];
const onNewConversation = vi.fn();
const { container } = render(
<AskAiScreen
{...baseProps}
messages={messages}
status="error"
askAiError={new Error('Too many requests (AI-205)')}
onNewConversation={onNewConversation}
/>
);
expect(
within(container).getByText('Too many requests')
).toBeInTheDocument();
expect(within(container).queryByText('Chat error')).not.toBeInTheDocument();
expect(within(container).getByText('hello')).toBeInTheDocument();
fireEvent.click(
within(container).getByRole('button', {
name: 'Start a new conversation',
})
);
expect(onNewConversation).toHaveBeenCalledTimes(1);
});
it('does not offer a new conversation for token output errors', () => {
const messages: UIMessage[] = [
{
id: '1',
role: 'user',
parts: [{ type: 'text', text: 'hello' }],
},
];
const error = new Error(
JSON.stringify({
error: 'Could not complete response due to token output limits',
type: 'TokenOutputLimitError',
})
);
const { container } = render(
<AskAiScreen
{...baseProps}
messages={messages}
status="error"
askAiError={error}
/>
);
expect(
within(container).getByText(
'Could not complete response due to token output limits'
)
).toBeInTheDocument();
expect(
within(container).queryByRole('button', {
name: 'Start a new conversation',
})
).not.toBeInTheDocument();
});
});
describe('Sidepanel PromptForm', () => {
it('shows a blocking error and recovery action', () => {
const onStartNewConversation = vi.fn();
const { container } = render(
<PromptForm
exchanges={[]}
isStreaming={false}
blockingErrorMessage="Rate limit exceeded"
showBlockingError={true}
onSend={vi.fn()}
onStopStreaming={vi.fn()}
onStartNewConversation={onStartNewConversation}
/>
);
expect(within(container).getByRole('alert')).toHaveTextContent(
'Rate limit exceeded'
);
expect(within(container).getByRole('textbox')).toHaveAttribute('readonly');
expect(within(container).getByRole('textbox')).toHaveAttribute(
'aria-disabled',
'true'
);
fireEvent.click(
within(container).getByRole('button', {
name: 'Start a new conversation',
})
);
expect(onStartNewConversation).toHaveBeenCalledTimes(1);
});
it('does not submit a draft while blocked', () => {
const onSend = vi.fn();
const props = {
exchanges: [],
isStreaming: false,
onSend,
onStopStreaming: vi.fn(),
onStartNewConversation: vi.fn(),
};
const { container, rerender } = render(<PromptForm {...props} />);
const input = within(container).getByRole('textbox');
fireEvent.change(input, { target: { value: 'draft question' } });
rerender(<PromptForm {...props} showBlockingError={true} />);
fireEvent.keyDown(input, { key: 'Enter' });
expect(onSend).not.toHaveBeenCalled();
expect(input).toHaveValue('draft question');
});
});

View file

@ -249,19 +249,38 @@ describe('AskAiSearchBox', () => {
expect(onAskAgain).not.toHaveBeenCalled();
});
it('uses thread-depth behavior in Ask AI mode', () => {
it('uses prompt-blocking behavior in Ask AI mode', () => {
const onNewConversation = vi.fn();
const onAskAgain = vi.fn();
renderAskAiSearchBox({ isThreadDepthError: true, onNewConversation });
renderAskAiSearchBox({
promptBlockingChrome: 'full',
promptBlockingErrorId: 'blocking-error',
onNewConversation,
onAskAgain,
});
const input = screen.getByPlaceholderText('Conversation limit reached');
expect(input).toBeDisabled();
expect(input).toHaveAttribute('readonly');
expect(input).toHaveAttribute('aria-disabled', 'true');
expect(input).toHaveAttribute('aria-describedby', 'blocking-error');
expect(fireEvent.keyDown(input, { key: 'Tab' })).toBe(true);
expect(fireEvent.keyDown(input, { key: 'Enter' })).toBe(false);
expect(onAskAgain).not.toHaveBeenCalled();
fireEvent.click(
screen.getByRole('button', { name: 'Back to keyword search' })
screen.getAllByRole('button', { name: 'Start a new conversation' })[0]
);
expect(onNewConversation).toHaveBeenCalledTimes(1);
});
it('keeps the regular placeholder with minimal blocking chrome', () => {
renderAskAiSearchBox({ promptBlockingChrome: 'minimal' });
expect(
screen.getByPlaceholderText('Ask another question...')
).toHaveAttribute('readonly');
});
});

View file

@ -1,3 +1,5 @@
import { readStringField } from './utils/askAiBlockingMatchers';
export const agentStudioBaseUrl = (appId: string): string =>
`https://${appId}.algolia.net/agent-studio/1`;
@ -8,26 +10,64 @@ interface AgentStudioValidationError extends Error {
// Parse Agent Studio errors as they are returned as JSON rather than Markdown/text
export const getAgentStudioErrorMessage = (error: Error): Error => {
let errorMessage = error.message;
const raw = error.message;
let parsed: unknown;
try {
const parsedError = JSON.parse(error.message) as Error;
// Check for known errors that we know how to parse
if (parsedError.name === 'ValidationError') {
const validationError = parsedError as AgentStudioValidationError;
if (validationError.detail && validationError.detail.length > 0) {
const { msg, loc } = validationError.detail[0];
const field = loc.at(-1);
errorMessage = `${msg}: ${field}`;
}
} else {
errorMessage = parsedError.message;
}
parsed = JSON.parse(raw);
} catch {
// We don't care about this catch, we default to the error.message above
return new Error(raw);
}
let iterations = 0;
while (typeof parsed === 'string' && iterations < 10) {
iterations += 1;
const serializedError = parsed.trim();
try {
parsed = JSON.parse(serializedError);
} catch {
return new Error(serializedError);
}
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return new Error(raw);
}
const parsedRecord = parsed as Record<string, unknown>;
const parsedError = parsed as Error & {
code?: string;
errorCode?: string;
error?: string;
};
let errorMessage = raw;
if (parsedError.name === 'ValidationError') {
const validationError = parsedError as AgentStudioValidationError;
if (validationError.detail && validationError.detail.length > 0) {
const { msg, loc } = validationError.detail[0];
const field = loc.at(-1);
errorMessage = `${msg}: ${field}`;
}
} else {
errorMessage =
readStringField(parsedRecord, 'message') ??
readStringField(parsedRecord, 'error') ??
raw;
}
const code = parsedError.code ?? parsedError.errorCode;
if (
typeof code === 'string' &&
code.trim() &&
!errorMessage.toUpperCase().includes(code.trim().toUpperCase())
) {
errorMessage = `${errorMessage} (${code.trim()})`;
}
return new Error(errorMessage);

View file

@ -63,7 +63,8 @@ interface AskAiSearchBoxProps extends AutocompleteApi<
setAskAiState: (state: AskAiState) => void;
onNewConversation: () => void;
onViewConversationHistory: () => void;
isThreadDepthError?: boolean;
promptBlockingChrome?: 'minimal' | 'full';
promptBlockingErrorId?: string;
}
export function AskAiSearchBox({
@ -111,16 +112,14 @@ export function AskAiSearchBox({
const renderMoreOptions =
props.isAskAiActive && askAiState !== 'conversation-history';
// Use the thread depth error state passed from parent
const isThreadDepthError = props.isThreadDepthError || false;
const isPromptBlocked = Boolean(props.promptBlockingChrome);
let searchPlaceholder = props.placeholder;
if (askAiState === 'new-conversation') {
searchPlaceholder = newConversationPlaceholder;
}
// Override placeholder when thread depth error occurs (only in Ask AI mode)
if (isThreadDepthError && props.isAskAiActive) {
if (props.promptBlockingChrome === 'full' && props.isAskAiActive) {
searchPlaceholder = threadDepthErrorPlaceholder;
}
@ -158,6 +157,12 @@ export function AskAiSearchBox({
? ('enter' as const)
: ('search' as const),
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (isPromptBlocked && props.isAskAiActive && blockedKeys.has(e.key)) {
e.preventDefault();
e.stopPropagation();
return;
}
let askAiHandled = false;
// block these up, down, enter listeners when Ask AI is active
if (props.isAskAiActive && blockedKeys.has(e.key)) {
@ -182,6 +187,11 @@ export function AskAiSearchBox({
origOnKeyDown?.(e);
},
onChange: (e: React.ChangeEvent<HTMLInputElement>): void => {
if (isPromptBlocked && props.isAskAiActive) {
e.preventDefault();
return;
}
if (props.isAskAiActive) {
props.setQuery(e.currentTarget.value);
// block search when Ask AI is active
@ -193,12 +203,17 @@ export function AskAiSearchBox({
}
origOnChange?.(e);
},
disabled: isAskAiStreaming || (isThreadDepthError && props.isAskAiActive),
disabled: isAskAiStreaming,
readOnly: isPromptBlocked && props.isAskAiActive,
'aria-disabled': isPromptBlocked && props.isAskAiActive,
'aria-describedby':
isPromptBlocked && props.isAskAiActive
? props.promptBlockingErrorId
: undefined,
};
const handleAskAiBackClick = React.useCallback((): void => {
// If there's a thread depth error, start a new conversation instead of exiting
if (isThreadDepthError) {
if (isPromptBlocked) {
props.onNewConversation();
return;
}
@ -210,15 +225,23 @@ export function AskAiSearchBox({
}
onAskAiToggle(false);
}, [askAiState, isThreadDepthError, onAskAiToggle, setAskAiState, props]);
}, [askAiState, isPromptBlocked, onAskAiToggle, setAskAiState, props]);
const leadingElement = props.isAskAiActive ? (
<button
type="button"
tabIndex={0}
className="DocSearch-Action DocSearch-AskAi-Return"
title={backToKeywordSearchButtonText}
aria-label={backToKeywordSearchButtonAriaLabel}
title={
isPromptBlocked
? startNewConversationText
: backToKeywordSearchButtonText
}
aria-label={
isPromptBlocked
? startNewConversationText
: backToKeywordSearchButtonAriaLabel
}
onClick={handleAskAiBackClick}
>
<BackIcon />

View file

@ -1,16 +1,20 @@
import { describe, it, expect } from 'vitest';
import { getAgentStudioErrorMessage } from '../../askai';
import type {
AIMessage,
AIMessagePart,
SearchToolPart,
} from '../../types/AskiAi';
import {
getAskAiBlockingBannerMessage,
getAgentPromptSuggestions,
getSearchToolQueries,
isAIToolPart,
isAlgoliaMCPSearchOutputPart,
isThreadDepthError,
isAskAiPromptBlockingError,
showAskAiBlockingBannerNewConversationLink,
sanitizeMessagesForRequest,
getMessageContent,
} from '../ai';
@ -51,6 +55,101 @@ describe('isThreadDepthError', () => {
});
});
describe('Agent Studio prompt-blocking errors', () => {
it.each(['AI-203', 'AI-205', 'AI-224', 'AI-225'])(
'blocks error code %s',
(code) => {
expect(isAskAiPromptBlockingError(new Error(`Failed (${code})`))).toBe(
true
);
}
);
it.each([
'Rate limit exceeded',
'Domain is not whitelisted',
'Maximum token limit reached',
'Maximum agent steps exceeded',
])('blocks matching message: %s', (errorMessage) => {
expect(isAskAiPromptBlockingError(new Error(errorMessage))).toBe(true);
});
it('does not block unrelated errors', () => {
expect(isAskAiPromptBlockingError(new Error('Network failed'))).toBe(false);
});
it('hides recovery when a new conversation cannot resolve the error', () => {
expect(
showAskAiBlockingBannerNewConversationLink(
new Error('Request blocked for this domain')
)
).toBe(false);
expect(
showAskAiBlockingBannerNewConversationLink(
new Error('Could not complete response due to token output limits')
)
).toBe(false);
expect(
showAskAiBlockingBannerNewConversationLink(
new Error('Rate limit exceeded')
)
).toBe(true);
});
it('uses the human message from JSON errors', () => {
const error = new Error(
JSON.stringify({
error: 'TOO_MANY_REQUESTS',
message: 'Rate limit exceeded. Retry after 60 seconds.',
})
);
expect(getAskAiBlockingBannerMessage(error)).toBe(
'Rate limit exceeded. Retry after 60 seconds.'
);
});
it('matches retry-after messages and case-insensitive JSON fields', () => {
const error = new Error(
JSON.stringify({ Message: 'Please retry after 60 seconds' })
);
expect(isAskAiPromptBlockingError(error)).toBe(true);
expect(getAskAiBlockingBannerMessage(error)).toBe(
'Please retry after 60 seconds'
);
});
it('provides a fallback for code-only conversation limits', () => {
const error = new Error(JSON.stringify({ code: 'AI-217' }));
expect(getAskAiBlockingBannerMessage(error)).toBeUndefined();
});
it('normalizes nested JSON while preserving the error code', () => {
const error = getAgentStudioErrorMessage(
new Error(
JSON.stringify(
JSON.stringify({ message: 'Too many requests', code: 'AI-205' })
)
)
);
expect(error.message).toBe('Too many requests (AI-205)');
expect(isAskAiPromptBlockingError(error)).toBe(true);
expect(getAskAiBlockingBannerMessage(error)).toBe('Too many requests');
});
it('uses a fallback for type-only token output errors', () => {
const error = new Error(JSON.stringify({ type: 'TokenOutputLimitError' }));
expect(isAskAiPromptBlockingError(error)).toBe(true);
expect(getAskAiBlockingBannerMessage(error)).toBe(
'Could not complete response due to token output limits'
);
});
});
function message(id: string, parts: AIMessagePart[]): AIMessage {
return {
id,

View file

@ -13,6 +13,11 @@ import type {
ToolCalls,
} from '../types/AskiAi';
import {
isTokenOutputLimitError,
readStringField,
resolvePromptBlockingError,
} from './askAiBlockingMatchers';
import { sanitizeUrl, sanitizeUserInput } from './sanitize';
export interface ExtractedLink {
@ -129,6 +134,106 @@ export function isThreadDepthError(error?: Error): boolean {
return /(?:ai-217|conversation\s+depth)/i.test(error.message ?? '');
}
export function isAskAiPromptBlockingError(error?: Error): boolean {
return Boolean(
error &&
(isThreadDepthError(error) || resolvePromptBlockingError(error).blocking)
);
}
export function isAgentStudioTokenOutputLimitError(error?: Error): boolean {
return isTokenOutputLimitError(error);
}
export function showAskAiBlockingBannerNewConversationLink(
error?: Error
): boolean {
if (!error || isThreadDepthError(error)) {
return true;
}
return resolvePromptBlockingError(error).showNewConversationLink;
}
function extractAgentStudioErrorFieldMessage(raw: string): string | undefined {
let value = raw.trim();
for (let iteration = 0; iteration < 10 && value; iteration++) {
try {
const parsed: unknown = JSON.parse(value);
if (typeof parsed === 'string') {
value = parsed.trim();
continue;
}
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const record = parsed as Record<string, unknown>;
const message = readStringField(record, 'message');
const error = readStringField(record, 'error');
if (message) {
return message;
}
if (error) {
return error;
}
}
const fieldMatch =
/"message"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(value) ??
/"error"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(value);
return fieldMatch?.[1]
?.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\')
.trim();
} catch {
if (value.includes('\\"')) {
value = value.replace(/\\"/g, '"').replace(/\\\\/g, '\\').trim();
continue;
}
return undefined;
}
}
return undefined;
}
export function getAskAiBlockingBannerMessage(
error?: Error
): string | undefined {
if (!error) {
return undefined;
}
const extracted = extractAgentStudioErrorFieldMessage(error.message ?? '');
const isCodeOnlyThreadDepthError =
isThreadDepthError(error) &&
(/^\s*AI-217\s*$/i.test(error.message) ||
(/^\s*\{.*\}\s*$/.test(error.message) && !extracted));
if (isCodeOnlyThreadDepthError) {
return undefined;
}
const message = (
extracted ?? error.message.replace(/\s*\(AI-\d{3}\)\s*$/i, '')
).trim();
if (message && !(message.startsWith('{') && message.endsWith('}'))) {
return message;
}
if (isAgentStudioTokenOutputLimitError(error)) {
return 'Could not complete response due to token output limits';
}
return undefined;
}
export const EMPTY_TOOLS: Readonly<ToolCalls> = Object.freeze({});
export function isAIToolPart(

View file

@ -0,0 +1,170 @@
interface BlockingMatchContext {
message: string;
messageLower: string;
parsedJson: Record<string, unknown> | null;
code: string | undefined;
}
interface BlockingMatcher {
matches: (context: BlockingMatchContext) => boolean;
showNewConversationLink?: boolean;
}
const PROMPT_BLOCKING_CODES = new Set(['AI-203', 'AI-205', 'AI-224', 'AI-225']);
export function readStringField(
value: Record<string, unknown>,
key: string
): string | undefined {
for (const [field, fieldValue] of Object.entries(value)) {
if (
field.toLowerCase() === key.toLowerCase() &&
typeof fieldValue === 'string' &&
fieldValue.trim() !== ''
) {
return fieldValue.trim();
}
}
return undefined;
}
function extractErrorCode(message: string): string | undefined {
const directMatch = /\b(AI-\d{3})\b/i.exec(message);
if (directMatch) {
return directMatch[1].toUpperCase();
}
return undefined;
}
function parseJson(message: string): Record<string, unknown> | null {
try {
const parsed: unknown = JSON.parse(message);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
// Plain-text errors are handled by the other matchers.
}
return null;
}
function matchesDomainBlock(message: string): boolean {
return (
/\brequest blocked for this domain\b/.test(message) ||
/\bblocked for this domain\b/.test(message)
);
}
function matchesRateLimit(message: string): boolean {
return (
/\b429\b/.test(message) ||
/\brate\s*limit/.test(message) ||
/\bretry\s+after\s+\d+/.test(message) ||
/\btoo\s+many\s+attempts\b/.test(message) ||
/\btoo_many_requests\b/.test(message)
);
}
function matchesTokenOutputLimit(message: string): boolean {
return (
/\btokenoutputlimiterror\b/.test(message) ||
/could not complete response due to token output limits/.test(message)
);
}
function matchesAccessOrLimit(message: string): boolean {
return (
/\bwhitelist(?:ed)?\b/.test(message) ||
/\bnot\s+allowed\s+for\s+this\s+domain\b/.test(message) ||
/\bcontext\s+length\b/.test(message) ||
/\b(?:max|maximum)\s+tokens?\b/.test(message) ||
/\btoken\s+limit\b/.test(message) ||
/\btoken\s+output\b/.test(message) ||
/\boutput\s+limits?\b/.test(message) ||
/\bstep\s+limit\b/.test(message) ||
/\b(?:max|maximum)(?:\s+agent)?\s+steps?\b/.test(message)
);
}
function jsonImpliesCostControl(value: Record<string, unknown>): boolean {
const type = readStringField(value, 'type') ?? '';
const error = readStringField(value, 'error') ?? '';
const message = readStringField(value, 'message') ?? '';
return (
/tokenoutput|outputlimit|steplimit|maxstep|ratelimit|domainnotallowed/i.test(
type
) ||
error.toUpperCase() === 'TOO_MANY_REQUESTS' ||
matchesRateLimit(`${error} ${message}`.toLowerCase()) ||
matchesAccessOrLimit(`${error} ${message}`.toLowerCase())
);
}
const BLOCKING_MATCHERS: BlockingMatcher[] = [
{
matches: ({ code }) => Boolean(code && PROMPT_BLOCKING_CODES.has(code)),
},
{
matches: ({ parsedJson }) =>
Boolean(
parsedJson &&
matchesDomainBlock(
(readStringField(parsedJson, 'message') ?? '').toLowerCase()
)
),
showNewConversationLink: false,
},
{
matches: ({ parsedJson }) =>
Boolean(parsedJson && jsonImpliesCostControl(parsedJson)),
},
{
matches: ({ messageLower }) => matchesTokenOutputLimit(messageLower),
showNewConversationLink: false,
},
{
matches: ({ messageLower }) => matchesRateLimit(messageLower),
},
{
matches: ({ messageLower }) => matchesDomainBlock(messageLower),
showNewConversationLink: false,
},
{
matches: ({ messageLower }) => matchesAccessOrLimit(messageLower),
},
];
export function resolvePromptBlockingError(error: Error): {
blocking: boolean;
showNewConversationLink: boolean;
} {
const message = error.message ?? '';
const context: BlockingMatchContext = {
message,
messageLower: message.toLowerCase(),
parsedJson: parseJson(message),
code: extractErrorCode(message),
};
const matches = BLOCKING_MATCHERS.filter((matcher) =>
matcher.matches(context)
);
return {
blocking: matches.length > 0,
showNewConversationLink: matches.every(
(matcher) => matcher.showNewConversationLink !== false
),
};
}
export function isTokenOutputLimitError(error?: Error): boolean {
return Boolean(
error && matchesTokenOutputLimit((error.message ?? '').toLowerCase())
);
}