feat(client): render context budget errors with readable copy

empty_messages and final_context_overflow are structured errors thrown by
the agents SDK, but they were missing from the typed-error dispatch table
and surfaced as raw JSON inside the generic failure text. Map both to
localized copy: empty_messages keeps its token budget breakdown in a
details block, final_context_overflow interpolates the projected and
available token counts.
This commit is contained in:
Marco Beretta 2026-08-06 19:37:18 +02:00
parent 6fdf2c8c63
commit aefd3c9d14
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
4 changed files with 123 additions and 2 deletions

View file

@ -37,6 +37,22 @@ type TGenericError = {
info: string;
};
type TContextOverflow = {
info?: string;
provider?: string;
projectedMessageTokens?: number;
availableMessageTokens?: number;
};
/**
* SDK boilerplate already covered by the localized headline; whatever remains
* (specific guidance and the token budget breakdown) renders as details.
*/
const emptyMessagesBoilerplate = [
'Message pruning removed all messages as none fit in the context window.',
'Please increase the context window size or make your message shorter.',
];
const errorMessages = {
[ErrorTypes.MODERATION]: 'com_error_moderation',
[ErrorTypes.NO_USER_KEY]: 'com_error_no_user_key',
@ -79,6 +95,39 @@ const errorMessages = {
[ErrorTypes.GOOGLE_VIDEO_UNPROCESSABLE]: 'com_error_google_video_unprocessable',
[ErrorTypes.RESOURCE_RECOVERY_REQUIRED]: 'com_error_resource_recovery_required',
[ErrorTypes.STREAM_EXPIRED]: 'com_error_stream_expired',
[ErrorTypes.EMPTY_MESSAGES]: (json: TGenericError, localize: LocalizeFunction) => {
const detail = emptyMessagesBoilerplate
.reduce((info, sentence) => info.replace(sentence, ''), json.info ?? '')
.trim();
return (
<>
{localize('com_error_empty_messages')}
{detail && (
<>
<br />
<br />
<CodeBlock
lang={localize('com_ui_details')}
error={true}
allowExecution={false}
codeChildren={detail}
/>
</>
)}
</>
);
},
[ErrorTypes.FINAL_CONTEXT_OVERFLOW]: (json: TContextOverflow, localize: LocalizeFunction) => {
const { projectedMessageTokens: projected, availableMessageTokens: available } = json;
const message = localize('com_error_final_context_overflow');
if (typeof projected !== 'number' || typeof available !== 'number') {
return message;
}
return `${message} ${localize('com_error_context_tokens_detail', {
0: projected,
1: available,
})}`;
},
[ViolationTypes.BAN]:
'Your account has been temporarily banned due to violations of our service.',
[ViolationTypes.ILLEGAL_MODEL_REQUEST]: (json: TGenericError, localize: LocalizeFunction) => {

View file

@ -11,8 +11,16 @@ import Error from '../Error';
jest.mock('~/hooks', () => ({
useLocalize:
() =>
(key: string): string =>
(jest.requireActual('~/locales/en/translation.json') as Record<string, string>)[key] ?? key,
(key: string, values?: Record<string, unknown>): string => {
const template =
(jest.requireActual('~/locales/en/translation.json') as Record<string, string>)[key] ?? key;
if (!values) {
return template;
}
return template.replace(/\{\{(\w+)\}\}/g, (match, name) =>
values[name] != null ? String(values[name]) : match,
);
},
}));
const catalog = translation as Record<string, string>;
@ -49,3 +57,56 @@ describe('Error — typed provider errors', () => {
).toBeInTheDocument();
});
});
describe('Error — agent context budget errors', () => {
beforeAll(() => {
/** CodeBlock observes its code bar; jsdom ships no IntersectionObserver. */
(global as { IntersectionObserver?: unknown }).IntersectionObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
});
it('renders localized copy and the token breakdown for empty_messages', () => {
/** Mirrors the SDK payload as embedded by the agents controller's error prefix. */
const info =
'Message pruning removed all messages as none fit in the context window. Please increase the context window size or make your message shorter.\nToken budget breakdown:\n maxContextTokens: 10\n messageTokens: 32 (4 messages)\n availableForMessages: 9';
const payload = `An error occurred while processing the request: ${JSON.stringify({
type: ErrorTypes.EMPTY_MESSAGES,
info,
})}`;
render(<Error text={payload} />);
expect(screen.getByText(catalog.com_error_empty_messages)).toBeInTheDocument();
expect(screen.getByText(/Token budget breakdown/)).toBeInTheDocument();
expect(screen.queryByText(/Something went wrong/)).not.toBeInTheDocument();
});
it('renders localized copy with token counts for final_context_overflow', () => {
const payload = `An error occurred while processing the request: ${JSON.stringify({
type: ErrorTypes.FINAL_CONTEXT_OVERFLOW,
info: 'Provider message formatting exceeded the context budget and no safe synthetic-context compaction could make it fit.',
provider: 'openAI',
projectedMessageTokens: 3,
availableMessageTokens: 0,
})}`;
render(<Error text={payload} />);
expect(
screen.getByText(new RegExp(catalog.com_error_final_context_overflow.slice(0, 40))),
).toBeInTheDocument();
expect(screen.getByText(/need 3 tokens, but only 0 are available/)).toBeInTheDocument();
expect(screen.queryByText(/Something went wrong/)).not.toBeInTheDocument();
});
it('falls back to the plain sentence when overflow token counts are absent', () => {
const payload = JSON.stringify({
type: ErrorTypes.FINAL_CONTEXT_OVERFLOW,
info: 'Fallback provider message formatting exceeded the context budget before invocation.',
});
render(<Error text={payload} />);
expect(screen.getByText(catalog.com_error_final_context_overflow)).toBeInTheDocument();
});
});

