feat(v5): Back port cost control errors (#2965)
This commit is contained in:
parent
8c84c4d823
commit
ee9fddb152
17 changed files with 810 additions and 97 deletions
6
.changeset/bright-errors-block.md
Normal file
6
.changeset/bright-errors-block.md
Normal 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)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ export interface AskAiScreenStateProps<
|
|||
onNewConversation: () => void;
|
||||
memoryEnabled?: boolean;
|
||||
resultBadgeKey?: string;
|
||||
promptBlockingErrorId?: string;
|
||||
}
|
||||
|
||||
export const AskAiScreenState = React.memo(
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
170
packages/docsearch-react/src/utils/askAiBlockingMatchers.ts
Normal file
170
packages/docsearch-react/src/utils/askAiBlockingMatchers.ts
Normal 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())
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue