1
0
Fork 0

feat(agentStudio): cost controls errors surface (#2878)

This commit is contained in:
Felipe Bermudez 2026-04-23 13:24:36 -05:00 committed by GitHub
parent cf42ab519b
commit 166b76de2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 801 additions and 158 deletions

View file

@ -12,8 +12,10 @@ import type { AIMessage } from './types/AskiAi';
import {
extractLinksFromMessage,
filterExchangesForThreadDepthError,
getAskAiBlockingBannerMessage,
getMessageContent,
getThreadDepthErrorUserFacingMessage,
isAskAiPromptBlockingError,
showAskAiBlockingBannerNewConversationLink,
isThreadDepthError,
} from './utils/ai';
import { groupConsecutiveToolResults } from './utils/groupConsecutiveToolResults';
@ -60,11 +62,7 @@ export type AskAiScreenTranslations = Partial<{
*/
errorTitleText: string;
/**
* Message shown when thread depth limit is exceeded (AI-217 error).
*/
threadDepthExceededMessage: string;
/**
* Button text for starting a new conversation after thread depth error.
* Button text for starting a new conversation after a blocking Ask AI error.
*/
startNewConversationButtonText: string;
}>;
@ -125,7 +123,7 @@ function AskAiExchangeCard({
duringToolCallText = 'Searching...',
} = translations;
const isThreadDepth = isThreadDepthError(askAiError);
const isPromptBlockingError = isAskAiPromptBlockingError(askAiError, Boolean(agentStudio));
const assistantContent = useMemo(() => getMessageContent(assistantMessage), [assistantMessage]);
const userContent = useMemo(() => getMessageContent(userMessage), [userMessage]);
@ -156,7 +154,7 @@ function AskAiExchangeCard({
</div>
<div className="DocSearch-AskAiScreen-Message DocSearch-AskAiScreen-Message--assistant">
<div className="DocSearch-AskAiScreen-MessageContent">
{loadingStatus === 'error' && askAiError && isLastExchange && !isThreadDepth && (
{loadingStatus === 'error' && askAiError && isLastExchange && !isPromptBlockingError && (
<div className="DocSearch-AskAiScreen-MessageContent DocSearch-AskAiScreen-Error">
<AlertIcon />
<div className="DocSearch-AskAiScreen-Error-Content">
@ -373,18 +371,21 @@ export function AskAiSourcesPanel({ urlsToDisplay, relatedSourcesText }: AskAiSo
export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps): JSX.Element | null {
const {
disclaimerText = 'Answers are generated with AI which can make mistakes. Verify responses.',
threadDepthExceededMessage = 'This conversation is now closed to keep responses accurate.',
startNewConversationButtonText = 'Start a new conversation',
} = translations;
const { messages, askAiError, status, agentStudio } = props;
// Check if there's a thread depth error
const hasThreadDepthError = useMemo(() => {
return status === 'error' && isThreadDepthError(askAiError);
}, [status, askAiError]);
const hasPromptBlockingError = useMemo(() => {
return status === 'error' && isAskAiPromptBlockingError(askAiError, Boolean(agentStudio));
}, [status, askAiError, agentStudio]);
const threadDepthApiMessage = useMemo(() => getThreadDepthErrorUserFacingMessage(askAiError), [askAiError]);
const blockingApiMessage = useMemo(() => getAskAiBlockingBannerMessage(askAiError), [askAiError]);
const showBlockingBannerNewConversationLink = showAskAiBlockingBannerNewConversationLink(
askAiError,
Boolean(agentStudio),
);
// Group messages into exchanges (user + assistant pairs)
const exchanges: Exchange[] = useMemo(() => {
@ -400,33 +401,35 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps):
}
}
return filterExchangesForThreadDepthError(grouped, hasThreadDepthError);
}, [messages, hasThreadDepthError]);
return filterExchangesForThreadDepthError(grouped, hasPromptBlockingError);
}, [messages, hasPromptBlockingError]);
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');
/** Thread depth only appears after at least one assistant reply;
* other Agent Studio blocks can occur on the first turn.
* */
const showBlockingBanner =
hasPromptBlockingError && (isThreadDepthError(askAiError) ? messages.some((m) => m.role === 'assistant') : true);
return (
<div className="DocSearch-AskAiScreen DocSearch-AskAiScreen-Container">
{/* Thread Depth Error */}
{showThreadDepthError && (
{/* Agent Studio cost-control errors */}
{showBlockingBanner && (
<div className="DocSearch-AskAiScreen-MessageContent DocSearch-AskAiScreen-Error DocSearch-AskAiScreen-Error--ThreadDepth">
<div className="DocSearch-AskAiScreen-Error-Content">
{threadDepthApiMessage ? (
<p className="DocSearch-AskAiScreen-Error-Title">{threadDepthApiMessage}</p>
{blockingApiMessage ? <p className="DocSearch-AskAiScreen-Error-Title">{blockingApiMessage}</p> : null}
{showBlockingBannerNewConversationLink ? (
<p>
<button type="button" className="DocSearch-ThreadDepthError-Link" onClick={props.onNewConversation}>
{startNewConversationButtonText}
</button>{' '}
to continue.
</p>
) : null}
<p>
{threadDepthExceededMessage}{' '}
<button type="button" className="DocSearch-ThreadDepthError-Link" onClick={props.onNewConversation}>
{startNewConversationButtonText}
</button>{' '}
to continue.
</p>
</div>
</div>
)}

View file

@ -36,7 +36,7 @@ import { useSuggestedQuestions } from './useSuggestedQuestions';
import { useTouchEvents } from './useTouchEvents';
import { useTrapFocus } from './useTrapFocus';
import { groupBy, identity, noop, removeHighlightTags, isModifierEvent, scrollTo as scrollToUtils } from './utils';
import { buildDummyAskAiHit, isThreadDepthError } from './utils/ai';
import { buildDummyAskAiHit, isAgentStudioTokenOutputLimitError, isAskAiPromptBlockingError } from './utils/ai';
import { manageLocalStorageQuota } from './utils/storage';
export type ModalTranslations = Partial<{
@ -445,10 +445,16 @@ export function DocSearchModal({
prevStatus.current = status;
}, [status, messages, conversations, disableUserPersonalization, stoppedStream]);
// Check if there's a thread depth error (AI-217)
const hasThreadDepthError = React.useMemo(() => {
return status === 'error' && isThreadDepthError(askAiError as Error | undefined);
}, [status, askAiError]);
const hasAskAiPromptBlockingError = React.useMemo(() => {
return status === 'error' && isAskAiPromptBlockingError(askAiError as Error | undefined, agentStudio);
}, [status, askAiError, agentStudio]);
const askAiBlockingChrome = React.useMemo((): 'minimal' | 'thread-depth' | undefined => {
const blocked = hasAskAiPromptBlockingError && askAiState !== 'new-conversation';
if (!blocked) return undefined;
// Match thread-depth modal chrome for all blocking errors except token output limit (minimal).
return isAgentStudioTokenOutputLimitError(askAiError as Error | undefined) ? 'minimal' : 'thread-depth';
}, [hasAskAiPromptBlockingError, askAiState, askAiError]);
const createSyntheticParent = React.useCallback(function createSyntheticParent(
item: InternalDocSearchHit,
@ -900,7 +906,8 @@ export function DocSearchModal({
askAiError={askAiError}
askAiState={askAiState}
setAskAiState={setAskAiState}
isThreadDepthError={hasThreadDepthError && askAiState !== 'new-conversation'}
isThreadDepthError={hasAskAiPromptBlockingError && askAiState !== 'new-conversation'}
askAiBlockingChrome={askAiBlockingChrome}
onClose={onClose}
onAskAiToggle={onAskAiToggle}
onAskAgain={(query) => {

View file

@ -58,6 +58,7 @@ interface SearchBoxProps
onNewConversation: () => void;
onViewConversationHistory: () => void;
isThreadDepthError?: boolean;
askAiBlockingChrome?: 'minimal' | 'thread-depth';
}
export function SearchBox({
@ -129,8 +130,8 @@ export function SearchBox({
searchPlaceholder = newConversationPlaceholder;
}
// Override placeholder when thread depth error occurs (only in Ask AI mode)
if (isThreadDepthError && props.isAskAiActive) {
// Override placeholder when prompt is blocked (only in Ask AI mode); cost-control uses minimal chrome
if (isThreadDepthError && props.isAskAiActive && props.askAiBlockingChrome !== 'minimal') {
searchPlaceholder = threadDepthErrorPlaceholder;
}

View file

@ -9,7 +9,7 @@ import type { StoredSearchPlugin } from '../stored-searches';
import { ToolCall, type ToolCallTranslations } from '../ToolCall';
import type { StoredAskAiState } from '../types';
import { isAIToolPart, type AIMessage } from '../types/AskiAi';
import { extractLinksFromMessage, getMessageContent, isThreadDepthError } from '../utils/ai';
import { extractLinksFromMessage, getMessageContent, isAskAiPromptBlockingError } from '../utils/ai';
import { groupConsecutiveToolResults } from '../utils/groupConsecutiveToolResults';
import { AggregatedSearchBlock } from './AggregatedSearchBlock';
@ -62,11 +62,7 @@ export type ConversationScreenTranslations = Partial<
*/
errorTitleText: string;
/**
* Message shown when thread depth limit is exceeded (AI-217).
*/
threadDepthExceededMessage: string;
/**
* Button label to start a new conversation after a thread depth error.
* Button label to start a new conversation after a blocking Ask AI error.
*/
startNewConversationButtonText: string;
}
@ -113,7 +109,7 @@ const ConversationExchange = React.forwardRef<HTMLDivElement, ConversationnExcha
errorTitleText = 'Chat error',
} = translations;
const isThreadDepth = isThreadDepthError(streamError);
const isPromptBlockingError = isAskAiPromptBlockingError(streamError, Boolean(agentStudio));
const assistantContent = useMemo(() => getMessageContent(assistantMessage), [assistantMessage]);
const userContent = useMemo(() => getMessageContent(userMessage), [userMessage]);
@ -140,7 +136,7 @@ const ConversationExchange = React.forwardRef<HTMLDivElement, ConversationnExcha
</div>
<div className="DocSearch-AskAiScreen-Message DocSearch-AskAiScreen-Message--assistant">
<div className="DocSearch-AskAiScreen-MessageContent">
{status === 'error' && streamError && isLastExchange && !isThreadDepth && (
{status === 'error' && streamError && isLastExchange && !isPromptBlockingError && (
<div className="DocSearch-AskAiScreen-MessageContent DocSearch-AskAiScreen-Error">
<AlertIcon />
<div className="DocSearch-AskAiScreen-Error-Content">

View file

@ -35,11 +35,7 @@ export type PromptFormTranslations = Partial<{
**/
threadDepthErrorPlaceholder: string;
/**
* Message shown in the thread-depth banner above the prompt (AI-217).
**/
threadDepthExceededMessage: string;
/**
* Button label in the thread-depth banner to start a new conversation.
* Button label in the blocking-error banner to start a new conversation.
**/
startNewConversationButtonText: string;
/**
@ -56,6 +52,8 @@ type Props = {
onStopStreaming: () => void;
showThreadDepthBanner: boolean;
threadDepthApiMessage?: string;
/** If false, the banner shows only the API message (for example, token output limit). */
showBlockingBannerNewConversationLink?: boolean;
onStartNewConversation: () => void;
};
@ -71,6 +69,7 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
onStopStreaming,
showThreadDepthBanner,
threadDepthApiMessage,
showBlockingBannerNewConversationLink = true,
onStartNewConversation,
},
ref,
@ -89,7 +88,6 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
promptLabelText = 'Press Enter to send, or Shift and Enter for new line.',
promptAriaLabelText = 'Prompt input',
threadDepthErrorPlaceholder = 'Conversation limit reached',
threadDepthExceededMessage = 'This conversation is now closed to keep responses accurate.',
startNewConversationButtonText = 'Start a new conversation',
threadDepthBannerContinueText = 'to continue.',
} = translations;
@ -164,67 +162,69 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
{threadDepthApiMessage ? (
<p className="DocSearch-Sidepanel-ThreadDepthBanner-apiMessage">{threadDepthApiMessage}</p>
) : null}
<p>
{threadDepthExceededMessage}{' '}
<button type="button" className="DocSearch-ThreadDepthError-Link" onClick={onStartNewConversation}>
{startNewConversationButtonText}
</button>{' '}
{threadDepthBannerContinueText}
</p>
{showBlockingBannerNewConversationLink ? (
<p>
<button type="button" className="DocSearch-ThreadDepthError-Link" onClick={onStartNewConversation}>
{startNewConversationButtonText}
</button>{' '}
{threadDepthBannerContinueText}
</p>
) : null}
</div>
) : null}
<form
className="DocSearch-Sidepanel-Prompt--form"
onSubmit={(e) => {
e.preventDefault();
{!showThreadDepthBanner ? (
<form
className="DocSearch-Sidepanel-Prompt--form"
onSubmit={(e) => {
e.preventDefault();
if (isStreaming || showThreadDepthBanner) return;
if (isStreaming) return;
handleSend();
}}
>
<textarea
ref={promptRef}
placeholder={promptPlaceholder}
className="DocSearch-Sidepanel-Prompt--textarea"
value={userPrompt}
aria-label={promptAriaLabelText}
aria-labelledby="prompt-label"
autoComplete="off"
translate="no"
rows={isMobile ? 1 : 2}
disabled={showThreadDepthBanner}
onKeyDown={handleKeyDown}
onInput={managePromptHeight}
onChange={(e) => setUserPrompt(e.target.value)}
/>
<span id="prompt-label" className="sr-only">
{promptLabelText}
</span>
<div className="DocSearch-Sidepanel-Prompt--actions">
{isStreaming && (
<button
type="button"
title="Stop streaming"
className="DocSearch-Sidepanel-Prompt--stop"
onClick={onStopStreaming}
>
<StopIcon />
</button>
)}
{!isStreaming && !showThreadDepthBanner && (
<button
type="submit"
aria-label="Send question"
title="Send question"
className="DocSearch-Sidepanel-Prompt--submit"
aria-disabled={userPrompt === ''}
>
<SendIcon />
</button>
)}
</div>
</form>
handleSend();
}}
>
<textarea
ref={promptRef}
placeholder={promptPlaceholder}
className="DocSearch-Sidepanel-Prompt--textarea"
value={userPrompt}
aria-label={promptAriaLabelText}
aria-labelledby="prompt-label"
autoComplete="off"
translate="no"
rows={isMobile ? 1 : 2}
onKeyDown={handleKeyDown}
onInput={managePromptHeight}
onChange={(e) => setUserPrompt(e.target.value)}
/>
<span id="prompt-label" className="sr-only">
{promptLabelText}
</span>
<div className="DocSearch-Sidepanel-Prompt--actions">
{isStreaming && (
<button
type="button"
title="Stop streaming"
className="DocSearch-Sidepanel-Prompt--stop"
onClick={onStopStreaming}
>
<StopIcon />
</button>
)}
{!isStreaming && (
<button
type="submit"
aria-label="Send question"
title="Send question"
className="DocSearch-Sidepanel-Prompt--submit"
aria-disabled={userPrompt === ''}
>
<SendIcon />
</button>
)}
</div>
</form>
) : null}
<p className="DocSearch-Sidepanel-Prompt--disclaimer">{promptDisclaimerText}</p>
</div>
);

View file

@ -12,7 +12,9 @@ import { useSuggestedQuestions } from '../useSuggestedQuestions';
import {
buildDummyAskAiHit,
filterExchangesForThreadDepthError,
getThreadDepthErrorUserFacingMessage,
getAskAiBlockingBannerMessage,
isAskAiPromptBlockingError,
showAskAiBlockingBannerNewConversationLink,
isThreadDepthError,
} from '../utils/ai';
@ -209,27 +211,26 @@ function SidepanelInner(
searchClient,
});
const hasThreadDepthError = React.useMemo(
() => status === 'error' && isThreadDepthError(askAiError),
[status, askAiError],
const hasAskAiPromptBlockingError = React.useMemo(
() => status === 'error' && isAskAiPromptBlockingError(askAiError, agentStudio),
[status, askAiError, agentStudio],
);
const displayExchanges = React.useMemo(
() => filterExchangesForThreadDepthError(exchanges, hasThreadDepthError),
[exchanges, hasThreadDepthError],
() => filterExchangesForThreadDepthError(exchanges, hasAskAiPromptBlockingError),
[exchanges, hasAskAiPromptBlockingError],
);
const showThreadDepthBanner =
sidepanelState === 'conversation' && hasThreadDepthError && messages.some((m) => m.role === 'assistant');
sidepanelState === 'conversation' &&
hasAskAiPromptBlockingError &&
(isThreadDepthError(askAiError) ? messages.some((m) => m.role === 'assistant') : true);
const threadDepthApiMessage = React.useMemo(() => getThreadDepthErrorUserFacingMessage(askAiError), [askAiError]);
const threadDepthApiMessage = React.useMemo(() => getAskAiBlockingBannerMessage(askAiError), [askAiError]);
const promptFormTranslations = React.useMemo(
() => ({
...translations.promptForm,
...(translations.conversationScreen?.threadDepthExceededMessage !== undefined
? { threadDepthExceededMessage: translations.conversationScreen.threadDepthExceededMessage }
: {}),
...(translations.conversationScreen?.startNewConversationButtonText !== undefined
? { startNewConversationButtonText: translations.conversationScreen.startNewConversationButtonText }
: {}),
@ -474,6 +475,10 @@ function SidepanelInner(
isStreaming={isStreaming}
showThreadDepthBanner={showThreadDepthBanner}
threadDepthApiMessage={threadDepthApiMessage}
showBlockingBannerNewConversationLink={showAskAiBlockingBannerNewConversationLink(
askAiError,
Boolean(agentStudio),
)}
translations={promptFormTranslations}
onSend={handleSend}
onStartNewConversation={handleStartNewConversation}

View file

@ -64,7 +64,7 @@ describe('AskAiScreen', () => {
const onNewConversation = (): void => {};
const { container, getByText } = render(
const { container } = render(
<AskAiScreen
{...baseProps}
messages={messages}
@ -74,12 +74,77 @@ describe('AskAiScreen', () => {
/>,
);
expect(
getByText('This conversation is now closed to keep responses accurate.', { exact: false }),
).toBeInTheDocument();
expect(getByText('Start a new conversation')).toBeInTheDocument();
expect(within(container).getByText('AI-217 - Thread depth exceeded')).toBeInTheDocument();
expect(within(container).getByText('Start a new conversation')).toBeInTheDocument();
expect(within(container).getByText(/to continue\./i)).toBeInTheDocument();
// Bound queries from `render()` use `baseElement` (often `document.body`), so a prior test can still
// match. Restrict to this instance's root so we only assert on this tree.
expect(within(container).queryByText('Chat error')).not.toBeInTheDocument();
});
it('for Agent Studio cost-control errors, shows the blocking banner (message + start new conversation), not inline Chat error', () => {
const messages: UIMessage[] = [
{
id: '1',
role: 'user',
parts: [{ type: 'text', text: 'hello' }],
},
];
const { container } = render(
<AskAiScreen
{...baseProps}
agentStudio={true}
messages={messages}
status="error"
askAiError={new Error('Too many requests (AI-205)')}
onNewConversation={(): void => {}}
/>,
);
expect(within(container).getByText('Too many requests')).toBeInTheDocument();
expect(within(container).getByText('Start a new conversation')).toBeInTheDocument();
expect(within(container).getByText(/to continue\./i)).toBeInTheDocument();
expect(
within(container).queryByText('This conversation is now closed to keep responses accurate.', { exact: false }),
).not.toBeInTheDocument();
});
it('for Agent Studio token output limit, banner shows only the human message without start-new-conversation', () => {
const messages: UIMessage[] = [
{
id: '1',
role: 'user',
parts: [{ type: 'text', text: 'Hello' }],
},
{
id: '2',
role: 'assistant',
parts: [{ type: 'text', text: 'Hello! How can I assist' }],
},
];
const raw = JSON.stringify({
error: 'Could not complete response due to token output limits',
type: 'TokenOutputLimitError',
statusCode: 400,
});
const { container } = render(
<AskAiScreen
{...baseProps}
agentStudio={true}
messages={messages}
status="error"
askAiError={new Error(raw)}
onNewConversation={(): void => {}}
/>,
);
expect(within(container).getByText('Could not complete response due to token output limits')).toBeInTheDocument();
expect(within(container).queryByText('Start a new conversation')).not.toBeInTheDocument();
expect(within(container).queryByText(/to continue\./i)).not.toBeInTheDocument();
expect(within(container).queryByText('Chat error')).not.toBeInTheDocument();
expect(within(container).queryByText(/\{"error":/)).not.toBeInTheDocument();
});
});

View file

@ -1,6 +1,6 @@
import { ASK_AI_API_URL, BETA_ASK_AI_API_URL } from './constants';
import { extractAgentStudioErrorFieldMessage } from './utils/ai';
// ... existing imports ...
const TOKEN_KEY = 'askai_token';
export const agentStudioBaseUrl = (appId: string): string => `https://${appId}.algolia.net/agent-studio/1`;
@ -112,26 +112,66 @@ 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
const extracted = extractAgentStudioErrorFieldMessage(raw);
return new Error(extracted ?? raw);
}
while (typeof parsed === 'string') {
try {
parsed = JSON.parse(parsed.trim());
} catch {
const extracted = extractAgentStudioErrorFieldMessage(raw);
return new Error(extracted ?? (parsed as string));
}
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
const extracted = extractAgentStudioErrorFieldMessage(raw);
return new Error(extracted ?? raw);
}
const parsedError = parsed as Error & {
code?: string;
errorCode?: string;
message?: string;
error?: string;
};
if (parsedError.name === 'ValidationError') {
const validationError = parsedError as AgentStudioValidationError;
let errorMessage = raw;
if (validationError.detail && validationError.detail.length > 0) {
const { msg, loc } = validationError.detail[0];
const field = loc.at(-1);
errorMessage = `${msg}: ${field}`;
}
return new Error(errorMessage);
}
const extracted = extractAgentStudioErrorFieldMessage(raw);
let errorMessage: string;
if (extracted) {
errorMessage = extracted;
} else if (typeof parsedError.message === 'string' && parsedError.message.trim() !== '') {
errorMessage = parsedError.message.trim();
} else if (typeof parsedError.error === 'string' && parsedError.error.trim() !== '') {
errorMessage = parsedError.error.trim();
} else {
errorMessage = raw;
}
const code = parsedError.code ?? parsedError.errorCode;
if (typeof code === 'string' && code.trim() !== '') {
const c = code.trim();
if (!errorMessage.toUpperCase().includes(c.toUpperCase())) {
errorMessage = `${errorMessage} (${c})`;
}
}
return new Error(errorMessage);

View file

@ -1,9 +1,34 @@
export {
extractAgentStudioErrorFieldMessage,
filterExchangesForThreadDepthError,
getAskAiBlockingBannerMessage,
getAskAiPromptBlockingUserFacingMessage,
getThreadDepthErrorUserFacingMessage,
isAgentStudioTokenOutputLimitError,
isAskAiPromptBlockingError,
isThreadDepthError,
showAskAiBlockingBannerNewConversationLink,
} from './utils/ai';
export {
agentStudioPromptBlockingMatchers,
AGENT_STUDIO_PROMPT_BLOCKING_CODES,
extractAiErrorCodeFromMessage,
matchesAgentStudioContextOrTokenLimitsPlainMessage,
matchesAgentStudioMaxStepsMessage,
matchesAgentStudioRateLimitMessage,
matchesAgentStudioTokenOutputLimitPlainMessage,
matchesAgentStudioWhitelistOrNotAllowedDomainPlainMessage,
matchesRequestBlockedForThisDomainMessage,
matchesThreadDepthLimitError,
newConversationErrorMatchers,
readAgentStudioJsonStringField,
resolveAgentStudioPromptBlocking,
type AgentStudioBlockingMatchContext,
type AgentStudioPromptBlockingMatcher,
type NewConversationErrorMatcher,
} from './utils/askAiBlockingMatchers';
export * from './DocSearch';
export * from './DocSearchButton';
export * from './DocSearchModal';

View file

@ -1,7 +1,17 @@
import { describe, expect, it } from 'vitest';
import type { AIMessage } from '../../types/AskiAi';
import { filterExchangesForThreadDepthError, getThreadDepthErrorUserFacingMessage, isThreadDepthError } from '../ai';
import {
filterExchangesForThreadDepthError,
extractAgentStudioErrorFieldMessage,
getAskAiBlockingBannerMessage,
getAskAiPromptBlockingUserFacingMessage,
getThreadDepthErrorUserFacingMessage,
isAgentStudioTokenOutputLimitError,
isAskAiPromptBlockingError,
isThreadDepthError,
showAskAiBlockingBannerNewConversationLink,
} from '../ai';
describe('isThreadDepthError', () => {
it('detects AI-217 in message (any case)', () => {
@ -69,3 +79,118 @@ describe('getThreadDepthErrorUserFacingMessage', () => {
expect(getThreadDepthErrorUserFacingMessage(new Error('Network failed'))).toBeUndefined();
});
});
describe('isAskAiPromptBlockingError (Agent Studio cost controls)', () => {
it('detects API codes when agentStudio is true', () => {
expect(isAskAiPromptBlockingError(new Error('x (AI-203)'), true)).toBe(true);
expect(isAskAiPromptBlockingError(new Error('x (AI-205)'), true)).toBe(true);
expect(isAskAiPromptBlockingError(new Error('x (AI-224)'), true)).toBe(true);
});
it('does not treat Agent Studio codes as blocking when agentStudio is false', () => {
expect(isAskAiPromptBlockingError(new Error('x (AI-205)'), false)).toBe(false);
});
it('still blocks thread depth when agentStudio is false', () => {
expect(isAskAiPromptBlockingError(new Error('AI-217'), false)).toBe(true);
});
it('matches message heuristics when agentStudio is true', () => {
expect(isAskAiPromptBlockingError(new Error('Rate limit exceeded'), true)).toBe(true);
expect(isAskAiPromptBlockingError(new Error('Domain is not whitelisted'), true)).toBe(true);
expect(isAskAiPromptBlockingError(new Error('Maximum token limit reached'), true)).toBe(true);
expect(isAskAiPromptBlockingError(new Error('Maximum steps exceeded'), true)).toBe(true);
});
it('omits “Start new conversation” for domain-block API copy but keeps it for other blocks', () => {
expect(showAskAiBlockingBannerNewConversationLink(new Error('Request blocked for this domain'), true)).toBe(false);
expect(
showAskAiBlockingBannerNewConversationLink(
new Error(JSON.stringify({ message: 'Request blocked for this domain' })),
true,
),
).toBe(false);
expect(showAskAiBlockingBannerNewConversationLink(new Error('Domain is not whitelisted'), true)).toBe(true);
expect(showAskAiBlockingBannerNewConversationLink(new Error('Rate limit exceeded'), true)).toBe(true);
expect(showAskAiBlockingBannerNewConversationLink(new Error('AI-217'), false)).toBe(true);
expect(showAskAiBlockingBannerNewConversationLink(new Error('AI-217'), true)).toBe(true);
const tokenRaw = JSON.stringify({
error: 'Could not complete response due to token output limits',
type: 'TokenOutputLimitError',
statusCode: 400,
});
expect(showAskAiBlockingBannerNewConversationLink(new Error(tokenRaw), true)).toBe(false);
});
it('detects Agent Studio JSON token output limit errors', () => {
const raw = JSON.stringify({
error: 'Could not complete response due to token output limits',
type: 'TokenOutputLimitError',
statusCode: 400,
});
expect(isAskAiPromptBlockingError(new Error(raw), true)).toBe(true);
expect(getAskAiPromptBlockingUserFacingMessage(new Error(raw))).toBe(
'Could not complete response due to token output limits',
);
expect(isAgentStudioTokenOutputLimitError(new Error(raw))).toBe(true);
expect(isAgentStudioTokenOutputLimitError(new Error('Too many requests (AI-205)'))).toBe(false);
});
});
describe('getAskAiPromptBlockingUserFacingMessage', () => {
it('strips a trailing (AI-xxx) suffix from transformed Agent Studio errors', () => {
expect(getAskAiPromptBlockingUserFacingMessage(new Error('Too many requests (AI-205)'))).toBe('Too many requests');
});
});
describe('getAskAiBlockingBannerMessage', () => {
it('returns the token-output fallback when the error is token limit but the message is empty', () => {
expect(
getAskAiBlockingBannerMessage(new Error(JSON.stringify({ type: 'TokenOutputLimitError', statusCode: 400 }))),
).toBe('Could not complete response due to token output limits');
});
});
describe('extractAgentStudioErrorFieldMessage', () => {
it('reads error from normal JSON', () => {
const raw = JSON.stringify({
error: 'Could not complete response due to token output limits',
type: 'TokenOutputLimitError',
statusCode: 400,
});
expect(extractAgentStudioErrorFieldMessage(raw)).toBe('Could not complete response due to token output limits');
});
it('reads error from JSON with escaped quotes (serialized twice)', () => {
const inner = JSON.stringify({
error: 'Could not complete response due to token output limits',
type: 'TokenOutputLimitError',
statusCode: 400,
});
const raw = JSON.stringify(inner);
expect(extractAgentStudioErrorFieldMessage(raw)).toBe('Could not complete response due to token output limits');
});
it('reads error from brace payload with backslash-escaped quotes', () => {
const raw =
'{\\"error\\": \\"Could not complete response due to token output limits\\", \\"type\\": \\"TokenOutputLimitError\\", \\"statusCode\\": 400}';
expect(extractAgentStudioErrorFieldMessage(raw)).toBe('Could not complete response due to token output limits');
});
it('treats domain-not-allowed style messages as Agent Studio prompt-blocking', () => {
expect(isAskAiPromptBlockingError(new Error('Request blocked for this domain'), true)).toBe(true);
expect(getAskAiBlockingBannerMessage(new Error('Request blocked for this domain'))).toBe(
'Request blocked for this domain',
);
});
it('prefers message over machine error code (TOO_MANY_REQUESTS)', () => {
const raw = JSON.stringify({
error: 'TOO_MANY_REQUESTS',
message: 'Rate limit exceeded. Retry after 60 seconds.',
});
expect(extractAgentStudioErrorFieldMessage(raw)).toBe('Rate limit exceeded. Retry after 60 seconds.');
expect(isAskAiPromptBlockingError(new Error(raw), true)).toBe(true);
expect(getAskAiBlockingBannerMessage(new Error(raw))).toBe('Rate limit exceeded. Retry after 60 seconds.');
});
});

View file

@ -3,6 +3,11 @@ import type { TextUIPart } from 'ai';
import type { StoredAskAiState } from '../types';
import type { AIMessage } from '../types/AskiAi';
import {
matchesThreadDepthLimitError,
readAgentStudioJsonStringField,
resolveAgentStudioPromptBlocking,
} from './askAiBlockingMatchers';
import { sanitizeUserInput } from './sanitize';
type ExtractedLink = {
@ -116,8 +121,7 @@ export function filterExchangesForThreadDepthError<T extends ExchangeWithOptiona
function threadDepthFromPlainText(message: string): boolean {
if (!message) return false;
if (message.toUpperCase().includes('AI-217')) return true;
return /thread\s+depth/i.test(message);
return matchesThreadDepthLimitError(message.toLowerCase());
}
function messageLooksLikeThreadDepth(message: string): boolean {
@ -149,6 +153,151 @@ export function isThreadDepthError(error?: Error): boolean {
return messageLooksLikeThreadDepth(error.message ?? '');
}
function messageLooksLikeAgentStudioCostControl(error: Error): boolean {
return resolveAgentStudioPromptBlocking(error).blocking;
}
/**
* Whether further prompts should be blocked: thread depth (all backends) or Agent Studio cost controls.
*/
export function isAskAiPromptBlockingError(error: Error | undefined, agentStudio: boolean): boolean {
if (!error) return false;
if (isThreadDepthError(error)) return true;
if (!agentStudio) return false;
return messageLooksLikeAgentStudioCostControl(error);
}
/**
* Agent Studio stream hit the completion token ceiling (`TokenOutputLimitError`).
* This case uses a message-only banner (no Start a new conversation row).
*/
export function isAgentStudioTokenOutputLimitError(error?: Error): boolean {
if (!error) return false;
const msg = error.message ?? '';
if (/TokenOutputLimitError/i.test(msg)) return true;
if (/could not complete response due to token output limits/i.test(msg)) return true;
try {
const p = JSON.parse(msg) as { type?: string; error?: string };
if (typeof p.type === 'string' && /^TokenOutputLimitError$/i.test(p.type.trim())) {
return true;
}
if (typeof p.error === 'string' && /token output limits/i.test(p.error)) {
return true;
}
} catch {
// not JSON
}
return false;
}
/**
* Whether the blocking banner should include Start a new conversation to continue.
* Thread depth and most Agent Studio limits keep it; token output limit and request blocked for this domain omit it.
*/
export function showAskAiBlockingBannerNewConversationLink(error: Error | undefined, agentStudio: boolean): boolean {
if (!error) return true;
if (isAgentStudioTokenOutputLimitError(error)) return false;
if (isThreadDepthError(error)) return true;
if (!agentStudio) return true;
return resolveAgentStudioPromptBlocking(error).showNewConversationLink;
}
function stripTrailingAiCodeSuffix(message: string): string {
return message.replace(/\s*\(AI-\d{3}\)\s*$/i, '').trim();
}
/**
* Pulls `message` or `error` from Agent Studio JSON payloads, including double-encoded JSON
* and objects serialized with escaped quotes (`{\"error\": \"...\"}`).
*/
export function extractAgentStudioErrorFieldMessage(raw: string): string | undefined {
let s = raw.trim();
if (!s) return undefined;
let iterations = 0;
while (iterations < 10) {
iterations += 1;
try {
const v: unknown = JSON.parse(s);
if (typeof v === 'string') {
const next = v.trim();
if (!next) return undefined;
s = next;
} else if (v && typeof v === 'object' && !Array.isArray(v)) {
const o = v as Record<string, unknown>;
const msg = readAgentStudioJsonStringField(o, 'message');
if (msg) {
return msg;
}
const err = readAgentStudioJsonStringField(o, 'error');
if (err) {
return err;
}
return undefined;
} else {
return undefined;
}
} catch {
if (/\\"/.test(s)) {
s = s.replace(/\\"/g, '"').replace(/\\\\/g, '\\').trim();
} else {
const mMsg = /"message"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(s);
if (mMsg?.[1]) {
return mMsg[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\').trim();
}
const mErr = /"error"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(s);
if (mErr?.[1]) {
return mErr[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\').trim();
}
return undefined;
}
}
}
return undefined;
}
/**
* Prefer the API `message` when the body was JSON; otherwise the thrown message (without a trailing `(AI-xxx)` suffix).
* Only meaningful when {@link isAskAiPromptBlockingError} would return true for this error.
*/
export function getAskAiPromptBlockingUserFacingMessage(error?: Error): string | undefined {
if (!error) return undefined;
const raw = error.message ?? '';
const extracted = extractAgentStudioErrorFieldMessage(raw);
if (extracted) {
return extracted;
}
const stripped = stripTrailingAiCodeSuffix(raw.trim());
return stripped !== '' ? stripped : undefined;
}
const TOKEN_OUTPUT_LIMIT_FALLBACK = 'Could not complete response due to token output limits';
function looksLikeJsonObjectString(s: string): boolean {
const t = s.trim();
return t.startsWith('{') && t.endsWith('}');
}
/**
* Message shown in the top blocking banner (parsed API text, never raw JSON when avoidable).
*/
export function getAskAiBlockingBannerMessage(error?: Error): string | undefined {
if (!error) return undefined;
if (isAgentStudioTokenOutputLimitError(error)) {
const m = getAskAiPromptBlockingUserFacingMessage(error);
if (m && !looksLikeJsonObjectString(m)) {
return m;
}
return TOKEN_OUTPUT_LIMIT_FALLBACK;
}
return getAskAiPromptBlockingUserFacingMessage(error);
}
/**
* Prefer the API `message` field when the error body is JSON; otherwise the thrown message string.
* Only meaningful when {@link isThreadDepthError} is true.
@ -156,17 +305,5 @@ export function isThreadDepthError(error?: Error): boolean {
export function getThreadDepthErrorUserFacingMessage(error?: Error): string | undefined {
if (!error || !isThreadDepthError(error)) return undefined;
const raw = error.message ?? '';
try {
const parsed = JSON.parse(raw) as { message?: string };
if (typeof parsed.message === 'string' && parsed.message.trim() !== '') {
return parsed.message.trim();
}
} catch {
// not JSON — fall through
}
const trimmed = raw.trim();
return trimmed !== '' ? trimmed : undefined;
return getAskAiPromptBlockingUserFacingMessage(error);
}

View file

@ -0,0 +1,239 @@
/**
* Registry of Agent Studio prompt-blocking matchers (cost controls, access, limits).
* Add cases here instead of scattering regex across call sites.
* Set `showNewConversationLink` to false to omit the Start a new conversation to continue row when starting over does not fix the issue (for example, domain allowlisting).
*/
export type AgentStudioBlockingMatchContext = {
message: string;
messageLower: string;
parsedJson: Record<string, unknown> | null;
extractedCodeUpper: string | undefined;
};
export type AgentStudioPromptBlockingMatcher = {
matches: (ctx: AgentStudioBlockingMatchContext) => boolean;
showNewConversationLink?: boolean;
};
/** Agent Studio cost / access control codes that block further input. */
export const AGENT_STUDIO_PROMPT_BLOCKING_CODES = new Set([
'AI-203', // Forbidden (e.g. domain not whitelisted)
'AI-205', // Rate limited
'AI-224', // Context / max token length
'AI-225', // Max agent steps (reserved; also matched by message heuristics)
]);
export function readAgentStudioJsonStringField(o: Record<string, unknown>, key: string): string | undefined {
for (const [k, v] of Object.entries(o)) {
if (k.toLowerCase() === key.toLowerCase() && typeof v === 'string' && v.trim() !== '') {
return v.trim();
}
}
return undefined;
}
export function extractAiErrorCodeFromMessage(message: string): string | undefined {
const direct = /\b(AI-\d{3})\b/i.exec(message);
if (direct) return direct[1].toUpperCase();
try {
const parsed = JSON.parse(message) as { code?: string; errorCode?: string };
const c = parsed.code ?? parsed.errorCode;
if (typeof c === 'string' && /AI-\d{3}/i.test(c)) {
return c.trim().toUpperCase();
}
} catch {
// ignore
}
return undefined;
}
/** Plain-text / stringified JSON heuristic; JSON-shaped payloads still use `isThreadDepthError` in `ai.ts`. */
export function matchesThreadDepthLimitError(normalizedMessage: string): boolean {
return normalizedMessage.includes('ai-217') || /thread\s+depth/.test(normalizedMessage);
}
/** API copy for domain allowlisting — starting a new conversation does not resolve it. */
export function matchesRequestBlockedForThisDomainMessage(normalizedMessage: string): boolean {
return (
/\brequest blocked for this domain\b/.test(normalizedMessage) ||
/\bblocked for this domain\b/.test(normalizedMessage)
);
}
export function matchesAgentStudioMaxStepsMessage(normalizedMessage: string): boolean {
const m = normalizedMessage;
// Explicit phrases only (no nested optional quantifiers — avoids ReDoS / unsafe-regex tooling noise).
return (
/\bstep limit\b/.test(m) ||
/\bmax steps\b/.test(m) ||
/\bmax step\b/.test(m) ||
/\bmaximum steps\b/.test(m) ||
/\bmaximum step\b/.test(m) ||
/\bmax agent steps\b/.test(m) ||
/\bmax agent step\b/.test(m) ||
/\bmaximum agent steps\b/.test(m) ||
/\bmaximum agent step\b/.test(m)
);
}
export function matchesAgentStudioRateLimitMessage(normalizedMessage: string): boolean {
return (
/\b429\b/.test(normalizedMessage) ||
/\brate\s*limit/i.test(normalizedMessage) ||
/\btoo\s+many\s+attempts\b/.test(normalizedMessage) ||
/\btoo_many_requests\b/.test(normalizedMessage)
);
}
export function matchesAgentStudioTokenOutputLimitPlainMessage(message: string): boolean {
return /\bTokenOutputLimitError\b/i.test(message);
}
export function matchesAgentStudioWhitelistOrNotAllowedDomainPlainMessage(normalizedMessage: string): boolean {
return (
/\bwhitelist(ed)?\b/.test(normalizedMessage) || /\bnot\s+allowed\s+for\s+this\s+domain\b/.test(normalizedMessage)
);
}
export function matchesAgentStudioContextOrTokenLimitsPlainMessage(normalizedMessage: string): boolean {
const m = normalizedMessage;
return (
/\bcontext\s+length\b/.test(m) ||
/\bmax tokens\b/.test(m) ||
/\bmax token\b/.test(m) ||
/\bmaximum tokens\b/.test(m) ||
/\bmaximum token\b/.test(m) ||
/\btoken\s+limit\b/.test(m) ||
/\btoken\s+output\b/.test(m) ||
/\boutput\s+limits?\b/.test(m)
);
}
function jsonMessageIsRequestBlockedForDomain(parsed: Record<string, unknown>): boolean {
const msg = readAgentStudioJsonStringField(parsed, 'message') ?? '';
return matchesRequestBlockedForThisDomainMessage(msg.toLowerCase());
}
/** JSON-shaped errors other than the domain API string in `message` (that case is its own matcher). */
function jsonPayloadImpliesCostControlExcludingRequestBlockedDomainMessage(parsed: Record<string, unknown>): boolean {
const type = typeof parsed.type === 'string' ? parsed.type : '';
if (/tokenoutput|outputlimit|steplimit|maxstep|ratelimit|domainnotallowed/i.test(type)) {
return true;
}
const errCode = readAgentStudioJsonStringField(parsed, 'error') ?? '';
if (errCode.toUpperCase() === 'TOO_MANY_REQUESTS') {
return true;
}
if (
/token output|output limits|token limits|rate limit|whitelist|step limit|max steps|could not complete response due to token/i.test(
errCode,
)
) {
return true;
}
const msg = readAgentStudioJsonStringField(parsed, 'message') ?? '';
if (/rate limit exceeded|retry after \d+/i.test(msg)) {
return true;
}
if (/whitelist/i.test(msg)) {
return true;
}
const lower = msg.toLowerCase();
const notAllowedAt = lower.indexOf('not allowed');
if (notAllowedAt !== -1 && lower.indexOf('domain', notAllowedAt) !== -1) {
return true;
}
return false;
}
function buildAgentStudioBlockingContext(error: Error): AgentStudioBlockingMatchContext {
const message = error.message ?? '';
const messageLower = message.toLowerCase();
let parsedJson: Record<string, unknown> | null = null;
try {
const p = JSON.parse(message) as Record<string, unknown>;
if (p && typeof p === 'object' && !Array.isArray(p)) {
parsedJson = p;
}
} catch {
// not JSON
}
return {
message,
messageLower,
parsedJson,
extractedCodeUpper: extractAiErrorCodeFromMessage(message),
};
}
/**
* Ordered registry: first match does not win alone `resolveAgentStudioPromptBlocking`
* OR-combines blocking and AND-combines show new conversation (any `false` hides the row).
*/
export const agentStudioPromptBlockingMatchers: AgentStudioPromptBlockingMatcher[] = [
{
matches: (c) =>
typeof c.extractedCodeUpper === 'string' && AGENT_STUDIO_PROMPT_BLOCKING_CODES.has(c.extractedCodeUpper),
},
{
matches: (c) => c.parsedJson !== null && jsonMessageIsRequestBlockedForDomain(c.parsedJson),
showNewConversationLink: false,
},
{
matches: (c) =>
c.parsedJson !== null && jsonPayloadImpliesCostControlExcludingRequestBlockedDomainMessage(c.parsedJson),
},
{
matches: (c) => matchesAgentStudioTokenOutputLimitPlainMessage(c.message),
showNewConversationLink: false,
},
{
matches: (c) => matchesAgentStudioRateLimitMessage(c.messageLower),
},
{
matches: (c) => matchesRequestBlockedForThisDomainMessage(c.messageLower),
showNewConversationLink: false,
},
{
matches: (c) => matchesAgentStudioWhitelistOrNotAllowedDomainPlainMessage(c.messageLower),
},
{
matches: (c) => matchesAgentStudioContextOrTokenLimitsPlainMessage(c.messageLower),
},
{
matches: (c) => matchesAgentStudioMaxStepsMessage(c.messageLower),
},
];
export function resolveAgentStudioPromptBlocking(error: Error): {
blocking: boolean;
showNewConversationLink: boolean;
} {
const ctx = buildAgentStudioBlockingContext(error);
const matched = agentStudioPromptBlockingMatchers.filter((m) => m.matches(ctx));
if (matched.length === 0) {
return { blocking: false, showNewConversationLink: true };
}
const showNewConversationLink = matched.every((m) => m.showNewConversationLink !== false);
return { blocking: true, showNewConversationLink };
}
/**
* Plain-text matchers over `Error.message.toLowerCase()` (callers pass `error.message.toLowerCase()`).
* Thread depth: first entry mirrors common plain cases; JSON-shaped payloads still need `isThreadDepthError` in `ai.ts`.
*
* Blocking UX and Start new conversation visibility are driven by `agentStudioPromptBlockingMatchers`.
*/
export type NewConversationErrorMatcher = (normalizedMessage: string) => boolean;
export const newConversationErrorMatchers: NewConversationErrorMatcher[] = [
(m) => matchesThreadDepthLimitError(m),
(m) => matchesAgentStudioRateLimitMessage(m),
(m) => matchesRequestBlockedForThisDomainMessage(m),
(m) => matchesAgentStudioWhitelistOrNotAllowedDomainPlainMessage(m),
(m) => matchesAgentStudioContextOrTokenLimitsPlainMessage(m),
(m) => matchesAgentStudioMaxStepsMessage(m),
];