View file

@ -380,12 +380,15 @@
"com_error_expired_user_key": "Provided key for {{0}} expired at {{1}}. Please provide a new key and try again.",
"com_error_files_dupe": "Duplicate file detected.",
"com_error_files_empty": "Empty files are not allowed.",
"com_error_context_tokens_detail": "Your messages need {{0}} tokens, but only {{1}} are available.",
"com_error_empty_messages": "Your messages do not fit in the current context window, so there was nothing left to send to the model. Increase the max context size from the conversation parameters, shorten your message, or start a new conversation.",
"com_error_files_process": "An error occurred while processing the file.",
"com_error_files_unsupported": "This file type can't be attached here.",
"com_error_files_upload": "An error occurred while uploading the file.",
"com_error_files_upload_canceled": "The file upload request was canceled. Note: the file upload may still be processing and will need to be manually deleted.",
"com_error_files_upload_too_large": "The file is too large. Please upload a file smaller than {{0}} MB",
"com_error_files_validation": "An error occurred while validating the file.",
"com_error_final_context_overflow": "The request exceeds the current context window and could not be reduced to fit. Increase the max context size from the conversation parameters or shorten your message.",
"com_error_google_tool_conflict": "Usage of built-in Google tools are not supported with external tools. Please disable either the built-in tools or the external tools.",
"com_error_google_video_unprocessable": "The linked video could not be processed. It is most likely too long for this model, but it may also be unavailable in this region or restricted. Try a shorter video, or describe the relevant parts in your message instead.",
"com_error_heic_conversion": "Failed to convert HEIC image to JPEG. Please try converting the image manually or use a different format.",

View file

@ -2950,6 +2950,14 @@ export enum ErrorTypes {
* SSE stream 404 job completed, expired, or was deleted before the subscriber connected
*/
STREAM_EXPIRED = 'stream_expired',
/**
* Context pruning removed every message; nothing fits the configured context window
*/
EMPTY_MESSAGES = 'empty_messages',
/**
* Formatted provider payload exceeded the context budget before invocation
*/
FINAL_CONTEXT_OVERFLOW = 'final_context_overflow',
}
/**